pi-tuck 1.0.1 → 1.0.4

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +65 -3
  2. package/README.md +138 -57
  3. package/dist/index.d.ts +2 -1
  4. package/dist/index.js +7 -4
  5. package/dist/src/budget.d.ts +17 -4
  6. package/dist/src/budget.js +33 -10
  7. package/dist/src/build-id.d.ts +1 -1
  8. package/dist/src/build-id.js +1 -1
  9. package/dist/src/compaction.d.ts +7 -0
  10. package/dist/src/compaction.js +32 -16
  11. package/dist/src/diagnostics.d.ts +2 -1
  12. package/dist/src/omissions.d.ts +1 -1
  13. package/dist/src/omissions.js +8 -8
  14. package/dist/src/tuck/core/brief.d.ts +13 -43
  15. package/dist/src/tuck/core/brief.js +41 -255
  16. package/dist/src/tuck/core/filter-noise.js +2 -1
  17. package/dist/src/tuck/core/format.d.ts +2 -6
  18. package/dist/src/tuck/core/format.js +4 -10
  19. package/dist/src/tuck/core/normalize.js +22 -50
  20. package/dist/src/tuck/core/shrink.d.ts +33 -0
  21. package/dist/src/tuck/core/shrink.js +218 -0
  22. package/dist/src/tuck/core/summarize.d.ts +15 -7
  23. package/dist/src/tuck/core/summarize.js +17 -22
  24. package/dist/src/tuck/core/transcript.d.ts +24 -13
  25. package/dist/src/tuck/core/transcript.js +251 -83
  26. package/dist/src/tuck/types.d.ts +4 -21
  27. package/dist/src/tuck.d.ts +8 -10
  28. package/dist/src/tuck.js +23 -25
  29. package/index.ts +8 -5
  30. package/package.json +1 -1
  31. package/src/budget.ts +29 -9
  32. package/src/build-id.ts +1 -1
  33. package/src/compaction.ts +33 -14
  34. package/src/diagnostics.ts +1 -1
  35. package/src/omissions.ts +8 -8
  36. package/src/tuck/core/brief.ts +48 -265
  37. package/src/tuck/core/filter-noise.ts +2 -1
  38. package/src/tuck/core/format.ts +4 -11
  39. package/src/tuck/core/normalize.ts +21 -50
  40. package/src/tuck/core/shrink.ts +217 -0
  41. package/src/tuck/core/summarize.ts +26 -24
  42. package/src/tuck/core/transcript.ts +238 -82
  43. package/src/tuck/types.ts +9 -20
  44. package/src/tuck.ts +30 -32
  45. package/dist/src/tuck/core/brief-meter.d.ts +0 -42
  46. package/dist/src/tuck/core/brief-meter.js +0 -273
  47. package/dist/src/tuck/core/rank.d.ts +0 -42
  48. package/dist/src/tuck/core/rank.js +0 -292
  49. package/src/tuck/core/brief-meter.ts +0 -235
  50. package/src/tuck/core/rank.ts +0 -331
package/index.ts CHANGED
@@ -2,6 +2,7 @@ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil
2
2
  import { readFileSync, realpathSync } from "node:fs";
3
3
  import { join, sep } from "node:path";
4
4
  import { installCompaction } from "./src/compaction";
5
+ import { validCutAt } from "./src/budget";
5
6
  import { createSemanticRecall } from "./src/semantic-recall";
6
7
  import { defaultRoot } from "./src/assets";
7
8
  import { ensureSemanticRuntime } from "./src/setup";
@@ -14,13 +15,14 @@ export type { SemanticRecallConfig, PriorResult, SemanticState } from "./src/sem
14
15
  export { ensureSemanticRuntime } from "./src/setup";
15
16
 
16
17
  /** pi-tuck as a Pi extension: compaction and history recall always; semantic recall too, unless `pi-tuck.json` in Pi's agent folder
17
- * says `{"semanticRecall": false}`. Its runtime is set up in the background the first time a session starts. */
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. */
18
20
  export default async function (pi: ExtensionAPI) {
19
21
  const semanticRecall = createSemanticRecall();
20
22
  // `/tuck-semantic off` holds for the rest of the session, including over a setup that finishes later.
21
23
  let off = false;
22
- const handle = await installCompaction(pi, { semanticRecall, onSemanticSetting: async enabled => { off = !enabled; } });
23
24
  const settings = readSettings(join(getAgentDir(), "pi-tuck.json"));
25
+ const handle = await installCompaction(pi, { semanticRecall, cutAt: settings.cutAt, onSemanticSetting: async enabled => { off = !enabled; } });
24
26
  if (settings.semanticRecall === false) return;
25
27
  let session = 0, said = "", setup: Promise<unknown> | undefined;
26
28
  // A context outlives its session (a setup can finish after /new); a notice to an ended session is dropped.
@@ -55,14 +57,15 @@ export default async function (pi: ExtensionAPI) {
55
57
  });
56
58
  }
57
59
 
58
- function readSettings(path: string): { semanticRecall?: boolean; error?: string } {
60
+ function readSettings(path: string): { semanticRecall?: boolean; cutAt?: number; error?: string } {
59
61
  let text: string;
60
62
  try { text = readFileSync(path, "utf8"); } catch { return {}; }
61
63
  try {
62
64
  const value = JSON.parse(text);
63
- if (value && typeof value === "object" && (value.semanticRecall === undefined || typeof value.semanticRecall === "boolean")) return value;
65
+ if (value && typeof value === "object" && (value.semanticRecall === undefined || typeof value.semanticRecall === "boolean") &&
66
+ (value.cutAt === undefined || validCutAt(value.cutAt))) return value;
64
67
  } catch { /* reported below */ }
65
- return { error: `${path} should be a JSON object like {"semanticRecall": false}` };
68
+ return { error: `${path} should be a JSON object like {"semanticRecall": false, "cutAt": 0.8}, with cutAt a number above 0 and at most 1` };
66
69
  }
67
70
 
68
71
  const reason = (error: unknown) => error instanceof Error ? error.message : String(error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-tuck",
3
- "version": "1.0.1",
3
+ "version": "1.0.4",
4
4
  "description": "Deterministic compaction for Pi: compact summaries that keep the dialogue and the files touched, plus history recall by keyword and by meaning, all local.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/budget.ts CHANGED
@@ -16,8 +16,20 @@ export interface ToolShape { name: string; description: string; parameters: unkn
16
16
 
17
17
  /** Triggers fire this fraction of the room early. The margin absorbs request growth a boundary cannot see; it never enters the calibration factor. */
18
18
  export const TRIGGER_MARGIN_FRACTION = 0.02;
19
- /** The limit a cut is triggered at and preferably planned to; the exact room stays the limit of every throw and fault. */
20
- export const bandOf = (room: number): number => room - Math.ceil(Math.max(0, room) * TRIGGER_MARGIN_FRACTION);
19
+ /** Whether `value` is a cut point: a fraction of the room above 0 and at most 1. */
20
+ export const validCutAt = (value: unknown): value is number => typeof value === "number" && value > 0 && value <= 1;
21
+ /**
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.
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
27
+ * input under automatic and explicit prompt caching (about 17% less at 128k, 10-14% at 32k). */
28
+ export const DEFAULT_CUT_AT = 0.6;
29
+ export const bandOf = (room: number, cutAt = 1): number => {
30
+ const positive = Math.max(0, room);
31
+ return Math.min(room - Math.ceil(positive * TRIGGER_MARGIN_FRACTION), Math.floor(positive * cutAt));
32
+ };
21
33
 
22
34
  /** The APIs whose request converter (pi-ai openai-responses-shared) sends each signed thinking block back as an input reasoning item. */
23
35
  const REPLAYING_APIS: ReadonlySet<string> = new Set(["openai-responses", "azure-openai-responses", "openai-codex-responses"]);
@@ -78,7 +90,7 @@ export const planningReserve = (window: number, targetRoom: number, factor: numb
78
90
  window - Math.floor(targetRoom / factor) + mandatory;
79
91
 
80
92
  export type Target = "band" | "room";
81
- /** A planned cut: the conversation it leaves, and the summary view that produced it (whole dialogue or ranked). */
93
+ /** A planned cut: the conversation it leaves, and the summary view that produced it (whole dialogue or shrunk). */
82
94
  export type Plan<D extends object = {}> = ({ draft: true; after: number; mode: string } & D) | { draft: false; capacityRejected: boolean; reason: string };
83
95
  export type Decision<D extends object = {}> =
84
96
  /** The request fits below the band, or an opportunistic cut cannot help: no cut and no fault; the request goes to the guard. */
@@ -105,26 +117,34 @@ export interface CutInput<D extends object = {}> {
105
117
  export type Verdict<D extends object = {}> = Exclude<Decision<D>, { kind: "defer" }>;
106
118
  /**
107
119
  * Rule 4. The exact room is planned first. When its plan already lands below the band it is taken as it is (target "room"): it keeps the
108
- * room plan's content exactly, because every allowance of the planner is derived from the reserve and the band's is 2% smaller. Only
120
+ * room plan's content exactly, because every allowance of the planner is derived from the reserve and the band's is smaller. Only
109
121
  * otherwise is the band planned. Inside the band a cut is opportunistic, so a failed plan is never a fault and never falls back to the
110
122
  * room (a cut that still lands in the band would trigger again every round); the exact room would cut nothing there, so a band cut is
111
123
  * taken only when the room can plan a cut too and with the same mode (a lossy view the room could not even plan is a quality change with
112
124
  * no fault to prevent). At or above the room, where a cut is due, a band plan of another mode gives way to the room plan (today's
113
125
  * view), and a band plan stands when the room has none (a cut instead of a fault).
126
+ * 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
+ * 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
+ * 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.
114
130
  * The margin adds no new decision fault. Exceptions thrown by `plan` are not caught here: they are faults, and they now surface at
115
131
  * boundaries inside the band too, where no planner ran before the margin.
116
132
  */
117
133
  export function chooseCut<D extends object = {}>(input: CutInput<D> & { opportunistic: boolean }): Verdict<D> {
118
134
  const { before, mandatory, room, band, fits, plan, unreduced } = input;
119
- const exact = plan("room");
120
- if (exact.draft && exact.after < before && fits(exact.after + mandatory, band)) return { kind: "cut", target: "room", plan: exact };
135
+ const lands = (p: Plan<D>): p is Extract<Plan<D>, { draft: true }> => p.draft && p.after < before && fits(p.after + mandatory, band);
136
+ let roomPlan: Plan<D> | undefined;
137
+ const exact = () => roomPlan ??= plan("room");
138
+ if (band >= bandOf(room)) { const e = exact(); if (lands(e)) return { kind: "cut", target: "room", plan: e }; }
121
139
  const preferred = plan("band");
122
140
  if (input.opportunistic) {
123
- if (!(preferred.draft && preferred.after < before && fits(preferred.after + mandatory, band))) return { kind: "none" };
124
- return exact.draft && exact.mode === preferred.mode ? { kind: "cut", target: "band", plan: preferred } : { kind: "none" };
141
+ if (!lands(preferred)) return { kind: "none" };
142
+ const e = exact();
143
+ return e.draft && e.mode === preferred.mode ? { kind: "cut", target: "band", plan: preferred } : { kind: "none" };
125
144
  }
145
+ const e = exact();
126
146
  let chosen: Plan<D> = preferred, target: Target = "band";
127
- if (!preferred.draft || (exact.draft && exact.mode !== preferred.mode)) { chosen = exact; target = "room"; }
147
+ if (!preferred.draft || (e.draft && e.mode !== preferred.mode)) { chosen = e; target = "room"; }
128
148
  if (!chosen.draft) return { kind: "fault", capacity: chosen.capacityRejected, message: chosen.reason };
129
149
  if (chosen.after >= before || !fits(chosen.after + mandatory, room))
130
150
  return { kind: "fault", capacity: !fits(chosen.after + mandatory, room), message: unreduced };
package/src/build-id.ts CHANGED
@@ -1 +1 @@
1
- export const BUILD_ID = "2a0ebe00760f2226d888fbca503dbde44d32623cb644186a6dd2eef59b65fbe5";
1
+ export const BUILD_ID = "fb0ab667f5bfcd22fd5cc6edb89cd859426fd7c28bb58c2bc7b9fbec6fcdd3da";
package/src/compaction.ts CHANGED
@@ -5,7 +5,7 @@ import { exportSource } from "./source-export.js";
5
5
  import { isDeepStrictEqual } from "node:util";
6
6
  import { assertNativeToolRounds } from "./protocol.js";
7
7
  import { projectedMessages } from "./omissions.js";
8
- import { bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart,
8
+ import { DEFAULT_CUT_AT, bandOf, chooseCut, conversationTokens, decideCut, gateCut, incomingEventTokens, incomingTokens, measurableConversationTokens, planningReserve, projectedConversationTokens, requestTokens, systemPart, validCutAt,
9
9
  type AgentMessage, type Messages, type Plan, type Target, type ToolShape } from "./budget.js";
10
10
  import { isContextOverflow } from "@earendil-works/pi-ai";
11
11
  import { convertToLlm, SessionManager, SettingsManager, type ExtensionAPI, type ExtensionContext, type SessionEntry } from "@earendil-works/pi-coding-agent";
@@ -40,7 +40,17 @@ const RecallInput = Type.Union([
40
40
  ]);
41
41
  const SearchInput = Type.Object({query,priorResults:Type.Optional(Type.Array(Type.Object({entryId,sourceSha:Type.String({minLength:64,maxLength:64}),start:Type.Integer({minimum:0}),end:Type.Integer({minimum:0})},{additionalProperties:false}),{maxItems:20}))},{additionalProperties:false});
42
42
  const reply = (value: unknown) => ({ content: [{ type: "text" as const, text: JSON.stringify(value) }], details: {} });
43
- class CapacityError extends Error {}
43
+ /** The compactor's verdict that this attempt cannot be made: the cut it needs cannot be made, or the request cannot fit. It is a decision about the
44
+ * state as it was, so it ends with the attempt (`forgetRefusal`) and the next attempt decides afresh. Any other failure is a fault in the compactor
45
+ * or its host, and stays until the session restarts. */
46
+ class Refusal extends Error {}
47
+ /** A refusal for want of room, which the user can remedy: it carries advice. */
48
+ class CapacityError extends Refusal {}
49
+ /** What a capacity refusal tells the operator. When the provider has counted far more input than pi-tuck's estimate, the refusal most
50
+ * likely comes from that counting (an upstream behind a router can spend much more context on tool schemas), so the notice says so. */
51
+ export const refusalNotice = (message: string, factor: number): string =>
52
+ message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry." +
53
+ (factor >= 1.5 ? ` This provider has counted up to ${factor.toFixed(1)}x the input pi-tuck estimates; through a router such as OpenRouter, a different upstream may count less.` : "");
44
54
  /** The most a seed or an overflow raise may set the calibration factor to. A paired reply's own ratio is not bounded by it. */
45
55
  export const CALIBRATION_CEILING = 1.5;
46
56
  /** How many recent paired replies (and overflow bounds, and the seed while it is among them) the calibration factor is the maximum of. */
@@ -83,6 +93,10 @@ export interface CompactionOptions {
83
93
  /** Optional estimated complete-input ceiling, separate from physical output reservation.
84
94
  * The host owns final serialization/transport counting; this is not an exact wire-token cap. */
85
95
  activeInputTokens?: number | ((ctx: ExtensionContext) => number);
96
+ /** 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,
97
+ * 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
98
+ * stays the limit of every throw, fault and the complete-context guard. Anything else is rejected when the compactor is installed. */
99
+ cutAt?: number;
86
100
  /** The response reserve: the input room is the context window less this. By default the room never exceeds the window less the model's
87
101
  * 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
88
102
  * transport's behavior declares an exact reserve with `responseExact: true`, and the room is then the window less `response` exactly. */
@@ -101,6 +115,8 @@ export interface CompactionOptions {
101
115
  }
102
116
 
103
117
  export async function installCompaction(pi: ExtensionAPI, options: CompactionOptions = {}) {
118
+ const cutAt = options.cutAt ?? DEFAULT_CUT_AT;
119
+ if (!validCutAt(cutAt)) throw new Error("Invalid cut point: cutAt must be a number above 0 and at most 1");
104
120
  const semantic = options.semanticRecall ?? createSemanticRecall();
105
121
  let semanticTransition: Promise<void> = Promise.resolve();
106
122
  let semanticRequest = 0;
@@ -115,7 +131,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
115
131
  if (found) { recorder.capture("observed-entry", "current-native-branch-memory-not-durable", { entryId: found.id, diagnosticReceiptId: proposalId, details: found.type === "compaction" ? found.details : undefined }); recorder.cut(); proposalId = undefined; }
116
132
  });
117
133
  const captureDraft = (draft: NonNullable<ReturnType<typeof tuckDraft>["compaction"]>, ctx: ExtensionContext, reserve: number, reason: string, tokensBefore: number, retainCurrentInput: boolean, operatorManual: boolean, previousSummary?: string, budget?: { response: number; host: number; mandatory: number; target: Target; margin: number }) => recorder.measureInline(() => {
118
- const attached = attachReceipt(draft, reason, reserve, ctx.model?.contextWindow ?? 0, { responseReserveTokens: budget?.response, hostInputTokens: budget?.host, mandatoryInputTokens: budget?.mandatory, target: budget?.target, triggerMarginTokens: budget?.margin, calibrationFactor: factor, calibrationWindow: [...ratios], calibrationIdentity: identity, activeInputCeilingTokens: lastCeiling });
134
+ const attached = attachReceipt(draft, reason, reserve, ctx.model?.contextWindow ?? 0, { responseReserveTokens: budget?.response, hostInputTokens: budget?.host, mandatoryInputTokens: budget?.mandatory, target: budget?.target, triggerMarginTokens: budget?.margin, ...(cutAt === 1 ? {} : { cutAt }), calibrationFactor: factor, calibrationWindow: [...ratios], calibrationIdentity: identity, activeInputCeilingTokens: lastCeiling });
119
135
  proposalId = receiptId(attached);
120
136
  if (recorder.enabled) { try {
121
137
  const filtered = permittedEntries(ctx.sessionManager.getEntries(), options.recallPolicy);
@@ -186,7 +202,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
186
202
  // The cut planner's raw reserve for the target limit, on the same factor as every other consumer.
187
203
  const planReserve = (target: Target, room: number, mandatory: number, ctx: ExtensionContext) => {
188
204
  identityFor(ctx);
189
- return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? bandOf(room) : room, factor, mandatory);
205
+ return planningReserve(ctx.model?.contextWindow ?? 0, target === "band" ? bandOf(room, cutAt) : room, factor, mandatory);
190
206
  };
191
207
  pi.on("message_end", (event, ctx) => {
192
208
  if (event.message.role !== "assistant") return;
@@ -214,7 +230,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
214
230
  if (input > 0 && Number.isSafeInteger(input) && Number.isFinite(ratio)) learn(ratio);
215
231
  });
216
232
  pi.on("agent_start", () => { admittedRun = true; pending = undefined; phase = "idle"; });
217
- pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; });
233
+ pi.on("agent_settled", () => { admittedRun = false; pending = undefined; phase = "idle"; forgetRefusal(); });
218
234
  const active = () => options.active?.() ?? true;
219
235
  const settingsFor = () => options.settings ?? SettingsManager.inMemory(pi.getSettings());
220
236
  // The standalone default reserves the model's catalogue maximum output: a transport may omit the output cap, and then only that
@@ -237,10 +253,10 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
237
253
  throw new Error("Invalid active-input token ceiling");
238
254
  return Math.min((ctx.model?.contextWindow ?? 0) - response, ceiling ?? Infinity);
239
255
  };
240
- // The exact room, and the band a cut is triggered at (budget.ts); every throw and every fault uses the exact room.
256
+ // 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.
241
257
  const limits = (ctx: ExtensionContext) => {
242
258
  const { response } = requestReserve(ctx), room = inputRoom(response, ctx);
243
- return { response, room, band: bandOf(room) };
259
+ return { response, room, band: bandOf(room, cutAt) };
244
260
  };
245
261
  const fits = (tokens: number, limit: number, ctx: ExtensionContext) => corrected(tokens, ctx) < limit;
246
262
  // The saved conversation and a cut's quantities are counted by the one function the guard counts with (strict, as the context hook is).
@@ -268,10 +284,13 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
268
284
  pendingBoundaryCommit = false;
269
285
  pending = undefined;
270
286
  try { options.fail?.(error); } finally {
271
- try { if (notify) ctx.ui.notify(failure.message + "; shorten the input, use a model with a larger window, or lower Pi's compaction.reserveTokens, then retry.", "error"); }
287
+ try { if (notify) ctx.ui.notify(refusalNotice(failure.message, factor), "error"); }
272
288
  finally { ctx.abort(); }
273
289
  }
274
290
  };
291
+ // The one place a refusal is dropped: when its attempt has settled, when a prompt arrives, and when the operator asks for a cut. A run that no
292
+ // input started (a custom message) has only the first of these.
293
+ const forgetRefusal = () => { if (failure instanceof Refusal) failure = undefined; };
275
294
  const access = () => {
276
295
  if (failure) throw failure;
277
296
  if (!active()) throw new Error("Compaction host is inactive");
@@ -284,7 +303,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
284
303
  let phase: "idle" | "decided" | "starting" = "idle";
285
304
  pi.on("input", async (event, ctx) => {
286
305
  if (!ctx.isIdle() || phase === "starting") return; // a steering or follow-up message joins the running request
287
- if (failure instanceof CapacityError) failure = undefined;
306
+ forgetRefusal();
288
307
  phase = "decided";
289
308
  try { await preflight(ctx, incomingTokens(event.text, event.images), ctx.getSystemPrompt(), pi.getActiveTools()); }
290
309
  catch (error) { fail(error, ctx); }
@@ -340,7 +359,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
340
359
  // Pi also labels the awaited idle incoming preflight "manual".
341
360
  const incoming = pendingCut;
342
361
  const operatorManual = event.reason === "manual" && !(incoming && !admittedRun);
343
- if (operatorManual && !admittedRun && failure instanceof CapacityError) failure = undefined;
362
+ if (operatorManual && !admittedRun) forgetRefusal();
344
363
  access();
345
364
  if (event.customInstructions?.trim()) {
346
365
  recorder.stop("no-cut-custom-instructions");
@@ -385,7 +404,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
385
404
  if (decision.kind === "none") return { cancel: true };
386
405
  recorder.stop("no-cut");
387
406
  ctx.ui.notify(decision.message, "warning");
388
- if (!operatorManual) fail(decision.capacity ? new CapacityError(decision.message) : new Error(decision.message), ctx);
407
+ if (!operatorManual) fail(new (decision.capacity ? CapacityError : Refusal)(decision.message), ctx);
389
408
  return { cancel: true };
390
409
  } catch (error) {
391
410
  try { fail(error, ctx); } finally { return { cancel: true }; }
@@ -432,14 +451,14 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
432
451
  // planner declined it, Pi had nothing to compact, an earlier handler cancelled it) it is no fault, unless a failure is latched
433
452
  // or the compactor proposed a cut that then failed to commit (the session may hold what the file lacks).
434
453
  if (cut.opportunistic && !failure && !cut.proposed) resolve();
435
- else reject(error.message === "Compaction cancelled" && failure instanceof CapacityError ? failure : error);
454
+ else reject(error.message === "Compaction cancelled" && failure instanceof Refusal ? failure : error);
436
455
  } }));
437
456
  } finally { pendingCut = undefined; }
438
457
  access();
439
458
  // The cut awaited: take the snapshot again. An opportunistic cut never throws; the request is not over the room, and the guard decides.
440
459
  const next = snapshot(), after = next.before + next.mandatory, afterFits = fits(after, limits(ctx).room, ctx);
441
460
  if (!cut.opportunistic && (after >= total || !afterFits))
442
- throw new (afterFits ? Error : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
461
+ throw new (afterFits ? Refusal : CapacityError)("Incoming Pi preflight compaction did not reduce context within the estimated request budget");
443
462
  };
444
463
  pi.on("before_agent_start", async (event, ctx) => {
445
464
  const prompted = phase === "decided";
@@ -486,7 +505,7 @@ export async function installCompaction(pi: ExtensionAPI, options: CompactionOpt
486
505
  return { draft: true, after: measure(messages), mode: draft.mode, compaction: draft.compaction, reserve };
487
506
  },
488
507
  });
489
- if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Error)(decision.message);
508
+ if (decision.kind === "fault") throw new (decision.capacity ? CapacityError : Refusal)(decision.message);
490
509
  if (decision.kind !== "cut") return;
491
510
  const compaction = captureDraft(decision.plan.compaction, ctx, decision.plan.reserve, "tool-loop", before, true, false, previousSummary,
492
511
  { response, host, mandatory, target: decision.target, margin: room - band });
@@ -7,7 +7,7 @@ import { homedir } from 'node:os';
7
7
  import { createRecall, type RecallPolicy } from './recall.js';
8
8
  import type { SessionEntry } from '@earendil-works/pi-coding-agent';
9
9
  export const DIAGNOSTIC_BUILD = BUILD_ID;
10
- export interface Receipt { id: string; build: string; reason: string; selectedView: string; boundary: string; settings: { draftReservedTokens: number; contextWindow: number; responseReserveTokens?: number; hostInputTokens?: number; mandatoryInputTokens?: number; target?: 'band' | 'room'; triggerMarginTokens?: number; calibrationFactor?: number; calibrationWindow?: number[]; calibrationIdentity?: string; activeInputCeilingTokens?: number | null }; before: { value: number; unit: 'estimated-input-tokens'; scope: 'native-preparation' | 'public-conversation' }; after: { value: number | null; unit: 'estimated-input-tokens'; scope: 'draft-preview' }; }
10
+ export interface Receipt { id: string; build: string; reason: string; selectedView: string; boundary: string; settings: { draftReservedTokens: number; contextWindow: number; responseReserveTokens?: number; hostInputTokens?: number; mandatoryInputTokens?: number; target?: 'band' | 'room'; triggerMarginTokens?: number; cutAt?: number; calibrationFactor?: number; calibrationWindow?: number[]; calibrationIdentity?: string; activeInputCeilingTokens?: number | null }; before: { value: number; unit: 'estimated-input-tokens'; scope: 'native-preparation' | 'public-conversation' }; after: { value: number | null; unit: 'estimated-input-tokens'; scope: 'draft-preview' }; }
11
11
  export function attachReceipt<T extends { details?: unknown; firstKeptEntryId: string; tokensBefore: number }>(draft: T, reason: string, reserveTokens: number, contextWindow: number, resolved: Partial<Receipt['settings']> = {}): T & { details: Record<string, unknown> & { diagnosticReceipt: Receipt } } {
12
12
  const details = draft.details && typeof draft.details === 'object' ? draft.details as Record<string, unknown> : {};
13
13
  const receipt: Receipt = { id: randomUUID(), build: DIAGNOSTIC_BUILD, reason, selectedView: typeof details.view === 'string' ? details.view : 'deterministic-selected-draft', boundary: draft.firstKeptEntryId, settings: { draftReservedTokens: reserveTokens, contextWindow, ...resolved }, before: { value: draft.tokensBefore, unit: 'estimated-input-tokens', scope: reason === 'tool-loop' ? 'public-conversation' : 'native-preparation' }, after: { value: typeof details.tokensAfter === 'number' ? details.tokensAfter : null, unit: 'estimated-input-tokens', scope: 'draft-preview' } };
package/src/omissions.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ExtensionContext, SessionEntry } from '@earendil-works/pi-coding-agent';
2
2
  import { buildGlobalIndexById } from './tuck/core/global-indices';
3
- import { TRANSCRIPT_HEADER, parseRecords, splitSummary } from './tuck/core/transcript';
3
+ import { parseRecords, splitSummary, transcriptHeader } from './tuck/core/transcript';
4
4
  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> {
@@ -12,30 +12,30 @@ export function omittedSourceIndices(entries: readonly SessionEntry[], branch: r
12
12
  }
13
13
  return new Set([...edits].flatMap(([id, omitted]) => omitted && indices.has(id) ? [indices.get(id)!] : []));
14
14
  }
15
- /** Preserve surviving record bytes/order. No text matching or semantic inference. */
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 {
17
17
  if (!omitted.size) return summary;
18
18
  const split = splitSummary(summary);
19
19
  if (!split) throw new Error('Cannot apply native source omission to an unsupported compaction summary');
20
- const { body, closing } = split;
21
- const records = parseRecords(body), lines = body ? body.split('\n') : [];
20
+ const { body, closing, version } = split;
21
+ const records = parseRecords(body, version), lines = body ? body.split('\n') : [];
22
22
  const retained = records.flatMap((record, i) =>
23
23
  (record.sourceIndex !== undefined && omitted.has(record.sourceIndex)) || record.sourceIndices?.some(index => omitted.has(index)) ? [] : [lines[i]!]);
24
- return TRANSCRIPT_HEADER + '\n' + retained.join('\n') + closing;
24
+ return transcriptHeader(version) + '\n' + retained.join('\n') + closing;
25
25
  }
26
26
 
27
27
  /** Custom quotes carry native entry identity; no text matching or legacy fallback. */
28
28
  export function filterCustomQuotes(summary: string, entries: readonly SessionEntry[], branch: readonly SessionEntry[], policy: RecallPolicy = {}): string {
29
29
  const split = splitSummary(summary);
30
30
  if (!split) throw new Error('Cannot resolve custom sources in an unsupported compaction summary');
31
- const { body, closing } = split;
31
+ const { body, closing, version } = split;
32
32
  const omitted = new Set(branch.flatMap(entry => entry.type === 'context_edit' && entry.replacement === null ? [entry.targetId] : []));
33
33
  const eligible = createRecall(policy).isHistorySource, indices = buildGlobalIndexById(entries);
34
34
  const globalCounts = new Map<string, number>(), branchSources = new Map<string, SessionEntry[]>();
35
35
  for (const entry of entries) globalCounts.set(entry.id, (globalCounts.get(entry.id) ?? 0) + 1);
36
36
  for (const entry of branch) { const found = branchSources.get(entry.id) ?? []; found.push(entry); branchSources.set(entry.id, found); }
37
37
  const lines = body ? body.split('\n') : [];
38
- const records = parseRecords(body);
38
+ const records = parseRecords(body, version);
39
39
  const retained = records.flatMap((record, i) => {
40
40
  if (record.role !== 'custom') return [lines[i]!];
41
41
  const id = record.sourceEntryId, source = id === undefined ? undefined : branchSources.get(id);
@@ -45,7 +45,7 @@ export function filterCustomQuotes(summary: string, entries: readonly SessionEnt
45
45
  if (!(entry.type === 'custom_message' || entry.type === 'message' && entry.message.role === 'custom') || !eligible(entry)) return [];
46
46
  return [lines[i]!];
47
47
  });
48
- return TRANSCRIPT_HEADER + '\n' + retained.join('\n') + closing;
48
+ return transcriptHeader(version) + '\n' + retained.join('\n') + closing;
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. */