pi-codex-compaction 0.1.2 → 0.1.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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,21 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.1.4] - 2026-09-15
10
+
11
+ ### Added
12
+
13
+ - Show `[compaction (codex)] Checkpoint saved.` in the TUI after a Codex checkpoint is saved, to distinguish it from standard Pi compaction.
14
+
15
+ ## [0.1.3] - 2026-09-11
16
+
17
+ ### Fixed
18
+
19
+ - Estimate compaction tokens separately from request bytes so long Codex sessions do not fall back merely because UTF-8 bytes exceed the token budget.
20
+ - Respect request-transform field removals when sizing, restrict warnings to TUI mode, and distinguish preparation failures from transport failures.
21
+ - Preserve the independent 16 MiB request ceiling, count the complete transformed envelope, and reject unavailable context limits.
22
+ - Record safe fallback reasons and numeric size diagnostics in local custom session entries; notify when UI is available without exposing request or provider content.
23
+
9
24
  ## [0.1.2] - 2026-09-11
10
25
 
11
26
  ### Fixed
package/CONTRIBUTING.md CHANGED
@@ -29,6 +29,33 @@ Before opening a pull request:
29
29
  - Preserve current-model compaction, context bounds, cancellation, HTTPS, and standard Pi fallback behavior.
30
30
  - Add a regression test when changing wire parsing, request construction, checkpoint rehydration, or model-switch fallback behavior.
31
31
 
32
+ ## Token-budget regression and smoke checks
33
+
34
+ `tests/integration.test.mjs` uses the real Pi compaction hook, serializer, and
35
+ session tree with fake credentials and mocked Responses transport. A synthetic
36
+ long transcript must produce a remote checkpoint even when its UTF-8 bytes
37
+ exceed the numeric token budget. Oversized input must still invoke the standard
38
+ compactor and record a safe fallback reason. A later model request must not
39
+ contain the diagnostic entry. No private session fixtures or live requests are
40
+ needed for these tests.
41
+
42
+ The token conversion follows Codex's ordinary JSON-item heuristic:
43
+ [byte-to-token estimate](https://github.com/openai/codex/blob/654b0a77d0d2f81aa21f61caf7af4be88fe550bb/codex-rs/utils/string/src/truncate.rs)
44
+ and [history sizing](https://github.com/openai/codex/blob/654b0a77d0d2f81aa21f61caf7af4be88fe550bb/codex-rs/core/src/context_manager/history.rs).
45
+ This package keeps all serialized opaque/image bytes in its estimate instead of
46
+ copying Codex's modality-specific discounts. Never equate bytes and tokens or
47
+ remove the independent wire-size ceiling.
48
+
49
+ For an offline replay, reconstruct a compaction's discarded history in memory,
50
+ use fake authentication, replace `fetch` with a fixture checkpoint response,
51
+ and call `createRemoteCompaction`. Print only sizes and the success/fallback
52
+ code. Do not save the transcript, request, auth headers, or opaque content.
53
+
54
+ For an approved live smoke test, follow the procedure in [README.md](./README.md#reference-and-smoke-test).
55
+ Check for `fromHook: true` and `details.kind: "pi-codex-compaction"` on success.
56
+ On fallback, inspect only the reason and counters in the custom diagnostic
57
+ entry. Never inspect or print `encryptedContent`.
58
+
32
59
  ## Code of conduct
33
60
 
34
61
  This project follows the Contributor Covenant Code of Conduct.
package/README.md CHANGED
@@ -9,7 +9,7 @@ Keep long Pi sessions usable on OpenAI Codex models by replacing Pi's local summ
9
9
  - Retains the normal Codex Responses request envelope, including system instructions, active tool schemas, reasoning settings, prompt-cache fields, and routing fields.
10
10
  - Persists Codex's opaque encrypted checkpoint and rehydrates it only for supported Codex requests.
11
11
  - Reuses checkpoints only for the same model, trusted endpoint, Codex account, and authentication mode.
12
- - Bounds input with a UTF-8-aware token estimate, trims tool output when necessary, retries transient failures, and honors cancellation.
12
+ - Bounds input with a Codex-style UTF-8 token estimate and a separate hard byte limit, trims tool output when necessary, retries transient failures, and honors cancellation.
13
13
  - Falls back to standard Pi compaction on failure or when custom compaction instructions are requested.
14
14
  - Keeps a bounded readable transcript excerpt so switching models or providers remains usable.
15
15
 
@@ -35,11 +35,51 @@ pi -e /path/to/pi-mono/packages/pi-codex-compaction
35
35
 
36
36
  ## Behavior
37
37
 
38
+ After a Codex checkpoint is saved, the TUI shows
39
+ `[compaction (codex)] Checkpoint saved.` Pi's built-in `[compaction]` heading
40
+ remains unchanged. The notice is not added to model context or stored in the
41
+ session, and is not replayed on reload. Standard Pi compaction does not show it.
42
+ Print, JSON, and RPC modes receive no extra notification.
43
+
38
44
  When Pi starts compaction on a supported Codex model, the extension sends a streamed Responses request whose `input` contains only discardable history, any compatible prior checkpoint, and a `compaction_trigger` item. The normal request envelope is retained because Codex's compaction path is parity-tested against ordinary Responses requests; this includes the effective system prompt, active tool definitions, reasoning level, prompt-cache fields, and routing fields. The request uses the `remote_compaction_v2` beta feature, and the returned opaque checkpoint and bounded provider usage are stored in the Pi compaction entry. Later requests rehydrate the raw checkpoint only when the model, endpoint, account, and authentication mode match; other providers/models receive the bounded textual fallback instead.
39
45
 
40
46
  Compaction uses the model active when Pi triggers it. If a session switches from a larger to a smaller model, the remote request is bounded against the new model's context window and tool outputs are reduced before sending. A previous opaque checkpoint is treated as incompatible after a model, endpoint, account, or authentication-mode switch; Pi's readable previous summary is sent instead. If the full request still cannot fit, the extension leaves compaction to Pi's normal implementation.
41
47
 
42
- If the remote request fails, is cancelled, or returns an unexpected response, Pi's standard compaction path runs. Custom compaction instructions also use Pi's standard path because RemoteCompactionV2 has no documented custom-instructions field. The direct checkpoint request is restricted to `https://chatgpt.com`, rejects redirects, limits request/response size, and never decodes or logs `encrypted_content`. No configuration is required.
48
+ If the remote request fails or returns an unexpected response, Pi's standard compaction path runs. Cancellation remains cancelled. Custom compaction instructions also use Pi's standard path because RemoteCompactionV2 has no documented custom-instructions field. The direct checkpoint request is restricted to `https://chatgpt.com`, rejects redirects, limits request/response size, and never decodes or logs `encrypted_content`. No configuration is required.
49
+
50
+ ### Size limits
51
+
52
+ Input is estimated as `ceil(UTF-8 request bytes / 4)`, with 8,192 tokens reserved
53
+ from the active model's context window. This uses Codex's ordinary-item heuristic,
54
+ not an exact tokenizer. The complete transformed request is counted, including
55
+ system instructions, tool definitions, and routing fields. Opaque checkpoints
56
+ and image data remain counted at their serialized size; they are not decoded or
57
+ discounted. Non-ASCII text uses UTF-8 bytes, not JavaScript string length.
58
+
59
+ The uncompressed request also has an independent **16 MiB hard limit**. Tool
60
+ outputs are reduced only when one of these limits is exceeded. User messages,
61
+ tool calls, and opaque checkpoints are not removed. If the remaining request
62
+ still cannot fit, or the model's context limit is unknown, standard Pi compaction
63
+ runs. The estimate can differ from the server's token count; a server rejection
64
+ still uses the existing fallback.
65
+
66
+ ### Fallback diagnostics
67
+
68
+ A fallback on a supported model records a local custom session entry with type
69
+ `pi-codex-compaction:fallback:v1`. It contains `version: 1` and a reason:
70
+ `custom-instructions`, `auth-unavailable`, `request-unavailable`,
71
+ `context-window-unavailable`, `context-limit`, `request-size-limit`, or
72
+ `remote-failed`. Size failures also include estimated tokens, token budget,
73
+ request bytes, byte limit, and the number of tool outputs reduced.
74
+ Unexpected preparation failures use `request-unavailable`; `remote-failed`
75
+ is reserved for failures from the transport call.
76
+
77
+ No prompt, tool content, encrypted checkpoint, account identifier, credential, or
78
+ raw provider error is included. These entries are not sent to the model. Pi
79
+ shows a warning only in TUI mode when notifications are available; print, JSON,
80
+ and RPC modes get no extra notifications or console output. Unsupported models
81
+ and cancelled attempts do not create fallback diagnostics. Diagnostic storage
82
+ or notification failure does not stop the standard compactor.
43
83
 
44
84
  ## Development
45
85
 
@@ -61,6 +101,7 @@ versioned `pi.events` contracts let cooperating extensions supply it:
61
101
  - `pi-codex-compaction:request:v1`: `{ ctx, messages, payload }`, after input
62
102
  assembly and before size checks. A listener can replace `payload`. This event
63
103
  is not the general `before_provider_request` chain and does not carry auth.
104
+ Size checks use the transformed envelope, including field removals.
64
105
 
65
106
  Other extensions' private request changes are not applied automatically.
66
107
  Unknown third-party grammar metadata needs cooperation through the tools event.
@@ -78,6 +119,9 @@ API. Async tools and mid-turn steering require upstream Pi support.
78
119
 
79
120
  For a small live test, load this package and select `openai-codex/gpt-6-astra`.
80
121
  Send two short messages, run `/compact`, then ask about the first message.
122
+ Confirm `[compaction (codex)] Checkpoint saved.` appears. Then run
123
+ `/compact Focus on recent work` and confirm the standard-compaction warning
124
+ appears without a Codex success notice.
81
125
  Repeat with `/fast on` and `pi-codex-tools` loaded. Check that compaction succeeds,
82
126
  the continuation retains context, and session usage includes compaction tokens.
83
127
  Use only a temporary file if you test `apply_patch`. Do not generate images.
package/SECURITY.md CHANGED
@@ -35,4 +35,18 @@ Cooperating local extensions can inspect and transform compaction inputs through
35
35
  the documented event bus before size checks. These events contain no credentials.
36
36
  They have the same trust level as other installed Pi extensions.
37
37
 
38
+ Context sizing uses `ceil(UTF-8 serialized request bytes / 4)` with an 8,192-token
39
+ reserve from the active model's context window. It is an estimate, not a strict
40
+ tokenizer bound. The complete transformed envelope is counted, including opaque
41
+ content at its serialized size. A separate 16 MiB uncompressed request limit is
42
+ enforced before network I/O. The HTTPS, redirect, response-size, checkpoint
43
+ compatibility, and cancellation checks remain independent of token estimation.
44
+
45
+ Fallback diagnostics store only a fixed reason code and finite non-negative
46
+ size counters in `pi-codex-compaction:fallback:v1` custom session entries.
47
+ These local records never include credentials, account/model identifiers,
48
+ request content, encrypted checkpoints, or raw errors, and do not enter model
49
+ context. No external diagnostic telemetry is added. They use the existing
50
+ session's permissions and retention policy.
51
+
38
52
  See [CONTRIBUTING.md](./CONTRIBUTING.md) for development and validation instructions.
@@ -7,6 +7,8 @@ import { BETA_FEATURE, getCodexAccountFingerprint } from "../src/codex-wire.js";
7
7
  import { reportInstallTelemetry } from "../src/install-telemetry.js";
8
8
  import {
9
9
  applyRemoteCompactionMarker,
10
+ COMPACTION_FALLBACK_ENTRY,
11
+ type CompactionFallback,
10
12
  createRemoteCompaction,
11
13
  findActiveRemoteCompaction,
12
14
  getCodexAuthKind,
@@ -14,12 +16,46 @@ import {
14
16
  supportsRemoteCompaction,
15
17
  } from "../src/remote-compaction.js";
16
18
 
19
+ const FALLBACK_MESSAGES: Record<CompactionFallback["reason"], string> = {
20
+ "custom-instructions": "custom compaction instructions require the standard compactor",
21
+ "auth-unavailable": "Codex authentication is unavailable",
22
+ "request-unavailable": "the Codex request could not be prepared",
23
+ "context-window-unavailable": "the active model's context limit is unavailable",
24
+ "context-limit": "the estimated input exceeds the active model's token budget",
25
+ "request-size-limit": "the request exceeds the 16 MiB byte limit",
26
+ "remote-failed": "the remote request failed",
27
+ };
28
+
17
29
  export default function piCodexCompaction(pi: ExtensionAPI): void {
18
30
  reportInstallTelemetry();
19
31
 
20
32
  const onBeforeCompact: ExtensionHandler<SessionBeforeCompactEvent, { compaction?: NonNullable<Awaited<ReturnType<typeof createRemoteCompaction>>> }> = async (event, ctx) => {
21
33
  if (!supportsRemoteCompaction(ctx.model)) return undefined;
22
34
 
35
+ let reported = false;
36
+ const reportFallback = (diagnostic: CompactionFallback) => {
37
+ if (reported || event.signal.aborted) return;
38
+ reported = true;
39
+ // No text, model/account identifiers, headers, or provider errors belong
40
+ // in diagnostics. Custom entries do not enter the model's context.
41
+ const data: Record<string, unknown> = { version: 1, reason: diagnostic.reason };
42
+ for (const key of ["estimatedTokens", "tokenBudget", "requestBytes", "byteLimit", "trimmedToolOutputs"] as const) {
43
+ const value = diagnostic[key];
44
+ if (typeof value === "number" && Number.isFinite(value) && value >= 0) data[key] = value;
45
+ }
46
+ try {
47
+ pi.appendEntry(COMPACTION_FALLBACK_ENTRY, data);
48
+ } catch {
49
+ // Diagnostics must not prevent the standard compactor from running.
50
+ }
51
+ if (ctx.mode === "tui" && ctx.hasUI) {
52
+ try {
53
+ ctx.ui.notify(`Codex remote compaction skipped: ${FALLBACK_MESSAGES[diagnostic.reason]}. Using standard Pi compaction.`, "warning");
54
+ } catch {
55
+ // Notification failure must not change compaction behavior either.
56
+ }
57
+ }
58
+ };
23
59
  try {
24
60
  const compaction = await createRemoteCompaction(event, ctx, () => {
25
61
  const active = new Set(pi.getActiveTools());
@@ -37,17 +73,27 @@ export default function piCodexCompaction(pi: ExtensionAPI): void {
37
73
  // and before network I/O. Never include auth in this event.
38
74
  pi.events?.emit("pi-codex-compaction:request:v1", data);
39
75
  return data.payload;
40
- });
76
+ }, reportFallback);
41
77
  return compaction ? { compaction } : undefined;
42
78
  } catch {
43
- if (!event.signal.aborted && ctx.hasUI) {
44
- ctx.ui.notify("Codex remote compaction failed; using standard Pi compaction.", "warning");
45
- }
79
+ // Transport failures already have a reason; other exceptions mean the
80
+ // compaction request could not be prepared or processed.
81
+ reportFallback({ reason: "request-unavailable" });
46
82
  return undefined;
47
83
  }
48
84
  };
49
85
  pi.on("session_before_compact", onBeforeCompact);
50
86
 
87
+ pi.on("session_compact", (event, ctx) => {
88
+ if (ctx.mode !== "tui" || !ctx.hasUI || !event.fromExtension) return;
89
+ if (!findActiveRemoteCompaction([event.compactionEntry])) return;
90
+ try {
91
+ ctx.ui.notify("[compaction (codex)] Checkpoint saved.", "info");
92
+ } catch {
93
+ // Display failures must not affect a saved checkpoint or continuation.
94
+ }
95
+ });
96
+
51
97
  pi.on("before_provider_headers", (event, ctx) => {
52
98
  if (!supportsRemoteCompaction(ctx.model)) return;
53
99
  const existing = event.headers["x-codex-beta-features"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-codex-compaction",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Use OpenAI Codex RemoteCompactionV2 for supported Pi sessions.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/codex-wire.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
 
3
- const MAX_REQUEST_BYTES = 16 * 1024 * 1024;
3
+ export const MAX_COMPACTION_REQUEST_BYTES = 16 * 1024 * 1024;
4
4
  const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
5
5
  const MAX_ENCRYPTED_CONTENT_CHARS = 2_000_000;
6
6
  const REQUEST_HEADER_TIMEOUT_MS = 30_000;
@@ -57,7 +57,7 @@ export async function requestRemoteCompactionWithUsage(
57
57
  const accountId = extractAccountId(request.apiKey);
58
58
  const headers = buildHeaders(request, accountId);
59
59
  const body = JSON.stringify(request.body);
60
- if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES) {
60
+ if (Buffer.byteLength(body, "utf8") > MAX_COMPACTION_REQUEST_BYTES) {
61
61
  throw new Error("Codex compaction request exceeded the size limit");
62
62
  }
63
63
 
@@ -5,6 +5,7 @@ import { convertToLlm, serializeConversation, sessionEntryToContextMessages } fr
5
5
  import type { SessionEntry } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
7
  getCodexAccountFingerprint,
8
+ MAX_COMPACTION_REQUEST_BYTES,
8
9
  requestRemoteCompactionWithUsage,
9
10
  resolveCodexResponsesUrl,
10
11
  type CodexCompactionUsage,
@@ -12,10 +13,24 @@ import {
12
13
 
13
14
  export const REMOTE_SUMMARY_MARKER = "[pi-codex-compaction:v1]";
14
15
  export const REMOTE_COMPACTION_KIND = "pi-codex-compaction";
16
+ export const COMPACTION_FALLBACK_ENTRY = "pi-codex-compaction:fallback:v1";
17
+
18
+ export interface CompactionFallback {
19
+ reason: "custom-instructions" | "auth-unavailable" | "request-unavailable" |
20
+ "context-window-unavailable" | "context-limit" | "request-size-limit" | "remote-failed";
21
+ estimatedTokens?: number;
22
+ tokenBudget?: number;
23
+ requestBytes?: number;
24
+ byteLimit?: number;
25
+ trimmedToolOutputs?: number;
26
+ }
15
27
 
16
28
  const FALLBACK_SUMMARY_MAX_CHARS = 12_000;
17
29
  const MAX_ENCRYPTED_CONTENT_CHARS = 2_000_000;
18
30
  const COMPACTION_RESPONSE_RESERVE_TOKENS = 8_192;
31
+ // Codex's ordinary-item estimate uses ceil(UTF-8 bytes / 4), not bytes as
32
+ // tokens. This is a heuristic, not a tokenizer or a context-fit guarantee.
33
+ const APPROX_BYTES_PER_TOKEN = 4;
19
34
  const TRUNCATED_TOOL_OUTPUT = "[Tool output omitted from the Codex compaction request to fit the active model context window.]";
20
35
  const PI_COMPACTION_SUMMARY_PREFIX = "The conversation history before this point was compacted into the following summary:";
21
36
 
@@ -96,6 +111,7 @@ export async function createRemoteCompaction(
96
111
  getTools: () => readonly ToolInfoLike[],
97
112
  thinkingLevel?: string,
98
113
  prepareRequest?: (payload: Record<string, unknown>, messages: readonly AgentMessage[]) => Record<string, unknown>,
114
+ onFallback?: (diagnostic: CompactionFallback) => void,
99
115
  ): Promise<{
100
116
  summary: string;
101
117
  firstKeptEntryId: string;
@@ -103,17 +119,22 @@ export async function createRemoteCompaction(
103
119
  details: RemoteCompactionDetails;
104
120
  usage?: Usage;
105
121
  } | undefined> {
122
+ if (event.signal.aborted) return undefined;
123
+ const skip = (reason: CompactionFallback["reason"]) => {
124
+ onFallback?.({ reason });
125
+ return undefined;
126
+ };
106
127
  // Pi's custom focus is part of the standard summarizer contract. The
107
128
  // Responses compaction envelope has no documented equivalent, so do not
108
129
  // silently discard it.
109
- if (event.customInstructions?.trim()) return undefined;
130
+ if (event.customInstructions?.trim()) return skip("custom-instructions");
110
131
 
111
132
  const model = ctx.model as CodexModel | undefined;
112
133
  if (!model || !supportsRemoteCompaction(model)) return undefined;
113
134
 
114
135
  const endpoint = resolveCodexResponsesUrl(model.baseUrl);
115
136
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model as Model<any>);
116
- if (!auth.ok || !auth.apiKey) return undefined;
137
+ if (!auth.ok || !auth.apiKey) return skip("auth-unavailable");
117
138
  const accountFingerprint = getCodexAccountFingerprint(auth.apiKey);
118
139
  const authKind = getCodexAuthKind(ctx.modelRegistry, model as Model<any>);
119
140
 
@@ -135,9 +156,9 @@ export async function createRemoteCompaction(
135
156
  event.signal,
136
157
  thinkingLevel,
137
158
  );
138
- if (!providerPayload) return undefined;
159
+ if (!providerPayload) return skip("request-unavailable");
139
160
  const providerInput = providerPayload.input;
140
- if (!Array.isArray(providerInput)) return undefined;
161
+ if (!Array.isArray(providerInput)) return skip("request-unavailable");
141
162
  const providerTools = Array.isArray(providerPayload.tools) ? providerPayload.tools : [];
142
163
 
143
164
  const input = appendCompactionItems(providerInput, event.preparation, compatiblePrevious);
@@ -150,13 +171,14 @@ export async function createRemoteCompaction(
150
171
  store: false,
151
172
  stream: true,
152
173
  });
153
- if (!Array.isArray(requestBody.input)) return undefined;
174
+ if (!Array.isArray(requestBody.input)) return skip("request-unavailable");
154
175
  const boundedInput = boundCompactionInput(
155
176
  requestBody.input,
156
177
  instructions,
157
178
  Array.isArray(requestBody.tools) ? requestBody.tools : providerTools,
158
179
  model.contextWindow,
159
180
  requestBody,
181
+ onFallback,
160
182
  );
161
183
  if (!boundedInput) return undefined;
162
184
 
@@ -170,6 +192,11 @@ export async function createRemoteCompaction(
170
192
  input: boundedInput,
171
193
  },
172
194
  signal: event.signal,
195
+ }).catch((error: unknown) => {
196
+ // Only failures from the transport path receive this reason. Preparation
197
+ // failures are classified by the extension's outer fallback handler.
198
+ if (!event.signal.aborted) onFallback?.({ reason: "remote-failed" });
199
+ throw error;
173
200
  });
174
201
 
175
202
  const fallback = buildFallbackSummary(event.preparation, messages);
@@ -211,16 +238,25 @@ export function boundCompactionInput(
211
238
  tools: readonly unknown[],
212
239
  contextWindow: number,
213
240
  requestPayload?: Record<string, unknown>,
241
+ onLimit?: (diagnostic: CompactionFallback) => void,
214
242
  ): unknown[] | undefined {
215
- const budget = Math.max(1, Math.floor(contextWindow - COMPACTION_RESPONSE_RESERVE_TOKENS));
216
- // A byte-level bound is conservative when the active model's tokenizer is unavailable:
217
- // a token can be represented by a single UTF-8 byte, but not fewer.
218
- const budgetBytes = budget;
243
+ if (!Number.isFinite(contextWindow) || contextWindow <= 0) {
244
+ onLimit?.({ reason: "context-window-unavailable" });
245
+ return undefined;
246
+ }
247
+ const budgetTokens = Math.max(0, Math.floor(contextWindow) - COMPACTION_RESPONSE_RESERVE_TOKENS);
219
248
  const bounded = input.map((item) => item);
220
- let requestBytes = estimateConservativeBytes(
249
+ let requestBytes = serializedBytes(
221
250
  buildCompactionRequest(requestPayload, bounded, instructions, tools),
222
251
  );
223
- if (requestBytes <= budgetBytes) return bounded;
252
+ if (!Number.isFinite(requestBytes)) {
253
+ onLimit?.({ reason: "request-unavailable" });
254
+ return undefined;
255
+ }
256
+ const fits = () => requestBytes <= MAX_COMPACTION_REQUEST_BYTES &&
257
+ Math.ceil(requestBytes / APPROX_BYTES_PER_TOKEN) <= budgetTokens;
258
+ if (fits()) return bounded;
259
+ let trimmedToolOutputs = 0;
224
260
 
225
261
  // ponytail: trim tool outputs first; if structural content still exceeds the active model window,
226
262
  // let Pi's standard compaction path handle the request instead of inventing a lossy transcript rewrite.
@@ -229,11 +265,24 @@ export function boundCompactionInput(
229
265
  const replacement = trimToolOutput(item);
230
266
  if (!replacement) continue;
231
267
 
232
- requestBytes += estimateConservativeBytes(replacement) - estimateConservativeBytes(item);
268
+ const removedBytes = serializedBytes(item) - serializedBytes(replacement);
269
+ // A short output can be smaller than the omission notice. Never make a
270
+ // request larger while trying to fit either limit.
271
+ if (removedBytes <= 0) continue;
272
+ requestBytes -= removedBytes;
233
273
  bounded[index] = replacement;
234
- if (requestBytes <= budgetBytes) return bounded;
274
+ trimmedToolOutputs++;
275
+ if (fits()) return bounded;
235
276
  }
236
277
 
278
+ onLimit?.({
279
+ reason: requestBytes > MAX_COMPACTION_REQUEST_BYTES ? "request-size-limit" : "context-limit",
280
+ estimatedTokens: Math.ceil(requestBytes / APPROX_BYTES_PER_TOKEN),
281
+ tokenBudget: budgetTokens,
282
+ requestBytes,
283
+ byteLimit: MAX_COMPACTION_REQUEST_BYTES,
284
+ trimmedToolOutputs,
285
+ });
237
286
  return undefined;
238
287
  }
239
288
 
@@ -486,10 +535,12 @@ function buildCompactionRequest(
486
535
  instructions: string,
487
536
  tools: readonly unknown[],
488
537
  ): Record<string, unknown> {
489
- const payload: Record<string, unknown> = requestPayload ? { ...requestPayload } : {};
490
- payload.instructions = instructions;
538
+ // A supplied envelope is authoritative, including fields a listener removed.
539
+ // Separate defaults apply only to callers without a complete payload.
540
+ const payload: Record<string, unknown> = requestPayload
541
+ ? { ...requestPayload }
542
+ : { instructions, ...(tools.length > 0 ? { tools } : {}) };
491
543
  payload.input = input;
492
- if (tools.length > 0 || requestPayload && "tools" in requestPayload) payload.tools = tools;
493
544
  return payload;
494
545
  }
495
546
 
@@ -510,10 +561,10 @@ function trimToolOutput(value: unknown): unknown | undefined {
510
561
  return undefined;
511
562
  }
512
563
 
513
- function estimateConservativeBytes(value: unknown): number {
564
+ function serializedBytes(value: unknown): number {
514
565
  try {
515
566
  const json = JSON.stringify(value) ?? "";
516
- return new TextEncoder().encode(json).byteLength;
567
+ return Buffer.byteLength(json, "utf8");
517
568
  } catch {
518
569
  return Number.POSITIVE_INFINITY;
519
570
  }