pi-codex-compaction 0.1.0
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 +20 -0
- package/CODE_OF_CONDUCT.md +45 -0
- package/CONTRIBUTING.md +34 -0
- package/LICENSE +21 -0
- package/README.md +48 -0
- package/SECURITY.md +28 -0
- package/extensions/index.ts +52 -0
- package/index.ts +1 -0
- package/package.json +70 -0
- package/src/codex-wire.ts +211 -0
- package/src/index.ts +4 -0
- package/src/install-telemetry.ts +76 -0
- package/src/remote-compaction.ts +388 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
This project follows the spirit of [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and uses semantic versioning.
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-08-02
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- Initial `pi-codex-compaction` extension.
|
|
14
|
+
- OpenAI Codex RemoteCompactionV2 support with standard Pi compaction fallback.
|
|
15
|
+
- Opaque checkpoint persistence and bounded cross-model fallback context.
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- Use Pi's compat provider entry point so the extension loads through the runtime extension loader.
|
|
20
|
+
- Rehydrate summaries emitted as untyped Responses message items after model switches.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Contributor Covenant Code of Conduct
|
|
2
|
+
|
|
3
|
+
## Our Pledge
|
|
4
|
+
|
|
5
|
+
We pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
|
6
|
+
|
|
7
|
+
## Our Standards
|
|
8
|
+
|
|
9
|
+
Examples of behavior that contributes to a positive environment include:
|
|
10
|
+
|
|
11
|
+
- Demonstrating empathy and kindness toward other people
|
|
12
|
+
- Being respectful of differing opinions, viewpoints, and experiences
|
|
13
|
+
- Giving and gracefully accepting constructive feedback
|
|
14
|
+
- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience
|
|
15
|
+
- Focusing on what is best for the community, rather than us as individuals
|
|
16
|
+
|
|
17
|
+
Examples of unacceptable behavior by participants include:
|
|
18
|
+
|
|
19
|
+
- Trolling, insulting or derogatory comments, and personal or political attacks
|
|
20
|
+
- Public or private harassment
|
|
21
|
+
- Publishing others' private information, such as a physical or electronic address, without explicit permission
|
|
22
|
+
- Other conduct which could reasonably be considered inappropriate in a professional setting
|
|
23
|
+
|
|
24
|
+
## Enforcement Responsibilities
|
|
25
|
+
|
|
26
|
+
Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful.
|
|
27
|
+
|
|
28
|
+
Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
|
29
|
+
|
|
30
|
+
## Scope
|
|
31
|
+
|
|
32
|
+
This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event.
|
|
33
|
+
|
|
34
|
+
## Enforcement
|
|
35
|
+
|
|
36
|
+
Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at jose@mocito.dev. All complaints will be reviewed and investigated promptly and fairly.
|
|
37
|
+
|
|
38
|
+
Community leaders are obligated to respect the privacy and security of the reporter of any incident.
|
|
39
|
+
|
|
40
|
+
## Attribution
|
|
41
|
+
|
|
42
|
+
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
|
|
43
|
+
|
|
44
|
+
[homepage]: https://www.contributor-covenant.org
|
|
45
|
+
[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
|
package/CONTRIBUTING.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Contributing
|
|
2
|
+
|
|
3
|
+
Thanks for your interest in contributing to `pi-codex-compaction`.
|
|
4
|
+
|
|
5
|
+
## Development setup
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run -w packages/pi-codex-compaction check
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
This package is source-distributed: Pi loads the TypeScript extension files directly. There is no build step for runtime use.
|
|
13
|
+
|
|
14
|
+
## Pull request checklist
|
|
15
|
+
|
|
16
|
+
Before opening a pull request:
|
|
17
|
+
|
|
18
|
+
- Run `npm run -w packages/pi-codex-compaction check`.
|
|
19
|
+
- Run `npm test -w packages/pi-codex-compaction`.
|
|
20
|
+
- Run `npm audit --omit=dev`.
|
|
21
|
+
- Run `npm run -w packages/pi-codex-compaction pack:dry-run` and confirm the package contents are intentional.
|
|
22
|
+
- Update `README.md` if user-visible behavior changes.
|
|
23
|
+
- Update `CHANGELOG.md` for notable changes.
|
|
24
|
+
- Keep examples and paths generic; do not commit API keys, tokens, auth headers, local settings, or provider configuration containing secrets.
|
|
25
|
+
|
|
26
|
+
## Coding guidelines
|
|
27
|
+
|
|
28
|
+
- Keep the Codex provider/API capability check explicit and future-compatible.
|
|
29
|
+
- Preserve current-model compaction, context bounds, cancellation, HTTPS, and standard Pi fallback behavior.
|
|
30
|
+
- Add a regression test when changing wire parsing, request construction, checkpoint rehydration, or model-switch fallback behavior.
|
|
31
|
+
|
|
32
|
+
## Code of conduct
|
|
33
|
+
|
|
34
|
+
This project follows the Contributor Covenant Code of Conduct.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jose Mocito
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# pi-codex-compaction
|
|
2
|
+
|
|
3
|
+
Keep long Pi sessions usable on OpenAI Codex models by replacing Pi's local summary request with Codex's provider-side **RemoteCompactionV2** checkpoint when the current model supports the Codex Responses API.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- Uses the current `openai-codex` model for each compaction; it never silently changes the session model.
|
|
8
|
+
- Sends only the history Pi is discarding, plus the previous Codex checkpoint, so the incoming/kept user message is not duplicated.
|
|
9
|
+
- Persists Codex's opaque encrypted checkpoint and rehydrates it only for supported Codex requests.
|
|
10
|
+
- Caps compaction input and response size and falls back to standard Pi compaction on failure.
|
|
11
|
+
- Keeps a bounded readable transcript excerpt so switching models or providers remains usable.
|
|
12
|
+
|
|
13
|
+
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.
|
|
14
|
+
|
|
15
|
+
## Installation
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install npm:pi-codex-compaction
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For local development:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
pi install /path/to/pi-mono/packages/pi-codex-compaction
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Or load it for one run:
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pi -e /path/to/pi-mono/packages/pi-codex-compaction
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Behavior
|
|
34
|
+
|
|
35
|
+
When Pi starts compaction on a supported Codex model, the extension sends a streamed Responses request containing a `compaction_trigger` item and the `remote_compaction_v2` beta feature. The returned opaque checkpoint is stored in the Pi compaction entry. Later supported Codex requests replace the textual compaction marker with the raw checkpoint; other providers/models receive the bounded textual fallback instead.
|
|
36
|
+
|
|
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 it still cannot fit, the extension leaves compaction to Pi's normal implementation.
|
|
38
|
+
|
|
39
|
+
If the remote request fails, is cancelled, or returns an unexpected response, Pi's standard compaction path runs. No configuration is required.
|
|
40
|
+
|
|
41
|
+
## Development
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
npm install
|
|
45
|
+
npm run -w packages/pi-codex-compaction check
|
|
46
|
+
npm test -w packages/pi-codex-compaction
|
|
47
|
+
npm run -w packages/pi-codex-compaction pack:dry-run
|
|
48
|
+
```
|
package/SECURITY.md
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# Security Policy
|
|
2
|
+
|
|
3
|
+
## Supported versions
|
|
4
|
+
|
|
5
|
+
Security fixes are provided for the latest released version of `pi-codex-compaction`.
|
|
6
|
+
|
|
7
|
+
## Reporting a vulnerability
|
|
8
|
+
|
|
9
|
+
Please do not open a public issue for suspected security vulnerabilities.
|
|
10
|
+
|
|
11
|
+
Report privately through [GitHub Security Advisories](https://github.com/jvm/pi-mono/security/advisories/new) or by contacting the repository maintainer through GitHub. Include:
|
|
12
|
+
|
|
13
|
+
- a description of the issue;
|
|
14
|
+
- steps to reproduce;
|
|
15
|
+
- affected versions or commits, if known;
|
|
16
|
+
- any suggested mitigation.
|
|
17
|
+
|
|
18
|
+
## Security model
|
|
19
|
+
|
|
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
|
+
|
|
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 supported Codex requests can reuse it. The checkpoint is not decoded, printed, or logged.
|
|
23
|
+
|
|
24
|
+
The extension bounds compaction input and response size, validates the Codex HTTPS endpoint and account claim, honors Pi cancellation, and 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
|
+
|
|
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
|
+
|
|
28
|
+
See [CONTRIBUTING.md](./CONTRIBUTING.md) for development and validation instructions.
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ExtensionAPI,
|
|
3
|
+
ExtensionHandler,
|
|
4
|
+
SessionBeforeCompactEvent,
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { BETA_FEATURE } from "../src/codex-wire.js";
|
|
7
|
+
import { reportInstallTelemetry } from "../src/install-telemetry.js";
|
|
8
|
+
import {
|
|
9
|
+
applyRemoteCompactionMarker,
|
|
10
|
+
createRemoteCompaction,
|
|
11
|
+
findActiveRemoteCompaction,
|
|
12
|
+
supportsRemoteCompaction,
|
|
13
|
+
} from "../src/remote-compaction.js";
|
|
14
|
+
|
|
15
|
+
export default function piCodexCompaction(pi: ExtensionAPI): void {
|
|
16
|
+
reportInstallTelemetry();
|
|
17
|
+
|
|
18
|
+
const onBeforeCompact: ExtensionHandler<SessionBeforeCompactEvent, { compaction?: NonNullable<Awaited<ReturnType<typeof createRemoteCompaction>>> }> = async (event, ctx) => {
|
|
19
|
+
if (!supportsRemoteCompaction(ctx.model)) return undefined;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const compaction = await createRemoteCompaction(event, ctx, () => {
|
|
23
|
+
const active = new Set(pi.getActiveTools());
|
|
24
|
+
return pi.getAllTools()
|
|
25
|
+
.filter((tool) => active.has(tool.name))
|
|
26
|
+
.map(({ name, description, parameters }) => ({ name, description, parameters }));
|
|
27
|
+
});
|
|
28
|
+
return compaction ? { compaction } : undefined;
|
|
29
|
+
} catch {
|
|
30
|
+
if (!event.signal.aborted && ctx.hasUI) {
|
|
31
|
+
ctx.ui.notify("Codex remote compaction failed; using standard Pi compaction.", "warning");
|
|
32
|
+
}
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
pi.on("session_before_compact", onBeforeCompact);
|
|
37
|
+
|
|
38
|
+
pi.on("before_provider_headers", (event, ctx) => {
|
|
39
|
+
if (!supportsRemoteCompaction(ctx.model)) return;
|
|
40
|
+
const existing = event.headers["x-codex-beta-features"];
|
|
41
|
+
const features = existing?.split(",").map((feature) => feature.trim()).filter(Boolean) ?? [];
|
|
42
|
+
if (!features.includes(BETA_FEATURE)) features.push(BETA_FEATURE);
|
|
43
|
+
event.headers["x-codex-beta-features"] = features.join(",");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
pi.on("before_provider_request", (event, ctx) => {
|
|
47
|
+
if (!supportsRemoteCompaction(ctx.model)) return;
|
|
48
|
+
const details = findActiveRemoteCompaction(ctx.sessionManager.buildContextEntries());
|
|
49
|
+
if (!details) return;
|
|
50
|
+
return applyRemoteCompactionMarker(event.payload, details);
|
|
51
|
+
});
|
|
52
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { default } from "./extensions/index.js";
|
package/package.json
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-codex-compaction",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Use OpenAI Codex RemoteCompactionV2 for supported Pi sessions.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "Jose Mocito",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/jvm/pi-mono.git",
|
|
11
|
+
"directory": "packages/pi-codex-compaction"
|
|
12
|
+
},
|
|
13
|
+
"bugs": {
|
|
14
|
+
"url": "https://github.com/jvm/pi-mono/issues"
|
|
15
|
+
},
|
|
16
|
+
"homepage": "https://github.com/jvm/pi-mono/tree/main/packages/pi-codex-compaction#readme",
|
|
17
|
+
"keywords": [
|
|
18
|
+
"pi-package",
|
|
19
|
+
"pi-extension",
|
|
20
|
+
"pi",
|
|
21
|
+
"compaction",
|
|
22
|
+
"openai-codex",
|
|
23
|
+
"remote-compaction"
|
|
24
|
+
],
|
|
25
|
+
"exports": {
|
|
26
|
+
".": "./src/index.ts"
|
|
27
|
+
},
|
|
28
|
+
"pi": {
|
|
29
|
+
"extensions": [
|
|
30
|
+
"./index.ts"
|
|
31
|
+
]
|
|
32
|
+
},
|
|
33
|
+
"files": [
|
|
34
|
+
"index.ts",
|
|
35
|
+
"extensions",
|
|
36
|
+
"src",
|
|
37
|
+
"README.md",
|
|
38
|
+
"LICENSE",
|
|
39
|
+
"CHANGELOG.md",
|
|
40
|
+
"SECURITY.md",
|
|
41
|
+
"CONTRIBUTING.md",
|
|
42
|
+
"CODE_OF_CONDUCT.md"
|
|
43
|
+
],
|
|
44
|
+
"scripts": {
|
|
45
|
+
"check": "tsc --noEmit",
|
|
46
|
+
"typecheck": "tsc --noEmit",
|
|
47
|
+
"test": "node --import tsx --test tests/*.test.mjs",
|
|
48
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@earendil-works/pi-ai": "*",
|
|
52
|
+
"@earendil-works/pi-coding-agent": "*"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@earendil-works/pi-ai": "^0.80.10",
|
|
56
|
+
"@earendil-works/pi-coding-agent": "^0.80.10",
|
|
57
|
+
"@types/node": "^26.1.1",
|
|
58
|
+
"tsx": "^4.23.1",
|
|
59
|
+
"typescript": "^7.0.2"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
},
|
|
64
|
+
"engines": {
|
|
65
|
+
"node": ">=20.6.0"
|
|
66
|
+
},
|
|
67
|
+
"dependencies": {
|
|
68
|
+
"@mocito/install-telemetry": "0.1.1"
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
const MAX_RESPONSE_BYTES = 4 * 1024 * 1024;
|
|
2
|
+
const MAX_ENCRYPTED_CONTENT_CHARS = 2_000_000;
|
|
3
|
+
const REQUEST_TIMEOUT_MS = 120_000;
|
|
4
|
+
const ACCOUNT_ID_CLAIM = "https://api.openai.com/auth";
|
|
5
|
+
export const BETA_FEATURE = "remote_compaction_v2";
|
|
6
|
+
|
|
7
|
+
export interface CodexCompactionRequest {
|
|
8
|
+
model: {
|
|
9
|
+
id: string;
|
|
10
|
+
baseUrl: string;
|
|
11
|
+
headers?: Record<string, string>;
|
|
12
|
+
};
|
|
13
|
+
apiKey: string;
|
|
14
|
+
authHeaders?: Record<string, string>;
|
|
15
|
+
sessionId?: string;
|
|
16
|
+
body: Record<string, unknown>;
|
|
17
|
+
signal?: AbortSignal;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function requestRemoteCompaction(request: CodexCompactionRequest): Promise<string> {
|
|
21
|
+
const accountId = extractAccountId(request.apiKey);
|
|
22
|
+
const headers = buildHeaders(request, accountId);
|
|
23
|
+
const { signal, cleanup } = requestSignal(request.signal);
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
const response = await fetch(resolveCodexResponsesUrl(request.model.baseUrl), {
|
|
27
|
+
method: "POST",
|
|
28
|
+
headers,
|
|
29
|
+
body: JSON.stringify(request.body),
|
|
30
|
+
signal,
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
if (!response.ok) {
|
|
34
|
+
if (response.body) await response.body.cancel().catch(() => undefined);
|
|
35
|
+
throw new Error(`Codex compaction request returned HTTP ${response.status}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return parseCompactionSse(await readBoundedBody(response));
|
|
39
|
+
} finally {
|
|
40
|
+
cleanup();
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveCodexResponsesUrl(baseUrl: string): string {
|
|
45
|
+
const url = new URL(baseUrl);
|
|
46
|
+
if (url.protocol !== "https:") {
|
|
47
|
+
throw new Error("Codex compaction requires an HTTPS endpoint");
|
|
48
|
+
}
|
|
49
|
+
if (url.username || url.password) {
|
|
50
|
+
throw new Error("Codex compaction endpoint must not contain URL credentials");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
54
|
+
if (path.endsWith("/codex/responses")) {
|
|
55
|
+
return url.toString();
|
|
56
|
+
}
|
|
57
|
+
url.pathname = path.endsWith("/codex") ? `${path}/responses` : `${path}/codex/responses`;
|
|
58
|
+
return url.toString();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function parseCompactionSse(sse: string): string {
|
|
62
|
+
const encryptedContents = new Set<string>();
|
|
63
|
+
let completed = false;
|
|
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
|
+
}
|
|
96
|
+
|
|
97
|
+
if (!completed) {
|
|
98
|
+
throw new Error("Codex compaction stream ended before response completion");
|
|
99
|
+
}
|
|
100
|
+
if (encryptedContents.size !== 1) {
|
|
101
|
+
throw new Error(`Codex compaction returned ${encryptedContents.size} checkpoint items`);
|
|
102
|
+
}
|
|
103
|
+
return [...encryptedContents][0];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function buildHeaders(request: CodexCompactionRequest, accountId: string): Headers {
|
|
107
|
+
const headers = new Headers();
|
|
108
|
+
for (const [name, value] of Object.entries(request.model.headers ?? {})) headers.set(name, value);
|
|
109
|
+
for (const [name, value] of Object.entries(request.authHeaders ?? {})) headers.set(name, value);
|
|
110
|
+
|
|
111
|
+
headers.set("Authorization", `Bearer ${request.apiKey}`);
|
|
112
|
+
headers.set("chatgpt-account-id", accountId);
|
|
113
|
+
headers.set("originator", "pi");
|
|
114
|
+
headers.set("OpenAI-Beta", "responses=experimental");
|
|
115
|
+
headers.set("x-codex-beta-features", BETA_FEATURE);
|
|
116
|
+
headers.set("accept", "text/event-stream");
|
|
117
|
+
headers.set("content-type", "application/json");
|
|
118
|
+
|
|
119
|
+
const sessionId = safeSessionId(request.sessionId);
|
|
120
|
+
if (sessionId) {
|
|
121
|
+
headers.set("session-id", sessionId);
|
|
122
|
+
headers.set("x-client-request-id", sessionId);
|
|
123
|
+
}
|
|
124
|
+
return headers;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function extractAccountId(token: string): string {
|
|
128
|
+
const segment = token.split(".")[1];
|
|
129
|
+
if (!segment) throw new Error("Codex authentication token has no account claim");
|
|
130
|
+
|
|
131
|
+
try {
|
|
132
|
+
const payload = JSON.parse(Buffer.from(segment, "base64url").toString("utf8")) as unknown;
|
|
133
|
+
const claim = isRecord(payload) ? payload[ACCOUNT_ID_CLAIM] : undefined;
|
|
134
|
+
const accountId = isRecord(claim) ? claim.chatgpt_account_id : undefined;
|
|
135
|
+
if (typeof accountId !== "string" || !/^[A-Za-z0-9._-]{1,256}$/.test(accountId)) {
|
|
136
|
+
throw new Error("missing account claim");
|
|
137
|
+
}
|
|
138
|
+
return accountId;
|
|
139
|
+
} catch {
|
|
140
|
+
throw new Error("Codex authentication token has no valid account claim");
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function safeSessionId(value: string | undefined): string | undefined {
|
|
145
|
+
if (!value || !/^[A-Za-z0-9_-]{1,64}$/.test(value)) return undefined;
|
|
146
|
+
return value;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function collectOutputItems(value: unknown, encryptedContents: Set<string>): void {
|
|
150
|
+
if (!Array.isArray(value)) return;
|
|
151
|
+
for (const item of value) collectCompactionItem(item, encryptedContents);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function collectCompactionItem(value: unknown, encryptedContents: Set<string>): void {
|
|
155
|
+
if (!isRecord(value) || value.type !== "compaction") return;
|
|
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);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function readBoundedBody(response: Response): Promise<string> {
|
|
167
|
+
if (!response.body) throw new Error("Codex compaction response has no body");
|
|
168
|
+
|
|
169
|
+
const reader = response.body.getReader();
|
|
170
|
+
const decoder = new TextDecoder();
|
|
171
|
+
let bytes = 0;
|
|
172
|
+
let text = "";
|
|
173
|
+
try {
|
|
174
|
+
while (true) {
|
|
175
|
+
const { done, value } = await reader.read();
|
|
176
|
+
if (done) break;
|
|
177
|
+
bytes += value.byteLength;
|
|
178
|
+
if (bytes > MAX_RESPONSE_BYTES) {
|
|
179
|
+
throw new Error("Codex compaction response exceeded the size limit");
|
|
180
|
+
}
|
|
181
|
+
text += decoder.decode(value, { stream: true });
|
|
182
|
+
}
|
|
183
|
+
return text + decoder.decode();
|
|
184
|
+
} finally {
|
|
185
|
+
await reader.cancel().catch(() => undefined);
|
|
186
|
+
reader.releaseLock();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function requestSignal(signal: AbortSignal | undefined): { signal: AbortSignal; cleanup: () => void } {
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
const onAbort = () => controller.abort(signal?.reason);
|
|
193
|
+
if (signal?.aborted) {
|
|
194
|
+
controller.abort(signal.reason);
|
|
195
|
+
} else {
|
|
196
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
197
|
+
}
|
|
198
|
+
const timeout = setTimeout(() => controller.abort(new Error("Codex compaction request timed out")), REQUEST_TIMEOUT_MS);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
signal: controller.signal,
|
|
202
|
+
cleanup: () => {
|
|
203
|
+
clearTimeout(timeout);
|
|
204
|
+
signal?.removeEventListener("abort", onAbort);
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
210
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
211
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { reportInstallTelemetry as report } from "@mocito/install-telemetry";
|
|
5
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
const PACKAGE_NAME = "pi-codex-compaction";
|
|
8
|
+
const INSTALL_TELEMETRY_ENDPOINT = "https://mocito.dev/api/report-install";
|
|
9
|
+
const CI_ENVIRONMENT_VARIABLES = [
|
|
10
|
+
"APPVEYOR",
|
|
11
|
+
"BITBUCKET_BUILD_NUMBER",
|
|
12
|
+
"BUILDKITE",
|
|
13
|
+
"CIRCLECI",
|
|
14
|
+
"CODESPACES",
|
|
15
|
+
"DRONE",
|
|
16
|
+
"GITHUB_ACTIONS",
|
|
17
|
+
"GITLAB_CI",
|
|
18
|
+
"JENKINS_URL",
|
|
19
|
+
"NETLIFY",
|
|
20
|
+
"TEAMCITY_VERSION",
|
|
21
|
+
"TF_BUILD",
|
|
22
|
+
"TRAVIS",
|
|
23
|
+
"VERCEL",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
interface PiSettingsDocument {
|
|
27
|
+
enableInstallTelemetry?: unknown;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function readJsonFile(path: string): unknown {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
33
|
+
} catch {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isTruthyEnvFlag(value: string | undefined): boolean {
|
|
39
|
+
if (!value) return false;
|
|
40
|
+
return value === "1" || value.toLowerCase() === "true" || value.toLowerCase() === "yes";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isPresentEnvFlag(value: string | undefined): boolean {
|
|
44
|
+
if (!value) return false;
|
|
45
|
+
const normalized = value.toLowerCase();
|
|
46
|
+
return normalized !== "0" && normalized !== "false" && normalized !== "no";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isInstallTelemetryEnabled(): boolean {
|
|
50
|
+
if (isTruthyEnvFlag(process.env.CI)) return false;
|
|
51
|
+
if (CI_ENVIRONMENT_VARIABLES.some((name) => isPresentEnvFlag(process.env[name]))) return false;
|
|
52
|
+
if (isTruthyEnvFlag(process.env.PI_OFFLINE)) return false;
|
|
53
|
+
if (process.env.PI_TELEMETRY !== undefined) return isTruthyEnvFlag(process.env.PI_TELEMETRY);
|
|
54
|
+
|
|
55
|
+
const settings = readJsonFile(join(getAgentDir(), "settings.json")) as PiSettingsDocument;
|
|
56
|
+
return settings.enableInstallTelemetry !== false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getPackageVersion(): string {
|
|
60
|
+
const packageJson = readJsonFile(fileURLToPath(new URL("../package.json", import.meta.url))) as { version?: unknown };
|
|
61
|
+
return typeof packageJson.version === "string" && packageJson.version.length > 0 ? packageJson.version : "0.0.0";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function reportInstallTelemetry(): void {
|
|
65
|
+
try {
|
|
66
|
+
void report({
|
|
67
|
+
endpoint: INSTALL_TELEMETRY_ENDPOINT,
|
|
68
|
+
tool: PACKAGE_NAME,
|
|
69
|
+
version: getPackageVersion(),
|
|
70
|
+
statePath: join(getAgentDir(), "extensions", "pi-codex-compaction-install.json"),
|
|
71
|
+
enabled: isInstallTelemetryEnabled(),
|
|
72
|
+
}).catch(() => undefined);
|
|
73
|
+
} catch {
|
|
74
|
+
// Best-effort telemetry: ignore local policy and filesystem failures.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
import { stream as captureProviderPayload } from "@earendil-works/pi-ai/compat";
|
|
2
|
+
import type { Model, Tool } from "@earendil-works/pi-ai";
|
|
3
|
+
import type { ExtensionContext, SessionBeforeCompactEvent } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import { convertToLlm, serializeConversation, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { requestRemoteCompaction } from "./codex-wire.js";
|
|
7
|
+
|
|
8
|
+
export const REMOTE_SUMMARY_MARKER = "[pi-codex-compaction:v1]";
|
|
9
|
+
export const REMOTE_COMPACTION_KIND = "pi-codex-compaction";
|
|
10
|
+
|
|
11
|
+
const FALLBACK_SUMMARY_MAX_CHARS = 12_000;
|
|
12
|
+
const MAX_ENCRYPTED_CONTENT_CHARS = 2_000_000;
|
|
13
|
+
const COMPACTION_RESPONSE_RESERVE_TOKENS = 8_192;
|
|
14
|
+
const TRUNCATED_TOOL_OUTPUT = "[Tool output omitted from the Codex compaction request to fit the active model context window.]";
|
|
15
|
+
const PI_COMPACTION_SUMMARY_PREFIX = "The conversation history before this point was compacted into the following summary:";
|
|
16
|
+
|
|
17
|
+
export interface RemoteCompactionDetails {
|
|
18
|
+
kind: typeof REMOTE_COMPACTION_KIND;
|
|
19
|
+
version: 1;
|
|
20
|
+
provider: "openai-codex";
|
|
21
|
+
model: string;
|
|
22
|
+
encryptedContent: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type CodexModel = Pick<Model<any>, "id" | "api" | "provider" | "baseUrl" | "contextWindow" | "headers">;
|
|
26
|
+
type AgentMessage = ReturnType<typeof sessionEntryToContextMessages>[number];
|
|
27
|
+
|
|
28
|
+
export function supportsRemoteCompaction(model: Pick<CodexModel, "api" | "provider"> | undefined): boolean {
|
|
29
|
+
return model?.provider === "openai-codex" && model.api === "openai-codex-responses";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function findActiveRemoteCompaction(entries: readonly SessionEntry[]): RemoteCompactionDetails | undefined {
|
|
33
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
34
|
+
const entry = entries[index];
|
|
35
|
+
if (entry.type !== "compaction") continue;
|
|
36
|
+
return isRemoteCompactionDetails(entry.details) ? entry.details : undefined;
|
|
37
|
+
}
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function applyRemoteCompactionMarker(payload: unknown, details: RemoteCompactionDetails): unknown | undefined {
|
|
42
|
+
if (!isRecord(payload) || !Array.isArray(payload.input)) return undefined;
|
|
43
|
+
|
|
44
|
+
let replaced = false;
|
|
45
|
+
const input = payload.input.map((item) => {
|
|
46
|
+
if (replaced || !isCompactionSummaryInput(item)) return item;
|
|
47
|
+
replaced = true;
|
|
48
|
+
return {
|
|
49
|
+
type: "compaction",
|
|
50
|
+
encrypted_content: details.encryptedContent,
|
|
51
|
+
};
|
|
52
|
+
});
|
|
53
|
+
return replaced ? { ...payload, input } : undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function createRemoteCompaction(
|
|
57
|
+
event: SessionBeforeCompactEvent,
|
|
58
|
+
ctx: ExtensionContext,
|
|
59
|
+
getTools: () => readonly ToolInfoLike[],
|
|
60
|
+
): Promise<{
|
|
61
|
+
summary: string;
|
|
62
|
+
firstKeptEntryId: string;
|
|
63
|
+
tokensBefore: number;
|
|
64
|
+
details: RemoteCompactionDetails;
|
|
65
|
+
} | undefined> {
|
|
66
|
+
const model = ctx.model as CodexModel | undefined;
|
|
67
|
+
if (!model || !supportsRemoteCompaction(model)) return undefined;
|
|
68
|
+
|
|
69
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model as Model<any>);
|
|
70
|
+
if (!auth.ok || !auth.apiKey) return undefined;
|
|
71
|
+
|
|
72
|
+
const previous = findActiveRemoteCompaction(ctx.sessionManager.buildContextEntries());
|
|
73
|
+
const messages = [...event.preparation.messagesToSummarize, ...event.preparation.turnPrefixMessages];
|
|
74
|
+
const instructions = ctx.getSystemPrompt() || "You are a helpful assistant.";
|
|
75
|
+
const sessionId = ctx.sessionManager.getSessionId();
|
|
76
|
+
const providerPayload = await captureCodexPayload(
|
|
77
|
+
model,
|
|
78
|
+
instructions,
|
|
79
|
+
convertToLlm(messages),
|
|
80
|
+
getTools(),
|
|
81
|
+
auth.apiKey,
|
|
82
|
+
sessionId,
|
|
83
|
+
event.signal,
|
|
84
|
+
);
|
|
85
|
+
if (!providerPayload) return undefined;
|
|
86
|
+
const providerInput = providerPayload.input;
|
|
87
|
+
if (!Array.isArray(providerInput)) return undefined;
|
|
88
|
+
const providerTools = Array.isArray(providerPayload.tools) ? providerPayload.tools : [];
|
|
89
|
+
|
|
90
|
+
const input = appendCompactionItems(providerInput, event.preparation, previous);
|
|
91
|
+
const boundedInput = boundCompactionInput(input, instructions, providerTools, model.contextWindow);
|
|
92
|
+
if (!boundedInput) return undefined;
|
|
93
|
+
|
|
94
|
+
const encryptedContent = await requestRemoteCompaction({
|
|
95
|
+
model,
|
|
96
|
+
apiKey: auth.apiKey,
|
|
97
|
+
authHeaders: auth.headers,
|
|
98
|
+
sessionId,
|
|
99
|
+
body: {
|
|
100
|
+
...providerPayload,
|
|
101
|
+
model: model.id,
|
|
102
|
+
store: false,
|
|
103
|
+
stream: true,
|
|
104
|
+
input: boundedInput,
|
|
105
|
+
},
|
|
106
|
+
signal: event.signal,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const fallback = buildFallbackSummary(event.preparation, messages);
|
|
110
|
+
return {
|
|
111
|
+
summary: `${REMOTE_SUMMARY_MARKER}\n\n${fallback}`,
|
|
112
|
+
firstKeptEntryId: event.preparation.firstKeptEntryId,
|
|
113
|
+
tokensBefore: event.preparation.tokensBefore,
|
|
114
|
+
details: {
|
|
115
|
+
kind: REMOTE_COMPACTION_KIND,
|
|
116
|
+
version: 1,
|
|
117
|
+
provider: "openai-codex",
|
|
118
|
+
model: model.id,
|
|
119
|
+
encryptedContent,
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function buildCompactionInput(
|
|
125
|
+
model: CodexModel,
|
|
126
|
+
preparation: SessionBeforeCompactEvent["preparation"],
|
|
127
|
+
messages: AgentMessage[],
|
|
128
|
+
previous: RemoteCompactionDetails | undefined,
|
|
129
|
+
): unknown[] {
|
|
130
|
+
return appendCompactionItems(convertCompactionMessages(model, convertToLlm(messages)), preparation, previous);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function boundCompactionInput(
|
|
134
|
+
input: readonly unknown[],
|
|
135
|
+
instructions: string,
|
|
136
|
+
tools: readonly unknown[],
|
|
137
|
+
contextWindow: number,
|
|
138
|
+
): unknown[] | undefined {
|
|
139
|
+
const budget = Math.max(1, contextWindow - COMPACTION_RESPONSE_RESERVE_TOKENS);
|
|
140
|
+
const bounded = input.map((item) => item);
|
|
141
|
+
let estimatedTokens = estimateConservativeTokens({ instructions, input: bounded, tools });
|
|
142
|
+
const fits = () => estimatedTokens <= budget;
|
|
143
|
+
if (fits()) return bounded;
|
|
144
|
+
|
|
145
|
+
// ponytail: trim tool outputs first; if structural content still exceeds the active model window,
|
|
146
|
+
// let Pi's standard compaction path handle the request instead of inventing a lossy transcript rewrite.
|
|
147
|
+
for (let index = bounded.length - 1; index >= 0; index--) {
|
|
148
|
+
const item = bounded[index];
|
|
149
|
+
if (!isRecord(item)) continue;
|
|
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;
|
|
156
|
+
if (!replacement) continue;
|
|
157
|
+
|
|
158
|
+
estimatedTokens += estimateConservativeTokens(replacement) - estimateConservativeTokens(item);
|
|
159
|
+
bounded[index] = replacement;
|
|
160
|
+
if (fits()) return bounded;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return undefined;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function buildFallbackSummary(preparation: SessionBeforeCompactEvent["preparation"], messages: AgentMessage[]): string {
|
|
167
|
+
const transcript = serializeConversation(convertToLlm(messages));
|
|
168
|
+
const previous = preparation.previousSummary ? `Previous checkpoint fallback:\n${preparation.previousSummary}` : "";
|
|
169
|
+
const content = [
|
|
170
|
+
"This checkpoint is opaque to non-Codex providers. Use the transcript excerpt below when continuing this session.",
|
|
171
|
+
previous,
|
|
172
|
+
transcript ? `Discarded conversation excerpt:\n${transcript}` : "",
|
|
173
|
+
]
|
|
174
|
+
.filter(Boolean)
|
|
175
|
+
.join("\n\n");
|
|
176
|
+
return `<codex-compaction-fallback>\n${limitText(content, FALLBACK_SUMMARY_MAX_CHARS)}\n</codex-compaction-fallback>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function captureCodexPayload(
|
|
180
|
+
model: CodexModel,
|
|
181
|
+
instructions: string,
|
|
182
|
+
messages: ReturnType<typeof convertToLlm>,
|
|
183
|
+
tools: readonly ToolInfoLike[],
|
|
184
|
+
apiKey: string,
|
|
185
|
+
sessionId: string,
|
|
186
|
+
signal: AbortSignal,
|
|
187
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
188
|
+
if (signal.aborted) return undefined;
|
|
189
|
+
|
|
190
|
+
let payload: unknown;
|
|
191
|
+
const captureError = new Error("capture Codex request payload");
|
|
192
|
+
const stream = captureProviderPayload(model as Model<any>, {
|
|
193
|
+
systemPrompt: instructions,
|
|
194
|
+
messages,
|
|
195
|
+
tools: [...tools],
|
|
196
|
+
}, {
|
|
197
|
+
apiKey,
|
|
198
|
+
sessionId,
|
|
199
|
+
signal,
|
|
200
|
+
onPayload(next) {
|
|
201
|
+
payload = next;
|
|
202
|
+
throw captureError;
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
await stream.result();
|
|
207
|
+
return isRecord(payload) ? payload : undefined;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function appendCompactionItems(
|
|
211
|
+
input: readonly unknown[],
|
|
212
|
+
preparation: SessionBeforeCompactEvent["preparation"],
|
|
213
|
+
previous: RemoteCompactionDetails | undefined,
|
|
214
|
+
): unknown[] {
|
|
215
|
+
const output = [...input];
|
|
216
|
+
if (previous) {
|
|
217
|
+
output.unshift({ type: "compaction", encrypted_content: previous.encryptedContent });
|
|
218
|
+
} else if (preparation.previousSummary) {
|
|
219
|
+
output.unshift({
|
|
220
|
+
type: "message",
|
|
221
|
+
role: "user",
|
|
222
|
+
content: [{ type: "input_text", text: limitText(preparation.previousSummary, FALLBACK_SUMMARY_MAX_CHARS) }],
|
|
223
|
+
status: "completed",
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
output.push({ type: "compaction_trigger" });
|
|
227
|
+
return output;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function convertCompactionMessages(model: CodexModel, messages: readonly unknown[]): unknown[] {
|
|
231
|
+
const input: unknown[] = [];
|
|
232
|
+
let messageIndex = 0;
|
|
233
|
+
|
|
234
|
+
for (const rawMessage of messages) {
|
|
235
|
+
if (!isRecord(rawMessage)) continue;
|
|
236
|
+
const role = rawMessage.role;
|
|
237
|
+
|
|
238
|
+
if (role === "user") {
|
|
239
|
+
const content = convertUserContent(rawMessage.content);
|
|
240
|
+
if (content.length > 0) input.push({ type: "message", role: "user", content });
|
|
241
|
+
} else if (role === "assistant") {
|
|
242
|
+
const output: unknown[] = [];
|
|
243
|
+
const sourceIsSameResponsesApi = rawMessage.provider === model.provider && rawMessage.api === model.api;
|
|
244
|
+
const differentModel = sourceIsSameResponsesApi && rawMessage.model !== model.id;
|
|
245
|
+
let textIndex = 0;
|
|
246
|
+
if (Array.isArray(rawMessage.content)) {
|
|
247
|
+
for (const rawBlock of rawMessage.content) {
|
|
248
|
+
if (!isRecord(rawBlock)) continue;
|
|
249
|
+
if (rawBlock.type === "thinking" && typeof rawBlock.thinkingSignature === "string") {
|
|
250
|
+
try {
|
|
251
|
+
const reasoning = JSON.parse(rawBlock.thinkingSignature) as unknown;
|
|
252
|
+
if (isRecord(reasoning)) output.push(reasoning);
|
|
253
|
+
} catch {
|
|
254
|
+
// Ignore malformed reasoning signatures; the textual/tool history remains usable.
|
|
255
|
+
}
|
|
256
|
+
} else if (rawBlock.type === "text" && typeof rawBlock.text === "string") {
|
|
257
|
+
const signature = parseTextSignature(rawBlock.textSignature);
|
|
258
|
+
output.push({
|
|
259
|
+
type: "message",
|
|
260
|
+
role: "assistant",
|
|
261
|
+
content: [{ type: "output_text", text: rawBlock.text, annotations: [] }],
|
|
262
|
+
status: "completed",
|
|
263
|
+
id: signature?.id ?? `msg_pi_${messageIndex}${textIndex ? `_${textIndex}` : ""}`,
|
|
264
|
+
...(signature?.phase ? { phase: signature.phase } : {}),
|
|
265
|
+
});
|
|
266
|
+
textIndex++;
|
|
267
|
+
} else if (rawBlock.type === "toolCall" && typeof rawBlock.id === "string") {
|
|
268
|
+
const [callId, itemIdRaw] = splitToolCallId(rawBlock.id);
|
|
269
|
+
output.push({
|
|
270
|
+
type: "function_call",
|
|
271
|
+
...(itemIdRaw && !differentModel ? { id: normalizeResponseItemId(itemIdRaw) } : {}),
|
|
272
|
+
call_id: callId,
|
|
273
|
+
name: typeof rawBlock.name === "string" ? rawBlock.name : "",
|
|
274
|
+
arguments: JSON.stringify(rawBlock.arguments ?? {}),
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
input.push(...output);
|
|
280
|
+
} else if (role === "toolResult") {
|
|
281
|
+
const content = Array.isArray(rawMessage.content) ? rawMessage.content : [];
|
|
282
|
+
const text = content
|
|
283
|
+
.filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
|
|
284
|
+
.map((part) => part.text as string)
|
|
285
|
+
.join("\n");
|
|
286
|
+
const hasImage = content.some((part) => isRecord(part) && part.type === "image");
|
|
287
|
+
const toolCallId = typeof rawMessage.toolCallId === "string" ? rawMessage.toolCallId : "";
|
|
288
|
+
input.push({
|
|
289
|
+
type: "function_call_output",
|
|
290
|
+
call_id: splitToolCallId(toolCallId)[0],
|
|
291
|
+
output: text || (hasImage ? "(see attached image)" : "(no tool output)"),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
messageIndex++;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return input;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function convertUserContent(value: unknown): unknown[] {
|
|
302
|
+
if (typeof value === "string") return [{ type: "input_text", text: value }];
|
|
303
|
+
if (!Array.isArray(value)) return [];
|
|
304
|
+
|
|
305
|
+
const content: unknown[] = [];
|
|
306
|
+
for (const part of value) {
|
|
307
|
+
if (!isRecord(part)) continue;
|
|
308
|
+
if (part.type === "text" && typeof part.text === "string") {
|
|
309
|
+
content.push({ type: "input_text", text: part.text });
|
|
310
|
+
} else if (part.type === "image" && typeof part.mimeType === "string" && typeof part.data === "string") {
|
|
311
|
+
content.push({ type: "input_image", detail: "auto", image_url: `data:${part.mimeType};base64,${part.data}` });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
return content;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function parseTextSignature(value: unknown): { id: string; phase?: string } | undefined {
|
|
318
|
+
if (typeof value !== "string" || value.length === 0) return undefined;
|
|
319
|
+
if (!value.startsWith("{")) return { id: value.slice(0, 64) };
|
|
320
|
+
try {
|
|
321
|
+
const parsed = JSON.parse(value) as unknown;
|
|
322
|
+
if (!isRecord(parsed) || typeof parsed.id !== "string") return undefined;
|
|
323
|
+
return {
|
|
324
|
+
id: parsed.id.slice(0, 64),
|
|
325
|
+
...(typeof parsed.phase === "string" ? { phase: parsed.phase } : {}),
|
|
326
|
+
};
|
|
327
|
+
} catch {
|
|
328
|
+
return undefined;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function splitToolCallId(value: string): [string, string | undefined] {
|
|
333
|
+
const separator = value.indexOf("|");
|
|
334
|
+
return separator < 0 ? [value, undefined] : [value.slice(0, separator), value.slice(separator + 1)];
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function normalizeResponseItemId(value: string): string {
|
|
338
|
+
let normalized = value.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64).replace(/_+$/, "");
|
|
339
|
+
if (!normalized.startsWith("fc")) normalized = `fc_${normalized}`;
|
|
340
|
+
return normalized.slice(0, 64);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isCompactionSummaryInput(value: unknown): boolean {
|
|
344
|
+
if (!isRecord(value) || (value.type !== undefined && value.type !== "message") || value.role !== "user" || !Array.isArray(value.content)) {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
return value.content.some(
|
|
348
|
+
(part) =>
|
|
349
|
+
isRecord(part) &&
|
|
350
|
+
part.type === "input_text" &&
|
|
351
|
+
typeof part.text === "string" &&
|
|
352
|
+
part.text.trimStart().startsWith(PI_COMPACTION_SUMMARY_PREFIX) &&
|
|
353
|
+
part.text.includes(REMOTE_SUMMARY_MARKER),
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function isRemoteCompactionDetails(value: unknown): value is RemoteCompactionDetails {
|
|
358
|
+
return (
|
|
359
|
+
isRecord(value) &&
|
|
360
|
+
value.kind === REMOTE_COMPACTION_KIND &&
|
|
361
|
+
value.version === 1 &&
|
|
362
|
+
value.provider === "openai-codex" &&
|
|
363
|
+
typeof value.model === "string" &&
|
|
364
|
+
value.model.length > 0 &&
|
|
365
|
+
typeof value.encryptedContent === "string" &&
|
|
366
|
+
value.encryptedContent.length > 0 &&
|
|
367
|
+
value.encryptedContent.length <= MAX_ENCRYPTED_CONTENT_CHARS
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function estimateConservativeTokens(value: unknown): number {
|
|
372
|
+
// One UTF-8 byte per token is deliberately conservative without a provider tokenizer.
|
|
373
|
+
return new TextEncoder().encode(JSON.stringify(value) ?? "").byteLength;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function limitText(text: string, maxChars: number): string {
|
|
377
|
+
if (text.length <= maxChars) return text;
|
|
378
|
+
const omission = "\n\n[… earlier fallback context omitted …]\n\n";
|
|
379
|
+
const available = Math.max(0, maxChars - omission.length);
|
|
380
|
+
const head = Math.floor(available * 0.35);
|
|
381
|
+
return `${text.slice(0, head)}${omission}${text.slice(text.length - (available - head))}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
type ToolInfoLike = Pick<Tool, "name" | "description" | "parameters">;
|
|
385
|
+
|
|
386
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
387
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
388
|
+
}
|