pi-codex-compaction 0.1.0 → 0.1.1
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 +16 -0
- package/README.md +7 -4
- package/SECURITY.md +2 -2
- package/extensions/index.ts +22 -3
- package/package.json +4 -4
- package/src/codex-wire.ts +348 -80
- package/src/install-telemetry.ts +8 -7
- package/src/remote-compaction.ts +191 -38
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,22 @@ This project follows the spirit of [Keep a Changelog](https://keepachangelog.com
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.1.1] - 2026-08-06
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Let `enableInstallTelemetry: false` override an enabled `PI_TELEMETRY` environment flag.
|
|
14
|
+
- Keep the normal Codex Responses request envelope while excluding Pi's retained user input from compaction history.
|
|
15
|
+
- Avoid reusing opaque checkpoints across model, endpoint, account, or authentication-mode changes.
|
|
16
|
+
- Avoid reusing opaque checkpoints when the authentication mode cannot be verified.
|
|
17
|
+
- Reject version 1 opaque checkpoints from existing sessions and use readable fallback context until a new checkpoint is created.
|
|
18
|
+
- Fall back to Pi's standard compactor when custom compaction instructions are supplied.
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- Incremental bounded SSE parsing, transient request retries, response idle timeouts, provider usage capture, and file-operation fallback metadata.
|
|
23
|
+
- Trusted-origin and redirect protections for direct Codex compaction requests.
|
|
24
|
+
|
|
9
25
|
## [0.1.0] - 2026-08-02
|
|
10
26
|
|
|
11
27
|
### Added
|
package/README.md
CHANGED
|
@@ -6,8 +6,11 @@ Keep long Pi sessions usable on OpenAI Codex models by replacing Pi's local summ
|
|
|
6
6
|
|
|
7
7
|
- Uses the current `openai-codex` model for each compaction; it never silently changes the session model.
|
|
8
8
|
- Sends only the history Pi is discarding, plus the previous Codex checkpoint, so the incoming/kept user message is not duplicated.
|
|
9
|
+
- Retains the normal Codex Responses request envelope, including system instructions, active tool schemas, reasoning settings, prompt-cache fields, and routing fields.
|
|
9
10
|
- Persists Codex's opaque encrypted checkpoint and rehydrates it only for supported Codex requests.
|
|
10
|
-
-
|
|
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.
|
|
13
|
+
- Falls back to standard Pi compaction on failure or when custom compaction instructions are requested.
|
|
11
14
|
- Keeps a bounded readable transcript excerpt so switching models or providers remains usable.
|
|
12
15
|
|
|
13
16
|
The current Codex catalog includes models such as `gpt-5.6-sol`, `gpt-5.6-terra`, and `gpt-5.6-luna`. Capability detection follows the provider/API contract (`openai-codex` + `openai-codex-responses`) rather than a brittle model-name list.
|
|
@@ -32,11 +35,11 @@ pi -e /path/to/pi-mono/packages/pi-codex-compaction
|
|
|
32
35
|
|
|
33
36
|
## Behavior
|
|
34
37
|
|
|
35
|
-
When Pi starts compaction on a supported Codex model, the extension sends a streamed Responses request
|
|
38
|
+
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.
|
|
36
39
|
|
|
37
|
-
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. If
|
|
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.
|
|
38
41
|
|
|
39
|
-
If the remote request fails, is cancelled, or returns an unexpected response, Pi's standard compaction path runs. No configuration is required.
|
|
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.
|
|
40
43
|
|
|
41
44
|
## Development
|
|
42
45
|
|
package/SECURITY.md
CHANGED
|
@@ -19,9 +19,9 @@ Report privately through [GitHub Security Advisories](https://github.com/jvm/pi-
|
|
|
19
19
|
|
|
20
20
|
`pi-codex-compaction` is a Pi package. Pi extensions execute with the same permissions as the local user running Pi. Users should review installed Pi packages and only install packages from sources they trust.
|
|
21
21
|
|
|
22
|
-
For supported `openai-codex` models, the extension sends the portion of conversation Pi is about to discard to the OpenAI Codex Responses endpoint over HTTPS, using credentials and provider headers resolved by Pi. It stores the provider-issued opaque `encrypted_content` checkpoint in the local Pi session's compaction details so
|
|
22
|
+
For supported `openai-codex` models, the extension sends the portion of conversation Pi is about to discard to the OpenAI Codex Responses endpoint over HTTPS, using credentials and provider headers resolved by Pi. The request also includes the normal Codex Responses envelope: the effective system prompt, active tool schemas, reasoning settings, and provider-generated cache/routing fields. It stores the provider-issued opaque `encrypted_content` checkpoint in the local Pi session's compaction details so compatible Codex requests can reuse it. The checkpoint is not decoded, printed, or logged.
|
|
23
23
|
|
|
24
|
-
The extension bounds compaction input and response size, validates the
|
|
24
|
+
The extension bounds request, compaction input, and response size, validates the HTTPS endpoint against the official `chatgpt.com` origin, rejects redirects, validates the account claim shape, binds checkpoints to a hashed account identity/model/endpoint/authentication mode, honors Pi cancellation, and retries only transient transport failures. It falls back to standard Pi compaction on authentication, transport, response, or context-limit failures. A bounded textual transcript excerpt remains in the compaction summary so switching to another model or provider does not leave the session with only an unusable Codex checkpoint. The fallback may contain conversation content already present in the local session and is still subject to the user's normal Pi session-file permissions.
|
|
25
25
|
|
|
26
26
|
The extension never logs prompts, conversation contents, credentials, authorization headers, or raw provider responses. Install/update telemetry is best-effort and sends only package/version/runtime metadata; it can be disabled with `PI_OFFLINE=1`, `PI_TELEMETRY=0`, or Pi's `enableInstallTelemetry: false` setting.
|
|
27
27
|
|
package/extensions/index.ts
CHANGED
|
@@ -3,12 +3,14 @@ import type {
|
|
|
3
3
|
ExtensionHandler,
|
|
4
4
|
SessionBeforeCompactEvent,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import { BETA_FEATURE } from "../src/codex-wire.js";
|
|
6
|
+
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
10
|
createRemoteCompaction,
|
|
11
11
|
findActiveRemoteCompaction,
|
|
12
|
+
getCodexAuthKind,
|
|
13
|
+
isRemoteCompactionCompatible,
|
|
12
14
|
supportsRemoteCompaction,
|
|
13
15
|
} from "../src/remote-compaction.js";
|
|
14
16
|
|
|
@@ -24,7 +26,7 @@ export default function piCodexCompaction(pi: ExtensionAPI): void {
|
|
|
24
26
|
return pi.getAllTools()
|
|
25
27
|
.filter((tool) => active.has(tool.name))
|
|
26
28
|
.map(({ name, description, parameters }) => ({ name, description, parameters }));
|
|
27
|
-
});
|
|
29
|
+
}, pi.getThinkingLevel?.());
|
|
28
30
|
return compaction ? { compaction } : undefined;
|
|
29
31
|
} catch {
|
|
30
32
|
if (!event.signal.aborted && ctx.hasUI) {
|
|
@@ -47,6 +49,23 @@ export default function piCodexCompaction(pi: ExtensionAPI): void {
|
|
|
47
49
|
if (!supportsRemoteCompaction(ctx.model)) return;
|
|
48
50
|
const details = findActiveRemoteCompaction(ctx.sessionManager.buildContextEntries());
|
|
49
51
|
if (!details) return;
|
|
50
|
-
|
|
52
|
+
|
|
53
|
+
return (async () => {
|
|
54
|
+
const model = ctx.model;
|
|
55
|
+
if (!model) return undefined;
|
|
56
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
57
|
+
if (!auth.ok || !auth.apiKey) return undefined;
|
|
58
|
+
|
|
59
|
+
let accountFingerprint: string;
|
|
60
|
+
try {
|
|
61
|
+
accountFingerprint = getCodexAccountFingerprint(auth.apiKey);
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
if (!isRemoteCompactionCompatible(details, model, accountFingerprint, getCodexAuthKind(ctx.modelRegistry, model))) {
|
|
66
|
+
return undefined;
|
|
67
|
+
}
|
|
68
|
+
return applyRemoteCompactionMarker(event.payload, details);
|
|
69
|
+
})();
|
|
51
70
|
});
|
|
52
71
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-codex-compaction",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Use OpenAI Codex RemoteCompactionV2 for supported Pi sessions.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -52,9 +52,9 @@
|
|
|
52
52
|
"@earendil-works/pi-coding-agent": "*"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
|
-
"@earendil-works/pi-ai": "^0.
|
|
56
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
57
|
-
"@types/node": "^26.1.
|
|
55
|
+
"@earendil-works/pi-ai": "^0.82.1",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^0.82.1",
|
|
57
|
+
"@types/node": "^26.1.2",
|
|
58
58
|
"tsx": "^4.23.1",
|
|
59
59
|
"typescript": "^7.0.2"
|
|
60
60
|
},
|
package/src/codex-wire.ts
CHANGED
|
@@ -1,7 +1,15 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const MAX_REQUEST_BYTES = 16 * 1024 * 1024;
|
|
1
4
|
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
2
5
|
const MAX_ENCRYPTED_CONTENT_CHARS = 2_000_000;
|
|
3
|
-
const
|
|
6
|
+
const REQUEST_HEADER_TIMEOUT_MS = 30_000;
|
|
7
|
+
const REQUEST_IDLE_TIMEOUT_MS = 120_000;
|
|
8
|
+
const MAX_RETRIES = 2;
|
|
9
|
+
const RETRY_BASE_DELAY_MS = 200;
|
|
10
|
+
const MAX_RETRY_DELAY_MS = 30_000;
|
|
4
11
|
const ACCOUNT_ID_CLAIM = "https://api.openai.com/auth";
|
|
12
|
+
const TRUSTED_CODEX_HOST = "chatgpt.com";
|
|
5
13
|
export const BETA_FEATURE = "remote_compaction_v2";
|
|
6
14
|
|
|
7
15
|
export interface CodexCompactionRequest {
|
|
@@ -17,28 +25,84 @@ export interface CodexCompactionRequest {
|
|
|
17
25
|
signal?: AbortSignal;
|
|
18
26
|
}
|
|
19
27
|
|
|
28
|
+
export interface CodexCompactionUsage {
|
|
29
|
+
inputTokens?: number;
|
|
30
|
+
outputTokens?: number;
|
|
31
|
+
totalTokens?: number;
|
|
32
|
+
cachedInputTokens?: number;
|
|
33
|
+
reasoningTokens?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CodexCompactionResult {
|
|
37
|
+
encryptedContent: string;
|
|
38
|
+
usage?: CodexCompactionUsage;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Keep the original string-returning API for callers that only need the
|
|
43
|
+
* checkpoint. The extension uses requestRemoteCompactionWithUsage below.
|
|
44
|
+
*/
|
|
20
45
|
export async function requestRemoteCompaction(request: CodexCompactionRequest): Promise<string> {
|
|
46
|
+
return (await requestRemoteCompactionWithUsage(request)).encryptedContent;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function requestRemoteCompactionWithUsage(
|
|
50
|
+
request: CodexCompactionRequest,
|
|
51
|
+
): Promise<CodexCompactionResult> {
|
|
52
|
+
const endpoint = resolveCodexResponsesUrl(request.model.baseUrl);
|
|
21
53
|
const accountId = extractAccountId(request.apiKey);
|
|
22
54
|
const headers = buildHeaders(request, accountId);
|
|
23
|
-
const
|
|
55
|
+
const body = JSON.stringify(request.body);
|
|
56
|
+
if (new TextEncoder().encode(body).byteLength > MAX_REQUEST_BYTES) {
|
|
57
|
+
throw new Error("Codex compaction request exceeded the size limit");
|
|
58
|
+
}
|
|
24
59
|
|
|
60
|
+
const requestState = requestSignal(request.signal);
|
|
25
61
|
try {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
62
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
63
|
+
requestState.startAttempt();
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const response = await fetch(endpoint, {
|
|
67
|
+
method: "POST",
|
|
68
|
+
headers,
|
|
69
|
+
body,
|
|
70
|
+
redirect: "error",
|
|
71
|
+
signal: requestState.signal,
|
|
72
|
+
});
|
|
73
|
+
requestState.headersReceived();
|
|
37
74
|
|
|
38
|
-
|
|
75
|
+
if (!response.ok) {
|
|
76
|
+
await response.body?.cancel().catch(() => undefined);
|
|
77
|
+
const error = new CodexCompactionError(
|
|
78
|
+
`Codex compaction request returned HTTP ${response.status}`,
|
|
79
|
+
isRetryableStatus(response.status),
|
|
80
|
+
);
|
|
81
|
+
if (!error.retryable || attempt >= MAX_RETRIES) throw error;
|
|
82
|
+
await waitForRetry(response, attempt, requestState.signal);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
return await readCompactionSse(response, requestState.signal, requestState.touch);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (!isRetryableError(error) || attempt >= MAX_RETRIES || requestState.signal.aborted) {
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
await waitForRetry(undefined, attempt, requestState.signal);
|
|
93
|
+
}
|
|
94
|
+
} catch (error) {
|
|
95
|
+
if (requestState.signal.aborted || !isRetryableError(error) || attempt >= MAX_RETRIES) {
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
await waitForRetry(undefined, attempt, requestState.signal);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
39
101
|
} finally {
|
|
40
|
-
cleanup();
|
|
102
|
+
requestState.cleanup();
|
|
41
103
|
}
|
|
104
|
+
|
|
105
|
+
throw new Error("Codex compaction request failed");
|
|
42
106
|
}
|
|
43
107
|
|
|
44
108
|
export function resolveCodexResponsesUrl(baseUrl: string): string {
|
|
@@ -46,6 +110,9 @@ export function resolveCodexResponsesUrl(baseUrl: string): string {
|
|
|
46
110
|
if (url.protocol !== "https:") {
|
|
47
111
|
throw new Error("Codex compaction requires an HTTPS endpoint");
|
|
48
112
|
}
|
|
113
|
+
if (url.hostname.toLowerCase() !== TRUSTED_CODEX_HOST) {
|
|
114
|
+
throw new Error("Codex compaction endpoint is not a trusted Codex origin");
|
|
115
|
+
}
|
|
49
116
|
if (url.username || url.password) {
|
|
50
117
|
throw new Error("Codex compaction endpoint must not contain URL credentials");
|
|
51
118
|
}
|
|
@@ -59,48 +126,16 @@ export function resolveCodexResponsesUrl(baseUrl: string): string {
|
|
|
59
126
|
}
|
|
60
127
|
|
|
61
128
|
export function parseCompactionSse(sse: string): string {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
for (const frame of sse.split(/\r?\n\r?\n/)) {
|
|
66
|
-
const data = frame
|
|
67
|
-
.split(/\r?\n/)
|
|
68
|
-
.filter((line) => line.startsWith("data:"))
|
|
69
|
-
.map((line) => line.slice(5).trim())
|
|
70
|
-
.join("\n")
|
|
71
|
-
.trim();
|
|
72
|
-
if (!data || data === "[DONE]") continue;
|
|
73
|
-
|
|
74
|
-
let event: unknown;
|
|
75
|
-
try {
|
|
76
|
-
event = JSON.parse(data) as unknown;
|
|
77
|
-
} catch {
|
|
78
|
-
throw new Error("Codex compaction returned invalid SSE data");
|
|
79
|
-
}
|
|
80
|
-
if (!isRecord(event)) continue;
|
|
81
|
-
|
|
82
|
-
if (event.type === "error" || event.type === "response.failed") {
|
|
83
|
-
throw new Error("Codex compaction response failed");
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (event.type === "response.output_item.done") {
|
|
87
|
-
collectCompactionItem(event.item, encryptedContents);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
if (event.type === "response.completed" || event.type === "response.done") {
|
|
91
|
-
const response = isRecord(event.response) ? event.response : undefined;
|
|
92
|
-
collectOutputItems(response?.output, encryptedContents);
|
|
93
|
-
completed = response?.status === undefined || response.status === "completed";
|
|
94
|
-
}
|
|
95
|
-
}
|
|
129
|
+
return parseCompactionSseResult(sse).encryptedContent;
|
|
130
|
+
}
|
|
96
131
|
|
|
97
|
-
|
|
98
|
-
|
|
132
|
+
export function parseCompactionSseResult(sse: string): CodexCompactionResult {
|
|
133
|
+
if (new TextEncoder().encode(sse).byteLength > MAX_RESPONSE_BYTES) {
|
|
134
|
+
throw new Error("Codex compaction response exceeded the size limit");
|
|
99
135
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
return [...encryptedContents][0];
|
|
136
|
+
const parser = new CompactionSseParser();
|
|
137
|
+
parser.push(sse);
|
|
138
|
+
return parser.finish();
|
|
104
139
|
}
|
|
105
140
|
|
|
106
141
|
function buildHeaders(request: CodexCompactionRequest, accountId: string): Headers {
|
|
@@ -124,6 +159,10 @@ function buildHeaders(request: CodexCompactionRequest, accountId: string): Heade
|
|
|
124
159
|
return headers;
|
|
125
160
|
}
|
|
126
161
|
|
|
162
|
+
export function getCodexAccountFingerprint(token: string): string {
|
|
163
|
+
return `sha256:${createHash("sha256").update(extractAccountId(token)).digest("hex")}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
127
166
|
function extractAccountId(token: string): string {
|
|
128
167
|
const segment = token.split(".")[1];
|
|
129
168
|
if (!segment) throw new Error("Codex authentication token has no account claim");
|
|
@@ -146,61 +185,290 @@ function safeSessionId(value: string | undefined): string | undefined {
|
|
|
146
185
|
return value;
|
|
147
186
|
}
|
|
148
187
|
|
|
149
|
-
function
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
const encryptedContent = value.encrypted_content;
|
|
157
|
-
if (
|
|
158
|
-
typeof encryptedContent === "string" &&
|
|
159
|
-
encryptedContent.length > 0 &&
|
|
160
|
-
encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
|
|
161
|
-
) {
|
|
162
|
-
encryptedContents.add(encryptedContent);
|
|
188
|
+
async function readCompactionSse(
|
|
189
|
+
response: Response,
|
|
190
|
+
signal: AbortSignal,
|
|
191
|
+
touch: () => void,
|
|
192
|
+
): Promise<CodexCompactionResult> {
|
|
193
|
+
if (!response.body) {
|
|
194
|
+
throw new CodexCompactionError("Codex compaction response has no body", true);
|
|
163
195
|
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
async function readBoundedBody(response: Response): Promise<string> {
|
|
167
|
-
if (!response.body) throw new Error("Codex compaction response has no body");
|
|
168
196
|
|
|
169
197
|
const reader = response.body.getReader();
|
|
170
198
|
const decoder = new TextDecoder();
|
|
199
|
+
const parser = new CompactionSseParser();
|
|
171
200
|
let bytes = 0;
|
|
172
|
-
let text = "";
|
|
173
201
|
try {
|
|
174
202
|
while (true) {
|
|
175
203
|
const { done, value } = await reader.read();
|
|
176
204
|
if (done) break;
|
|
205
|
+
touch();
|
|
177
206
|
bytes += value.byteLength;
|
|
178
207
|
if (bytes > MAX_RESPONSE_BYTES) {
|
|
179
|
-
throw new
|
|
208
|
+
throw new CodexCompactionError("Codex compaction response exceeded the size limit", false);
|
|
180
209
|
}
|
|
181
|
-
|
|
210
|
+
parser.push(decoder.decode(value, { stream: true }));
|
|
182
211
|
}
|
|
183
|
-
|
|
212
|
+
parser.push(decoder.decode());
|
|
213
|
+
return parser.finish();
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (error instanceof CodexCompactionError) throw error;
|
|
216
|
+
if (signal.aborted) throw error;
|
|
217
|
+
throw new CodexCompactionError("Codex compaction response stream failed", true);
|
|
184
218
|
} finally {
|
|
185
219
|
await reader.cancel().catch(() => undefined);
|
|
186
220
|
reader.releaseLock();
|
|
187
221
|
}
|
|
188
222
|
}
|
|
189
223
|
|
|
190
|
-
|
|
224
|
+
class CompactionSseParser {
|
|
225
|
+
private buffer = "";
|
|
226
|
+
private completed = false;
|
|
227
|
+
private status: string | undefined;
|
|
228
|
+
private compactionItems: Array<string | undefined> = [];
|
|
229
|
+
private usage: CodexCompactionUsage | undefined;
|
|
230
|
+
|
|
231
|
+
push(chunk: string): void {
|
|
232
|
+
this.buffer += chunk;
|
|
233
|
+
this.buffer = this.buffer.replace(/\r\n/g, "\n");
|
|
234
|
+
while (true) {
|
|
235
|
+
const separator = this.buffer.indexOf("\n\n");
|
|
236
|
+
if (separator < 0) break;
|
|
237
|
+
const frame = this.buffer.slice(0, separator);
|
|
238
|
+
this.buffer = this.buffer.slice(separator + 2);
|
|
239
|
+
this.processFrame(frame);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
finish(): CodexCompactionResult {
|
|
244
|
+
if (this.buffer.trim()) this.processFrame(this.buffer);
|
|
245
|
+
if (!this.completed) {
|
|
246
|
+
throw new CodexCompactionError("Codex compaction stream ended before response completion", true);
|
|
247
|
+
}
|
|
248
|
+
if (this.status && this.status !== "completed") {
|
|
249
|
+
throw new CodexCompactionError("Codex compaction response failed", false);
|
|
250
|
+
}
|
|
251
|
+
if (this.compactionItems.length !== 1) {
|
|
252
|
+
throw new CodexCompactionError(
|
|
253
|
+
`Codex compaction returned ${this.compactionItems.length} checkpoint items`,
|
|
254
|
+
false,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
const encryptedContent = this.compactionItems[0];
|
|
259
|
+
if (!encryptedContent) {
|
|
260
|
+
throw new CodexCompactionError("Codex compaction returned an invalid checkpoint", false);
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
encryptedContent,
|
|
264
|
+
...(this.usage ? { usage: this.usage } : {}),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private processFrame(frame: string): void {
|
|
269
|
+
const data = frame
|
|
270
|
+
.split("\n")
|
|
271
|
+
.filter((line) => line.startsWith("data:"))
|
|
272
|
+
.map((line) => line.slice(5).trim())
|
|
273
|
+
.join("\n")
|
|
274
|
+
.trim();
|
|
275
|
+
if (!data || data === "[DONE]") return;
|
|
276
|
+
|
|
277
|
+
let event: unknown;
|
|
278
|
+
try {
|
|
279
|
+
event = JSON.parse(data) as unknown;
|
|
280
|
+
} catch {
|
|
281
|
+
throw new CodexCompactionError("Codex compaction returned invalid SSE data", false);
|
|
282
|
+
}
|
|
283
|
+
if (!isRecord(event)) return;
|
|
284
|
+
|
|
285
|
+
if (event.type === "error" || event.type === "response.failed") {
|
|
286
|
+
throw new CodexCompactionError("Codex compaction response failed", false);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (event.type === "response.output_item.done") {
|
|
290
|
+
this.collectCompactionItem(event.item);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (event.type === "response.completed" || event.type === "response.done") {
|
|
294
|
+
const response = isRecord(event.response) ? event.response : undefined;
|
|
295
|
+
this.status = typeof response?.status === "string" ? response.status : undefined;
|
|
296
|
+
this.usage = parseUsage(response?.usage ?? event.usage);
|
|
297
|
+
|
|
298
|
+
// Codex emits output_item.done before response.completed. Retain this
|
|
299
|
+
// fallback for compact Responses implementations that only return the
|
|
300
|
+
// completed response's output array.
|
|
301
|
+
if (this.compactionItems.length === 0) {
|
|
302
|
+
collectOutputItems(response?.output, (item) => this.collectCompactionItem(item));
|
|
303
|
+
}
|
|
304
|
+
this.completed = true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private collectCompactionItem(value: unknown): void {
|
|
309
|
+
if (!isRecord(value) || value.type !== "compaction") return;
|
|
310
|
+
const encryptedContent = value.encrypted_content;
|
|
311
|
+
this.compactionItems.push(
|
|
312
|
+
typeof encryptedContent === "string" &&
|
|
313
|
+
encryptedContent.length > 0 &&
|
|
314
|
+
encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
|
|
315
|
+
? encryptedContent
|
|
316
|
+
: undefined,
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function collectOutputItems(value: unknown, collect: (item: unknown) => void): void {
|
|
322
|
+
if (!Array.isArray(value)) return;
|
|
323
|
+
for (const item of value) collect(item);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function parseUsage(value: unknown): CodexCompactionUsage | undefined {
|
|
327
|
+
if (!isRecord(value)) return undefined;
|
|
328
|
+
const inputTokens = finiteNonNegativeNumber(value.input_tokens);
|
|
329
|
+
const outputTokens = finiteNonNegativeNumber(value.output_tokens);
|
|
330
|
+
const totalTokens = finiteNonNegativeNumber(value.total_tokens);
|
|
331
|
+
const inputDetails = isRecord(value.input_tokens_details) ? value.input_tokens_details : undefined;
|
|
332
|
+
const outputDetails = isRecord(value.output_tokens_details) ? value.output_tokens_details : undefined;
|
|
333
|
+
const cachedInputTokens = finiteNonNegativeNumber(inputDetails?.cached_tokens);
|
|
334
|
+
const reasoningTokens = finiteNonNegativeNumber(outputDetails?.reasoning_tokens);
|
|
335
|
+
if (
|
|
336
|
+
inputTokens === undefined &&
|
|
337
|
+
outputTokens === undefined &&
|
|
338
|
+
totalTokens === undefined &&
|
|
339
|
+
cachedInputTokens === undefined &&
|
|
340
|
+
reasoningTokens === undefined
|
|
341
|
+
) {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
...(inputTokens !== undefined ? { inputTokens } : {}),
|
|
346
|
+
...(outputTokens !== undefined ? { outputTokens } : {}),
|
|
347
|
+
...(totalTokens !== undefined ? { totalTokens } : {}),
|
|
348
|
+
...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}),
|
|
349
|
+
...(reasoningTokens !== undefined ? { reasoningTokens } : {}),
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function finiteNonNegativeNumber(value: unknown): number | undefined {
|
|
354
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isRetryableStatus(status: number): boolean {
|
|
358
|
+
return status === 408 || status === 429 || status === 500 || status === 502 || status === 503 || status === 504;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function waitForRetry(
|
|
362
|
+
response: Response | undefined,
|
|
363
|
+
attempt: number,
|
|
364
|
+
signal: AbortSignal,
|
|
365
|
+
): Promise<void> {
|
|
366
|
+
const retryAfter = response ? parseRetryAfter(response.headers) : undefined;
|
|
367
|
+
const delay = Math.min(
|
|
368
|
+
MAX_RETRY_DELAY_MS,
|
|
369
|
+
retryAfter ?? RETRY_BASE_DELAY_MS * 2 ** attempt,
|
|
370
|
+
);
|
|
371
|
+
await new Promise<void>((resolve, reject) => {
|
|
372
|
+
let settled = false;
|
|
373
|
+
const onAbort = () => {
|
|
374
|
+
if (settled) return;
|
|
375
|
+
settled = true;
|
|
376
|
+
clearTimeout(timer);
|
|
377
|
+
signal.removeEventListener("abort", onAbort);
|
|
378
|
+
reject(signal.reason ?? new Error("Codex compaction request aborted"));
|
|
379
|
+
};
|
|
380
|
+
const timer = setTimeout(() => {
|
|
381
|
+
if (settled) return;
|
|
382
|
+
settled = true;
|
|
383
|
+
signal.removeEventListener("abort", onAbort);
|
|
384
|
+
resolve();
|
|
385
|
+
}, delay);
|
|
386
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
387
|
+
if (signal.aborted) onAbort();
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function parseRetryAfter(headers: Headers): number | undefined {
|
|
392
|
+
const milliseconds = headers.get("retry-after-ms");
|
|
393
|
+
if (milliseconds) {
|
|
394
|
+
const value = Number(milliseconds);
|
|
395
|
+
if (Number.isFinite(value) && value >= 0) return value;
|
|
396
|
+
}
|
|
397
|
+
const seconds = headers.get("retry-after");
|
|
398
|
+
if (!seconds) return undefined;
|
|
399
|
+
const numeric = Number(seconds);
|
|
400
|
+
if (Number.isFinite(numeric) && numeric >= 0) return numeric * 1_000;
|
|
401
|
+
const date = Date.parse(seconds);
|
|
402
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
class CodexCompactionError extends Error {
|
|
406
|
+
constructor(
|
|
407
|
+
message: string,
|
|
408
|
+
readonly retryable: boolean,
|
|
409
|
+
) {
|
|
410
|
+
super(message);
|
|
411
|
+
this.name = "CodexCompactionError";
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function isRetryableError(error: unknown): boolean {
|
|
416
|
+
return error instanceof CodexCompactionError
|
|
417
|
+
? error.retryable
|
|
418
|
+
: error instanceof Error;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
interface RequestSignal {
|
|
422
|
+
signal: AbortSignal;
|
|
423
|
+
startAttempt: () => void;
|
|
424
|
+
headersReceived: () => void;
|
|
425
|
+
touch: () => void;
|
|
426
|
+
cleanup: () => void;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function requestSignal(signal: AbortSignal | undefined): RequestSignal {
|
|
191
430
|
const controller = new AbortController();
|
|
431
|
+
let headerTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
432
|
+
let idleTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
433
|
+
|
|
192
434
|
const onAbort = () => controller.abort(signal?.reason);
|
|
193
435
|
if (signal?.aborted) {
|
|
194
436
|
controller.abort(signal.reason);
|
|
195
437
|
} else {
|
|
196
438
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
197
439
|
}
|
|
198
|
-
|
|
440
|
+
|
|
441
|
+
const clearTimers = () => {
|
|
442
|
+
if (headerTimeout) clearTimeout(headerTimeout);
|
|
443
|
+
if (idleTimeout) clearTimeout(idleTimeout);
|
|
444
|
+
headerTimeout = undefined;
|
|
445
|
+
idleTimeout = undefined;
|
|
446
|
+
};
|
|
447
|
+
const touch = () => {
|
|
448
|
+
if (idleTimeout) clearTimeout(idleTimeout);
|
|
449
|
+
idleTimeout = setTimeout(
|
|
450
|
+
() => controller.abort(new Error("Codex compaction response idle timeout")),
|
|
451
|
+
REQUEST_IDLE_TIMEOUT_MS,
|
|
452
|
+
);
|
|
453
|
+
};
|
|
199
454
|
|
|
200
455
|
return {
|
|
201
456
|
signal: controller.signal,
|
|
457
|
+
startAttempt: () => {
|
|
458
|
+
clearTimers();
|
|
459
|
+
headerTimeout = setTimeout(
|
|
460
|
+
() => controller.abort(new Error("Codex compaction request timed out waiting for response headers")),
|
|
461
|
+
REQUEST_HEADER_TIMEOUT_MS,
|
|
462
|
+
);
|
|
463
|
+
},
|
|
464
|
+
headersReceived: () => {
|
|
465
|
+
if (headerTimeout) clearTimeout(headerTimeout);
|
|
466
|
+
headerTimeout = undefined;
|
|
467
|
+
touch();
|
|
468
|
+
},
|
|
469
|
+
touch,
|
|
202
470
|
cleanup: () => {
|
|
203
|
-
|
|
471
|
+
clearTimers();
|
|
204
472
|
signal?.removeEventListener("abort", onAbort);
|
|
205
473
|
},
|
|
206
474
|
};
|
package/src/install-telemetry.ts
CHANGED
|
@@ -46,14 +46,15 @@ function isPresentEnvFlag(value: string | undefined): boolean {
|
|
|
46
46
|
return normalized !== "0" && normalized !== "false" && normalized !== "no";
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
function isInstallTelemetryEnabled(): boolean {
|
|
50
|
-
if (isTruthyEnvFlag(
|
|
51
|
-
if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(
|
|
52
|
-
if (isTruthyEnvFlag(
|
|
53
|
-
if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
|
|
49
|
+
export function isInstallTelemetryEnabled(env: NodeJS.ProcessEnv = process.env, settingsPath = join(getAgentDir(), "settings.json")): boolean {
|
|
50
|
+
if (isTruthyEnvFlag(env.CI)) return false;
|
|
51
|
+
if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(env[name]))) return false;
|
|
52
|
+
if (isTruthyEnvFlag(env.PI_OFFLINE)) return false;
|
|
54
53
|
|
|
55
|
-
const settings = readJsonFile(
|
|
56
|
-
|
|
54
|
+
const settings = readJsonFile(settingsPath) as PiSettingsDocument;
|
|
55
|
+
if (settings.enableInstallTelemetry === false) return false;
|
|
56
|
+
if (env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(env.PI_TELEMETRY);
|
|
57
|
+
return true;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
function getPackageVersion(): string {
|
package/src/remote-compaction.ts
CHANGED
|
@@ -3,7 +3,12 @@ import type { Model, Tool } from "@earendil-works/pi-ai";
|
|
|
3
3
|
import type { ExtensionContext, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
|
|
4
4
|
import { convertToLlm, serializeConversation, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getCodexAccountFingerprint,
|
|
8
|
+
requestRemoteCompactionWithUsage,
|
|
9
|
+
resolveCodexResponsesUrl,
|
|
10
|
+
type CodexCompactionUsage,
|
|
11
|
+
} from "./codex-wire.js";
|
|
7
12
|
|
|
8
13
|
export const REMOTE_SUMMARY_MARKER = "[pi-codex-compaction:v1]";
|
|
9
14
|
export const REMOTE_COMPACTION_KIND = "pi-codex-compaction";
|
|
@@ -16,14 +21,24 @@ const PI_COMPACTION_SUMMARY_PREFIX = "The conversation history before this point
|
|
|
16
21
|
|
|
17
22
|
export interface RemoteCompactionDetails {
|
|
18
23
|
kind: typeof REMOTE_COMPACTION_KIND;
|
|
19
|
-
version:
|
|
24
|
+
version: 2;
|
|
20
25
|
provider: "openai-codex";
|
|
21
26
|
model: string;
|
|
27
|
+
endpoint: string;
|
|
28
|
+
accountFingerprint: string;
|
|
29
|
+
authKind: CodexAuthKind;
|
|
22
30
|
encryptedContent: string;
|
|
31
|
+
usage?: CodexCompactionUsage;
|
|
23
32
|
}
|
|
24
33
|
|
|
25
|
-
type
|
|
34
|
+
export type CodexAuthKind = "oauth" | "api-key" | "unknown";
|
|
35
|
+
|
|
36
|
+
type CodexModel = Pick<
|
|
37
|
+
Model<any>,
|
|
38
|
+
"id" | "api" | "provider" | "baseUrl" | "contextWindow" | "headers" | "reasoning"
|
|
39
|
+
>;
|
|
26
40
|
type AgentMessage = ReturnType<typeof sessionEntryToContextMessages>[number];
|
|
41
|
+
type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
|
|
27
42
|
|
|
28
43
|
export function supportsRemoteCompaction(model: Pick<CodexModel, "api" | "provider"> | undefined): boolean {
|
|
29
44
|
return model?.provider === "openai-codex" && model.api === "openai-codex-responses";
|
|
@@ -38,6 +53,28 @@ export function findActiveRemoteCompaction(entries: readonly SessionEntry[]): Re
|
|
|
38
53
|
return undefined;
|
|
39
54
|
}
|
|
40
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Check the model, endpoint, and account before reusing an opaque checkpoint.
|
|
58
|
+
* The checkpoint is intentionally not treated as portable across those
|
|
59
|
+
* boundaries because the service can bind its encryption to them.
|
|
60
|
+
*/
|
|
61
|
+
export function isRemoteCompactionCompatible(
|
|
62
|
+
details: RemoteCompactionDetails,
|
|
63
|
+
model: Pick<CodexModel, "id" | "api" | "provider" | "baseUrl">,
|
|
64
|
+
accountFingerprint: string,
|
|
65
|
+
authKind: CodexAuthKind = "unknown",
|
|
66
|
+
): boolean {
|
|
67
|
+
if (!supportsRemoteCompaction(model) || details.model !== model.id) return false;
|
|
68
|
+
if (details.accountFingerprint !== accountFingerprint) return false;
|
|
69
|
+
if (details.authKind === "unknown" || authKind === "unknown") return false;
|
|
70
|
+
if (details.authKind !== authKind) return false;
|
|
71
|
+
try {
|
|
72
|
+
return details.endpoint === resolveCodexResponsesUrl(model.baseUrl);
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
41
78
|
export function applyRemoteCompactionMarker(payload: unknown, details: RemoteCompactionDetails): unknown | undefined {
|
|
42
79
|
if (!isRecord(payload) || !Array.isArray(payload.input)) return undefined;
|
|
43
80
|
|
|
@@ -57,19 +94,31 @@ export async function createRemoteCompaction(
|
|
|
57
94
|
event: SessionBeforeCompactEvent,
|
|
58
95
|
ctx: ExtensionContext,
|
|
59
96
|
getTools: () => readonly ToolInfoLike[],
|
|
97
|
+
thinkingLevel?: string,
|
|
60
98
|
): Promise<{
|
|
61
99
|
summary: string;
|
|
62
100
|
firstKeptEntryId: string;
|
|
63
101
|
tokensBefore: number;
|
|
64
102
|
details: RemoteCompactionDetails;
|
|
65
103
|
} | undefined> {
|
|
104
|
+
// Pi's custom focus is part of the standard summarizer contract. The
|
|
105
|
+
// Responses compaction envelope has no documented equivalent, so do not
|
|
106
|
+
// silently discard it.
|
|
107
|
+
if (event.customInstructions?.trim()) return undefined;
|
|
108
|
+
|
|
66
109
|
const model = ctx.model as CodexModel | undefined;
|
|
67
110
|
if (!model || !supportsRemoteCompaction(model)) return undefined;
|
|
68
111
|
|
|
112
|
+
const endpoint = resolveCodexResponsesUrl(model.baseUrl);
|
|
69
113
|
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model as Model<any>);
|
|
70
114
|
if (!auth.ok || !auth.apiKey) return undefined;
|
|
115
|
+
const accountFingerprint = getCodexAccountFingerprint(auth.apiKey);
|
|
116
|
+
const authKind = getCodexAuthKind(ctx.modelRegistry, model as Model<any>);
|
|
71
117
|
|
|
72
118
|
const previous = findActiveRemoteCompaction(ctx.sessionManager.buildContextEntries());
|
|
119
|
+
const compatiblePrevious = previous && isRemoteCompactionCompatible(previous, model, accountFingerprint, authKind)
|
|
120
|
+
? previous
|
|
121
|
+
: undefined;
|
|
73
122
|
const messages = [...event.preparation.messagesToSummarize, ...event.preparation.turnPrefixMessages];
|
|
74
123
|
const instructions = ctx.getSystemPrompt() || "You are a helpful assistant.";
|
|
75
124
|
const sessionId = ctx.sessionManager.getSessionId();
|
|
@@ -79,28 +128,39 @@ export async function createRemoteCompaction(
|
|
|
79
128
|
convertToLlm(messages),
|
|
80
129
|
getTools(),
|
|
81
130
|
auth.apiKey,
|
|
131
|
+
auth.headers,
|
|
82
132
|
sessionId,
|
|
83
133
|
event.signal,
|
|
134
|
+
thinkingLevel,
|
|
84
135
|
);
|
|
85
136
|
if (!providerPayload) return undefined;
|
|
86
137
|
const providerInput = providerPayload.input;
|
|
87
138
|
if (!Array.isArray(providerInput)) return undefined;
|
|
88
139
|
const providerTools = Array.isArray(providerPayload.tools) ? providerPayload.tools : [];
|
|
89
140
|
|
|
90
|
-
const input = appendCompactionItems(providerInput, event.preparation,
|
|
91
|
-
const
|
|
141
|
+
const input = appendCompactionItems(providerInput, event.preparation, compatiblePrevious);
|
|
142
|
+
const requestBody = {
|
|
143
|
+
...providerPayload,
|
|
144
|
+
model: model.id,
|
|
145
|
+
store: false,
|
|
146
|
+
stream: true,
|
|
147
|
+
};
|
|
148
|
+
const boundedInput = boundCompactionInput(
|
|
149
|
+
input,
|
|
150
|
+
instructions,
|
|
151
|
+
providerTools,
|
|
152
|
+
model.contextWindow,
|
|
153
|
+
requestBody,
|
|
154
|
+
);
|
|
92
155
|
if (!boundedInput) return undefined;
|
|
93
156
|
|
|
94
|
-
const
|
|
157
|
+
const result = await requestRemoteCompactionWithUsage({
|
|
95
158
|
model,
|
|
96
159
|
apiKey: auth.apiKey,
|
|
97
160
|
authHeaders: auth.headers,
|
|
98
161
|
sessionId,
|
|
99
162
|
body: {
|
|
100
|
-
...
|
|
101
|
-
model: model.id,
|
|
102
|
-
store: false,
|
|
103
|
-
stream: true,
|
|
163
|
+
...requestBody,
|
|
104
164
|
input: boundedInput,
|
|
105
165
|
},
|
|
106
166
|
signal: event.signal,
|
|
@@ -113,10 +173,14 @@ export async function createRemoteCompaction(
|
|
|
113
173
|
tokensBefore: event.preparation.tokensBefore,
|
|
114
174
|
details: {
|
|
115
175
|
kind: REMOTE_COMPACTION_KIND,
|
|
116
|
-
version:
|
|
176
|
+
version: 2,
|
|
117
177
|
provider: "openai-codex",
|
|
118
178
|
model: model.id,
|
|
119
|
-
|
|
179
|
+
endpoint,
|
|
180
|
+
accountFingerprint,
|
|
181
|
+
authKind,
|
|
182
|
+
encryptedContent: result.encryptedContent,
|
|
183
|
+
...(result.usage ? { usage: result.usage } : {}),
|
|
120
184
|
},
|
|
121
185
|
};
|
|
122
186
|
}
|
|
@@ -135,29 +199,28 @@ export function boundCompactionInput(
|
|
|
135
199
|
instructions: string,
|
|
136
200
|
tools: readonly unknown[],
|
|
137
201
|
contextWindow: number,
|
|
202
|
+
requestPayload?: Record<string, unknown>,
|
|
138
203
|
): unknown[] | undefined {
|
|
139
|
-
const budget = Math.max(1, contextWindow - COMPACTION_RESPONSE_RESERVE_TOKENS);
|
|
204
|
+
const budget = Math.max(1, Math.floor(contextWindow - COMPACTION_RESPONSE_RESERVE_TOKENS));
|
|
205
|
+
// A byte-level bound is conservative when the active model's tokenizer is unavailable:
|
|
206
|
+
// a token can be represented by a single UTF-8 byte, but not fewer.
|
|
207
|
+
const budgetBytes = budget;
|
|
140
208
|
const bounded = input.map((item) => item);
|
|
141
|
-
let
|
|
142
|
-
|
|
143
|
-
|
|
209
|
+
let requestBytes = estimateConservativeBytes(
|
|
210
|
+
buildCompactionRequest(requestPayload, bounded, instructions, tools),
|
|
211
|
+
);
|
|
212
|
+
if (requestBytes <= budgetBytes) return bounded;
|
|
144
213
|
|
|
145
214
|
// ponytail: trim tool outputs first; if structural content still exceeds the active model window,
|
|
146
215
|
// let Pi's standard compaction path handle the request instead of inventing a lossy transcript rewrite.
|
|
147
216
|
for (let index = bounded.length - 1; index >= 0; index--) {
|
|
148
217
|
const item = bounded[index];
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const replacement = item.type === "function_call_output"
|
|
152
|
-
? { ...item, output: TRUNCATED_TOOL_OUTPUT }
|
|
153
|
-
: item.type === "tool_search_output"
|
|
154
|
-
? { ...item, tools: [] }
|
|
155
|
-
: undefined;
|
|
218
|
+
const replacement = trimToolOutput(item);
|
|
156
219
|
if (!replacement) continue;
|
|
157
220
|
|
|
158
|
-
|
|
221
|
+
requestBytes += estimateConservativeBytes(replacement) - estimateConservativeBytes(item);
|
|
159
222
|
bounded[index] = replacement;
|
|
160
|
-
if (
|
|
223
|
+
if (requestBytes <= budgetBytes) return bounded;
|
|
161
224
|
}
|
|
162
225
|
|
|
163
226
|
return undefined;
|
|
@@ -166,10 +229,12 @@ export function boundCompactionInput(
|
|
|
166
229
|
export function buildFallbackSummary(preparation: SessionBeforeCompactEvent["preparation"], messages: AgentMessage[]): string {
|
|
167
230
|
const transcript = serializeConversation(convertToLlm(messages));
|
|
168
231
|
const previous = preparation.previousSummary ? `Previous checkpoint fallback:\n${preparation.previousSummary}` : "";
|
|
232
|
+
const fileOperations = formatFileOperations(preparation.fileOps);
|
|
169
233
|
const content = [
|
|
170
234
|
"This checkpoint is opaque to non-Codex providers. Use the transcript excerpt below when continuing this session.",
|
|
171
235
|
previous,
|
|
172
236
|
transcript ? `Discarded conversation excerpt:\n${transcript}` : "",
|
|
237
|
+
fileOperations,
|
|
173
238
|
]
|
|
174
239
|
.filter(Boolean)
|
|
175
240
|
.join("\n\n");
|
|
@@ -182,26 +247,34 @@ async function captureCodexPayload(
|
|
|
182
247
|
messages: ReturnType<typeof convertToLlm>,
|
|
183
248
|
tools: readonly ToolInfoLike[],
|
|
184
249
|
apiKey: string,
|
|
250
|
+
authHeaders: Record<string, string> | undefined,
|
|
185
251
|
sessionId: string,
|
|
186
252
|
signal: AbortSignal,
|
|
253
|
+
thinkingLevel: string | undefined,
|
|
187
254
|
): Promise<Record<string, unknown> | undefined> {
|
|
188
255
|
if (signal.aborted) return undefined;
|
|
189
256
|
|
|
190
257
|
let payload: unknown;
|
|
191
258
|
const captureError = new Error("capture Codex request payload");
|
|
192
|
-
const
|
|
193
|
-
systemPrompt: instructions,
|
|
194
|
-
messages,
|
|
195
|
-
tools: [...tools],
|
|
196
|
-
}, {
|
|
259
|
+
const options: Record<string, unknown> = {
|
|
197
260
|
apiKey,
|
|
261
|
+
headers: authHeaders,
|
|
198
262
|
sessionId,
|
|
199
263
|
signal,
|
|
200
|
-
onPayload(next) {
|
|
264
|
+
onPayload(next: unknown) {
|
|
201
265
|
payload = next;
|
|
202
266
|
throw captureError;
|
|
203
267
|
},
|
|
204
|
-
}
|
|
268
|
+
};
|
|
269
|
+
if (model.reasoning && isThinkingLevel(thinkingLevel) && thinkingLevel !== "off") {
|
|
270
|
+
options.reasoningEffort = thinkingLevel;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const stream = captureProviderPayload(model as Model<any>, {
|
|
274
|
+
systemPrompt: instructions,
|
|
275
|
+
messages,
|
|
276
|
+
tools: [...tools],
|
|
277
|
+
}, options);
|
|
205
278
|
|
|
206
279
|
await stream.result();
|
|
207
280
|
return isRecord(payload) ? payload : undefined;
|
|
@@ -219,7 +292,9 @@ function appendCompactionItems(
|
|
|
219
292
|
output.unshift({
|
|
220
293
|
type: "message",
|
|
221
294
|
role: "user",
|
|
222
|
-
|
|
295
|
+
// This is bounded together with the complete request envelope below;
|
|
296
|
+
// the readable fallback limit is only for non-Codex continuation.
|
|
297
|
+
content: [{ type: "input_text", text: preparation.previousSummary }],
|
|
223
298
|
status: "completed",
|
|
224
299
|
});
|
|
225
300
|
}
|
|
@@ -358,19 +433,97 @@ function isRemoteCompactionDetails(value: unknown): value is RemoteCompactionDet
|
|
|
358
433
|
return (
|
|
359
434
|
isRecord(value) &&
|
|
360
435
|
value.kind === REMOTE_COMPACTION_KIND &&
|
|
361
|
-
value.version ===
|
|
436
|
+
value.version === 2 &&
|
|
362
437
|
value.provider === "openai-codex" &&
|
|
363
438
|
typeof value.model === "string" &&
|
|
364
439
|
value.model.length > 0 &&
|
|
440
|
+
value.model.length <= 256 &&
|
|
441
|
+
typeof value.endpoint === "string" &&
|
|
442
|
+
value.endpoint.length <= 2_048 &&
|
|
443
|
+
typeof value.accountFingerprint === "string" &&
|
|
444
|
+
/^sha256:[a-f0-9]{64}$/.test(value.accountFingerprint) &&
|
|
445
|
+
(value.authKind === "oauth" || value.authKind === "api-key" || value.authKind === "unknown") &&
|
|
365
446
|
typeof value.encryptedContent === "string" &&
|
|
366
447
|
value.encryptedContent.length > 0 &&
|
|
367
|
-
value.encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
|
|
448
|
+
value.encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS &&
|
|
449
|
+
(value.usage === undefined || isCodexCompactionUsage(value.usage))
|
|
368
450
|
);
|
|
369
451
|
}
|
|
370
452
|
|
|
371
|
-
function
|
|
372
|
-
|
|
373
|
-
|
|
453
|
+
export function getCodexAuthKind(
|
|
454
|
+
modelRegistry: unknown,
|
|
455
|
+
model: Model<any>,
|
|
456
|
+
): CodexAuthKind {
|
|
457
|
+
const isUsingOAuth = isRecord(modelRegistry) ? modelRegistry.isUsingOAuth : undefined;
|
|
458
|
+
if (typeof isUsingOAuth !== "function") return "unknown";
|
|
459
|
+
try {
|
|
460
|
+
return isUsingOAuth.call(modelRegistry, model) ? "oauth" : "api-key";
|
|
461
|
+
} catch {
|
|
462
|
+
return "unknown";
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function isCodexCompactionUsage(value: unknown): value is CodexCompactionUsage {
|
|
467
|
+
return isRecord(value) && Object.values(value).every(
|
|
468
|
+
(entry) => typeof entry === "number" && Number.isFinite(entry) && entry >= 0,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function buildCompactionRequest(
|
|
473
|
+
requestPayload: Record<string, unknown> | undefined,
|
|
474
|
+
input: readonly unknown[],
|
|
475
|
+
instructions: string,
|
|
476
|
+
tools: readonly unknown[],
|
|
477
|
+
): Record<string, unknown> {
|
|
478
|
+
const payload: Record<string, unknown> = requestPayload ? { ...requestPayload } : {};
|
|
479
|
+
payload.instructions = instructions;
|
|
480
|
+
payload.input = input;
|
|
481
|
+
if (tools.length > 0 || requestPayload && "tools" in requestPayload) payload.tools = tools;
|
|
482
|
+
return payload;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function trimToolOutput(value: unknown): unknown | undefined {
|
|
486
|
+
if (!isRecord(value)) return undefined;
|
|
487
|
+
if (
|
|
488
|
+
(value.type === "function_call_output" ||
|
|
489
|
+
value.type === "custom_tool_call_output" ||
|
|
490
|
+
value.type === "local_shell_call_output" ||
|
|
491
|
+
value.type === "computer_call_output") &&
|
|
492
|
+
value.output !== undefined
|
|
493
|
+
) {
|
|
494
|
+
return { ...value, output: TRUNCATED_TOOL_OUTPUT };
|
|
495
|
+
}
|
|
496
|
+
if (value.type === "tool_search_output" && Array.isArray(value.tools)) {
|
|
497
|
+
return { ...value, tools: [] };
|
|
498
|
+
}
|
|
499
|
+
return undefined;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function estimateConservativeBytes(value: unknown): number {
|
|
503
|
+
try {
|
|
504
|
+
const json = JSON.stringify(value) ?? "";
|
|
505
|
+
return new TextEncoder().encode(json).byteLength;
|
|
506
|
+
} catch {
|
|
507
|
+
return Number.POSITIVE_INFINITY;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function formatFileOperations(fileOps: { read: Set<string>; edited: Set<string> }): string {
|
|
512
|
+
const read = [...fileOps.read].filter((path): path is string => typeof path === "string").sort();
|
|
513
|
+
const edited = [...fileOps.edited].filter((path): path is string => typeof path === "string").sort();
|
|
514
|
+
if (read.length === 0 && edited.length === 0) return "";
|
|
515
|
+
return [
|
|
516
|
+
"File operations in discarded history:",
|
|
517
|
+
read.length > 0 ? `- Read: ${read.join(", ")}` : "",
|
|
518
|
+
edited.length > 0 ? `- Modified: ${edited.join(", ")}` : "",
|
|
519
|
+
]
|
|
520
|
+
.filter(Boolean)
|
|
521
|
+
.join("\n");
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function isThinkingLevel(value: string | undefined): value is ThinkingLevel {
|
|
525
|
+
return value === "off" || value === "minimal" || value === "low" || value === "medium" ||
|
|
526
|
+
value === "high" || value === "xhigh" || value === "max";
|
|
374
527
|
}
|
|
375
528
|
|
|
376
529
|
function limitText(text: string, maxChars: number): string {
|