pi-tuck 1.0.12 → 1.0.14

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.
@@ -52,7 +52,7 @@ const normalizeOne = (msg, msgIndex) => {
52
52
  };
53
53
  /**
54
54
  * Normalize messages to blocks. `sourceIndices`, when provided, supplies the
55
- * session-global `#N` index per input position (see src/core/global-indices.ts).
55
+ * session-global `#N` index per input position (see global-indices.ts).
56
56
  * A missing entry yields `sourceIndex: undefined`, which downstream renderers
57
57
  * display as no ref — never a window-relative number. Omitted entirely, the
58
58
  * legacy positional behavior is preserved (used by callers/tests that have no
@@ -65,7 +65,6 @@ export const normalize = (messages, sourceIndices, receiptContext, sourceEntryId
65
65
  if (sourceTimes && sourceTimes.length !== messages.length)
66
66
  throw new Error("Original times must align with compiler messages");
67
67
  const timeByIndex = Object.fromEntries((sourceIndices ?? messages.map((_, i) => i)).flatMap((index, i) => index === undefined || !sourceTimes ? [] : [[index, sourceTimes[i]]]));
68
- const context = receiptContext ?? { messages, sourceIndices: sourceIndices ?? messages.map((_, i) => i) };
69
68
  const pairs = new Map();
70
69
  const group = (id) => {
71
70
  if (typeof id !== "string" || !id)
@@ -77,17 +76,16 @@ export const normalize = (messages, sourceIndices, receiptContext, sourceEntryId
77
76
  }
78
77
  return pair;
79
78
  };
80
- context.messages.forEach((msg, order) => {
79
+ (receiptContext?.messages ?? messages).forEach((msg, order) => {
81
80
  if (msg.role === "assistant" && Array.isArray(msg.content))
82
81
  for (const part of msg.content) {
83
82
  if (part.type === "toolCall")
84
- group(part.id).calls.push({ name: part.name, order, index: context.sourceIndices[order], args: part.arguments });
83
+ group(part.id).calls.push({ name: part.name, order, args: part.arguments });
85
84
  }
86
85
  if (msg.role === "toolResult") {
87
86
  if (typeof msg.isError !== "boolean")
88
87
  throw new Error("Tool receipt lacks a recorded error status");
89
- group(msg.toolCallId).receipts.push({ name: msg.toolName,
90
- order, index: context.sourceIndices[order], isError: msg.isError, content: msg.content });
88
+ group(msg.toolCallId).receipts.push({ name: msg.toolName, order });
91
89
  }
92
90
  });
93
91
  // A call paired with exactly one later receipt of the same tool has a recorded outcome. An export through recall keeps its outcome block.
@@ -34,47 +34,20 @@ const toLiveMessage = (entry) => {
34
34
  return null;
35
35
  };
36
36
  const collectLiveMessages = (branchEntries) => {
37
- // Find the last compaction entry and its firstKeptEntryId
38
- let lastCompactionIdx = -1;
39
- let lastKeptId;
40
- for (let i = branchEntries.length - 1; i >= 0; i--) {
41
- if (branchEntries[i].type === "compaction") {
42
- lastCompactionIdx = i;
43
- lastKeptId = branchEntries[i].firstKeptEntryId;
44
- break;
45
- }
46
- }
47
- // Orphan recovery: triggers when lastKeptId is set to "" (sentinel from prior
48
- // compact-all) OR set to an id that no longer exists in the branch. In both cases,
49
- // start collecting from right after the last compaction entry.
50
- const hasPriorCompaction = lastCompactionIdx >= 0;
51
- const hasValidKeptId = !!lastKeptId && branchEntries.some((e) => e.id === lastKeptId);
52
- const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
53
- // Collect live messages
37
+ const lastCompactionIdx = branchEntries.findLastIndex(e => e.type === "compaction");
38
+ const lastKeptId = branchEntries[lastCompactionIdx]?.firstKeptEntryId;
39
+ const kept = lastKeptId ? branchEntries.findIndex(e => e.id === lastKeptId) : -1;
40
+ // An empty compact-all sentinel or a missing anchor resumes after the last
41
+ // compaction. With no prior compaction, collection starts at the beginning.
42
+ const start = kept >= 0 ? kept : lastCompactionIdx + 1;
54
43
  const liveMessages = [];
55
- if (orphanRecovery) {
56
- for (let i = lastCompactionIdx + 1; i < branchEntries.length; i++) {
57
- const e = branchEntries[i];
58
- if (e.type === "compaction")
59
- continue;
60
- const m = toLiveMessage(e);
61
- if (m)
62
- liveMessages.push({ entry: e, message: m });
63
- }
64
- }
65
- else {
66
- let foundKept = !lastKeptId; // if no prior compaction, start collecting immediately
67
- for (const e of branchEntries) {
68
- if (!foundKept && e.id === lastKeptId)
69
- foundKept = true;
70
- if (!foundKept)
71
- continue;
72
- if (e.type === "compaction")
73
- continue;
74
- const m = toLiveMessage(e);
75
- if (m)
76
- liveMessages.push({ entry: e, message: m });
77
- }
44
+ for (let i = start; i < branchEntries.length; i++) {
45
+ const e = branchEntries[i];
46
+ if (e.type === "compaction")
47
+ continue;
48
+ const m = toLiveMessage(e);
49
+ if (m)
50
+ liveMessages.push({ entry: e, message: m });
78
51
  }
79
52
  return liveMessages;
80
53
  };
package/dist/src/tuck.js CHANGED
@@ -53,8 +53,8 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
53
53
  const chars = (calibration.ok ? calibration.messages.reduce((n, m) => n + estimateSourceMessageChars(m), 0) : 0)
54
54
  + (input.previousSummary?.length ?? 0);
55
55
  const { charsPerToken } = tuck.calibrateCharsPerToken(chars, input.tokensBefore);
56
- // A preferred tail takes at most half the current input allowance, so a cut leaves room to grow: a tail that fills the room lands the
57
- // request just under the band and the next round cuts again. Current/fresh-source floors below may still require an explicit rejection.
56
+ // The planner's reserve includes mandatory input (system, host and incoming prompt), so this is half of the target room *after* that
57
+ // fixed input, not half of the whole room. Current/fresh-source floors below may still require an explicit rejection.
58
58
  const inputRoom = Math.floor(Math.max(0, (ctx.model?.contextWindow ?? 0) - reserveTokens - 1) / 2);
59
59
  const maxTokens = Math.min(MAX_TAIL_TOKENS, inputRoom);
60
60
  // Keep the current user turn native; older conversation competes in the
@@ -71,6 +71,11 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
71
71
  cut = { ok: true, messages: [], selectedIds: [], firstKeptEntryId: "", compactAll: true, keptUserTurns: 0, keepFallbackToCompactAll: false };
72
72
  if (!cut.ok)
73
73
  return { unavailable: `pi-tuck cannot select a cut: ${cut.reason}` };
74
+ const cutBefore = (cut, boundary, prefix) => ({
75
+ ...cut, messages: prefix.messages, selectedIds: prefix.selectedIds,
76
+ firstKeptEntryId: entries[boundary]?.id ?? "", compactAll: boundary === entries.length, keepFallbackToCompactAll: false,
77
+ keptUserTurns: entries.slice(boundary).filter(entry => entry.type === "message" && entry.message.role === "user").length,
78
+ });
74
79
  // The latest call's trailing receipts have not reached a subsequent response.
75
80
  // A preferred tail size must not erase this first exposure when the whole
76
81
  // fresh round fits. Existing full-draft admission rejects an impossible round.
@@ -93,9 +98,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
93
98
  const prefix = tuck.buildOwnCut(entries.slice(0, lastResponse), 0);
94
99
  if (!prefix.ok)
95
100
  return { unavailable: "No reducible history before the fresh tool round" };
96
- cut = { ...cut, messages: prefix.messages, selectedIds: prefix.selectedIds,
97
- firstKeptEntryId: response.id, compactAll: false, keepFallbackToCompactAll: false,
98
- keptUserTurns: entries.slice(lastResponse).filter(entry => entry.type === "message" && entry.message.role === "user").length };
101
+ cut = cutBefore(cut, lastResponse, prefix);
99
102
  }
100
103
  }
101
104
  // Direct shell output after the last response has not reached the model.
@@ -108,9 +111,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
108
111
  const prefix = tuck.buildOwnCut(entries.slice(0, freshBash), 0);
109
112
  if (!prefix.ok)
110
113
  return { unavailable: "No reducible history before the fresh shell output" };
111
- cut = { ...cut, messages: prefix.messages, selectedIds: prefix.selectedIds,
112
- firstKeptEntryId: entries[freshBash].id, compactAll: false, keepFallbackToCompactAll: false,
113
- keptUserTurns: entries.slice(freshBash).filter(entry => entry.type === "message" && entry.message.role === "user").length };
114
+ cut = cutBefore(cut, freshBash, prefix);
114
115
  }
115
116
  // Preserve the actual latest user source, never an inferred task/decision.
116
117
  // Explicit omission of it must not promote an earlier user source instead.
@@ -131,9 +132,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
131
132
  const prefix = tuck.buildOwnCut(entries.slice(0, userPosition), 0);
132
133
  if (!prefix.ok)
133
134
  return { unavailable: "No reducible history before the current user input" };
134
- cut = { ...cut, messages: prefix.messages, selectedIds: prefix.selectedIds,
135
- firstKeptEntryId: current.id, compactAll: false, keepFallbackToCompactAll: false,
136
- keptUserTurns: entries.slice(userPosition).filter(entry => entry.type === "message" && entry.message.role === "user").length };
135
+ cut = cutBefore(cut, userPosition, prefix);
137
136
  }
138
137
  if (cut.messages.length !== cut.selectedIds.length || (!cut.compactAll && !entries.some((e) => e.id === cut.firstKeptEntryId)))
139
138
  throw new Error("pi-tuck returned an invalid source boundary");
@@ -272,10 +271,7 @@ export function tuckObservedDraft(tuck, input, ctx, reserveTokens, measure, poli
272
271
  const prefix = tuck.buildOwnCut(entries.slice(0, floorBoundary), 0);
273
272
  if (!prefix.ok)
274
273
  return preferred;
275
- const retried = compileAtCut({ ...cut, budgetCut: undefined, messages: prefix.messages, selectedIds: prefix.selectedIds,
276
- firstKeptEntryId: entries[floorBoundary]?.id ?? "", compactAll: floorBoundary === entries.length,
277
- keepFallbackToCompactAll: false,
278
- keptUserTurns: entries.slice(floorBoundary).filter(entry => entry.type === "message" && entry.message.role === "user").length });
274
+ const retried = compileAtCut({ ...cutBefore(cut, floorBoundary, prefix), budgetCut: undefined });
279
275
  return preferred.compaction && retried.mode !== "dialogue" ? preferred : retried;
280
276
  }
281
277
  /** Numeric references use the original session index, then intersect with readable current-branch entries. */
package/index.ts CHANGED
@@ -16,7 +16,7 @@ export { ensureSemanticRuntime } from "./src/setup";
16
16
 
17
17
  /** pi-tuck as a Pi extension: compaction and history recall always; semantic recall too, unless `pi-tuck.json` in Pi's agent folder
18
18
  * says `{"semanticRecall": false}`. Its runtime is set up in the background the first time a session starts. The same file's `"cutAt"`
19
- * (a number above 0 and at most 1) is the fraction of the input room a cut is triggered at, 1 by default. */
19
+ * (a number above 0 and at most 1) is the fraction of the input room a cut is triggered at, 0.6 by default. */
20
20
  /** The oldest Pi with every hook and API pi-tuck uses, and the oldest its tests pass on (Pi 1.2 added the custom message that starts a
21
21
  * run to before_agent_start, which the prompt's cut counts). */
22
22
  export const PI_FLOOR = "1.2.0";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "Deterministic compaction for Pi with no model call, plus exact recall of earlier history. Search by meaning on Apple silicon.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/budget.ts CHANGED
@@ -3,6 +3,7 @@ import { convertToLlm, estimateTokens, type BeforeAgentStartEvent, type Extensio
3
3
  import { measurableMessages, projectedMessages } from "./omissions.js";
4
4
  import type { RecallPolicy } from "./recall.js";
5
5
  import { isDiscardedResponse } from "./protocol.js";
6
+ import { MAX_TAIL_TOKENS } from "./tuck/cut.js";
6
7
 
7
8
  /**
8
9
  * The one measure of the next request. The cut triggers, the cut planner (including the draft admission in tuck.ts), the post-cut
@@ -19,16 +20,16 @@ export const TRIGGER_MARGIN_FRACTION = 0.02;
19
20
  /** Whether `value` is a cut point: a fraction of the room above 0 and at most 1. */
20
21
  export const validCutAt = (value: unknown): value is number => typeof value === "number" && value > 0 && value <= 1;
21
22
  /**
22
- * The one owner of the band: the limit a cut is triggered at and preferably planned to. It is the room less the margin, or the cut point
23
- * `cutAt` (a fraction of the room) when that is lower. The exact room stays the limit of every throw, fault and the complete-context guard,
24
- * whatever the cut point. With `cutAt` 1 the band is the room less the margin alone.
23
+ * The one owner of the band: the limit a cut is triggered at and preferably planned to. An adaptive default may raise the cut point above
24
+ * 60% using the last cut's conversation size and current system size, both estimates, so they are converted to the room's units by the factor. Explicit cut points use the fraction alone. The margin caps both.
25
25
  */
26
- /** The default cut point: a cut once a request passes 60% of the room. Measured against 1 with paired replays (no answer loss) and billed
26
+ /** The initial default cut point: 60% of the room before a usable previous cut. Measured against 1 with paired replays (no answer loss) and billed
27
27
  * input under automatic and explicit prompt caching (about 17% less at 128k, 10-14% at 32k). */
28
28
  export const DEFAULT_CUT_AT = 0.6;
29
- export const bandOf = (room: number, cutAt = 1): number => {
29
+ export const bandOf = (room: number, cutAt = 1, adaptive?: { system: number; tokensAfter: number; factor: number }): number => {
30
30
  const positive = Math.max(0, room);
31
- return Math.min(room - Math.ceil(positive * TRIGGER_MARGIN_FRACTION), Math.floor(positive * cutAt));
31
+ const base = Math.floor(positive * cutAt);
32
+ return Math.min(room - Math.ceil(positive * TRIGGER_MARGIN_FRACTION), adaptive ? Math.max(base, Math.ceil((adaptive.system + adaptive.tokensAfter + MAX_TAIL_TOKENS / 2) * adaptive.factor)) : base);
32
33
  };
33
34
 
34
35
  /** The APIs whose request converter (pi-ai openai-responses-shared) sends each signed thinking block back as an input reasoning item. */
@@ -100,6 +101,8 @@ export type Decision<D extends object = {}> =
100
101
  | { kind: "cut"; target: Target; plan: Extract<Plan<D>, { draft: true }> }
101
102
  | { kind: "fault"; capacity: boolean; message: string };
102
103
  export interface CutInput<D extends object = {}> {
104
+ /** The band adapts by itself (no explicit cut point), so a cut that isn't due must also free a tenth of the room. */
105
+ usefulOnly?: boolean;
103
106
  /** Saved conversation, measured by the planner's own function. */
104
107
  before: number;
105
108
  /** Input that no cut can remove: system part, host input, queued messages, or the incoming prompt. */
@@ -126,19 +129,22 @@ export type Verdict<D extends object = {}> = Exclude<Decision<D>, { kind: "defer
126
129
  * A lean band, one the cut point binds (`band < bandOf(room)`), is planned first and the room plan landing below it is not taken for that:
127
130
  * the room plan keeps a native tail of up to half the room, so it can land just under a band that far below the room, and the next round
128
131
  * would cut again, freeing little. The room is planned only where the rules above use it: to compare the mode, or when the band has no plan
129
- * and a cut is due. With the default cut point the band is never lean and the order and the decisions are those above.
132
+ * and a cut is due. An adaptive band can also reach the margin, in which case the room plan is tried first.
130
133
  * The margin adds no new decision fault. Exceptions thrown by `plan` are not caught here: they are faults, and they now surface at
131
134
  * boundaries inside the band too, where no planner ran before the margin.
132
135
  */
133
136
  export function chooseCut<D extends object = {}>(input: CutInput<D> & { opportunistic: boolean }): Verdict<D> {
134
137
  const { before, mandatory, room, band, fits, plan, unreduced } = input;
135
138
  const lands = (p: Plan<D>): p is Extract<Plan<D>, { draft: true }> => p.draft && p.after < before && fits(p.after + mandatory, band);
139
+ // A cut no one needs yet must be worth its summary: one that frees under a tenth of the room would trigger again within a round or two.
140
+ // Without this, a band at the cap takes the room plan on every round where free room is small. A due cut is not asked: it has to happen.
141
+ const worthwhile = (p: Extract<Plan<D>, { draft: true }>) => !input.usefulOnly || !input.opportunistic || !fits(10 * (before - p.after), room);
136
142
  let roomPlan: Plan<D> | undefined;
137
143
  const exact = () => roomPlan ??= plan("room");
138
- if (band >= bandOf(room)) { const e = exact(); if (lands(e)) return { kind: "cut", target: "room", plan: e }; }
144
+ if (band >= bandOf(room)) { const e = exact(); if (lands(e) && worthwhile(e)) return { kind: "cut", target: "room", plan: e }; }
139
145
  const preferred = plan("band");
140
146
  if (input.opportunistic) {
141
- if (!lands(preferred)) return { kind: "none" };
147
+ if (!lands(preferred) || !worthwhile(preferred)) return { kind: "none" };
142
148
  const e = exact();
143
149
  return e.draft && e.mode === preferred.mode ? { kind: "cut", target: "band", plan: preferred } : { kind: "none" };
144
150
  }
package/src/build-id.ts CHANGED
@@ -1 +1 @@
1
- export const BUILD_ID = "f637995e7992c76737b37ae27dee5bb6bf47e959fd0f9677c3ae4d0c7530442c";
1
+ export const BUILD_ID = "9457180f73cad9d9bde254328dad70dd2abe8400e373a68e06ad455b5b66f203";
package/src/compaction.ts CHANGED
@@ -103,9 +103,9 @@ export interface CompactionOptions {
103
103
  /** Optional estimated complete-input ceiling, separate from physical output reservation.
104
104
  * The host owns final serialization/transport counting; this is not an exact wire-token cap. */
105
105
  activeInputTokens?: number | ((ctx: ExtensionContext) => number);
106
- /** The cut point: a fraction of the input room, above 0 and at most 1 (default 1). A cut is triggered at, and preferably planned to, the band,
106
+ /** 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,
107
107
  * 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
108
- * stays the limit of every throw, fault and the complete-context guard. Anything else is rejected when the compactor is installed. */
108
+ * stays the limit of every throw, fault and the complete-context guard. If omitted, the band adapts to the last cut. */
109
109
  cutAt?: number;
110
110
  /** The response reserve: the input room is the context window less this. By default the room never exceeds the window less the model's
111
111
  * catalogue maximum output, because a transport may omit the output cap and the reply can then grow to that maximum. A host that knows its
@@ -216,9 +216,9 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
216
216
  const learn = (value: number) => { ratios.push(value); if (ratios.length > CALIBRATION_WINDOW) ratios.shift(); factor = Math.max(1, ...ratios); };
217
217
  const corrected = (tokens: number, ctx: ExtensionContext) => { identityFor(ctx); return Math.ceil(tokens * factor); };
218
218
  // The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
219
- const planReserve = (target: Target, room: number, mandatory: number, ctx: ExtensionContext) => {
219
+ const planReserve = (target: Target, room: number, band: number, mandatory: number, ctx: ExtensionContext) => {
220
220
  identityFor(ctx);
221
- return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? bandOf(room, cutAt) : room, factor, mandatory);
221
+ return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? band : room, factor, mandatory);
222
222
  };
223
223
  pi.on("message_end", (event, ctx) => {
224
224
  if (event.message.role !== "assistant") return;
@@ -280,10 +280,16 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
280
280
  throw new Error("Invalid active-input token ceiling");
281
281
  return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
282
282
  };
283
- // The exact room, and the band a cut is triggered at (budget.ts, from this session's cutAt); every throw and every fault uses the exact room.
283
+ // Every throw, fault and complete-context guard uses the exact room.
284
284
  const limits = (ctx: ExtensionContext) => {
285
285
  const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
286
- return { response, room, band: bandOf(room, cutAt) };
286
+ return { response, room };
287
+ };
288
+ const decisionBand = (ctx: ExtensionContext, room: number, system: number) => {
289
+ const last = ctx.sessionManager.getBranch().findLast(entry => entry.type === "compaction");
290
+ const after = last?.type === "compaction" ? (last.details as { tokensAfter?: unknown } | undefined)?.tokensAfter : undefined;
291
+ return bandOf(room, cutAt, options.cutAt === undefined && typeof after === "number" && Number.isFinite(after) && after >= 0
292
+ ? { system, tokensAfter: after, factor } : undefined);
287
293
  };
288
294
  const fits = (tokens: number, limit: number, ctx: ExtensionContext) => corrected(tokens, ctx) < limit;
289
295
  // The saved conversation and a cut's quantities are counted by the one function the guard counts with (strict, as the context hook is).
@@ -413,13 +419,14 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
413
419
  ctx.ui.notify("pi-tuck does not accept summarization instructions; use /compact without arguments.", "warning");
414
420
  return { cancel: true };
415
421
  }
416
- const { response, room, band } = limits(ctx);
422
+ const { response, room } = limits(ctx);
417
423
  const retain = operatorManual || admittedRun;
418
424
  // A cut Pi asks for (an operator's /compact, Pi's threshold or overflow recovery) charges what the request must carry whatever is
419
425
  // cut: the system part and the host input. A prompt's cut is pi-tuck's own and never comes here.
420
426
  const saved = ctx.sessionManager.buildSessionProjection().messages;
421
427
  const host = hostTokens(ctx);
422
- const mandatory = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt()) + host;
428
+ const system = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt());
429
+ const mandatory = system + host, band = decisionBand(ctx, room, system);
423
430
  const before = plannerMeasureFor(ctx)(saved);
424
431
  // Pi's own usage threshold (reason "threshold") counts the last reply's output tokens, reasoning included, plus the messages after
425
432
  // 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
@@ -433,11 +440,11 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
433
440
  }
434
441
  // Pi checks old history before appending a new idle prompt. During an active run/manual cut the latest admitted input stays protected.
435
442
  const decision = chooseCut({
436
- before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
443
+ before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
437
444
  opportunistic: thresholdOpportunistic,
438
445
  unreduced: "Compaction did not reduce context within the estimated request budget",
439
446
  plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>; reserve: number }> => {
440
- const reserve = planReserve(target, room, mandatory, ctx);
447
+ const reserve = planReserve(target, room, band, mandatory, ctx);
441
448
  // 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
442
449
  // from any run that ended. A retry is inside its run.
443
450
  const draft = tuckDraft(tuck, event, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, retain, operatorManual, operatorManual || !admittedRun);
@@ -479,10 +486,11 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
479
486
  access();
480
487
  if (!settingsFor().getCompactionSettings(ctx.model).enabled) return;
481
488
  if (!ctx.model) throw missingModel();
482
- const { response, room, band } = limits(ctx);
489
+ const { response, room } = limits(ctx);
483
490
  // Public early view only: later handlers, image normalization and tool-loop growth remain the complete-context guard's responsibility.
484
491
  const saved = ctx.sessionManager.buildSessionProjection().messages;
485
492
  const system = systemPart(observedPrompt, toolShapes(tools), saved, ctx.getSystemPrompt());
493
+ const band = decisionBand(ctx, room, system);
486
494
  const host = hostTokens(ctx), measure = measureFor(ctx), before = measure(saved);
487
495
  const incoming = incomingPart + system, mandatory = incoming + host;
488
496
  if (!fits(incoming, room, ctx))
@@ -493,10 +501,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
493
501
  if (gate.kind !== "plan") return;
494
502
  const previousSummary = saved.find(message => message.role === "compactionSummary")?.summary;
495
503
  const decision = chooseCut({
496
- before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), opportunistic: gate.opportunistic,
504
+ before, mandatory, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined, opportunistic: gate.opportunistic,
497
505
  unreduced: "Incoming prompt compaction did not reduce context within the estimated request budget",
498
506
  plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
499
- const reserve = planReserve(target, room, mandatory, ctx);
507
+ const reserve = planReserve(target, room, band, mandatory, ctx);
500
508
  // 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
501
509
  // there buys nothing the request needed and costs history the next recall would have found.
502
510
  const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary, newPrompt: true, reRenderSummary: !gate.opportunistic }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, false);
@@ -532,12 +540,13 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
532
540
  if (event.entries.length || !isDeepStrictEqual(event.context.contextMessages,
533
541
  ctx.sessionManager.buildSessionProjection().messages)) return;
534
542
  if (!ctx.model) return;
535
- const { response, room, band } = limits(ctx);
543
+ const { response, room } = limits(ctx);
536
544
  const measure = measureFor(ctx), saved = event.context.contextMessages;
537
545
  // One snapshot per decision. Messages queued for the next request (steering, follow-ups) are in that request but not in the
538
546
  // saved conversation. Pi's peek returns the steering queue, else the follow-up queue; a follow-up is not in the very next
539
547
  // request while tools still run, so it can be counted early: conservative, and nothing else would cut at that later boundary.
540
548
  const system = systemPart(ctx.getSystemPrompt(), toolShapes(pi.getActiveTools()), saved, ctx.getSystemPrompt());
549
+ const band = decisionBand(ctx, room, system);
541
550
  const host = hostTokens(ctx);
542
551
  const queued = event.context.pendingMessages.length ? conversationTokens(event.context.pendingMessages as Messages) : 0;
543
552
  const mandatory = system + host + queued;
@@ -551,10 +560,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
551
560
  floorKept = undefined;
552
561
  const before = measure(saved);
553
562
  return decideCut({
554
- total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx),
563
+ total: before + mandatory, mandatory, before, room, band, fits: (tokens, limit) => fits(tokens, limit, ctx), usefulOnly: options.cutAt === undefined,
555
564
  unreduced: "Tool-loop preflight compaction did not reduce context within the estimated request budget",
556
565
  plan: (target): Plan<{ compaction: NonNullable<ReturnType<typeof tuckObservedDraft>["compaction"]>; reserve: number }> => {
557
- const reserve = planReserve(target, room, mandatory, ctx);
566
+ const reserve = planReserve(target, room, band, mandatory, ctx);
558
567
  const draft = tuckObservedDraft(tuck, { branchEntries: ctx.sessionManager.getBranch(), tokensBefore: before, previousSummary }, ctx, reserve, plannerMeasureFor(ctx), options.recallPolicy, true);
559
568
  if (!draft.compaction) {
560
569
  // The planner's own floor, so a clip's second budget is measured against what the smallest cut really keeps.
package/src/omissions.ts CHANGED
@@ -5,12 +5,12 @@ import { createRecall, type RecallPolicy } from './recall';
5
5
  /** Native branch edits supply exclusion; original global indices supply provenance. */
6
6
  export function omittedSourceIndices(entries: readonly SessionEntry[], branch: readonly SessionEntry[]): ReadonlySet<number> {
7
7
  const indices = buildGlobalIndexById(entries);
8
- const edits = new Map<string, boolean>();
8
+ const edits = new Set<string>();
9
9
  for (const entry of branch) if (entry.type === 'context_edit') {
10
10
  if (entry.replacement !== null) throw new Error('pi-tuck does not support replacement context edits');
11
- edits.set(entry.targetId, true);
11
+ edits.add(entry.targetId);
12
12
  }
13
- return new Set([...edits].flatMap(([id, omitted]) => omitted && indices.has(id) ? [indices.get(id)!] : []));
13
+ return new Set([...edits].flatMap(id => indices.has(id) ? [indices.get(id)!] : []));
14
14
  }
15
15
  /** Preserve surviving record bytes/order, and the summary's format (a v1 summary stays v1). No text matching or semantic inference. */
16
16
  export function filterOmittedSummary(summary: string, omitted: ReadonlySet<number>): string {
@@ -49,22 +49,20 @@ export function filterCustomQuotes(summary: string, entries: readonly SessionEnt
49
49
  }
50
50
  type ProjectedMessages = ReturnType<ExtensionContext['sessionManager']['buildSessionProjection']>['messages'];
51
51
  /** The saved conversation as this compactor delivers it: native omissions and ineligible custom quotes removed from summaries. */
52
- export function projectedMessages(messages: ProjectedMessages, ctx: ExtensionContext, policy?: RecallPolicy): ProjectedMessages {
52
+ function projectMessages(messages: ProjectedMessages, ctx: ExtensionContext, policy: RecallPolicy | undefined, allowForeign: boolean): ProjectedMessages {
53
53
  const entries = ctx.sessionManager.getEntries(), branch = ctx.sessionManager.getBranch();
54
54
  const omitted = omittedSourceIndices(entries, branch);
55
- return messages.map(message => message.role === 'compactionSummary'
55
+ return messages.map(message => message.role === 'compactionSummary' && (!allowForeign || splitSummary(message.summary) !== undefined)
56
56
  ? { ...message, summary: filterCustomQuotes(filterOmittedSummary(message.summary, omitted), entries, branch, policy) } : message);
57
57
  }
58
- /** Whether a compaction summary is in this compactor's own format. */
59
- const isTranscriptSummary = (summary: string) => splitSummary(summary) !== undefined;
58
+ export function projectedMessages(messages: ProjectedMessages, ctx: ExtensionContext, policy?: RecallPolicy): ProjectedMessages {
59
+ return projectMessages(messages, ctx, policy, false);
60
+ }
60
61
  /**
61
62
  * The same as `projectedMessages` for every summary in this compactor's format, and it never throws for any other: a summary of another
62
63
  * format (a Pi default summary, an empty draft) cannot be filtered and is left as it is. For counting a conversation that a cut is about
63
64
  * to replace, such as an operator's /compact on a session whose saved summary is foreign. The context hook and the triggers stay strict.
64
65
  */
65
66
  export function measurableMessages(messages: ProjectedMessages, ctx: ExtensionContext, policy?: RecallPolicy): ProjectedMessages {
66
- const entries = ctx.sessionManager.getEntries(), branch = ctx.sessionManager.getBranch();
67
- const omitted = omittedSourceIndices(entries, branch);
68
- return messages.map(message => message.role === 'compactionSummary' && isTranscriptSummary(message.summary)
69
- ? { ...message, summary: filterCustomQuotes(filterOmittedSummary(message.summary, omitted), entries, branch, policy) } : message);
67
+ return projectMessages(messages, ctx, policy, true);
70
68
  }
@@ -21,12 +21,11 @@ function physicalPath(path:string):string {
21
21
  const next=dirname(parent);if(next===parent)throw error;parts.unshift(basename(parent));parent=next;
22
22
  }}
23
23
  }
24
+ const inside=(base:string,target:string)=>{const rel=relative(base,target);return rel!=='..'&&!rel.startsWith('..'+sep)&&!isAbsolute(rel);};
24
25
  function outsideProject(project:string,path:string,kind:'configuration'|'index'|'runtime'|'scratch'){
25
- const inside=(base:string,target:string)=>{const rel=relative(base,target);return rel!=='..'&&!rel.startsWith('..'+sep)&&!isAbsolute(rel);};
26
26
  if(inside(resolve(project),resolve(path))||inside(physicalPath(project),physicalPath(path)))throw new Error('Semantic '+kind+' must be outside project');
27
27
  }
28
28
  function outsideRuntime(runtime:string,path:string,kind:'index'|'scratch'){
29
- const inside=(base:string,target:string)=>{const rel=relative(base,target);return rel!=='..'&&!rel.startsWith('..'+sep)&&!isAbsolute(rel);};
30
29
  if(inside(resolve(runtime),resolve(path))||inside(physicalPath(runtime),physicalPath(path)))throw new Error('Semantic '+kind+' must be outside immutable runtime');
31
30
  }
32
31
  function scratchParent(config:SemanticRecallConfig){return config.scratchDirectory??dirname(config.indexDirectory);}
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Session-global message indices — the single definition of the `#N` index space.
3
3
  *
4
- * Recall (`src/core/load-messages.ts`) numbers every `type == "message"` entry
4
+ * Recall numbers every `type == "message"` entry
5
5
  * in session-file order, counting across compaction windows and abandoned
6
6
  * branches. Compaction summaries must emit the same numbers, but `normalize`
7
7
  * historically numbered the selected window from zero. This module owns the
@@ -15,41 +15,27 @@ export const isCountedMessageEntry = (entry: any): boolean =>
15
15
  entry?.type === "message" && entry.message != null;
16
16
 
17
17
  /**
18
- * Accumulates the id → global-index mapping one entry at a time so both the
19
- * in-memory array path and the streaming file path share one counting rule.
20
- *
21
- * Entries without a usable id are still counted (they occupy an index) but
22
- * produce no map entry. Duplicate ids are ambiguous — dropped fail-closed so
23
- * callers emit no ref instead of a wrong one.
18
+ * Map session entry ids to their global message index (position among counted
19
+ * message entries in array order, which matches session-file order). Entries
20
+ * without a usable id still occupy an index. Duplicate ids are dropped
21
+ * fail-closed so callers emit no ref instead of a wrong one.
24
22
  */
25
- const createGlobalIndexBuilder = () => {
23
+ export const buildGlobalIndexById = (entries: readonly any[]): Map<string, number> => {
26
24
  const byId = new Map<string, number>();
27
25
  const ambiguous = new Set<string>();
28
26
  let messageIndex = 0;
29
- return {
30
- add(entry: any) {
31
- if (!isCountedMessageEntry(entry)) return;
32
- const id = entry?.id;
33
- if (typeof id === "string" && id.length > 0) {
34
- if (byId.has(id) || ambiguous.has(id)) {
35
- byId.delete(id);
36
- ambiguous.add(id);
37
- } else {
38
- byId.set(id, messageIndex);
39
- }
27
+ for (const entry of entries) {
28
+ if (!isCountedMessageEntry(entry)) continue;
29
+ const id = entry?.id;
30
+ if (typeof id === "string" && id.length > 0) {
31
+ if (byId.has(id) || ambiguous.has(id)) {
32
+ byId.delete(id);
33
+ ambiguous.add(id);
34
+ } else {
35
+ byId.set(id, messageIndex);
40
36
  }
41
- messageIndex++;
42
- },
43
- map: () => byId,
44
- };
45
- };
46
-
47
- /**
48
- * Map session entry ids to their global message index (position among counted
49
- * message entries in array order, which matches session-file order).
50
- */
51
- export const buildGlobalIndexById = (entries: readonly any[]): Map<string, number> => {
52
- const b = createGlobalIndexBuilder();
53
- for (const entry of entries) b.add(entry);
54
- return b.map();
37
+ }
38
+ messageIndex++;
39
+ }
40
+ return byId;
55
41
  };
@@ -54,7 +54,7 @@ const normalizeOne = (msg: CompilerMessage, msgIndex: number | undefined): Norma
54
54
 
55
55
  /**
56
56
  * Normalize messages to blocks. `sourceIndices`, when provided, supplies the
57
- * session-global `#N` index per input position (see src/core/global-indices.ts).
57
+ * session-global `#N` index per input position (see global-indices.ts).
58
58
  * A missing entry yields `sourceIndex: undefined`, which downstream renderers
59
59
  * display as no ref — never a window-relative number. Omitted entirely, the
60
60
  * legacy positional behavior is preserved (used by callers/tests that have no
@@ -71,23 +71,21 @@ export const normalize = (
71
71
  if (sourceEntryIds && sourceEntryIds.length !== messages.length) throw new Error("Original entry IDs must align with compiler messages");
72
72
  if (sourceTimes && sourceTimes.length !== messages.length) throw new Error("Original times must align with compiler messages");
73
73
  const timeByIndex = Object.fromEntries((sourceIndices ?? messages.map((_, i) => i)).flatMap((index, i) => index === undefined || !sourceTimes ? [] : [[index, sourceTimes[i]]]));
74
- const context = receiptContext ?? { messages, sourceIndices: sourceIndices ?? messages.map((_, i) => i) };
75
- const pairs = new Map<string, { calls: { name: string; order: number; index: number | undefined; args: Record<string, unknown> }[];
76
- receipts: { name: string; order: number; index: number | undefined; isError: boolean; content: Extract<CompilerMessage, { role: "toolResult" }>["content"] }[] }>();
74
+ const pairs = new Map<string, { calls: { name: string; order: number; args: Record<string, unknown> }[];
75
+ receipts: { name: string; order: number }[] }>();
77
76
  const group = (id: string) => {
78
77
  if (typeof id !== "string" || !id) throw new Error("Tool history lacks a recorded call identity");
79
78
  let pair = pairs.get(id);
80
79
  if (!pair) { pair = { calls: [], receipts: [] }; pairs.set(id, pair); }
81
80
  return pair;
82
81
  };
83
- context.messages.forEach((msg, order) => {
82
+ (receiptContext?.messages ?? messages).forEach((msg, order) => {
84
83
  if (msg.role === "assistant" && Array.isArray(msg.content)) for (const part of msg.content) {
85
- if (part.type === "toolCall") group(part.id).calls.push({ name: part.name, order, index: context.sourceIndices[order], args: part.arguments });
84
+ if (part.type === "toolCall") group(part.id).calls.push({ name: part.name, order, args: part.arguments });
86
85
  }
87
86
  if (msg.role === "toolResult") {
88
87
  if (typeof msg.isError !== "boolean") throw new Error("Tool receipt lacks a recorded error status");
89
- group(msg.toolCallId).receipts.push({ name: msg.toolName,
90
- order, index: context.sourceIndices[order], isError: msg.isError, content: msg.content });
88
+ group(msg.toolCallId).receipts.push({ name: msg.toolName, order });
91
89
  }
92
90
  });
93
91
  // A call paired with exactly one later receipt of the same tool has a recorded outcome. An export through recall keeps its outcome block.
package/src/tuck/cut.ts CHANGED
@@ -55,42 +55,18 @@ export type OwnCutResult =
55
55
  | { ok: false; reason: OwnCutCancelReason };
56
56
 
57
57
  const collectLiveMessages = (branchEntries: any[]): EntryWithMessage[] => {
58
- // Find the last compaction entry and its firstKeptEntryId
59
- let lastCompactionIdx = -1;
60
- let lastKeptId: string | undefined;
61
- for (let i = branchEntries.length - 1; i >= 0; i--) {
62
- if (branchEntries[i].type === "compaction") {
63
- lastCompactionIdx = i;
64
- lastKeptId = branchEntries[i].firstKeptEntryId;
65
- break;
66
- }
67
- }
68
-
69
- // Orphan recovery: triggers when lastKeptId is set to "" (sentinel from prior
70
- // compact-all) OR set to an id that no longer exists in the branch. In both cases,
71
- // start collecting from right after the last compaction entry.
72
- const hasPriorCompaction = lastCompactionIdx >= 0;
73
- const hasValidKeptId = !!lastKeptId && branchEntries.some((e: any) => e.id === lastKeptId);
74
- const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
75
-
76
- // Collect live messages
58
+ const lastCompactionIdx = branchEntries.findLastIndex(e => e.type === "compaction");
59
+ const lastKeptId = branchEntries[lastCompactionIdx]?.firstKeptEntryId;
60
+ const kept = lastKeptId ? branchEntries.findIndex(e => e.id === lastKeptId) : -1;
61
+ // An empty compact-all sentinel or a missing anchor resumes after the last
62
+ // compaction. With no prior compaction, collection starts at the beginning.
63
+ const start = kept >= 0 ? kept : lastCompactionIdx + 1;
77
64
  const liveMessages: EntryWithMessage[] = [];
78
- if (orphanRecovery) {
79
- for (let i = lastCompactionIdx + 1; i < branchEntries.length; i++) {
80
- const e = branchEntries[i];
81
- if (e.type === "compaction") continue;
82
- const m = toLiveMessage(e);
83
- if (m) liveMessages.push({ entry: e, message: m });
84
- }
85
- } else {
86
- let foundKept = !lastKeptId; // if no prior compaction, start collecting immediately
87
- for (const e of branchEntries) {
88
- if (!foundKept && e.id === lastKeptId) foundKept = true;
89
- if (!foundKept) continue;
90
- if (e.type === "compaction") continue;
91
- const m = toLiveMessage(e);
92
- if (m) liveMessages.push({ entry: e, message: m });
93
- }
65
+ for (let i = start; i < branchEntries.length; i++) {
66
+ const e = branchEntries[i];
67
+ if (e.type === "compaction") continue;
68
+ const m = toLiveMessage(e);
69
+ if (m) liveMessages.push({ entry: e, message: m });
94
70
  }
95
71
  return liveMessages;
96
72
  };