@criogaid/pi-codex-compaction 0.3.0 → 0.4.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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## 未发布
4
4
 
5
+ ## 0.4.0
6
+
7
+ - 更新压缩历史的恢复提示,明确使用当前包 `@criogaid/pi-codex-compaction`。保留旧会话摘要的精确识别与消息编辑保护,无需迁移会话文件。
8
+
9
+ - 压缩请求在会话、模型、历史、工具及设置匹配时,复用普通请求中观察到的系统指令、工具声明、缓存标识、思考与服务等级等选项。请求仍由 Pi 生成和发送,不沿用旧响应 ID、输出上限或传输开关。
10
+
11
+ - 重写 README:按“V2 默认启用、失败退回 Pi 原生压缩、独立摘要模型只接管原生压缩”说明压缩流程,补充流程图、示例和徽章。
12
+
5
13
  ## 0.3.0
6
14
 
7
15
  - npm 发布包名改为 `@criogaid/pi-codex-compaction`。新增标签触发的发布流程,通过版本核对、类型检查、测试和打包检查后发布,并提供源码来源证明;现有配置路径和会话格式保持不变。
package/README.md CHANGED
@@ -1,8 +1,14 @@
1
- # pi-codex-compaction
1
+ # @criogaid/pi-codex-compaction
2
2
 
3
- 为 [Pi](https://github.com/earendil-works/pi) 提供 Codex Remote Compaction V2,让支持它的服务压缩聊天记录。你也可以指定一个独立模型生成文字摘要,用一个模型聊天,用另一个模型压缩。
3
+ [![CI](https://github.com/Criogaid/pi-codex-compaction/actions/workflows/ci.yml/badge.svg)](https://github.com/Criogaid/pi-codex-compaction/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/@criogaid/pi-codex-compaction)](https://www.npmjs.com/package/@criogaid/pi-codex-compaction) [![npm downloads](https://img.shields.io/npm/dm/@criogaid/pi-codex-compaction)](https://www.npmjs.com/package/@criogaid/pi-codex-compaction) [![license](https://img.shields.io/npm/l/@criogaid/pi-codex-compaction)](LICENSE)
4
4
 
5
- 手动执行 `/compact`、Pi 自动压缩,以及聊天记录超出模型容量后触发的压缩,都使用这套设置。压缩不会改变当前对话模型或思考等级。
5
+ 让 [Pi](https://github.com/earendil-works/pi) 在 Codex 模型上使用 Codex Remote Compaction V2 压缩聊天记录,失败时自动退回 Pi 原生压缩,并且可以为原生压缩单独指定一个摘要模型。
6
+
7
+ - **装上即用:** 对 Codex 模型默认启用 V2 远程压缩,无需任何配置。
8
+ - **失败自动退回:** 当前模型不支持 V2,或 V2 请求失败时,改用 Pi 原生的文字摘要压缩,对话不会中断。
9
+ - **独立摘要模型(可选):** 退回 Pi 原生压缩时,可以使用你指定的模型和思考等级生成摘要。它只接管这一步,不影响 V2 远程压缩,也不改变当前对话模型。
10
+
11
+ [安装](#安装) · [工作方式](#工作方式) · [独立摘要模型](#独立摘要模型) · [配置](#配置) · [自定义网关](#自定义网关) · [注意事项](#注意事项)
6
12
 
7
13
  ## 安装
8
14
 
@@ -18,58 +24,85 @@ pi install npm:@criogaid/pi-codex-compaction
18
24
  pi install git:github.com/Criogaid/pi-codex-compaction
19
25
  ```
20
26
 
21
- 不要安装不带作用域的 `pi-codex-compaction`,它属于另一个仓库。
22
-
23
- ## 开始使用
27
+ > [!NOTE]
28
+ > 请安装带 `@criogaid` 作用域的包。不带作用域的 `pi-codex-compaction` 属于另一个仓库。
24
29
 
25
- 如果你使用 Pi 自带的 `openai-codex` 服务商配置和官方地址,安装后即可执行 `/compact`。扩展默认先尝试 V2;V2 失败时,交给 Pi 使用当前模型生成文字摘要。
30
+ 使用 Pi 自带的 `openai-codex` 服务商和官方地址时,安装后无需配置。手动执行 `/compact`、Pi 自动压缩,以及聊天记录超出模型容量后触发的压缩,都会经过本扩展。
26
31
 
27
- 要用指定模型生成文字摘要,执行:
32
+ ## 工作方式
28
33
 
29
34
  ```text
30
- /codex-compaction
35
+ 触发压缩(/compact、自动压缩、超出上下文)
36
+ |
37
+ v
38
+ V2 开启,且当前模型支持 V2? -- 否 ------------------+
39
+ | 是 |
40
+ v |
41
+ 用当前对话模型请求 V2 远程压缩 |
42
+ | |
43
+ v |
44
+ 成功? -- 是 --> 完成 |
45
+ | 否 |
46
+ v |
47
+ Pi 原生压缩(文字摘要) <----------------------------+
48
+ |
49
+ +-- 已启用独立摘要模型 --> 用指定模型和思考等级生成摘要
50
+ | 失败则停止本次压缩
51
+ |
52
+ +-- 未启用 -------------> 用当前对话模型生成摘要
31
53
  ```
32
54
 
33
- 菜单按以下顺序显示:
55
+ | 情况 | 压缩方式 | 使用的模型 |
56
+ | --- | --- | --- |
57
+ | V2 开启,模型支持 V2,请求成功 | V2 远程压缩 | 当前对话模型 |
58
+ | V2 请求失败 | Pi 原生压缩 | 独立摘要模型,未启用时为当前对话模型 |
59
+ | 模型不支持 V2,或 V2 已关闭 | Pi 原生压缩 | 独立摘要模型,未启用时为当前对话模型 |
34
60
 
35
- | 设置 | 用途 |
36
- | --- | --- |
37
- | Remote Compaction V2 | 是否优先尝试远程压缩,默认 On |
38
- | Use separate summary model | 是否使用独立模型生成文字摘要,默认 Off |
39
- | Summary model and thinking level | 选择摘要模型及其思考等级 |
61
+ 压缩完成后继续原来的对话,当前对话模型和思考等级保持不变。V2 失败、改用独立摘要模型时,Pi 会显示提示及实际使用的模型。
40
62
 
41
- 首次选择模型后,还需要打开 `Use separate summary model`。如果想始终使用指定模型生成文字摘要,同时关闭 `Remote Compaction V2`。
63
+ `/compact` 后附加的要求(例如“重点保留尚未完成的任务”)只对 Pi 原生压缩生效。V2 不接受这些要求,扩展会提示它们被忽略。
42
64
 
43
- 在终端菜单中选中一项,列表下方会显示它的用途及其与其他设置的关系。通过 RPC 使用 Pi 的客户端会在选择框标题中看到何时使用文字摘要,以及当前选用了哪个模型。
65
+ ## 独立摘要模型
44
66
 
45
- 终端菜单中按回车或空格切换开关,修改立即保存,菜单停留在原来的行。模型可以按服务商(Provider)、ID 或名称搜索。关闭 `Use separate summary model` 会保留已选模型;取消模型选择不会保存。RPC 模式每次操作后退出菜单。
67
+ 假设你平时用 `gpt-6-astra` 聊天,希望退回原生压缩时改用 `gpt-6.1-sol` 生成摘要:
46
68
 
47
- 例如,你希望保留当前对话模型,但用另一个模型压缩:在第三项选好模型和思考等级,将前两项分别设为 Off、On,再执行 `/compact`。扩展会显示实际使用的压缩模型,摘要完成后继续原来的对话。
69
+ 1. 执行 `/codex-compaction` 打开设置菜单。
70
+ 2. 在 `Summary model and thinking level` 中选择 `gpt-6.1-sol` 及其思考等级。
71
+ 3. 打开 `Use separate summary model`。
48
72
 
49
- ## 压缩规则
73
+ 之后的压缩过程如下:
50
74
 
51
- | V2 设置与当前模型 | 下一步 |
52
- | --- | --- |
53
- | V2 开启,模型支持协议 | 用当前模型尝试 V2;失败后改用文字摘要压缩 |
54
- | V2 关闭,或模型不支持协议 | 直接用文字摘要压缩 |
75
+ - `gpt-6-astra` 支持 V2 时,仍由 `gpt-6-astra` 完成 V2 远程压缩,不经过 `gpt-6.1-sol`。
76
+ - V2 失败或不可用时,由 `gpt-6.1-sol` 生成文字摘要。
77
+ - 压缩结束后继续使用 `gpt-6-astra` 对话。
55
78
 
56
- 需要文字摘要时,只有打开 `Use separate summary model` 且选好了模型,才会使用该模型;否则由 Pi 使用当前对话模型。模型的登录凭据和思考等级沿用 Pi 的配置。如果选择的是虚拟模型,Pi 会按它的配置选择实际发送请求的模型。
79
+ 如果想始终用 `gpt-6.1-sol` 生成文字摘要,再关闭 `Remote Compaction V2`。
57
80
 
58
- 如果启用的独立摘要模型不可用或请求失败,本次压缩会停止,不再改用当前对话模型。摘要为空、生成被中断或因长度限制而截断时,也会停止压缩。
81
+ 独立摘要模型的登录凭据沿用 Pi 的配置。选择虚拟模型时,Pi 按它的配置路由到实际发送请求的模型。
59
82
 
60
- 在 `/compact` 后面附加的要求,例如“重点保留尚未完成的任务”,只对文字摘要生效。V2 不接受这些要求,扩展会提示它们被忽略。
83
+ > [!IMPORTANT]
84
+ > 已启用的独立摘要模型不可用或请求失败时,本次压缩会停止,不会悄悄改用当前对话模型。摘要为空、生成被中断或因长度限制被截断时,也会停止压缩。
61
85
 
62
- ## 手动配置
86
+ ## 配置
63
87
 
64
- 设置保存在:
88
+ ### 设置菜单
65
89
 
66
- ```text
67
- ~/.pi/agent/extensions/pi-codex-compaction/config.json
68
- ```
90
+ 执行 `/codex-compaction`:
91
+
92
+ | 设置 | 默认 | 用途 |
93
+ | --- | --- | --- |
94
+ | `Remote Compaction V2` | On | 是否优先尝试 V2 远程压缩 |
95
+ | `Use separate summary model` | Off | 退回原生压缩时是否使用独立摘要模型 |
96
+ | `Summary model and thinking level` | 未选择 | 独立摘要模型及其思考等级 |
69
97
 
70
- 如果设置了 `PI_CODING_AGENT_DIR`,则使用该目录下的 `extensions/pi-codex-compaction/config.json`。菜单会在保存时创建文件;非交互模式可以直接编辑它。
98
+ - 终端菜单中按回车或空格切换开关,修改立即保存,光标停留在原来的行。选中一项时,列表下方会说明它的用途及与其他设置的关系。
99
+ - 模型可以按服务商(Provider)、ID 或名称搜索;取消选择不会保存。
100
+ - 关闭 `Use separate summary model` 会保留已选模型,之后重新打开即可使用。
101
+ - 通过 RPC 使用 Pi 的客户端会在选择框标题中看到何时使用文字摘要以及当前选用的模型,每次操作后退出菜单。
71
102
 
72
- 下面的配置优先尝试 V2,需要文字摘要时使用指定模型:
103
+ ### 配置文件
104
+
105
+ 设置保存在 `~/.pi/agent/extensions/pi-codex-compaction/config.json`。设置了 `PI_CODING_AGENT_DIR` 时,改用该目录下的 `extensions/pi-codex-compaction/config.json`。菜单会在保存时创建文件;非交互模式下可以直接编辑。
73
106
 
74
107
  ```json
75
108
  {
@@ -79,28 +112,38 @@ pi install git:github.com/Criogaid/pi-codex-compaction
79
112
  },
80
113
  "fallback": {
81
114
  "enabled": true,
82
- "provider": "your-provider",
83
- "model": "your-model",
115
+ "provider": "openai-codex",
116
+ "model": "gpt-6.1-sol",
84
117
  "thinkingLevel": "high"
85
118
  }
86
119
  }
87
120
  ```
88
121
 
89
- 将 `provider`、`model` 替换为 Pi 中的实际 ID,`thinkingLevel` 使用该模型支持的值。这里不保存 API key,模型和认证需要先在 Pi 中配置好。
122
+ | 字段 | 对应菜单项 | 说明 |
123
+ | --- | --- | --- |
124
+ | `remoteCompaction.enabled` | `Remote Compaction V2` | 省略 `remoteCompaction` 时默认开启 |
125
+ | `fallback.enabled` | `Use separate summary model` | 旧配置省略此字段时按开启处理 |
126
+ | `fallback.provider`、`fallback.model`、`fallback.thinkingLevel` | `Summary model and thinking level` | 使用 Pi 中的实际 ID 和该模型支持的思考等级;三项须同时提供或同时省略 |
90
127
 
91
- 配置中的 `fallback` 对应菜单里的独立摘要模型设置,`fallback.enabled` 对应 `Use separate summary model`。省略整个 `fallback` 表示未指定模型;旧配置如果省略了 `enabled`,按开启处理。模型的 `provider`、`model` 和 `thinkingLevel` 必须同时提供或同时省略。
128
+ 省略整个 `fallback` 表示未指定独立摘要模型。配置文件不保存 API key,模型和认证须先在 Pi 中配置好。
92
129
 
93
- 省略 `remoteCompaction` 默认开启 V2。配置文件须为 UTF-8 JSON,大小不超过 16 KiB。
130
+ <details>
131
+ <summary><strong>读取时机与校验规则</strong></summary>
94
132
 
95
- 每次压缩开始时读取配置,压缩途中修改设置会在下次生效。文件无法读取、JSON 格式错误或 V2 设置无效时会停止压缩。独立摘要模型的配置只在需要文字摘要时检查,因此这部分填错不会影响成功的 V2 压缩。
133
+ - 配置文件须为 UTF-8 JSON,大小不超过 16 KiB。
134
+ - 每次压缩开始时读取一次配置,压缩途中的修改在下次生效。
135
+ - 文件无法读取、JSON 格式错误或 V2 设置无效时,停止压缩。
136
+ - `fallback` 部分只在需要原生压缩时校验,因此它填错不会影响成功的 V2 压缩。
137
+ - `fallback` 无效时菜单显示 `Invalid`。你仍可切换 V2,已填写的内容会原样保留;重新选择模型即可修复,之后需要打开 `Use separate summary model` 才会使用它。
138
+ - 打开菜单后配置文件被其他程序修改时,保存会失败;重新打开菜单再操作即可。
96
139
 
97
- 独立摘要模型的配置无效时,菜单显示 `Invalid`。你仍可切换 V2,已填写的模型配置会保留。重新选择模型可以修复这部分配置,但之后需要打开 `Use separate summary model` 才会使用它。如果打开菜单后配置文件被其他程序修改,保存会失败;重新打开菜单再操作即可。
140
+ </details>
98
141
 
99
142
  ## 自定义网关
100
143
 
101
- 网关必须支持 Codex Remote Compaction V2,只有普通 Responses API 兼容性还不够。模型的 API 类型须为 `openai-responses` 或 `openai-codex-responses`。
144
+ 默认只有 Pi 自带的 `openai-codex` 服务商(官方地址)会尝试 V2。其他网关须本身支持 Codex Remote Compaction V2,仅兼容普通 Responses API 不够;模型的 API 类型须为 `openai-responses` 或 `openai-codex-responses`。
102
145
 
103
- 在 Pi 的 `models.json` 中为模型添加 `compat.remoteCompaction`。以下是 `openai-responses` 配置示例:
146
+ 在 Pi 的 `models.json` 中为模型添加 `compat.remoteCompaction`:
104
147
 
105
148
  ```json
106
149
  {
@@ -126,23 +169,19 @@ pi install git:github.com/Criogaid/pi-codex-compaction
126
169
 
127
170
  替换地址和模型 ID,并设置 `CUSTOM_CODEX_API_KEY` 环境变量。这个例子请求 `https://gateway.example.com/v1/responses`。
128
171
 
129
- 如果网关的压缩请求使用其他路径,可以在 `remoteCompaction` 内增加 `endpoint`。它的协议、主机和端口必须与 Pi 登录认证后实际使用的 `baseUrl` 一致,URL 不能包含用户名、密码、`?` 后的查询参数或 `#` 后的片段。填写这些字段前,须确认网关本身支持 V2。
130
-
131
- ## 使用前需要了解
132
-
133
- 关闭 V2 后改用文字摘要压缩,不影响已压缩的聊天记录。
172
+ 网关的压缩请求使用其他路径时,可以在 `remoteCompaction` 内增加 `endpoint`。它的协议、主机和端口须与 Pi 认证后实际使用的 `baseUrl` 一致,且不能包含用户名、密码、查询参数(`?`)或片段(`#`)。
134
173
 
135
- V2 压缩后的旧聊天记录以加密数据保存,只有支持它的模型服务才能使用,扩展无法把它还原成完整文字。重新打开会话或从会话创建分支时,可以继续使用这部分记录,但必须连接原来的服务商,并保持 API 类型和请求地址不变。换到其他服务商或地址后,文字摘要只能根据 Pi 仍能读取的摘要和消息生成,无法包含那部分加密的旧记录。
174
+ ## 注意事项
136
175
 
137
- 在同一服务和地址下切换模型,扩展也会继续发送已压缩的记录。如果新模型不接受这些数据,需要切回原模型。V2 协议仍属实验性质。
138
-
139
- 压缩后,Pi 还会保留一部分近期消息。如果这些消息被修改,扩展可能无法确认它们与已压缩记录是否对应,从而停止使用那部分记录。搭配会改写聊天内容的扩展时,请把本包放在 Pi 的 `packages` 列表中靠前的位置。
140
-
141
- 扩展会尽量让压缩请求与普通聊天请求使用相同的历史内容和系统提示词,便于模型服务复用缓存。但 Pi 和其他扩展仍可能改变最终发送的内容,因此不能保证缓存命中或费用下降。
176
+ - **V2 压缩结果绑定服务商。** V2 压缩后的旧记录以加密数据保存,只有原服务能读取,扩展无法还原成文字。重新打开会话或创建分支时可以继续使用,但须保持服务商、API 类型和请求地址不变。换到其他服务后,原生压缩只能根据 Pi 仍可读取的摘要和消息生成,无法包含这部分加密记录。
177
+ - **同一服务内切换模型。** 扩展会继续发送已压缩的记录;如果新模型不接受,需要切回原模型。V2 协议仍属实验性质。
178
+ - **关闭 V2 不影响已有记录。** 已经用 V2 压缩的记录仍会照常发送。
179
+ - **加载顺序。** 压缩后 Pi 会保留一部分近期消息。如果它们被其他扩展改写,本扩展可能无法确认其与已压缩记录对应,从而停止使用那部分记录。搭配会改写聊天内容的扩展时,请把本包放在 Pi 的 `packages` 列表靠前的位置。
180
+ - **缓存。** 会话、模型、历史前缀、工具和设置未变时,压缩请求会复用最近一次普通请求中观察到的系统指令、工具定义、缓存标识和思考等选项。关闭或隐藏的工具不会因为压缩重新出现在这份工具声明中。记录只覆盖本扩展收到的请求内容,之后执行的扩展仍可能改写它;服务端也自行决定是否使用缓存,因此不保证缓存命中或费用下降。
142
181
 
143
182
  ## 开发
144
183
 
145
- 使用 Node 24 或更新版本。在克隆后的仓库根目录运行:
184
+ 使用 Node 24 或更新版本,在仓库根目录运行:
146
185
 
147
186
  ```bash
148
187
  npm ci --ignore-scripts
@@ -151,21 +190,27 @@ npm test
151
190
  npm run pack:check
152
191
  ```
153
192
 
154
- 本地加载可以使用 `pi install .`。Pi 不会替本地包安装依赖,须先完成上面的安装步骤。
193
+ 本地加载使用 `pi install .`。Pi 不会替本地包安装依赖,须先完成上面的安装步骤。
194
+
195
+ 运行代码在 `src/`,入口为 `src/index.ts`,Pi 直接加载 TypeScript 源码;测试在 `tests/`,编译结果写入 Git 忽略的 `dist/`。打包检查同时验证发布文件清单和 Pi 的实际入口加载。[CI](.github/workflows/ci.yml) 在 Ubuntu 24.04 和 Windows 上使用 Node 24 执行验证。变更记录见 [CHANGELOG.md](CHANGELOG.md),维护规则见 [AGENTS.md](AGENTS.md)。
155
196
 
156
- 运行代码在 `src/`,入口是 `src/index.ts`;测试在 `tests/`,编译结果写入 Git 忽略的 `dist/`。Pi 直接加载 TypeScript 源码。打包检查同时验证发布文件清单和 Pi 的实际入口加载。
197
+ <details>
198
+ <summary><strong>发布流程</strong></summary>
157
199
 
158
- [CI](.github/workflows/ci.yml) 在 Ubuntu 24.04 和 Windows 上使用 Node 24 执行验证。变更记录见 [CHANGELOG.md](CHANGELOG.md),维护规则见 [AGENTS.md](AGENTS.md)。
200
+ [Publish](.github/workflows/publish.yml) 在推送 `v*` 标签时运行。标签须与 `package.json` 的版本一致,例如版本 `0.4.0` 对应标签 `v0.4.0`。目前只发布正式版本,不接受 `-beta`、`-rc` 等预发布后缀。
159
201
 
160
- ## 发布
202
+ 首次发布前,在仓库的 **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 权限缩小到该包。
161
203
 
162
- [Publish](.github/workflows/publish.yml) 在推送 `v*` 标签时运行。标签必须与 `package.json` 的版本一致,例如版本 `0.3.0` 对应标签 `v0.3.0`。流程目前只发布正式版本,不接受带 `-beta`、`-rc` 等后缀的预发布版本。
204
+ 准备版本时:
163
205
 
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 权限缩小到该包。
206
+ 1. 运行 `npm version <版本号> --no-git-tag-version`,同步更新 `package.json` 和 `package-lock.json`。
207
+ 2. 将 CHANGELOG 中“未发布”的改动整理到对应版本下。
208
+ 3. 运行上面的验证命令,通过后提交。
209
+ 4. 为该提交创建并推送 `v<版本号>` 标签。
165
210
 
166
- 准备版本时,运行 `npm version <版本号> --no-git-tag-version` 同步更新 `package.json` 和 `package-lock.json`,将本次改动从 CHANGELOG 的“未发布”整理到对应版本下。运行上面的三项验证命令,通过后提交,再为该提交创建并推送 `v<版本号>` 标签。
211
+ 工作流会核对版本,重新安装依赖,执行类型检查、测试和打包检查,全部通过后发布公开 npm 包,并附带可追溯到源码提交和工作流的来源证明(provenance)。普通分支推送不会发布;已发布的 npm 版本不能覆盖,后续修改须使用新版本号。
167
212
 
168
- 工作流会核对版本,重新安装依赖,执行类型检查、测试和打包检查,全部通过后才发布公开 npm 包,并附带可追溯到源码提交和工作流的来源证明(provenance)。普通分支推送不会发布;已经发布的 npm 版本不能覆盖,后续修改需要使用新版本号。
213
+ </details>
169
214
 
170
215
  ## 许可证
171
216
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@criogaid/pi-codex-compaction",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Provider-aware Codex Remote Compaction V2 for Pi.",
5
5
  "type": "module",
6
6
  "repository": {
package/src/checkpoint.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
 
21
21
  export const CHECKPOINT_KIND = "pi-codex-compaction";
22
22
  export const CHECKPOINT_VERSION = 1;
23
+ const EXTENSION_PACKAGE = "@criogaid/pi-codex-compaction";
23
24
 
24
25
  export interface CodexCheckpointDetails extends ProviderIdentity {
25
26
  kind: typeof CHECKPOINT_KIND;
@@ -62,12 +63,21 @@ export function fingerprintMessage(message: AgentMessage): string {
62
63
  export function checkpointMarker(checkpointId: string): string {
63
64
  return [
64
65
  `[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.",
66
+ "The compressed chat history could not be loaded. Do not infer missing details.",
67
+ `Ask the user to enable ${EXTENSION_PACKAGE} and reconnect to the model service that created it.`,
67
68
  ].join(" ");
68
69
  }
69
70
 
70
71
  export function fallbackSummary(checkpointId: string): string {
72
+ return [
73
+ `Earlier chat history was compressed by Codex Remote Compaction V2 (checkpoint ${checkpointId}).`,
74
+ `To use it, keep ${EXTENSION_PACKAGE} enabled and connected to the original model service.`,
75
+ "If it cannot be loaded, only Pi's retained recent messages are available; do not guess missing details.",
76
+ ].join(" ");
77
+ }
78
+
79
+ // The persisted v1 summary identifies checkpoints written before the package was renamed.
80
+ function legacyFallbackSummary(checkpointId: string): string {
71
81
  return [
72
82
  `Codex Remote Compaction V2 checkpoint ${checkpointId} stores the older history opaquely.`,
73
83
  "Full replay requires @oipsanthony/pi-codex-compaction and the original provider endpoint and model.",
@@ -202,9 +212,9 @@ export function projectCheckpointContext(
202
212
  messages: readonly AgentMessage[],
203
213
  details: CodexCheckpointDetails,
204
214
  ): AgentMessage[] | undefined {
205
- const summary = fallbackSummary(details.checkpointId);
215
+ const summaries = new Set([fallbackSummary(details.checkpointId), legacyFallbackSummary(details.checkpointId)]);
206
216
  const summaryIndex = messages.findIndex(
207
- (message) => message.role === "compactionSummary" && message.summary === summary,
217
+ (message) => message.role === "compactionSummary" && summaries.has(message.summary),
208
218
  );
209
219
  if (summaryIndex < 0) return undefined;
210
220
  const keptStart = summaryIndex + 1;
package/src/index.ts CHANGED
@@ -35,7 +35,7 @@ import { requestRemoteCompaction } from "./remote.js";
35
35
  import { requestFallbackCompaction } from "./fallback.js";
36
36
  import { COMPACTION_SETTINGS_RELATIVE_PATH, loadCompactionSettings, type CompactionConfiguration } from "./fallback-settings.js";
37
37
  import { registerCompactionCommand } from "./fallback-command.js";
38
- import { compactionRequest, RequestSnapshotTracker, type RequestSnapshots } from "./request-snapshot.js";
38
+ import { compactionRequest, providerRequestFor, RequestSnapshotTracker, type ProviderRequestInputs, type RequestSnapshots } from "./request-snapshot.js";
39
39
 
40
40
  const STATUS_KEY = "codex-compaction";
41
41
  const COMPLETION_ENTRY_TYPE = "pi-codex-compaction-completed";
@@ -75,6 +75,13 @@ function activeTools(pi: ExtensionAPI): Tool[] {
75
75
  }));
76
76
  }
77
77
 
78
+ function providerRequestInputs(pi: ExtensionAPI, ctx: ExtensionContext): ProviderRequestInputs {
79
+ return {
80
+ systemPrompt: ctx.getSystemPrompt(), thinkingLevel: pi.getThinkingLevel(), settings: pi.getSettings(),
81
+ activeTools: pi.getActiveTools(), tools: pi.getAllTools(),
82
+ };
83
+ }
84
+
78
85
  function canonicalMessages(ctx: ExtensionContext): AgentMessage[] {
79
86
  const branch = ctx.sessionManager.getBranch();
80
87
  return buildSessionContext(branch, branch.at(-1)?.id ?? null).messages;
@@ -198,6 +205,7 @@ async function compactRemotely(
198
205
  ctx.ui.notify("Codex Remote Compaction V2 does not accept custom instructions; they are ignored.", "warning");
199
206
  }
200
207
  const current = projectedCurrentMessages(event, supported.identity);
208
+ const inputs = providerRequestInputs(pi, ctx);
201
209
  const request = compactionRequest(snapshots, sessionId, supported, current.messages, {
202
210
  blockImages: settings.images?.blockImages ?? false,
203
211
  systemPrompt: () => ctx.getSystemPrompt(),
@@ -208,6 +216,7 @@ async function compactRemotely(
208
216
  model: supported.model,
209
217
  context: request.context,
210
218
  userItemOrigins: userItemOrigins(request.messages),
219
+ providerRequest: providerRequestFor(snapshots, sessionId, supported, current.messages, inputs),
211
220
  reasoning,
212
221
  sessionId,
213
222
  thinkingBudgets: settings.thinkingBudgets,
@@ -341,6 +350,7 @@ export function createCodexCompactionExtension(
341
350
  capableModel(ctx.model),
342
351
  () => canonicalMessages(ctx),
343
352
  () => ctx.getSystemPrompt(),
353
+ () => ({ payload: payload ?? event.payload, inputs: providerRequestInputs(pi, ctx) }),
344
354
  );
345
355
  return payload;
346
356
  });
package/src/remote.ts CHANGED
@@ -7,6 +7,7 @@ import { trimToolOutputsToContextWindow } from "./context-window.js";
7
7
  import { estimateImages, type ImageEstimates } from "./image-budget.js";
8
8
  import { contextUserItems, type UserItemOrigin } from "./retention-input.js";
9
9
  import { CodexCompactionProtocolError, createCompactionCollector, isObject, type JsonObject, prepareRemoteCompactionPayload } from "./protocol.js";
10
+ import { applyProviderRequest, type ProviderRequestSnapshot } from "./request-snapshot.js";
10
11
 
11
12
  const REMOTE_COMPACTION_FEATURE = "remote_compaction_v2";
12
13
  const REQUEST_TIMEOUT_MS = 300_000;
@@ -27,6 +28,7 @@ export interface RemoteCompactionRequest {
27
28
  /** Pi origins of the context's user messages, used to align provider user items with Pi roles. */
28
29
  userItemOrigins?: readonly UserItemOrigin[];
29
30
  priorCheckpoint?: { identity: ProviderIdentity; marker: string; replacementHistory: readonly JsonObject[] };
31
+ providerRequest?: ProviderRequestSnapshot;
30
32
  onPrepared?: () => void;
31
33
  fetch?: typeof globalThis.fetch;
32
34
  }
@@ -108,6 +110,7 @@ export async function requestRemoteCompaction(request: RemoteCompactionRequest):
108
110
  if (prior && !sameBackend(prior, identity)) {
109
111
  throw new CodexCompactionProtocolError("The active opaque checkpoint belongs to a different resolved provider backend");
110
112
  }
113
+ if (isObject(payload)) payload = applyProviderRequest(payload, resolved, request.providerRequest);
111
114
  const contextItems = contextUserItems(isObject(payload) ? payload.input : undefined, request.userItemOrigins);
112
115
  const payloadItems = isObject(payload) && Array.isArray(payload.input) ? payload.input.filter(isObject) : [];
113
116
  const estimates = await estimateImages([...payloadItems, ...request.priorCheckpoint?.replacementHistory ?? []], request.signal);
@@ -1,10 +1,11 @@
1
1
  // Own ordinary-request prompt and context snapshots that let compaction reuse Pi's projected request prefix,
2
2
  // and build compaction's provider context the way Pi 0.99 builds an ordinary request.
3
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
+ import type { AgentMessage, ThinkingLevel } from "@earendil-works/pi-agent-core";
4
4
  import { getCurrentSystemMessage, getSystemMessageText, type Context, type Message, type Tool } from "@earendil-works/pi-ai";
5
- import { convertToLlm } from "@earendil-works/pi-coding-agent";
5
+ import { convertToLlm, type ExtensionAPI, type ToolInfo } from "@earendil-works/pi-coding-agent";
6
6
  import { sameBackend, sameModel, type CapableModel, type ProviderIdentity } from "./capability.js";
7
7
  import { type CodexCheckpointDetails, fingerprintMessage, projectCheckpointRequest, withoutSystemMessages } from "./checkpoint.js";
8
+ import { isObject, type JsonObject } from "./protocol.js";
8
9
 
9
10
  const BLOCKED_IMAGE_TEXT = "Image reading is disabled.";
10
11
 
@@ -92,14 +93,17 @@ export function captureContextSnapshot(
92
93
  };
93
94
  }
94
95
 
95
- /** Reuse the projected request while its source is an unchanged prefix, then append newer messages. */
96
- export function reuseContextSnapshot(messages: AgentMessage[], snapshot: ContextSnapshot | undefined): AgentMessage[] {
97
- if (!snapshot) return messages;
96
+ function matchingSource(messages: readonly AgentMessage[], snapshot: Pick<ContextSnapshot, "sourceFingerprints">): AgentMessage[] | undefined {
98
97
  const source = requestSource(messages);
99
- if (source.length < snapshot.sourceFingerprints.length || snapshot.sourceFingerprints.some(
98
+ return source.length < snapshot.sourceFingerprints.length || snapshot.sourceFingerprints.some(
100
99
  (fingerprint, index) => fingerprintMessage(source[index]) !== fingerprint,
101
- )) return messages;
102
- return [...structuredClone(snapshot.messages), ...source.slice(snapshot.sourceFingerprints.length)];
100
+ ) ? undefined : source;
101
+ }
102
+
103
+ /** Reuse the projected request while its source is an unchanged prefix, then append newer messages. */
104
+ export function reuseContextSnapshot(messages: AgentMessage[], snapshot: ContextSnapshot | undefined): AgentMessage[] {
105
+ const source = snapshot && matchingSource(messages, snapshot);
106
+ return source ? [...structuredClone(snapshot.messages), ...source.slice(snapshot.sourceFingerprints.length)] : messages;
103
107
  }
104
108
 
105
109
  /** Pi 0.99 applies per-run prompt overrides after context hooks by collapsing system messages into one head. */
@@ -110,10 +114,95 @@ export function applyPromptOverride(messages: Message[], override: PromptOverrid
110
114
  return [{ ...declarations, content: override.text }, ...withoutSystemMessages(messages)];
111
115
  }
112
116
 
117
+ /** Public Pi state that can change declarations or request parameters without changing the conversation. */
118
+ export interface ProviderRequestInputs {
119
+ readonly systemPrompt: string;
120
+ readonly thinkingLevel: ThinkingLevel;
121
+ readonly settings: ReturnType<ExtensionAPI["getSettings"]>;
122
+ readonly activeTools: readonly string[];
123
+ readonly tools: readonly ToolInfo[];
124
+ }
125
+
126
+ interface ProviderRequestFields {
127
+ readonly instructions?: string;
128
+ readonly tools?: readonly JsonObject[];
129
+ readonly reasoning?: JsonObject;
130
+ readonly prompt_cache_key?: string;
131
+ readonly prompt_cache_retention?: string;
132
+ readonly service_tier?: string;
133
+ }
134
+
135
+ export interface ProviderRequestSnapshot extends SnapshotScope {
136
+ readonly sourceFingerprints: readonly string[];
137
+ readonly inputsKey: string;
138
+ readonly fields: ProviderRequestFields;
139
+ readonly verbosity?: string;
140
+ readonly prefix: readonly JsonObject[];
141
+ }
142
+
143
+ function declarationPrefix(input: readonly JsonObject[]): readonly JsonObject[] {
144
+ const end = input.findIndex((item) => item.type !== "additional_tools" &&
145
+ !((item.type === undefined || item.type === "message") && (item.role === "system" || item.role === "developer")));
146
+ return end < 0 ? input : input.slice(0, end);
147
+ }
148
+
149
+ function requestInputsKey(target: CapableModel, inputs: ProviderRequestInputs): string {
150
+ return JSON.stringify({ model: target.model, ...inputs });
151
+ }
152
+
153
+ function captureProviderRequest(
154
+ context: ContextSnapshot, target: CapableModel, payload: unknown, inputs: ProviderRequestInputs,
155
+ ): ProviderRequestSnapshot | undefined {
156
+ if (!isObject(payload) || payload.model !== target.model.id ||
157
+ !Array.isArray(payload.input) || !payload.input.every(isObject)) return undefined;
158
+ const { instructions, tools, reasoning, prompt_cache_key, prompt_cache_retention, service_tier, text } = payload;
159
+ const verbosity = isObject(text) ? text.verbosity : undefined;
160
+ if ((instructions !== undefined && typeof instructions !== "string") ||
161
+ (tools !== undefined && (!Array.isArray(tools) || !tools.every(isObject))) ||
162
+ (reasoning !== undefined && !isObject(reasoning)) ||
163
+ (prompt_cache_key !== undefined && typeof prompt_cache_key !== "string") ||
164
+ (prompt_cache_retention !== undefined && typeof prompt_cache_retention !== "string") ||
165
+ (service_tier !== undefined && typeof service_tier !== "string") ||
166
+ (text !== undefined && !isObject(text)) || (verbosity !== undefined && typeof verbosity !== "string")) return undefined;
167
+ return {
168
+ sessionId: context.sessionId, identity: context.identity, sourceFingerprints: context.sourceFingerprints,
169
+ inputsKey: requestInputsKey(target, inputs),
170
+ fields: structuredClone({ instructions, tools, reasoning, prompt_cache_key, prompt_cache_retention, service_tier }),
171
+ verbosity, prefix: structuredClone(declarationPrefix(payload.input)),
172
+ };
173
+ }
174
+
175
+ /** Reuse wire declarations only with an unchanged source prefix and request configuration. */
176
+ export function providerRequestFor(
177
+ snapshots: RequestSnapshots, sessionId: string, target: CapableModel, current: readonly AgentMessage[], inputs: ProviderRequestInputs,
178
+ ): ProviderRequestSnapshot | undefined {
179
+ const snapshot = snapshotFor(snapshots.providerRequest, sessionId, target);
180
+ return snapshot && matchingSource(current, snapshot) &&
181
+ snapshot.inputsKey === requestInputsKey(target, inputs) ? snapshot : undefined;
182
+ }
183
+
184
+ /** Replace only declarations and cache-related fields; Pi still owns the current input, transport, and output limits. */
185
+ export function applyProviderRequest(
186
+ payload: JsonObject, target: CapableModel, snapshot: ProviderRequestSnapshot | undefined,
187
+ ): JsonObject {
188
+ if (!snapshot || !sameBackend(snapshot.identity, target.identity) || !sameModel(snapshot.identity, target.model) ||
189
+ payload.model !== target.model.id || !Array.isArray(payload.input) || !payload.input.every(isObject)) return payload;
190
+ const updated: JsonObject = { ...payload, ...structuredClone(snapshot.fields),
191
+ input: [...structuredClone(snapshot.prefix), ...payload.input.slice(declarationPrefix(payload.input).length)] };
192
+ if (isObject(payload.text) || snapshot.verbosity !== undefined) {
193
+ const text = { ...(isObject(payload.text) ? payload.text : {}) };
194
+ delete text.verbosity;
195
+ if (snapshot.verbosity !== undefined) text.verbosity = snapshot.verbosity;
196
+ updated.text = Object.keys(text).length ? text : undefined;
197
+ }
198
+ return updated;
199
+ }
200
+
113
201
  /** The snapshots that the latest ordinary request left for compaction. */
114
202
  export interface RequestSnapshots {
115
203
  readonly promptOverride?: PromptOverride;
116
204
  readonly context?: ContextSnapshot;
205
+ readonly providerRequest?: ProviderRequestSnapshot;
117
206
  }
118
207
 
119
208
  interface TrackedSnapshots {
@@ -121,6 +210,7 @@ interface TrackedSnapshots {
121
210
  pendingSource?: { readonly sessionId: string; readonly fingerprints: readonly string[] };
122
211
  pendingContext?: ContextSnapshot;
123
212
  context?: ContextSnapshot;
213
+ providerRequest?: ProviderRequestSnapshot;
124
214
  }
125
215
 
126
216
  /**
@@ -172,10 +262,17 @@ export class RequestSnapshotTracker {
172
262
  target: CapableModel | undefined,
173
263
  canonical: () => AgentMessage[],
174
264
  systemPrompt: () => string,
265
+ observation: () => { readonly payload: unknown; readonly inputs: ProviderRequestInputs },
175
266
  ): void {
176
267
  this.state.promptOverride = target && capturePromptOverride(canonical(), sessionId, target, systemPrompt());
177
268
  // Keep the pending snapshot for retries that prepare a payload without running context hooks again.
178
269
  this.state.context = this.state.pendingContext;
270
+ this.state.providerRequest = undefined;
271
+ const context = target && snapshotFor(this.state.context, sessionId, target);
272
+ if (context && target) {
273
+ const { payload, inputs } = observation();
274
+ this.state.providerRequest = captureProviderRequest(context, target, payload, inputs);
275
+ }
179
276
  }
180
277
  }
181
278