pi-tuck 1.0.21 → 1.0.23

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,3 +1,6 @@
1
+ import { textOf } from "./tuck/core/content.js";
2
+ import { requestReplyFloor, carriesPrompt, piContextSafetyMargin } from "./request-capacity.js";
3
+ import { BUILD_ID } from "./build-id.js";
1
4
  import { createSemanticRecall, budgetSemantic, type SemanticRecallController } from "./semantic-recall";
2
5
  import { DIAGNOSTIC_BUILD, IncidentRecorder, attachReceipt, permittedEntries, receiptId, findReceiptEntry, type Receipt, type RecorderOptions } from "./diagnostics.js";
3
6
  import { createHash } from "node:crypto";
@@ -8,7 +11,7 @@ import { projectedMessages } from "./omissions.js";
8
11
  import { applyClips, clipBudgets, clipFor, type Clip } from "./clip.js";
9
12
  import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, systemPart, validCutAt,
10
13
  type AgentMessage, type Messages, type Plan, type Target, type ToolShape } from "./budget.js";
11
- import { getCurrentSystemMessage, isContextOverflow, type ImageContent, type ToolResultMessage } from "@earendil-works/pi-ai";
14
+ import { getCurrentSystemMessage, isContextOverflow, normalizeContext, type ImageContent, type ToolResultMessage } from "@earendil-works/pi-ai";
12
15
  import { calculateContextTokens, convertToLlm, estimateTokens, shouldCompact, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
13
16
  import { Type } from "typebox";
14
17
  import { Check } from "typebox/value";
@@ -102,15 +105,18 @@ export interface CompactionOptions {
102
105
  onSemanticSetting?: (enabled: boolean) => Promise<unknown>;
103
106
  semanticStatus?: () => unknown;
104
107
  diagnostics?: RecorderOptions;
108
+ /** Optional read-only observation of actual corrected budget checks; no request content. */
109
+ observeBudget?: (measurement: { rawTokens: number; tokens: number; factor: number; response: number; room: number; hardLine?: number; context: ExtensionContext }) => void;
105
110
  /** Optional estimated complete-input ceiling, separate from physical output reservation.
106
111
  * The host owns final serialization/transport counting; this is not an exact wire-token cap. */
107
112
  activeInputTokens?: number | ((ctx: ExtensionContext) => number);
108
113
  /** An explicit cut point: a fraction of the input room, above 0 and at most 1. A cut is triggered at, and preferably planned to, the band,
109
- * which is the room less 2% of it, or this fraction of the room when that is lower; a lower value cuts earlier and leaves less. The exact room
110
- * stays the limit of every throw, fault and the complete-context guard. If omitted, the band adapts to the last cut. */
114
+ * which is the room less 2% of it, or this fraction of the room when that is lower; a lower value cuts earlier and leaves less. The room
115
+ * owns cut targets; standalone final capacity admission uses the branch reply floor. If omitted, the band adapts to the last cut. */
111
116
  cutAt?: number;
112
117
  /** A host's response reserve: without `responseExact: true`, the catalogue maximum output stays its floor; with it, `response` is exact.
113
- * Standalone uses Pi's reserveTokens until a length stop or a context-overflow refusal on the current branch, then also holds the catalogue maximum.
118
+ * Standalone cuts against Pi's reserveTokens, then admits up to the window less Pi's safety margin and max(3,584, largest branch output), capped at that reserve.
119
+ * After a length stop or context overflow it holds the full reserve, also raised to the catalogue maximum.
114
120
  * Resume, fork and /tree follow that branch's history; a new session has no inherited fallback. */
115
121
  requestReserve?: (ctx: ExtensionContext) => { response: number; responseExact?: true };
116
122
  /** The exact messages the host's `context` hook adds to every final request beyond the saved conversation (for example a role
@@ -127,6 +133,9 @@ export interface CompactionOptions {
127
133
  }
128
134
 
129
135
  export async function installCompaction(pi: ExtensionAPI, options: CompactionOptions = {}) {
136
+ const piMargin = options.requestReserve ? 0 : await piContextSafetyMargin();
137
+ const piClamp = options.requestReserve ? undefined : await import('@earendil-works/pi-ai/api/simple-options')
138
+ .then(module => module.clampMaxTokensToContext).catch(() => undefined);
130
139
  const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
131
140
  if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
132
141
  const semantic = options.semanticRecall ?? createSemanticRecall(), search = options.search ?? true;
@@ -139,7 +148,8 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
139
148
  const semanticPending = new Set<Promise<unknown>>();
140
149
  // Semantic indexing outlives any one attempt, so it checks only that the host still grants access: a compactor failure, which ends with its
141
150
  // attempt, must not read as a revocation (that disables semantic recall and deletes its index for the rest of the session).
142
- const semanticProvider = (ctx: ExtensionContext) => () => memoizedEligibleSources(ctx, options.recallPolicy, hostAccess);
151
+ // 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.
152
+ 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);
143
153
  const recorder = new IncidentRecorder(options.diagnostics);
144
154
  let proposalId: string | undefined;
145
155
  const observe = (ctx: ExtensionContext) => recorder.measureInline(() => {
@@ -208,6 +218,11 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
208
218
  } });
209
219
  // Pi's native start/settled events bracket the run and its post-turn cuts.
210
220
  let admittedRun = false;
221
+ let runPrompt: string | undefined;
222
+ const protectedPrompt = (ctx: ExtensionContext) => runPrompt ? ctx.sessionManager.getBranch().findLast(entry =>
223
+ entry.type === "message" && entry.message.role === "user" && textOf(entry.message.content).includes(runPrompt!))?.id : undefined;
224
+ 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");
225
+ let estimatedTokens = 0;
211
226
  // One serial request, one accounting identity; observations never certify new content. A new identity (the first request after a session
212
227
  // 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`).
213
228
  let identity = "", factor = 1, ratios: number[] = [];
@@ -221,6 +236,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
221
236
  // 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.
222
237
  const learn = (value: number) => { ratios.push(value); if (ratios.length > CALIBRATION_WINDOW) ratios.shift(); factor = Math.max(1, ...ratios); };
223
238
  const corrected = (tokens: number, ctx: ExtensionContext) => { identityFor(ctx); return Math.ceil(tokens * factor); };
239
+ const observeBudget = (rawTokens: number, ctx: ExtensionContext) => {
240
+ if (options.observeBudget) options.observeBudget({ rawTokens, tokens: corrected(rawTokens, ctx), factor, ...limits(ctx), context: ctx });
241
+ };
224
242
  // The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
225
243
  const planReserve = (target: Target, room: number, band: number, mandatory: number, ctx: ExtensionContext) => {
226
244
  identityFor(ctx);
@@ -263,7 +281,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
263
281
  if (input > 0 && input <= ctx.model.contextWindow && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
264
282
  });
265
283
  pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
266
- pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
284
+ pi.on("agent_settled", () => { admittedRun = false; runPrompt = undefined; pending = undefined; phase = "idle"; forgetFailure(); clips.clear(); promptCut = undefined; });
267
285
  const active = () => options.active?.() ?? true;
268
286
  const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
269
287
  // The one reserve decision; every consumer takes its room from limits. Standalone starts at Pi's native cut point. Once a reply did
@@ -288,10 +306,12 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
288
306
  throw new Error("Invalid active-input token ceiling");
289
307
  return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
290
308
  };
291
- // Every throw, fault and complete-context guard uses the exact room.
309
+ // Room still owns every existing cut/clip attempt. Only final capacity admission uses the hard line.
292
310
  const limits = (ctx: ExtensionContext) => {
293
311
  const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
294
- return { response, room };
312
+ const floor = options.requestReserve ? response : requestReplyFloor(ctx.sessionManager.getBranch(), response, ctx.model?.maxTokens);
313
+ const hardLine = Math.min(Math.max(room, (ctx.model?.contextWindow ?? 0) - piMargin - floor), lastCeiling ?? Infinity);
314
+ return { response, room, hardLine, replyFloor: floor };
295
315
  };
296
316
  const decisionBand = (ctx: ExtensionContext, room: number, system: number) => {
297
317
  const last = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
@@ -331,12 +351,19 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
331
351
  // 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
332
352
  // before it names no entry to keep from, and read as it stands that drops the prompt too, so the model never sees it.
333
353
  let promptCutAt = 0;
334
- const keptFrom = (cut: CutEntry, ctx: ExtensionContext) => cut.firstKeptEntryId || ctx.sessionManager.getBranch()[promptCutAt]?.id || "";
354
+ const keptFrom = (cut: CutEntry, ctx: ExtensionContext) => cut.firstKeptEntryId ||
355
+ ctx.sessionManager.getBranch().slice(promptCutAt).find(entry => entry.type !== "custom" || entry.customType !== "pi-tuck-capacity")?.id || "";
335
356
  let failure: Error | undefined;
357
+ let refusalReceipted = false;
336
358
  let pendingBoundaryCommit = false;
337
359
  // 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
338
360
  // 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.
339
361
  const clips = new Map<string, Clip>();
362
+ const receipt = (kind: "refusal" | "above-room", reason: string, ctx: ExtensionContext) => {
363
+ let room: number | null = null, hardLine: number | null = null;
364
+ try { ({ room, hardLine } = limits(ctx)); } catch { /* Invalid configuration has no usable capacity. Preserve its original failure. */ }
365
+ pi.appendEntry("pi-tuck-capacity", { kind, reason, estimatedTokens, room, hardLine, buildId: BUILD_ID });
366
+ };
340
367
  const fail = (error: unknown, ctx: ExtensionContext) => {
341
368
  recorder.stop("native-attempt-failed");
342
369
  // 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
@@ -347,7 +374,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
347
374
  pendingBoundaryCommit = false;
348
375
  pending = undefined;
349
376
  promptCut = undefined;
350
- try { options.fail?.(error); } finally {
377
+ try {
378
+ if (!refusalReceipted) { refusalReceipted = true; receipt("refusal", failure.message, ctx); }
379
+ options.fail?.(error);
380
+ } finally {
351
381
  try { if (notify) ctx.ui.notify(failure instanceof CapacityError ? refusalNotice(failure.message, factor) : failure instanceof CommitFault
352
382
  ? `pi-tuck: ${failure.message}; the session holds a cut its file may lack, so start a new session.`
353
383
  : `pi-tuck could not plan this request (${failure.message}); the next prompt is planned afresh.`, "error"); }
@@ -356,7 +386,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
356
386
  };
357
387
  // 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
358
388
  // input started (a custom message) has only the first of these. No failure but a CommitFault outlives its attempt.
359
- const forgetFailure = () => { if (!(failure instanceof CommitFault)) failure = undefined; };
389
+ const forgetFailure = () => { refusalReceipted = false; if (!(failure instanceof CommitFault)) failure = undefined; };
360
390
  const access = () => {
361
391
  if (failure) throw failure;
362
392
  hostAccess();
@@ -406,7 +436,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
406
436
  // Bind the actual independent lexical offer, including attribution derived
407
437
  // from custom markers and receipts excluded from semantic indexing. Rebuild
408
438
  // under current caller policy after await, when the session moved; never send this block to the encoder.
409
- const wordOffer=()=>tuckRecall(tuck,ctx.sessionManager.getEntries(),ctx.sessionManager.getBranch(),{query:input.query},options.recallPolicy);
439
+ // Keyword search over a very long history can hit its time limit. That limit guards one call, not the semantic half: the agent still gets the
440
+ // semantic results, and is told the keyword half stopped (seen in the field on an 84.7 MB session, where the whole search failed instead).
441
+ const wordOffer=()=>{try{return tuckRecall(tuck,ctx.sessionManager.getEntries(),ctx.sessionManager.getBranch(),{query:input.query},options.recallPolicy);}
442
+ catch(error){if(error instanceof Error&&error.message.startsWith("Search aborted:"))return{status:"keyword_timeout",notice:"Keyword search stopped at its time limit on this long history; the semantic results follow. For a keyword match, use more specific words or read an entry by index.",hits:[]};throw error;}};
410
443
  // The key is taken in the same synchronous step as the first lexical pass. After the await the second pass runs only if it moved (see eligibilityKey); a moved key alone invalidates nothing.
411
444
  // The snapshot id check covers every in-contract change (any branch change moves the id); the key-gated second pass is defence in depth for out-of-contract callers.
412
445
  const provider=semanticProvider(ctx),initialSnapshot=provider(),initialKey=eligibilityKey(ctx),words=wordOffer(),wordText=JSON.stringify(words);
@@ -444,6 +477,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
444
477
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), saved, ctx.getSystemPrompt());
445
478
  const mandatory = system + host, band = decisionBand(ctx, room, system);
446
479
  const before = plannerMeasureFor(ctx)(saved);
480
+ observeBudget(before + mandatory, ctx);
447
481
  // Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
448
482
  // it: a basis this compactor does not plan on. It may ask for a cut on a request that fits. This compactor cuts only where its own
449
483
  // trigger would: nothing below the band, an opportunistic cut inside it, a due cut at or above the room. A refusal is a plain
@@ -455,25 +489,33 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
455
489
  thresholdOpportunistic = gate.opportunistic;
456
490
  }
457
491
  // Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
458
- const decision = chooseCut({
459
- before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
492
+ const decide = (targetRoom = room, targetBand = band) => chooseCut({
493
+ before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
460
494
  opportunistic: thresholdOpportunistic,
461
495
  unreduced: "Compaction did not reduce context within the estimated request budget",
462
496
  plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number }> => {
463
- const reserve = planReserve(target, room, band, mandatory, ctx);
497
+ const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
464
498
  // 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
465
499
  // from any run that ended. A retry is inside its run.
466
- const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
500
+ const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun, admittedRun ? protectedPrompt(ctx) : undefined);
467
501
  return draft.compaction ? { draft: true, after: draft.after, mode: draft.mode, compaction: draft.compaction, reserve }
468
502
  : { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
469
503
  },
470
504
  });
505
+ estimatedTokens = corrected(before + mandatory, ctx);
506
+ let decision = decide();
507
+ const { hardLine } = limits(ctx);
508
+ if (!operatorManual && hardLine > room && decision.kind === "fault") {
509
+ if (fits(before + mandatory, hardLine, ctx)) return { cancel: true };
510
+ decision = decide(hardLine, hardLine);
511
+ }
471
512
  if (decision.kind === "cut") { return { compaction: captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, event.reason, event.preparation.tokensBefore,
472
513
  retain, operatorManual, event.preparation.previousSummary, { response, host, mandatory, target: decision.target, margin: room - band }) }; }
473
514
  // An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
474
515
  if (decision.kind === "none") return { cancel: true };
475
516
  recorder.stop("no-cut");
476
517
  ctx.ui.notify(decision.message, "warning");
518
+ if (operatorManual) receipt("refusal", decision.message, ctx);
477
519
  if (!operatorManual) fail(new (decision.capacity ? CapacityError : Refusal)(decision.message), ctx);
478
520
  return { cancel: true };
479
521
  } catch (error) {
@@ -481,7 +523,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
481
523
  }
482
524
  });
483
525
  pi.on("session_before_tree", (event) => event.preparation.userWantsSummary ? { cancel: true } : undefined);
484
- pi.on("session_start", () => { clips.clear(); promptCut = undefined; recorder.stop("session-transition"); proposalId = undefined; failure = undefined; admittedRun = false; pendingBoundaryCommit = false; identity = ""; factor = 1; pending = undefined; phase = "idle"; });
526
+ 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"; });
485
527
  const historyToolsEnabled = (ctx: ExtensionContext) => Boolean(promptCut) || hasTuckCut(ctx.sessionManager.getBranch());
486
528
  const syncHistoryTools = (ctx: ExtensionContext) => { if (active()) applyHistoryTools(pi, historyToolsEnabled(ctx)); };
487
529
  pi.on("session_start", (_event, ctx) => syncHistoryTools(ctx));
@@ -516,27 +558,36 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
516
558
  const band = decisionBand(ctx, room, system);
517
559
  const host = hostSnapshot ?? hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
518
560
  const incoming = incomingPart + system, mandatory = incoming + host;
519
- if (!fits(incoming, room, ctx))
561
+ observeBudget(before + mandatory, ctx);
562
+ estimatedTokens = corrected(before + mandatory, ctx);
563
+ if (!fits(incoming, limits(ctx).hardLine, ctx))
520
564
  throw new CapacityError("Incoming Pi prompt and response reserve cannot fit the estimated request budget");
521
565
  // A cut removes only conversation. If the mandatory request, host input included, does not fit by itself, no cut can
522
566
  // help: leave it to the complete-context guard, which fails closed.
523
567
  const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
524
- if (gate.kind !== "plan") return;
568
+ if (gate.kind === "none") return;
525
569
  const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
526
- const decision = chooseCut({
527
- before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic: gate.opportunistic,
570
+ const decide = (targetRoom: number, targetBand: number, opportunistic: boolean) => chooseCut({
571
+ before, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic,
528
572
  unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
529
573
  plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
530
- const reserve = planReserve(target, room, band, mandatory, ctx);
574
+ const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
531
575
  // Only a due cut re-renders a summary that sits alone in the session: inside the band a cut is opportunistic, and a smaller summary
532
576
  // there buys nothing the request needed and costs history the next recall would have found.
533
- const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !gate.opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
577
+ const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
534
578
  if (!draft.compaction) return { draft: false, capacityRejected: Boolean(draft.capacityRejected), reason: draft.unavailable };
535
579
  const messages = withCut(ctx, { type: "compaction", ...draft.compaction, tokensBefore: before });
536
580
  assertNativeToolRounds(messages);
537
581
  return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
538
582
  },
539
583
  });
584
+ let decision = gate.kind === "plan" ? decide(room, band, gate.opportunistic) : { kind: "defer" as const };
585
+ const { hardLine } = limits(ctx);
586
+ if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
587
+ if (fits(before + mandatory, hardLine, ctx)) return;
588
+ decision = fits(mandatory, hardLine, ctx) ? decide(hardLine, hardLine, false) : { kind: "defer" };
589
+ }
590
+ if (decision.kind === "defer") return;
540
591
  // An opportunistic cut inside the band that cannot reach it is no fault: the request goes to the guard.
541
592
  if (decision.kind === "none") return;
542
593
  if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
@@ -547,6 +598,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
547
598
  pi.on("before_agent_start", async (event, ctx) => {
548
599
  const prompted = phase === "decided";
549
600
  phase = "starting";
601
+ runPrompt = event.messageType === "custom" ? undefined : event.prompt;
550
602
  try {
551
603
  syncHistoryTools(ctx);
552
604
  if (prompted) access(); // decided on input; a failure latched there still stops the run
@@ -622,19 +674,23 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
622
674
  // request besides the fresh results. Reset per decision, so a retry sizes itself against the cut it is retrying, not an earlier attempt's.
623
675
  let previews: Messages[] = [];
624
676
  let floorKept: number | undefined;
625
- const decide = () => {
677
+ let wholePromptFloor: number | undefined;
678
+ const protectedId = reason === "tool-loop" ? protectedPrompt(ctx) : undefined;
679
+ const decide = (targetRoom = room, targetBand = band) => {
626
680
  previews = [];
627
681
  floorKept = undefined;
682
+ wholePromptFloor = undefined;
628
683
  const before = measure(saved);
629
- const gate = gateCut({ total: before + mandatory, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx) });
684
+ const gate = gateCut({ total: before + mandatory, mandatory, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx) });
630
685
  if (gate.kind !== "plan") return gate;
631
686
  return chooseCut({
632
- opportunistic: gate.opportunistic, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
687
+ opportunistic: gate.opportunistic, mandatory, before, room: targetRoom, band: targetBand, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
633
688
  unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
634
689
  plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
635
- const reserve = planReserve(target, room, band, mandatory, ctx);
636
- const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
690
+ const reserve = planReserve(target, targetRoom, targetBand, mandatory, ctx);
691
+ const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: densityTokens ?? before, previousSummary, protectedPromptEntryId: protectedId }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
637
692
  if (!draft.compaction) {
693
+ if (protectedId && draft.floorMessages) wholePromptFloor = measure(draft.floorMessages) + mandatory;
638
694
  // The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
639
695
  if (draft.floorMessages && event.toolResults) {
640
696
  const fresh = new Set(event.toolResults.map(result => result.toolCallId));
@@ -662,23 +718,36 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
662
718
  // clipBudgets states the budgets tried and why they are counted in the estimate's units; `kept` is measured from the previews this decision
663
719
  // built, so the second budget is the cut's own floor and not a new formula. When nothing is clipped or the decision still faults, the
664
720
  // refusal stands.
665
- if (decision.kind === "fault" && event.toolResults) {
721
+ const tryClips = (clipRoom: number, targetBand: number) => {
722
+ if (decision.kind !== "fault" || !event.toolResults) return;
666
723
  const branch = ctx.sessionManager.getBranch();
667
724
  const entryIdOf = (toolCallId: string) => branch.findLast(entry => entry.type === "message" && entry.message.role === "toolResult" && entry.message.toolCallId === toolCallId)?.id;
668
725
  const fresh = new Set(event.toolResults.map(result => result.toolCallId));
669
726
  const kept = previews.length ? Math.max(...previews.map(preview => measure(preview.filter(message =>
670
727
  !(message.role === "toolResult" && fresh.has((message as ToolResultMessage).toolCallId)))))) : floorKept;
671
- for (const budget of clipBudgets(room, factor, mandatory, kept)) {
728
+ for (const budget of clipBudgets(clipRoom, factor, mandatory, kept)) {
672
729
  const round = clipFor(event.toolResults, entryIdOf, budget);
673
730
  if (!round.length) continue;
674
731
  for (const clip of round) clips.set(clip.toolCallId, clip);
675
- const clippedDecision = decide();
732
+ const clippedDecision = decide(clipRoom, targetBand);
676
733
  if (clippedDecision.kind === "fault") for (const clip of round) clips.delete(clip.toolCallId);
677
734
  else { decision = clippedDecision; break; }
678
735
  }
679
- }
736
+ };
737
+ tryClips(room, band);
680
738
  const before = measure(saved);
681
- if (decision.kind === "fault" && reason === "tool-loop") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
739
+ observeBudget(before + mandatory, ctx);
740
+ estimatedTokens = corrected(before + mandatory, ctx);
741
+ const { hardLine } = limits(ctx);
742
+ if (hardLine > room && (decision.kind === "defer" || decision.kind === "fault")) {
743
+ if (fits(before + mandatory, hardLine, ctx)) return;
744
+ decision = decide(hardLine, hardLine);
745
+ tryClips(hardLine, hardLine);
746
+ }
747
+ if (decision.kind === "fault" && reason === "tool-loop") {
748
+ if (wholePromptFloor !== undefined && !fits(wholePromptFloor, hardLine, ctx)) throw promptRefusal();
749
+ throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
750
+ }
682
751
  if (decision.kind !== "cut") return;
683
752
  const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, reason, before, true, false, previousSummary,
684
753
  { response, host, mandatory, target: decision.target, margin: room - band });
@@ -785,8 +854,19 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
785
854
  const messages = historyToolsEnabled(ctx) ? historyToolDeclarations(event.messages, toolShapes(pi.getActiveTools()), true) : event.messages;
786
855
  const system = requestSystemPart(ctx.getSystemPrompt(), pi.getActiveTools(), messages, ctx.getSystemPrompt());
787
856
  const tokens = system + conversationTokens(messages);
788
- if (!fits(tokens, limits(ctx).room, ctx))
857
+ observeBudget(tokens, ctx);
858
+ estimatedTokens = corrected(tokens, ctx);
859
+ const { room, hardLine, replyFloor } = limits(ctx);
860
+ if (runPrompt !== undefined && !carriesPrompt(messages, runPrompt))
861
+ 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");
862
+ if (!fits(tokens, hardLine, ctx))
789
863
  throw new CapacityError("Complete Pi context and response reserve exceed the estimated request budget");
864
+ // Pi's request estimator and its compaction estimator need not agree. The hard line is
865
+ // necessary; above the room, also require the real request clamp to preserve the floor.
866
+ if (!options.requestReserve && estimatedTokens > room && piClamp &&
867
+ piClamp(ctx.model, normalizeContext({ messages: convertToLlm(messages) }), ctx.model.maxTokens) < replyFloor)
868
+ throw new CapacityError("Pi's request clamp leaves less than the reply floor");
869
+ if (estimatedTokens > room) receipt("above-room", "Request exceeds the room and fits the hard line", ctx);
790
870
  if (recorder.enabled && !recorder.waitingForCut) recorder.measureInline(() => {
791
871
  recorder.request();
792
872
  // No system prompt or hidden assistant blocks; no provider payload/headers.
@@ -32,12 +32,12 @@ export const eligibilityKeyMoved = (ctx: Context, since: readonly unknown[]) =>
32
32
  * the same `SemanticAccessError` that `semanticEligibleSources` raises (the mapping below is that function's first statement, verbatim).
33
33
  * The policy is trusted by identity, so it must not change after install: a policy object mutated in place (a type added to
34
34
  * `privateCustomTypes`) or a `readAttemptMarker` with hidden state is not noticed until the history changes. */
35
- export function memoizedEligibleSources(ctx: Context, policy: RecallPolicy | undefined, access: () => void): Snapshot {
35
+ export function memoizedEligibleSources(ctx: Context, policy: RecallPolicy | undefined, access: () => void, pendingKeptId?: string): Snapshot {
36
36
  try { access(); } catch (error) { throw Object.assign(new Error(error instanceof Error ? error.message : 'Source access denied'), { name: 'SemanticAccessError' }); }
37
- const key = eligibilityKey(ctx), held = cache.get(ctx.sessionManager);
37
+ const key = [...eligibilityKey(ctx), pendingKeptId ?? null], held = cache.get(ctx.sessionManager);
38
38
  if (held && held.policy === policy && same(held.key, key)) return { ...held.stored, units: [], complete: false, indexed: new Set() };
39
39
  // Access was checked on the line above with nothing awaited in between, so the rebuild does not check it a second time.
40
- const built = semanticEligibleSources(ctx, policy ?? {}, () => {});
40
+ const built = semanticEligibleSources(ctx, policy ?? {}, () => {}, pendingKeptId);
41
41
  cache.set(ctx.sessionManager, { policy, key, stored: { id: built.id, session: built.session, project: built.project, sources: built.sources, throughEntryId: built.throughEntryId, viewStart: built.viewStart } });
42
42
  return built;
43
43
  }
@@ -0,0 +1,56 @@
1
+ import { isContextOverflow, normalizeContext } from '@earendil-works/pi-ai';
2
+ import type { SessionEntry } from '@earendil-works/pi-coding-agent';
3
+ import type { Messages } from './budget.js';
4
+ import { parseRecords, splitSummary } from './tuck/core/transcript.js';
5
+
6
+ /** Read Pi's private safety allowance through its public clamp, rather than copying it.
7
+ * Older/unavailable subpaths fall back to Pi 1.2–1.5's documented 4,096-token allowance.
8
+ */
9
+ export async function piContextSafetyMargin(): Promise<number> {
10
+ try {
11
+ const [{ clampMaxTokensToContext }, { estimateContextTokens }] = await Promise.all([
12
+ import('@earendil-works/pi-ai/api/simple-options'), import('@earendil-works/pi-ai/utils/estimate'),
13
+ ]);
14
+ const window = 1_000_000, context = normalizeContext({ messages: [] });
15
+ const margin = window - estimateContextTokens(context).tokens - clampMaxTokensToContext({ contextWindow: window, maxTokens: window } as never, context, window);
16
+ if (!Number.isSafeInteger(margin) || margin < 0 || margin >= window) throw new Error('Invalid Pi context margin');
17
+ return margin;
18
+ } catch { return 4_096; }
19
+ }
20
+
21
+ /** The smallest reply room a request above the room must leave after Pi's own clamp: above the user's p99 reply (3,270 tokens). */
22
+ export const REPLY_FLOOR_MIN = 3_584;
23
+
24
+ /** Read the whole current branch, including replies before cuts. No session-local fallback flag. */
25
+ export function branchReplyFloor(branch: readonly SessionEntry[], reserve: number): number {
26
+ let largest = REPLY_FLOOR_MIN;
27
+ for (const entry of branch) {
28
+ if (entry.type !== 'message' || entry.message.role !== 'assistant') continue;
29
+ const message = entry.message;
30
+ if (message.stopReason === 'length' || message.stopReason === 'error' && isContextOverflow(message)) return reserve;
31
+ const output = message.usage?.output;
32
+ if (typeof output === 'number' && Number.isFinite(output) && output > largest) largest = output;
33
+ }
34
+ return Math.min(reserve, Math.ceil(largest));
35
+ }
36
+
37
+ /** Admission never promises more output than a model with a positive reply cap can produce. */
38
+ export function requestReplyFloor(branch: readonly SessionEntry[], reserve: number, maxTokens?: number): number {
39
+ const floor = branchReplyFloor(branch, reserve);
40
+ return maxTokens !== undefined && maxTokens > 0 ? Math.min(floor, maxTokens) : floor;
41
+ }
42
+
43
+ /** Check decoded text, including a whole verbatim user quote in our summary; never JSON-escaped substring matching. */
44
+ export function carriesPrompt(messages: Messages, prompt: string): boolean {
45
+ if (!prompt.length) return true;
46
+ return messages.some(message => {
47
+ if (message.role === 'user') {
48
+ const text = typeof message.content === 'string' ? message.content : message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('\n');
49
+ return text.includes(prompt);
50
+ }
51
+ if (message.role !== 'compactionSummary') return false;
52
+ const split = splitSummary(message.summary);
53
+ return split !== null && split !== undefined && parseRecords(split.body, split.version).some(record =>
54
+ record.kind === 'excerpt' && record.role === 'user' && record.text.includes(prompt));
55
+ });
56
+ }
@@ -195,6 +195,11 @@ export function viewStart(branch:SessionEntry[]):number{
195
195
  }
196
196
  const TOOL_OUTPUT=new Set(['toolResult','bashExecution']);
197
197
  const isToolOutput=(source:Source)=>TOOL_OUTPUT.has(source.row.role);
198
+ /** Whether `now` only grew from `plan`: the same session and project, and every source the plan ranks still eligible with the same hash. */
199
+ function grownFrom(now:Snapshot,plan:Snapshot):boolean{
200
+ if(now.session!==plan.session||now.project!==plan.project)return false;if(now.id===plan.id)return true;
201
+ const shas=new Map(now.sources.map(source=>[source.entry.id,source.sha]));return plan.sources.every(source=>shas.get(source.entry.id)===source.sha);
202
+ }
198
203
  /** Sources in the order they are covered: out-of-view conversation (user messages, assistant text, tool-call names and arguments), then out-of-view tool output, then what is in view.
199
204
  * Each part is newest first, extending backward. */
200
205
  function windowOrder(snapshot:Snapshot):Source[]{
@@ -216,7 +221,9 @@ function windowCoverage(snapshot:Snapshot,prefix=indexedPrefix(snapshot)){
216
221
  return{indexedCharacters,windowCharacters:window.windowCharacters,outOfViewCharacters:window.conversationOutOfView+window.toolOutOfView,eligibleCharacters:window.eligibleCharacters,oldestCoveredEntryId:oldest?.entry.id??null,oldestCoveredTime:time(oldest),
217
222
  conversation:{indexedCharacters:conversation,outOfViewCharacters:window.conversationOutOfView},toolResults:{indexedCharacters:tools,outOfViewCharacters:window.toolOutOfView,oldestCoveredTime:time(oldestTool)}};
218
223
  }
219
- export function semanticEligibleSources(ctx:Pick<ExtensionContext,'sessionManager'|'cwd'>,policy:RecallPolicy,access:()=>void):Snapshot {
224
+ /** `pendingKeptId`: the first kept entry of a cut pi-tuck has applied to the requests but Pi has not yet saved (a prompt's cut is saved at the end of
225
+ * the first turn). Tools in that turn see the history as the model does: everything before that entry is out of view. */
226
+ export function semanticEligibleSources(ctx:Pick<ExtensionContext,'sessionManager'|'cwd'>,policy:RecallPolicy,access:()=>void,pendingKeptId?:string):Snapshot {
220
227
  try{access();}catch(error){throw Object.assign(new Error(error instanceof Error?error.message:'Source access denied'),{name:'SemanticAccessError'});}const branch=ctx.sessionManager.getBranch(),all=ctx.sessionManager.getEntries();
221
228
  if(branch.some(e=>e.type==='context_edit'&&e.replacement!==null))throw new Error('Unsupported replacement context edit');
222
229
  const omitted=new Set(branch.flatMap(e=>e.type==='context_edit'?[e.targetId]:[]));
@@ -225,7 +232,8 @@ export function semanticEligibleSources(ctx:Pick<ExtensionContext,'sessionManage
225
232
  const eligible=branch.filter(e=>!omitted.has(e.id)&&ids.get(e.id)===1),recall=createRecall(policy),attempts=recall.recallAttempts(eligible),links=recall.producerReads(eligible),indices=buildGlobalIndexById(all);
226
233
  const orders=new Map(branch.map((entry,order)=>[entry.id,order]));
227
234
  const sources=eligible.flatMap(entry=>{const order=orders.get(entry.id)!;const row=recall.discoveryEntry(entry,attempts,links);if(!row)return[];const {sourceText:unusedText,ranges:unusedRanges,...readable}=row;return[{entry,row:readable,sourceText:row.sourceText,ranges:row.ranges,order,sha:hash({entry,order,row:readable,ranges:row.ranges,index:indices.get(entry.id)})}];});
228
- const session=ctx.sessionManager.getSessionId(),id=hash({session,project:resolve(ctx.cwd),branch:branch.map(e=>[e.id,e.parentId]),sources:sources.map(s=>[s.entry.id,s.sha])});return{id,session,project:ctx.cwd,sources,units:[],complete:false,throughEntryId:sources.at(-1)?.entry.id??null,viewStart:viewStart(branch),indexed:new Set()};
235
+ const pending=pendingKeptId===undefined?-1:branch.findIndex(e=>e.id===pendingKeptId),start=Math.max(viewStart(branch),pending);
236
+ const session=ctx.sessionManager.getSessionId(),id=hash({session,project:resolve(ctx.cwd),branch:branch.map(e=>[e.id,e.parentId]),sources:sources.map(s=>[s.entry.id,s.sha]),...(pending>=0?{pendingKeptId}:{})});return{id,session,project:ctx.cwd,sources,units:[],complete:false,throughEntryId:sources.at(-1)?.entry.id??null,viewStart:start,indexed:new Set()};
229
237
  }
230
238
  export class SemanticRecallController {
231
239
  private readonly ownerId=randomUUID();private readonly indexFiles=new Set<string>();private closing?:Promise<void>;private joiningSearches=0;private activeSearches=new Set<Promise<SemanticPage>>();private config?:SemanticRecallConfig;private worker?:LocalWorker;private identity='';private provider?:()=>Snapshot;private generation=0;private cleanupError:string|null=null;private dirty=false;private operations={preparationEmbeddings:0,queryEmbeddings:0};
@@ -267,7 +275,7 @@ export class SemanticRecallController {
267
275
  private async runtime(project:string){if(this.idling)await this.idling;if(!this.enabled)throw abort();outsideProject(project,this.config!.runtimeRoot,'runtime');outsideProject(project,scratchParent(this.config!),'scratch');outsideRuntime(this.config!.runtimeRoot,scratchParent(this.config!),'scratch');if(this.worker&&this.recycle){const old=this.worker;await old.close();if(this.worker===old){this.worker=undefined;this.identity='';this.recycle=false;}if(!this.enabled)throw abort();}
268
276
  if(!this.worker){const worker=new LocalWorker(this.config!,project,error=>{if(this.worker!==worker)return;this.cleanupError=error instanceof Error?error.message:'Semantic cleanup failed';this.error=this.cleanupError;this.enabled=false;this.generation++;},reason=>{if(this.worker===worker)this.heal(new WorkerFailure(reason));},{startMs:this.timing.startDeadlineMs??START_DEADLINE_MS,callMs:this.timing.callDeadlineMs??CALL_DEADLINE_MS,embedTextMs:this.timing.embedTextMs??EMBED_TEXT_MS});this.worker=worker;}if(!this.identity){const ready=await this.worker.call('ready');if(ready.dimensions!==768||ready.maxTokens!==8192||!ready.offline)throw new Error('Incompatible semantic model; EG2 768/8192 offline required');this.identity=hash({worker:ready.identity,chunk:'eg2-passage256-focused-disjoint-direct-v3'});}return this.worker;}
269
277
  private path(snapshot:Snapshot){const directory=this.config!.indexDirectory;outsideProject(snapshot.project,directory,'index');return join(directory,hash({owner:this.ownerId,session:snapshot.session,project:resolve(snapshot.project)})+'.sqlite');}
270
- private async index(action:string,snapshot:Snapshot,extra:object={}){const path=this.path(snapshot),generation=this.generation,worker=await this.runtime(snapshot.project);if(!this.enabled||generation!==this.generation)throw abort();if(this.provider)this.recheck(this.provider,snapshot);this.indexFiles.add(path);const result=await worker.call('index',{path,identity:this.identity,action,projectDirectory:snapshot.project,...extra});if(!this.enabled||generation!==this.generation)throw abort();if(this.provider)this.recheck(this.provider,snapshot);return result;}
278
+ private async index(action:string,snapshot:Snapshot,extra:object={},boundId?:string){const path=this.path(snapshot),generation=this.generation,worker=await this.runtime(snapshot.project);if(!this.enabled||generation!==this.generation)throw abort();if(this.provider){if(boundId!==undefined)this.recheckGrown(this.provider,snapshot,boundId);else this.recheck(this.provider,snapshot);} /* a search's scoring may read a plan its snapshot grew from */this.indexFiles.add(path);const result=await worker.call('index',{path,identity:this.identity,action,projectDirectory:snapshot.project,...extra});if(!this.enabled||generation!==this.generation)throw abort();if(this.provider){if(boundId!==undefined)this.recheckGrown(this.provider,snapshot,boundId);else this.recheck(this.provider,snapshot);}return result;}
271
279
  private async prepare(provider:()=>Snapshot,full=true){
272
280
  if(!this.enabled)return;
273
281
  const generation=this.generation,started=performance.now();let snapshot=provider();const prior=this.current;
@@ -333,6 +341,9 @@ export class SemanticRecallController {
333
341
  /** A plan for this snapshot that is fully indexed or, for a search, has all its out-of-view conversation indexed. The tool output behind it and the in-view tail are not worth waiting for: they keep indexing in the background and the coverage statement says how far they got.
334
342
  * With no out-of-view conversation (no cut) nothing waits: the search returns at once. Only a window whose out-of-view part is tool output alone still waits for the whole window. */
335
343
  private readyFor(id:string|undefined,forSearch:boolean){const plan=this.current;return Boolean(plan&&plan.id===id&&(plan.complete||(forSearch&&plan.window!==undefined&&plan.window.searchUnits>0&&plan.units.slice(0,plan.window.searchUnits).every(unit=>plan.indexed.has(unit.key)))));}
344
+ /** A search may rank a plan its own snapshot has grown from (every source in the plan still eligible, unchanged), as long as the session has not
345
+ * moved since the search took that snapshot; a move during the search still fails it. Indexing keeps the exact `recheck`. */
346
+ private recheckGrown(provider:()=>Snapshot,snapshot:Snapshot,boundId:string){const now=provider();if(now.id!==boundId||!grownFrom(now,snapshot))throw new Error('Eligible branch or source changed before semantic admission');}
336
347
  private recheck(provider:()=>Snapshot,snapshot:Snapshot){const now=provider();if(now.id!==snapshot.id||now.session!==snapshot.session||now.project!==snapshot.project)throw new Error('Eligible branch or source changed before semantic admission');}
337
348
  private async joinPreparation(deadline:number,signal?:AbortSignal){
338
349
  const task=this.task;if(!task)return true;let timer:ReturnType<typeof setTimeout>|undefined,onAbort:(()=>void)|undefined;
@@ -355,15 +366,15 @@ export class SemanticRecallController {
355
366
  if(!planned){this.error=null;this.schedule(provider);}
356
367
  if(!(this.current?.id===bound.id&&((this.current.complete&&!this.current.units.length)||(indexedPrefix(this.current)>0&&this.worker!==undefined&&!this.recycle&&!this.idling)))){if(page)return page;return{status:'indexing_incomplete',notice:'No semantic ranking performed: nothing is out of view, so the whole history is in your context.',coverage:this.status(),groups:[]};}
357
368
  }else{
358
- if(this.current?.id!==bound.id){const prepared=!this.task||await this.joinPreparation(deadline,signal);if(signal?.aborted||queryGeneration!==this.generation)throw abort();if(!prepared||performance.now()>=deadline)return incomplete();this.error=null;this.schedule(provider);}else if(!this.current.complete){this.error=null;this.schedule(provider);}await this.waitForReady(Math.max(0,deadline-performance.now()),signal,bound.id,true);if(signal?.aborted||queryGeneration!==this.generation)throw abort();
369
+ if(this.current?.id!==bound.id){const prepared=!this.task||await this.joinPreparation(deadline,signal);if(signal?.aborted||queryGeneration!==this.generation)throw abort();if(prepared&&performance.now()<deadline){this.error=null;this.schedule(provider);await this.waitForReady(Math.max(0,deadline-performance.now()),signal,bound.id,true);}}else{if(!this.current.complete){this.error=null;this.schedule(provider);}await this.waitForReady(Math.max(0,deadline-performance.now()),signal,bound.id,true);}if(signal?.aborted||queryGeneration!==this.generation)throw abort();
359
370
  }
360
371
  // Ranking needs a plan for the bound snapshot and an indexed prefix; the coverage statement describes exactly that prefix and nothing else is ranked, so a search that reaches its wait limit with part of the window indexed ranks that part. Only an empty prefix is `indexing_incomplete`.
361
- const snapshot=this.current;if(!snapshot||snapshot.id!==bound.id||(snapshot.units.length?indexedPrefix(snapshot)===0:!snapshot.complete)){const failure=this.error??this.healReason;return failure&&!CHURN.test(failure)?unavailable():incomplete();}
372
+ const snapshot=this.current,older=snapshot!==undefined&&snapshot.id!==bound.id&&grownFrom(bound,snapshot);if(!snapshot||(snapshot.id!==bound.id&&!older)||(snapshot.units.length?indexedPrefix(snapshot)===0:!snapshot.complete)){const failure=this.error??this.healReason;return failure&&!CHURN.test(failure)?unavailable():incomplete();}
362
373
  if(!snapshot.units.length)return{status:'no_matches',notice:'No eligible original source units in this snapshot.',coverage:this.status(),method:'embedding',groups:[]};
363
- try{const worker=await this.runtime(snapshot.project);this.recheck(provider,snapshot);const encoded=await worker.call('embed',{texts:[query],kind:'query'},signal);this.operations.queryEmbeddings++;this.recheck(provider,snapshot);const prefix=indexedPrefix(snapshot),stored=snapshot.units.slice(0,prefix),byKey=new Map(stored.map(u=>[u.key,u])),covered=windowCoverage(snapshot,prefix)!;let ranked:{unit:Unit;score:number}[]=[],observed=0;for(let offset=0;offset<stored.length;offset+=SCORE_CHUNK){this.recheck(provider,snapshot);const scored=await this.index('score',snapshot,{keys:stored.slice(offset,offset+SCORE_CHUNK).map(u=>u.key),query:encoded.vectors[0]}) as {ranked:{key:string;score:number}[];observed:number};observed+=scored.observed;ranked.push(...scored.ranked.map(row=>({unit:byKey.get(row.key)!,score:row.score})));ranked.sort((a,b)=>b.score-a.score);ranked=ranked.slice(0,24);if(signal?.aborted)throw abort();await yieldWork();}if(observed!==stored.length)throw new Error('Snapshot index coverage changed');
364
- if(signal?.aborted||queryGeneration!==this.generation||!this.enabled)throw abort();this.recheck(provider,snapshot);const final=provider();const finalSources=new Map(final.sources.map(source=>[source.entry.id,source.sha]));if(snapshot.sources.some(source=>finalSources.get(source.entry.id)!==source.sha))throw new Error('Eligible source changed or access revoked before return');
374
+ try{const worker=await this.runtime(snapshot.project);this.recheckGrown(provider,snapshot,bound.id);const encoded=await worker.call('embed',{texts:[query],kind:'query'},signal);this.operations.queryEmbeddings++;this.recheckGrown(provider,snapshot,bound.id);const prefix=indexedPrefix(snapshot),stored=snapshot.units.slice(0,prefix),byKey=new Map(stored.map(u=>[u.key,u])),covered=windowCoverage(snapshot,prefix)!;let ranked:{unit:Unit;score:number}[]=[],observed=0;for(let offset=0;offset<stored.length;offset+=SCORE_CHUNK){this.recheckGrown(provider,snapshot,bound.id);const scored=await this.index('score',snapshot,{keys:stored.slice(offset,offset+SCORE_CHUNK).map(u=>u.key),query:encoded.vectors[0]},bound.id) as {ranked:{key:string;score:number}[];observed:number};observed+=scored.observed;ranked.push(...scored.ranked.map(row=>({unit:byKey.get(row.key)!,score:row.score})));ranked.sort((a,b)=>b.score-a.score);ranked=ranked.slice(0,24);if(signal?.aborted)throw abort();await yieldWork();}if(observed!==stored.length)throw new Error('Snapshot index coverage changed');
375
+ if(signal?.aborted||queryGeneration!==this.generation||!this.enabled)throw abort();this.recheckGrown(provider,snapshot,bound.id);const final=provider();const finalSources=new Map(final.sources.map(source=>[source.entry.id,source.sha]));if(snapshot.sources.some(source=>finalSources.get(source.entry.id)!==source.sha))throw new Error('Eligible source changed or access revoked before return');
365
376
  // What the caller already read only spares it a repeat. A reference that does not match this history spares nothing, and it must not fail the search.
366
- const usable=prior.filter(reference=>{const source=snapshot.sources.find(s=>s.entry.id===reference.entryId);return Number.isSafeInteger(reference.start)&&Number.isSafeInteger(reference.end)&&reference.start<=reference.end&&source!==undefined&&reference.sourceSha===source.sha&&reference.end<=source.sourceText.length;}),ignored=prior.length-usable.length;const previous=[...usable,...(words?.hits??[]).map((hit:any)=>({entryId:hit.entryId,start:hit.read.offset,end:hit.read.offset+hit.read.limit,sourceSha:snapshot.sources.find(s=>s.entry.id===hit.entryId)?.sha}))];return{status:'ok',notice:'Related original evidence, not current truth or proof that every qualification was found. Scores do not establish authority, event time or supersession. Sources preserve recorded time and original order.'+(ignored?` ${ignored} of the priorResults did not match this history and were ignored, so their text may appear again.`:'')+` Semantic ranking covers ${covered.conversation.indexedCharacters} of ${covered.conversation.outOfViewCharacters} characters of earlier conversation and ${covered.toolResults.indexedCharacters} of ${covered.toolResults.outOfViewCharacters} characters of earlier tool output (newest first, back to ${covered.toolResults.oldestCoveredTime??covered.oldestCoveredTime??'an unknown time'}); use tuck_recall for the rest.`,coverage:{...this.status(),window:covered},method:'embedding',groups:this.assemble(ranked.map(hit=>hit.unit),snapshot,previous)};
377
+ const usable=prior.filter(reference=>{const source=snapshot.sources.find(s=>s.entry.id===reference.entryId);return Number.isSafeInteger(reference.start)&&Number.isSafeInteger(reference.end)&&reference.start<=reference.end&&source!==undefined&&reference.sourceSha===source.sha&&reference.end<=source.sourceText.length;}),ignored=prior.length-usable.length;const previous=[...usable,...(words?.hits??[]).map((hit:any)=>({entryId:hit.entryId,start:hit.read.offset,end:hit.read.offset+hit.read.limit,sourceSha:snapshot.sources.find(s=>s.entry.id===hit.entryId)?.sha}))];return{status:'ok',notice:'Related original evidence, not current truth or proof that every qualification was found. Scores do not establish authority, event time or supersession. Sources preserve recorded time and original order.'+(ignored?` ${ignored} of the priorResults did not match this history and were ignored, so their text may appear again.`:'')+` Semantic ranking covers ${covered.conversation.indexedCharacters} of ${covered.conversation.outOfViewCharacters} characters of earlier conversation and ${covered.toolResults.indexedCharacters} of ${covered.toolResults.outOfViewCharacters} characters of earlier tool output (newest first, back to ${covered.toolResults.oldestCoveredTime??covered.oldestCoveredTime??'an unknown time'}); use tuck_recall for the rest.`+(older?` This ranking covers the history through entry ${snapshot.throughEntryId}: later entries are still being indexed, so search them by keyword.`:''),coverage:{...this.status(),window:covered},method:'embedding',groups:this.assemble(ranked.map(hit=>hit.unit),snapshot,previous)};
367
378
  }catch(error){if(error instanceof Error&&error.name==='SemanticAccessError'){await this.revoked(error);throw error;}if(signal?.aborted||queryGeneration!==this.generation||error instanceof Error&&error.name==='AbortError'){await this.cancelled();throw abort();}this.heal(error);return{status:'unavailable',notice:'Local semantic search failed; ordinary recall remains available. '+(error instanceof Error?error.message:'failure'),coverage:this.status(),groups:[]};}
368
379
  }
369
380
  private assemble(units:Unit[],snapshot:Snapshot,previous:PriorResult[]):SemanticGroup[]{
@@ -38,8 +38,8 @@ const startBudget = (): (() => void) => {
38
38
  return () => {
39
39
  if (Date.now() > deadline) {
40
40
  throw new Error(
41
- `Search aborted: query exceeded ${SEARCH_BUDGET_MS}ms. Simplify the pattern — ` +
42
- "nested quantifiers such as (a+)+ can make matching blow up.",
41
+ `Search aborted: query exceeded ${SEARCH_BUDGET_MS}ms on this history. ` +
42
+ "Use more specific words, or read an entry by index.",
43
43
  );
44
44
  }
45
45
  };
package/src/tuck.ts CHANGED
@@ -52,14 +52,14 @@ import { prepareKeywordQuery } from "./tuck/core/keyword-query";
52
52
  import { searchEntriesDetailed } from "./tuck/core/search-entries";
53
53
  export const tuck: Tuck = { buildOwnCut, applyTailBudget, calibrateCharsPerToken, buildGlobalIndexById, compileDialogue, prepareKeywordQuery, searchEntriesDetailed };
54
54
  /** Uses pi-tuck's own cut, calibrated maximum tail and dialogue compiler; Pi owns persistence and continuation. */
55
- export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false) {
56
- return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt }, ctx, reserveTokens, measure, policy, retainCurrentInput);
55
+ export function tuckDraft(tuck: Tuck, event: SessionBeforeCompactEvent, ctx: ExtensionContext, reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true, operatorManual = false, newPrompt = false, protectedPromptEntryId?: string) {
56
+ return tuckObservedDraft(tuck, { branchEntries: event.branchEntries, tokensBefore: event.preparation.tokensBefore, previousSummary: event.preparation.previousSummary, operatorManual, newPrompt, protectedPromptEntryId }, ctx, reserveTokens, measure, policy, retainCurrentInput);
57
57
  }
58
58
  /** Shared compiler observes canonical branch and current token estimate; no native preparation utility. */
59
59
  /** `newPrompt`: the cut is planned for a run that has not begun (a prompt Pi has not appended yet, an operator's manual cut, Pi's own cut before
60
60
  * a run a custom message starts); the session has moved on from an ended run.
61
61
  * `reRenderSummary`: the cut is due, so its summary may be re-rendered smaller (see the `no_live_messages` branch below). */
62
- export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEntry[]; tokensBefore: number; previousSummary?: string; operatorManual?: boolean; newPrompt?: boolean; reRenderSummary?: boolean }, ctx: ExtensionContext,
62
+ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEntry[]; tokensBefore: number; previousSummary?: string; operatorManual?: boolean; newPrompt?: boolean; reRenderSummary?: boolean; protectedPromptEntryId?: string }, ctx: ExtensionContext,
63
63
  reserveTokens: number, measure: Measure, policy: RecallPolicy = {}, retainCurrentInput = true): Draft {
64
64
  const { isHistorySource, readable } = createRecall(policy);
65
65
  const projected = ctx.sessionManager.buildSessionProjection();
@@ -143,6 +143,9 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
143
143
  const current = entries[userPosition];
144
144
  const userContent = current?.type === "message" && current.message.role === "user" ? current.message.content : undefined;
145
145
  const textOnly = isTextOnly(userContent);
146
+ // Steering can become the latest user source while the run still owns its original prompt.
147
+ const startingPosition = entries.findIndex(entry => entry.id === input.protectedPromptEntryId && entry.type === "message" && entry.message.role === "user");
148
+ const starting = entries[startingPosition];
146
149
  // Anchor the actual first source only; a native omission never promotes a later prompt.
147
150
  const firstUser = branch.find(entry => entry.type === "message" && entry.message.role === "user");
148
151
  const firstUserPosition = entries.findIndex(entry => entry.id === firstUser?.id && entry.type === "message");
@@ -280,7 +283,8 @@ export function tuckObservedDraft(tuck: Tuck, input: { branchEntries: SessionEnt
280
283
  if (index !== undefined && quoted.records.length) { selected.add(index); if (quoted.whole) whole.add(index); }
281
284
  };
282
285
  if (userPosition >= 0 && userPosition < boundary && textOnly) add(current!, Infinity);
283
- for (const entry of anchors) if (entry.id !== current?.id && positions.get(entry.id)! < boundary) add(entry, length);
286
+ if (startingPosition >= 0 && startingPosition < boundary && starting?.id !== current?.id) add(starting!, Infinity);
287
+ for (const entry of anchors) if (entry.id !== current?.id && entry.id !== starting?.id && positions.get(entry.id)! < boundary) add(entry, length);
284
288
  const omissions: TranscriptRecord[] = [];
285
289
  for (const role of ["user", "assistant", "toolResult"] as const) {
286
290
  const list = units[role], prefix = prefixes[role];