@criogaid/pi-codex-compaction 0.3.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 ADDED
@@ -0,0 +1,37 @@
1
+ # pi-codex-compaction
2
+
3
+ ## 未发布
4
+
5
+ ## 0.3.0
6
+
7
+ - npm 发布包名改为 `@criogaid/pi-codex-compaction`。新增标签触发的发布流程,通过版本核对、类型检查、测试和打包检查后发布,并提供源码来源证明;现有配置路径和会话格式保持不变。
8
+ - 要求 Pi 0.99.1 或更新版本。远程压缩沿用当前会话的思考等级、session ID、缓存与重试配置,并保留 Provider 认证环境及请求头覆盖。
9
+ - 复用普通请求的投影上下文和有效系统提示词;会话、模型、后端或历史前缀改变时停止复用。同一后端内切换模型仍可重放检查点。
10
+ - 按 Codex 默认规则筛选保留历史并估算 UTF-8 文本和图片预算,在发送前缩减超出可用上下文的末尾工具输出。
11
+ - 新增 `/codex-compaction` 菜单、V2 总开关及降级模型配置。开关明确保存布尔值,关闭时保留所选模型;V2 关闭或失败时按配置使用 Pi 原生摘要。配置在每次压缩开始时固定,已启用的降级模型失败则取消压缩。
12
+ - 将文本摘要设置命名为 `Use separate summary model` 和 `Summary model and thinking level`。使用 Pi 原生设置说明区域解释它们与 V2 的关系,RPC 显示相同的文本压缩条件。配置字段与压缩行为保持不变。
13
+ - 改写 README 和 V2 开关说明,用聊天记录、文字摘要和模型服务解释压缩行为与限制。
14
+ - 无效 fallback 配置仍允许打开菜单、切换 V2 或选择新模型。切换 V2 原样保留无效配置,合法配置写回规范格式;校验错误包含文件路径。
15
+ - V2 固定使用 SSE,确保压缩功能头不受已有 WebSocket 连接影响。普通对话和文本降级仍使用 Pi 的传输设置。
16
+ - 虚拟降级模型的展示限额不再提前缩减摘要预算,由 Pi 路由后的实际模型限制输出。
17
+ - 新检查点按 UTF-16 码元顺序排列对象键,避免指纹依赖系统语言。旧指纹只有在创建时分支上通过精确验证后才在内存中转换;checkpoint version 1 和消息编辑保护保持兼容。
18
+ - 当前模型不支持 V2 时,普通请求跳过消息指纹和上下文快照计算。
19
+ - 源码迁至 `src/`,测试迁至 `tests/`,通过 npm 锁文件安装依赖。删除独立迁移记录、设计归档和 Changesets 流程,打包及 Pi 入口加载检查归入测试。
20
+
21
+ ## 0.2.2
22
+
23
+ ### Patch Changes
24
+
25
+ - f8a5343: 精简并重写面向用户的安装、使用、配置和限制说明。
26
+
27
+ ## 0.2.1
28
+
29
+ ### Patch Changes
30
+
31
+ - 74df898: Update package repository metadata after renaming the monorepo to pi-extensions.
32
+
33
+ ## 0.2.0
34
+
35
+ ### Minor Changes
36
+
37
+ - 9b9fc40: 新增 Provider-aware 的 Codex Remote Compaction V2,支持严格的 endpoint、协议和 checkpoint 身份校验,并在失败时安全回退到 Pi 原生压缩。
package/LICENSE ADDED
@@ -0,0 +1,22 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Narumi
4
+ Copyright (c) 2026 Anthony
5
+
6
+ Permission is hereby granted, free of charge, to any person obtaining a copy
7
+ of this software and associated documentation files (the "Software"), to deal
8
+ in the Software without restriction, including without limitation the rights
9
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10
+ copies of the Software, and to permit persons to whom the Software is
11
+ furnished to do so, subject to the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be included in all
14
+ copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,172 @@
1
+ # pi-codex-compaction
2
+
3
+ 为 [Pi](https://github.com/earendil-works/pi) 提供 Codex Remote Compaction V2,让支持它的服务压缩聊天记录。你也可以指定一个独立模型生成文字摘要,用一个模型聊天,用另一个模型压缩。
4
+
5
+ 手动执行 `/compact`、Pi 自动压缩,以及聊天记录超出模型容量后触发的压缩,都使用这套设置。压缩不会改变当前对话模型或思考等级。
6
+
7
+ ## 安装
8
+
9
+ 需要 Pi 0.99.1 或更新版本:
10
+
11
+ ```bash
12
+ pi install npm:@criogaid/pi-codex-compaction
13
+ ```
14
+
15
+ 也可以从 Git 仓库安装:
16
+
17
+ ```bash
18
+ pi install git:github.com/Criogaid/pi-codex-compaction
19
+ ```
20
+
21
+ 不要安装不带作用域的 `pi-codex-compaction`,它属于另一个仓库。
22
+
23
+ ## 开始使用
24
+
25
+ 如果你使用 Pi 自带的 `openai-codex` 服务商配置和官方地址,安装后即可执行 `/compact`。扩展默认先尝试 V2;V2 失败时,交给 Pi 使用当前模型生成文字摘要。
26
+
27
+ 要用指定模型生成文字摘要,执行:
28
+
29
+ ```text
30
+ /codex-compaction
31
+ ```
32
+
33
+ 菜单按以下顺序显示:
34
+
35
+ | 设置 | 用途 |
36
+ | --- | --- |
37
+ | Remote Compaction V2 | 是否优先尝试远程压缩,默认 On |
38
+ | Use separate summary model | 是否使用独立模型生成文字摘要,默认 Off |
39
+ | Summary model and thinking level | 选择摘要模型及其思考等级 |
40
+
41
+ 首次选择模型后,还需要打开 `Use separate summary model`。如果想始终使用指定模型生成文字摘要,同时关闭 `Remote Compaction V2`。
42
+
43
+ 在终端菜单中选中一项,列表下方会显示它的用途及其与其他设置的关系。通过 RPC 使用 Pi 的客户端会在选择框标题中看到何时使用文字摘要,以及当前选用了哪个模型。
44
+
45
+ 终端菜单中按回车或空格切换开关,修改立即保存,菜单停留在原来的行。模型可以按服务商(Provider)、ID 或名称搜索。关闭 `Use separate summary model` 会保留已选模型;取消模型选择不会保存。RPC 模式每次操作后退出菜单。
46
+
47
+ 例如,你希望保留当前对话模型,但用另一个模型压缩:在第三项选好模型和思考等级,将前两项分别设为 Off、On,再执行 `/compact`。扩展会显示实际使用的压缩模型,摘要完成后继续原来的对话。
48
+
49
+ ## 压缩规则
50
+
51
+ | V2 设置与当前模型 | 下一步 |
52
+ | --- | --- |
53
+ | V2 开启,模型支持协议 | 用当前模型尝试 V2;失败后改用文字摘要压缩 |
54
+ | V2 关闭,或模型不支持协议 | 直接用文字摘要压缩 |
55
+
56
+ 需要文字摘要时,只有打开 `Use separate summary model` 且选好了模型,才会使用该模型;否则由 Pi 使用当前对话模型。模型的登录凭据和思考等级沿用 Pi 的配置。如果选择的是虚拟模型,Pi 会按它的配置选择实际发送请求的模型。
57
+
58
+ 如果启用的独立摘要模型不可用或请求失败,本次压缩会停止,不再改用当前对话模型。摘要为空、生成被中断或因长度限制而截断时,也会停止压缩。
59
+
60
+ 在 `/compact` 后面附加的要求,例如“重点保留尚未完成的任务”,只对文字摘要生效。V2 不接受这些要求,扩展会提示它们被忽略。
61
+
62
+ ## 手动配置
63
+
64
+ 设置保存在:
65
+
66
+ ```text
67
+ ~/.pi/agent/extensions/pi-codex-compaction/config.json
68
+ ```
69
+
70
+ 如果设置了 `PI_CODING_AGENT_DIR`,则使用该目录下的 `extensions/pi-codex-compaction/config.json`。菜单会在保存时创建文件;非交互模式可以直接编辑它。
71
+
72
+ 下面的配置优先尝试 V2,需要文字摘要时使用指定模型:
73
+
74
+ ```json
75
+ {
76
+ "version": 1,
77
+ "remoteCompaction": {
78
+ "enabled": true
79
+ },
80
+ "fallback": {
81
+ "enabled": true,
82
+ "provider": "your-provider",
83
+ "model": "your-model",
84
+ "thinkingLevel": "high"
85
+ }
86
+ }
87
+ ```
88
+
89
+ 将 `provider`、`model` 替换为 Pi 中的实际 ID,`thinkingLevel` 使用该模型支持的值。这里不保存 API key,模型和认证需要先在 Pi 中配置好。
90
+
91
+ 配置中的 `fallback` 对应菜单里的独立摘要模型设置,`fallback.enabled` 对应 `Use separate summary model`。省略整个 `fallback` 表示未指定模型;旧配置如果省略了 `enabled`,按开启处理。模型的 `provider`、`model` 和 `thinkingLevel` 必须同时提供或同时省略。
92
+
93
+ 省略 `remoteCompaction` 默认开启 V2。配置文件须为 UTF-8 JSON,大小不超过 16 KiB。
94
+
95
+ 每次压缩开始时读取配置,压缩途中修改设置会在下次生效。文件无法读取、JSON 格式错误或 V2 设置无效时会停止压缩。独立摘要模型的配置只在需要文字摘要时检查,因此这部分填错不会影响成功的 V2 压缩。
96
+
97
+ 独立摘要模型的配置无效时,菜单显示 `Invalid`。你仍可切换 V2,已填写的模型配置会保留。重新选择模型可以修复这部分配置,但之后需要打开 `Use separate summary model` 才会使用它。如果打开菜单后配置文件被其他程序修改,保存会失败;重新打开菜单再操作即可。
98
+
99
+ ## 自定义网关
100
+
101
+ 网关必须支持 Codex Remote Compaction V2,只有普通 Responses API 兼容性还不够。模型的 API 类型须为 `openai-responses` 或 `openai-codex-responses`。
102
+
103
+ 在 Pi 的 `models.json` 中为模型添加 `compat.remoteCompaction`。以下是 `openai-responses` 配置示例:
104
+
105
+ ```json
106
+ {
107
+ "providers": {
108
+ "custom-codex": {
109
+ "baseUrl": "https://gateway.example.com/v1",
110
+ "api": "openai-responses",
111
+ "apiKey": "$CUSTOM_CODEX_API_KEY",
112
+ "models": [
113
+ {
114
+ "id": "gpt-example",
115
+ "compat": {
116
+ "remoteCompaction": {
117
+ "protocol": "v2"
118
+ }
119
+ }
120
+ }
121
+ ]
122
+ }
123
+ }
124
+ }
125
+ ```
126
+
127
+ 替换地址和模型 ID,并设置 `CUSTOM_CODEX_API_KEY` 环境变量。这个例子请求 `https://gateway.example.com/v1/responses`。
128
+
129
+ 如果网关的压缩请求使用其他路径,可以在 `remoteCompaction` 内增加 `endpoint`。它的协议、主机和端口必须与 Pi 登录认证后实际使用的 `baseUrl` 一致,URL 不能包含用户名、密码、`?` 后的查询参数或 `#` 后的片段。填写这些字段前,须确认网关本身支持 V2。
130
+
131
+ ## 使用前需要了解
132
+
133
+ 关闭 V2 后改用文字摘要压缩,不影响已压缩的聊天记录。
134
+
135
+ V2 压缩后的旧聊天记录以加密数据保存,只有支持它的模型服务才能使用,扩展无法把它还原成完整文字。重新打开会话或从会话创建分支时,可以继续使用这部分记录,但必须连接原来的服务商,并保持 API 类型和请求地址不变。换到其他服务商或地址后,文字摘要只能根据 Pi 仍能读取的摘要和消息生成,无法包含那部分加密的旧记录。
136
+
137
+ 在同一服务和地址下切换模型,扩展也会继续发送已压缩的记录。如果新模型不接受这些数据,需要切回原模型。V2 协议仍属实验性质。
138
+
139
+ 压缩后,Pi 还会保留一部分近期消息。如果这些消息被修改,扩展可能无法确认它们与已压缩记录是否对应,从而停止使用那部分记录。搭配会改写聊天内容的扩展时,请把本包放在 Pi 的 `packages` 列表中靠前的位置。
140
+
141
+ 扩展会尽量让压缩请求与普通聊天请求使用相同的历史内容和系统提示词,便于模型服务复用缓存。但 Pi 和其他扩展仍可能改变最终发送的内容,因此不能保证缓存命中或费用下降。
142
+
143
+ ## 开发
144
+
145
+ 使用 Node 24 或更新版本。在克隆后的仓库根目录运行:
146
+
147
+ ```bash
148
+ npm ci --ignore-scripts
149
+ npm run typecheck
150
+ npm test
151
+ npm run pack:check
152
+ ```
153
+
154
+ 本地加载可以使用 `pi install .`。Pi 不会替本地包安装依赖,须先完成上面的安装步骤。
155
+
156
+ 运行代码在 `src/`,入口是 `src/index.ts`;测试在 `tests/`,编译结果写入 Git 忽略的 `dist/`。Pi 直接加载 TypeScript 源码。打包检查同时验证发布文件清单和 Pi 的实际入口加载。
157
+
158
+ [CI](.github/workflows/ci.yml) 在 Ubuntu 24.04 和 Windows 上使用 Node 24 执行验证。变更记录见 [CHANGELOG.md](CHANGELOG.md),维护规则见 [AGENTS.md](AGENTS.md)。
159
+
160
+ ## 发布
161
+
162
+ [Publish](.github/workflows/publish.yml) 在推送 `v*` 标签时运行。标签必须与 `package.json` 的版本一致,例如版本 `0.3.0` 对应标签 `v0.3.0`。流程目前只发布正式版本,不接受带 `-beta`、`-rc` 等后缀的预发布版本。
163
+
164
+ 首次发布前,在本仓库的 **Settings → Secrets and variables → Actions** 中添加 `NPM_TOKEN`。按 [npm 文档](https://docs.npmjs.com/creating-and-viewing-access-tokens)创建 granular access token,授予 `@criogaid` 作用域的发布权限(Read and write / publish and stage),并启用 Bypass two-factor authentication。包创建后,可以将 token 权限缩小到该包。
165
+
166
+ 准备版本时,运行 `npm version <版本号> --no-git-tag-version` 同步更新 `package.json` 和 `package-lock.json`,将本次改动从 CHANGELOG 的“未发布”整理到对应版本下。运行上面的三项验证命令,通过后提交,再为该提交创建并推送 `v<版本号>` 标签。
167
+
168
+ 工作流会核对版本,重新安装依赖,执行类型检查、测试和打包检查,全部通过后才发布公开 npm 包,并附带可追溯到源码提交和工作流的来源证明(provenance)。普通分支推送不会发布;已经发布的 npm 版本不能覆盖,后续修改需要使用新版本号。
169
+
170
+ ## 许可证
171
+
172
+ MIT,见 [LICENSE](LICENSE)。本仓库从 `Criogaid/pi-extensions-anthony` 提取并独立维护,实现基于 `@narumitw/pi-codex-compact`,保留 Narumi 和 Anthony 的署名。
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@criogaid/pi-codex-compaction",
3
+ "version": "0.3.0",
4
+ "description": "Provider-aware Codex Remote Compaction V2 for Pi.",
5
+ "type": "module",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/Criogaid/pi-codex-compaction.git"
9
+ },
10
+ "bugs": {
11
+ "url": "https://github.com/Criogaid/pi-codex-compaction/issues"
12
+ },
13
+ "homepage": "https://github.com/Criogaid/pi-codex-compaction#readme",
14
+ "author": "Anthony",
15
+ "license": "MIT",
16
+ "keywords": [
17
+ "pi-package",
18
+ "pi-extension",
19
+ "codex",
20
+ "compaction",
21
+ "remote-compaction"
22
+ ],
23
+ "files": [
24
+ "src",
25
+ "CHANGELOG.md",
26
+ "README.md",
27
+ "LICENSE"
28
+ ],
29
+ "scripts": {
30
+ "test": "tsc --project tsconfig.json && node --test \"dist/tests/*.test.js\"",
31
+ "typecheck": "tsc --noEmit --project tsconfig.json",
32
+ "pack:check": "tsc --project tsconfig.json && node --test dist/tests/package.test.js"
33
+ },
34
+ "publishConfig": {
35
+ "access": "public"
36
+ },
37
+ "peerDependencies": {
38
+ "@earendil-works/pi-agent-core": ">=0.99.1",
39
+ "@earendil-works/pi-ai": ">=0.99.1",
40
+ "@earendil-works/pi-coding-agent": ">=0.99.1",
41
+ "@earendil-works/pi-tui": ">=0.99.1"
42
+ },
43
+ "dependencies": {
44
+ "saxes": "6.0.0"
45
+ },
46
+ "devDependencies": {
47
+ "@earendil-works/pi-agent-core": "0.99.1",
48
+ "@earendil-works/pi-ai": "0.99.1",
49
+ "@earendil-works/pi-coding-agent": "0.99.1",
50
+ "@earendil-works/pi-tui": "0.99.1",
51
+ "@types/node": "26.1.1",
52
+ "typescript": "7.0.2"
53
+ },
54
+ "pi": {
55
+ "extensions": [
56
+ "src/index.ts"
57
+ ]
58
+ }
59
+ }
@@ -0,0 +1,95 @@
1
+ // Own model eligibility and endpoint identity; authentication supplies the effective base URL.
2
+ import type { Api, Model } from "@earendil-works/pi-ai";
3
+ import { hasApi } from "@earendil-works/pi-ai";
4
+ import { isObject } from "./protocol.js";
5
+
6
+ export const CODEX_API = "openai-codex-responses" as const;
7
+ export const OPENAI_RESPONSES_API = "openai-responses" as const;
8
+ export type RemoteCompactionApi = typeof CODEX_API | typeof OPENAI_RESPONSES_API;
9
+ const OFFICIAL_PROVIDER = "openai-codex";
10
+ const OFFICIAL_BASE_URL = "https://chatgpt.com/backend-api";
11
+
12
+ export interface ProviderIdentity {
13
+ readonly provider: string;
14
+ readonly api: RemoteCompactionApi;
15
+ readonly modelId: string;
16
+ readonly baseUrl: string;
17
+ readonly endpoint: string;
18
+ }
19
+ export interface CapableModel {
20
+ readonly model: Model<RemoteCompactionApi>;
21
+ readonly identity: ProviderIdentity;
22
+ }
23
+
24
+ export function normalizeUrl(value: string): string {
25
+ const url = new URL(value);
26
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error("URL must use HTTP or HTTPS");
27
+ if (url.username || url.password || url.search || url.hash) {
28
+ throw new Error("URL must not contain credentials, a query, or a fragment");
29
+ }
30
+ url.pathname = url.pathname.replace(/\/+$/, "") || "/";
31
+ return url.toString().replace(/\/$/, "");
32
+ }
33
+ /** Mirror Pi 0.99's resolveCodexUrl and the OpenAI SDK's `/responses` route for a normalized base URL. */
34
+ export function deriveEndpoint(baseUrl: string, api: RemoteCompactionApi): string {
35
+ if (api === OPENAI_RESPONSES_API) return `${baseUrl}/responses`;
36
+ if (baseUrl.endsWith("/codex/responses")) return baseUrl;
37
+ if (baseUrl.endsWith("/codex")) return `${baseUrl}/responses`;
38
+ return `${baseUrl}/codex/responses`;
39
+ }
40
+
41
+ function configuredEndpoint(model: Model<RemoteCompactionApi>, baseUrl: string): string | undefined {
42
+ // Pi preserves extension metadata but its built-in compatibility type does not declare it.
43
+ const compat: unknown = model.compat;
44
+ const value = isObject(compat) ? compat.remoteCompaction : undefined;
45
+ if (!isObject(value) || value.protocol !== "v2" ||
46
+ (value.endpoint !== undefined && typeof value.endpoint !== "string")) return undefined;
47
+ try {
48
+ const endpoint = typeof value.endpoint === "string"
49
+ ? normalizeUrl(value.endpoint) : deriveEndpoint(baseUrl, model.api);
50
+ return new URL(baseUrl).origin === new URL(endpoint).origin ? endpoint : undefined;
51
+ } catch {
52
+ return undefined;
53
+ }
54
+ }
55
+
56
+ export function capableModel(model: Model<Api> | undefined, effectiveBaseUrl?: string): CapableModel | undefined {
57
+ if (!model || (!hasApi(model, CODEX_API) && !hasApi(model, OPENAI_RESPONSES_API))) return undefined;
58
+ let configuredBaseUrl: string;
59
+ let baseUrl: string;
60
+ try {
61
+ configuredBaseUrl = normalizeUrl(model.baseUrl);
62
+ baseUrl = effectiveBaseUrl === undefined ? configuredBaseUrl : normalizeUrl(effectiveBaseUrl);
63
+ } catch {
64
+ return undefined;
65
+ }
66
+ const endpoint = model.provider === OFFICIAL_PROVIDER && hasApi(model, CODEX_API) && configuredBaseUrl === OFFICIAL_BASE_URL
67
+ ? deriveEndpoint(baseUrl, CODEX_API) : configuredEndpoint(model, baseUrl);
68
+ return endpoint ? {
69
+ model,
70
+ identity: { provider: model.provider, api: model.api, modelId: model.id, baseUrl, endpoint },
71
+ } : undefined;
72
+ }
73
+
74
+ /** Compare provider and API before authentication resolves the endpoint. */
75
+ export function sameProvider(
76
+ left: Pick<ProviderIdentity, "provider" | "api">,
77
+ right: { readonly provider: string; readonly api: string },
78
+ ): boolean {
79
+ return left.provider === right.provider && left.api === right.api;
80
+ }
81
+
82
+ export function sameModel(
83
+ left: Pick<ProviderIdentity, "provider" | "api" | "modelId">,
84
+ right: Pick<Model<Api>, "provider" | "api" | "id">,
85
+ ): boolean {
86
+ return sameProvider(left, right) && left.modelId === right.id;
87
+ }
88
+
89
+ /**
90
+ * Codex keeps compaction items across model switches on one backend and narrows that only by
91
+ * the server's comp_hash, which Pi does not expose. Checkpoints therefore bind to the backend.
92
+ */
93
+ export function sameBackend(left: ProviderIdentity, right: ProviderIdentity): boolean {
94
+ return sameProvider(left, right) && left.baseUrl === right.baseUrl && left.endpoint === right.endpoint;
95
+ }
@@ -0,0 +1,261 @@
1
+ // Own the versioned checkpoint format, endpoint binding, and exact Pi session projection.
2
+ // Normalize legacy fingerprints from their creation-time branch without rewriting session entries.
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
5
+ import { getCurrentSystemMessage, type Message } from "@earendil-works/pi-ai";
6
+ import {
7
+ buildSessionProjection,
8
+ sessionEntryToContextMessages,
9
+ type CompactionEntry,
10
+ type ProjectedSessionEntry,
11
+ type SessionEntry,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import { isObject, type JsonObject, REMOTE_COMPACTION_PROTOCOL, validateCompactionItem } from "./protocol.js";
14
+ import {
15
+ CODEX_API,
16
+ OPENAI_RESPONSES_API,
17
+ normalizeUrl,
18
+ type ProviderIdentity,
19
+ } from "./capability.js";
20
+
21
+ export const CHECKPOINT_KIND = "pi-codex-compaction";
22
+ export const CHECKPOINT_VERSION = 1;
23
+
24
+ export interface CodexCheckpointDetails extends ProviderIdentity {
25
+ kind: typeof CHECKPOINT_KIND;
26
+ version: typeof CHECKPOINT_VERSION;
27
+ checkpointId: string;
28
+ protocol: typeof REMOTE_COMPACTION_PROTOCOL;
29
+ replacementHistory: JsonObject[];
30
+ keptMessageFingerprints: string[];
31
+ createdAt: string;
32
+ }
33
+
34
+ function orderedValue(value: unknown, compareKeys: (left: string, right: string) => number): unknown {
35
+ if (Array.isArray(value)) return value.map((item) => orderedValue(item, compareKeys));
36
+ if (!isObject(value)) return value;
37
+ return Object.fromEntries(
38
+ Object.entries(value)
39
+ .sort(([left], [right]) => compareKeys(left, right))
40
+ .map(([key, child]) => [key, orderedValue(child, compareKeys)]),
41
+ );
42
+ }
43
+
44
+ function digestMessage(message: AgentMessage, compareKeys: (left: string, right: string) => number): string {
45
+ return createHash("sha256").update(JSON.stringify(orderedValue(message, compareKeys))).digest("hex");
46
+ }
47
+
48
+ // Session and context messages are replaced rather than mutated, so object identity keys both caches.
49
+ const fingerprints = new WeakMap<AgentMessage, string>();
50
+ const checkpoints = new WeakMap<SessionEntry, CodexCheckpointDetails | null>();
51
+
52
+ export function fingerprintMessage(message: AgentMessage): string {
53
+ let fingerprint = fingerprints.get(message);
54
+ if (fingerprint === undefined) {
55
+ // Compare UTF-16 code units; persisted hashes must not depend on the process locale or ICU collation.
56
+ fingerprint = digestMessage(message, (left, right) => left < right ? -1 : left > right ? 1 : 0);
57
+ fingerprints.set(message, fingerprint);
58
+ }
59
+ return fingerprint;
60
+ }
61
+
62
+ export function checkpointMarker(checkpointId: string): string {
63
+ return [
64
+ `[PI_CODEX_REMOTE_CHECKPOINT:${checkpointId}]`,
65
+ "Opaque checkpoint injection failed. Do not infer missing history; tell the user to re-enable",
66
+ "@oipsanthony/pi-codex-compaction with the checkpoint's provider and model.",
67
+ ].join(" ");
68
+ }
69
+
70
+ export function fallbackSummary(checkpointId: string): string {
71
+ return [
72
+ `Codex Remote Compaction V2 checkpoint ${checkpointId} stores the older history opaquely.`,
73
+ "Full replay requires @oipsanthony/pi-codex-compaction and the original provider endpoint and model.",
74
+ "Without them, only Pi's retained recent messages remain available.",
75
+ ].join(" ");
76
+ }
77
+
78
+ function markerMessage(checkpointId: string, timestamp: number): AgentMessage {
79
+ return {
80
+ role: "user",
81
+ content: [{ type: "text", text: checkpointMarker(checkpointId) }],
82
+ timestamp,
83
+ };
84
+ }
85
+
86
+ export function parseCheckpointDetails(value: unknown): CodexCheckpointDetails | undefined {
87
+ if (!isObject(value)) return undefined;
88
+ if (
89
+ value.kind !== CHECKPOINT_KIND ||
90
+ value.version !== CHECKPOINT_VERSION ||
91
+ typeof value.checkpointId !== "string" ||
92
+ value.checkpointId.length < 8 ||
93
+ typeof value.provider !== "string" ||
94
+ !value.provider ||
95
+ (value.api !== CODEX_API && value.api !== OPENAI_RESPONSES_API) ||
96
+ typeof value.modelId !== "string" ||
97
+ !value.modelId ||
98
+ typeof value.baseUrl !== "string" ||
99
+ typeof value.endpoint !== "string" ||
100
+ value.protocol !== REMOTE_COMPACTION_PROTOCOL ||
101
+ !Array.isArray(value.replacementHistory) ||
102
+ !Array.isArray(value.keptMessageFingerprints) ||
103
+ typeof value.createdAt !== "string"
104
+ ) {
105
+ return undefined;
106
+ }
107
+ try {
108
+ if (
109
+ normalizeUrl(value.baseUrl) !== value.baseUrl ||
110
+ normalizeUrl(value.endpoint) !== value.endpoint ||
111
+ new URL(value.baseUrl).origin !== new URL(value.endpoint).origin
112
+ ) {
113
+ return undefined;
114
+ }
115
+ } catch {
116
+ return undefined;
117
+ }
118
+ if (
119
+ value.replacementHistory.length === 0 ||
120
+ !value.replacementHistory.every(isObject) ||
121
+ !value.keptMessageFingerprints.every(
122
+ (fingerprint) => typeof fingerprint === "string" && /^[a-f0-9]{64}$/.test(fingerprint),
123
+ )
124
+ ) {
125
+ return undefined;
126
+ }
127
+ try {
128
+ const item = validateCompactionItem(value.replacementHistory.at(-1));
129
+ const parsed = structuredClone(value) as unknown as CodexCheckpointDetails;
130
+ parsed.replacementHistory[parsed.replacementHistory.length - 1] = item;
131
+ return parsed;
132
+ } catch {
133
+ return undefined;
134
+ }
135
+ }
136
+
137
+ function retainedEntries(
138
+ branchEntries: readonly SessionEntry[],
139
+ leafId: string | null,
140
+ firstKeptEntryId: string,
141
+ ): ProjectedSessionEntry[] | undefined {
142
+ const projection = buildSessionProjection([...branchEntries], leafId);
143
+ const keptIndex = projection.entries.findIndex((entry) => entry.sourceEntry.id === firstKeptEntryId);
144
+ return keptIndex < 0 ? undefined : projection.entries.slice(keptIndex);
145
+ }
146
+
147
+ /** Pi's context handlers see only non-system messages; Pi carries system messages separately. */
148
+ export function withoutSystemMessages<T extends { readonly role: string }>(messages: readonly T[]): T[] {
149
+ return messages.filter((message) => message.role !== "system");
150
+ }
151
+
152
+ function conversationMessages(entries: readonly ProjectedSessionEntry[]): AgentMessage[] {
153
+ // appendCompaction snapshots system messages separately from the retained conversation.
154
+ return withoutSystemMessages(entries.flatMap((entry) => entry.messages));
155
+ }
156
+
157
+ export function keptMessages(branchEntries: readonly SessionEntry[], firstKeptEntryId: string): AgentMessage[] {
158
+ const retained = retainedEntries(branchEntries, branchEntries.at(-1)?.id ?? null, firstKeptEntryId);
159
+ if (!retained) throw new Error("Pi compaction cut point is not present in the active context");
160
+ return conversationMessages(retained);
161
+ }
162
+
163
+ function normalizeLegacyFingerprints(
164
+ entries: readonly SessionEntry[],
165
+ entry: CompactionEntry,
166
+ details: CodexCheckpointDetails,
167
+ ): CodexCheckpointDetails {
168
+ // Validate at the checkpoint's parent, never against later context edits.
169
+ const retained = retainedEntries(entries, entry.parentId, entry.firstKeptEntryId);
170
+ if (!retained) return details;
171
+ const canonical = conversationMessages(retained);
172
+ const matches = (messages: readonly AgentMessage[], fingerprint: (message: AgentMessage) => string) =>
173
+ messages.length === details.keptMessageFingerprints.length &&
174
+ messages.every((message, index) => fingerprint(message) === details.keptMessageFingerprints[index]);
175
+ if (matches(canonical, fingerprintMessage)) return details;
176
+ // V1 did not record its locale. Only normalize old hashes when the old ordering proves an exact match.
177
+ const legacyFingerprint = (message: AgentMessage) => digestMessage(message, (left, right) => left.localeCompare(right));
178
+ const raw = retained.flatMap((item) => sessionEntryToContextMessages(item.sourceEntry));
179
+ if (!matches(canonical, legacyFingerprint) && !matches(raw, legacyFingerprint)) return details;
180
+ return { ...details, keptMessageFingerprints: canonical.map(fingerprintMessage) };
181
+ }
182
+
183
+ export function latestCheckpoint(
184
+ entries: readonly SessionEntry[],
185
+ ): { entry: CompactionEntry<CodexCheckpointDetails>; details: CodexCheckpointDetails } | undefined {
186
+ for (let index = entries.length - 1; index >= 0; index--) {
187
+ const entry = entries[index];
188
+ if (entry.type !== "compaction") continue;
189
+ // A checkpoint's ancestors are immutable, so its normalized details are stable per entry.
190
+ let details = checkpoints.get(entry);
191
+ if (details === undefined) {
192
+ const parsed = parseCheckpointDetails(entry.details);
193
+ details = parsed ? normalizeLegacyFingerprints(entries, entry, parsed) : null;
194
+ checkpoints.set(entry, details);
195
+ }
196
+ return details ? { entry: entry as CompactionEntry<CodexCheckpointDetails>, details } : undefined;
197
+ }
198
+ return undefined;
199
+ }
200
+
201
+ export function projectCheckpointContext(
202
+ messages: readonly AgentMessage[],
203
+ details: CodexCheckpointDetails,
204
+ ): AgentMessage[] | undefined {
205
+ const summary = fallbackSummary(details.checkpointId);
206
+ const summaryIndex = messages.findIndex(
207
+ (message) => message.role === "compactionSummary" && message.summary === summary,
208
+ );
209
+ if (summaryIndex < 0) return undefined;
210
+ const keptStart = summaryIndex + 1;
211
+ const keptEnd = keptStart + details.keptMessageFingerprints.length;
212
+ if (keptEnd > messages.length) return undefined;
213
+ for (let index = keptStart; index < keptEnd; index++) {
214
+ if (
215
+ fingerprintMessage(messages[index]) !== details.keptMessageFingerprints[index - keptStart]
216
+ ) {
217
+ return undefined;
218
+ }
219
+ }
220
+ return [
221
+ ...messages.slice(0, summaryIndex),
222
+ markerMessage(details.checkpointId, messages[summaryIndex].timestamp),
223
+ ...messages.slice(keptEnd),
224
+ ];
225
+ }
226
+
227
+ /**
228
+ * Project a full transcript the way Pi sends a request whose context handler changed messages:
229
+ * handlers see only non-system messages, and Pi collapses the system messages into one head.
230
+ */
231
+ export function projectCheckpointRequest(
232
+ messages: readonly AgentMessage[],
233
+ details: CodexCheckpointDetails,
234
+ ): AgentMessage[] | undefined {
235
+ const projected = projectCheckpointContext(withoutSystemMessages(messages), details);
236
+ if (!projected) return undefined;
237
+ const head = getCurrentSystemMessage(messages.filter((message): message is Message => message.role === "system"));
238
+ return head ? [head, ...projected] : projected;
239
+ }
240
+
241
+ export function createCheckpointDetails(input: {
242
+ identity: ProviderIdentity;
243
+ replacementHistory: JsonObject[];
244
+ keptMessages: readonly AgentMessage[];
245
+ checkpointId?: string;
246
+ createdAt?: string;
247
+ }): CodexCheckpointDetails {
248
+ const details: CodexCheckpointDetails = {
249
+ kind: CHECKPOINT_KIND,
250
+ version: CHECKPOINT_VERSION,
251
+ checkpointId: input.checkpointId ?? randomUUID(),
252
+ ...input.identity,
253
+ protocol: REMOTE_COMPACTION_PROTOCOL,
254
+ replacementHistory: structuredClone(input.replacementHistory),
255
+ keptMessageFingerprints: input.keptMessages.map(fingerprintMessage),
256
+ createdAt: input.createdAt ?? new Date().toISOString(),
257
+ };
258
+ const parsed = parseCheckpointDetails(details);
259
+ if (!parsed) throw new Error("Created an invalid Codex checkpoint");
260
+ return parsed;
261
+ }