pi-codex-compaction 0.1.2 → 0.1.3
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 +9 -0
- package/CONTRIBUTING.md +27 -0
- package/README.md +37 -2
- package/SECURITY.md +14 -0
- package/extensions/index.ts +40 -4
- package/package.json +1 -1
- package/src/codex-wire.ts +2 -2
- package/src/remote-compaction.ts +69 -18
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,15 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.3] - 2026-09-11
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Estimate compaction tokens separately from request bytes so long Codex sessions do not fall back merely because UTF-8 bytes exceed the token budget.
|
|
14
|
+
- Respect request-transform field removals when sizing, restrict warnings to TUI mode, and distinguish preparation failures from transport failures.
|
|
15
|
+
- Preserve the independent 16 MiB request ceiling, count the complete transformed envelope, and reject unavailable context limits.
|
|
16
|
+
- Record safe fallback reasons and numeric size diagnostics in local custom session entries; notify when UI is available without exposing request or provider content.
|
|
17
|
+
|
|
9
18
|
## [0.1.2] - 2026-09-11
|
|
10
19
|
|
|
11
20
|
### 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
|
|
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
|
|
|
@@ -39,7 +39,41 @@ When Pi starts compaction on a supported Codex model, the extension sends a stre
|
|
|
39
39
|
|
|
40
40
|
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
41
|
|
|
42
|
-
If the remote request fails
|
|
42
|
+
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.
|
|
43
|
+
|
|
44
|
+
### Size limits
|
|
45
|
+
|
|
46
|
+
Input is estimated as `ceil(UTF-8 request bytes / 4)`, with 8,192 tokens reserved
|
|
47
|
+
from the active model's context window. This uses Codex's ordinary-item heuristic,
|
|
48
|
+
not an exact tokenizer. The complete transformed request is counted, including
|
|
49
|
+
system instructions, tool definitions, and routing fields. Opaque checkpoints
|
|
50
|
+
and image data remain counted at their serialized size; they are not decoded or
|
|
51
|
+
discounted. Non-ASCII text uses UTF-8 bytes, not JavaScript string length.
|
|
52
|
+
|
|
53
|
+
The uncompressed request also has an independent **16 MiB hard limit**. Tool
|
|
54
|
+
outputs are reduced only when one of these limits is exceeded. User messages,
|
|
55
|
+
tool calls, and opaque checkpoints are not removed. If the remaining request
|
|
56
|
+
still cannot fit, or the model's context limit is unknown, standard Pi compaction
|
|
57
|
+
runs. The estimate can differ from the server's token count; a server rejection
|
|
58
|
+
still uses the existing fallback.
|
|
59
|
+
|
|
60
|
+
### Fallback diagnostics
|
|
61
|
+
|
|
62
|
+
A fallback on a supported model records a local custom session entry with type
|
|
63
|
+
`pi-codex-compaction:fallback:v1`. It contains `version: 1` and a reason:
|
|
64
|
+
`custom-instructions`, `auth-unavailable`, `request-unavailable`,
|
|
65
|
+
`context-window-unavailable`, `context-limit`, `request-size-limit`, or
|
|
66
|
+
`remote-failed`. Size failures also include estimated tokens, token budget,
|
|
67
|
+
request bytes, byte limit, and the number of tool outputs reduced.
|
|
68
|
+
Unexpected preparation failures use `request-unavailable`; `remote-failed`
|
|
69
|
+
is reserved for failures from the transport call.
|
|
70
|
+
|
|
71
|
+
No prompt, tool content, encrypted checkpoint, account identifier, credential, or
|
|
72
|
+
raw provider error is included. These entries are not sent to the model. Pi
|
|
73
|
+
shows a warning only in TUI mode when notifications are available; print, JSON,
|
|
74
|
+
and RPC modes get no extra notifications or console output. Unsupported models
|
|
75
|
+
and cancelled attempts do not create fallback diagnostics. Diagnostic storage
|
|
76
|
+
or notification failure does not stop the standard compactor.
|
|
43
77
|
|
|
44
78
|
## Development
|
|
45
79
|
|
|
@@ -61,6 +95,7 @@ versioned `pi.events` contracts let cooperating extensions supply it:
|
|
|
61
95
|
- `pi-codex-compaction:request:v1`: `{ ctx, messages, payload }`, after input
|
|
62
96
|
assembly and before size checks. A listener can replace `payload`. This event
|
|
63
97
|
is not the general `before_provider_request` chain and does not carry auth.
|
|
98
|
+
Size checks use the transformed envelope, including field removals.
|
|
64
99
|
|
|
65
100
|
Other extensions' private request changes are not applied automatically.
|
|
66
101
|
Unknown third-party grammar metadata needs cooperation through the tools event.
|
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.
|
package/extensions/index.ts
CHANGED
|
@@ -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,12 +73,12 @@ 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
|
-
|
|
44
|
-
|
|
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
|
};
|
package/package.json
CHANGED
package/src/codex-wire.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
|
|
3
|
-
const
|
|
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 (
|
|
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
|
|
package/src/remote-compaction.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
159
|
+
if (!providerPayload) return skip("request-unavailable");
|
|
139
160
|
const providerInput = providerPayload.input;
|
|
140
|
-
if (!Array.isArray(providerInput)) return
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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 =
|
|
249
|
+
let requestBytes = serializedBytes(
|
|
221
250
|
buildCompactionRequest(requestPayload, bounded, instructions, tools),
|
|
222
251
|
);
|
|
223
|
-
if (requestBytes
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
490
|
-
payload.
|
|
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
|
|
564
|
+
function serializedBytes(value: unknown): number {
|
|
514
565
|
try {
|
|
515
566
|
const json = JSON.stringify(value) ?? "";
|
|
516
|
-
return
|
|
567
|
+
return Buffer.byteLength(json, "utf8");
|
|
517
568
|
} catch {
|
|
518
569
|
return Number.POSITIVE_INFINITY;
|
|
519
570
|
}
|