pi-openai-toolkit 0.16.0 → 0.17.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-openai-toolkit
2
2
 
3
- Add Codex context windows, Responses compaction, hosted tools, and reviewed tool calls to Pi.
3
+ Add Codex context windows, Responses compaction, hosted tools, and tool-call review to Pi.
4
4
 
5
5
  [![npm version](https://img.shields.io/npm/v/pi-openai-toolkit.svg)](https://www.npmjs.com/package/pi-openai-toolkit)
6
6
  [![License: MIT](https://img.shields.io/npm/l/pi-openai-toolkit.svg)](LICENSE)
@@ -11,39 +11,37 @@ Add Codex context windows, Responses compaction, hosted tools, and reviewed tool
11
11
 
12
12
  | Feature | Use it to |
13
13
  | --- | --- |
14
- | Codex Remote Context | Use the adapted Codex window protocol to start a new context window and retrieve earlier windows with `history`. |
15
- | Remote Compaction v2 | Continue an eligible Responses session with an encrypted server checkpoint. |
16
- | Web Search routes | Choose local `pi-web-access`, hosted Responses `web_search`, or experimental CPA standalone `web_run` per exact model. |
17
- | Image generation | Call the hosted Responses image tool to generate images or edit explicitly supplied local references. |
18
- | Tool-call review | Use Toolkit's approval gate to ask a reviewer model whether selected tool calls may run. |
14
+ | Codex Remote Context | Start new windows with the adapted Codex protocol and retrieve earlier work through `history`. |
15
+ | Remote Compaction v2 | Continue Responses sessions with encrypted server checkpoints. |
16
+ | Web Search | Choose local `pi-web-access`, Responses `web_search`, or experimental CPA `web_run` per model. |
17
+ | Image generation | Generate or edit images with the hosted Responses image tool. |
18
+ | Tool-call review | Have a reviewer model approve selected calls through Toolkit's approval gate. |
19
19
 
20
- The package uses Pi's existing model, authentication, and session configuration. It does not add a provider or model.
20
+ The package uses Pi's existing models, authentication, and sessions. It does not add a provider or model.
21
21
 
22
22
  ## Install
23
23
 
24
- Requires Pi 0.85.1 or newer and Node.js 22.19.0 or newer.
24
+ Requires Pi 0.87.0+ and Node.js 22.19.0+.
25
25
 
26
26
  ```bash
27
27
  pi install npm:pi-openai-toolkit
28
28
  ```
29
29
 
30
- Use `--local` to install the extension in the current project. Toolkit policy is still global; there are no project or environment policy overlays.
31
-
32
- Without a config file, Remote Compaction v2 is enabled for eligible models, Remote Context windows are off, search is unmanaged, image generation is disabled, and Auto Mode is unavailable. These defaults do not verify backend support.
33
-
34
- The only Toolkit config file is:
30
+ Add `--local` for a project-local installation. Toolkit configuration is global in either case:
35
31
 
36
32
  `~/.pi/agent/extensions/pi-openai-toolkit/config.json`
37
33
 
38
- All JSON examples below except the Pi model-registration example use **configuration schema v2**. Create the file and parent directory if absent. Merge examples into an existing v2 document, keeping its other settings. An unversioned legacy file remains supported: do not add v2 fields to it or overwrite it with an example. First use `/toolkit-config migration-preview`; the [configuration reference](docs/configuration.md) explains the review and installed-version checks. Loading config and previewing migration never rewrite the source file.
34
+ Create the file and parent directory if needed. Merge the Toolkit examples below into an existing **schema v2** config; do not replace unrelated settings. For an unversioned legacy config, run `/toolkit-config migration-preview` first. See the [migration guide](docs/configuration.md#legacy-compatibility-and-migration).
35
+
36
+ By default, eligible models use Remote Compaction v2. Remote Context windows, image generation, and Auto Mode are off; search is unmanaged. Backend support is still required.
39
37
 
40
38
  ## Quick start: enable Remote Context
41
39
 
42
- This section is for Codex-style context windows. For search, images, or tool-call review only, skip to [Common tasks](#common-tasks).
40
+ For search, images, or tool-call review only, skip to [Common tasks](#common-tasks).
43
41
 
44
42
  ### Use Pi's built-in Codex provider
45
43
 
46
- You must already be signed in to Pi's built-in `openai-codex` provider. Create or merge this v2 Toolkit config:
44
+ Sign in to Pi's `openai-codex` provider, then add this Toolkit config:
47
45
 
48
46
  ```json
49
47
  {
@@ -54,19 +52,24 @@ You must already be signed in to Pi's built-in `openai-codex` provider. Create o
54
52
  }
55
53
  ```
56
54
 
57
- Start Pi with a model from your existing Codex catalog:
55
+ Start Pi with a model ID from your Codex catalog:
58
56
 
59
57
  ```bash
60
58
  pi --model openai-codex/<model-id>
61
59
  ```
62
60
 
63
- Replace `<model-id>` with the ID shown by your Pi setup. The session is activated when `new_context`, `get_context_remaining`, `history`, and `notes` appear. This verifies activation, not a completed backend round trip.
61
+ Look for `new_context`, `get_context_remaining`, `history`, and `notes`. Their presence confirms activation, not backend compatibility. Earlier windows stay accessible through `history` without being loaded into every request.
64
62
 
65
63
  ### Use a compatible gateway
66
64
 
67
- This route requires `openai-responses` and a gateway that preserves Remote Context's Codex protocol fields. A successful ordinary chat request does not prove Remote Context compatibility.
65
+ The gateway must support `openai-responses` and the Codex Remote Context protocol. Ordinary chat support is not enough.
66
+
67
+ If your model is already registered in `~/.pi/agent/models.json`, skip the registration example.
68
68
 
69
- If `~/.pi/agent/models.json` already registers a compatible model, skip registration and set Toolkit's exact model override below. Otherwise, add or merge this Pi provider entry. Replace the provider name, URL, environment variable, and model values with your setup. The numeric limits are examples, not project defaults.
69
+ <details>
70
+ <summary>Register a gateway model in Pi</summary>
71
+
72
+ Merge this into Pi's models file. Replace the provider name, URL, key variable, model ID, and limits with your setup; the numbers are examples.
70
73
 
71
74
  ```json
72
75
  {
@@ -88,19 +91,23 @@ If `~/.pi/agent/models.json` already registers a compatible model, skip registra
88
91
  }
89
92
  ```
90
93
 
91
- Set the referenced key before starting Pi. In PowerShell:
94
+ Set the key in PowerShell:
92
95
 
93
96
  ```powershell
94
97
  $env:MY_GATEWAY_KEY = "replace-with-your-gateway-key"
95
98
  ```
96
99
 
97
- In a POSIX shell:
100
+ Or in a POSIX shell:
98
101
 
99
102
  ```bash
100
103
  export MY_GATEWAY_KEY="replace-with-your-gateway-key"
101
104
  ```
102
105
 
103
- Use the same terminal to start Pi. In the Toolkit config, make the key exactly match the registered provider and model ID:
106
+ Start Pi from the same terminal.
107
+
108
+ </details>
109
+
110
+ Add the exact registered `provider/model-id` to Toolkit's config:
104
111
 
105
112
  ```json
106
113
  {
@@ -118,45 +125,23 @@ Use the same terminal to start Pi. In the Toolkit config, make the key exactly m
118
125
  pi --model my-gateway/gpt-5.6-luna
119
126
  ```
120
127
 
121
- Check for `new_context`, `get_context_remaining`, `history`, and `notes`. If absent, read the notification and check `/toolkit-config`, the exact model key, Pi API, credentials, and base URL. The transport setting opts into a protocol profile; it does not register a model or prove the backend supports it.
122
-
123
- Earlier windows remain retrievable through `history`, but are not all automatically inserted into the current context.
128
+ Check for the same context tools listed above. If they are missing, use `/toolkit-config` and check the notification, model key, API, credentials, and URL.
124
129
 
125
130
  ## Common tasks
126
131
 
127
- ### Checkpoint a managed session with `/compact`
132
+ ### Save work and switch windows with `/compact`
128
133
 
129
- In an active Remote Context session, run `/compact` (optionally followed by checkpoint instructions) to save work in notes and enter one new window in the same Pi session. Toolkit temporarily selects `context.remoteCompaction.model`, waits for a new successful persisted notes checkpoint and window marker, then restores the original model and thinking level before the first new-window request. That model is instructed to read the checkpoint receipt before continuing.
134
+ In an active Remote Context session, run `/compact` to save work to notes and enter a new window without a conversation summary. You can append instructions to the command.
130
135
 
131
- To choose a checkpoint model, merge this into your v2 configuration and replace the model reference with an existing Pi model:
136
+ To use a separate checkpoint model, set `context.remoteCompaction.model` under `defaults` or an exact model override. It must support Remote Context on the same backend/account; leaving it unset uses the current model. Toolkit restores your original model and thinking level before continuing. See [context settings and requirements](docs/configuration.md#context).
132
137
 
133
- ```json
134
- {
135
- "schemaVersion": 2,
136
- "defaults": {
137
- "context": {
138
- "mode": "remote-windows",
139
- "remoteCompaction": { "model": "my-gateway/gpt-5.6-luna" }
140
- }
141
- }
142
- }
143
- ```
144
-
145
- The checkpoint model also needs Remote Context support, the same backend/account, enough context capacity, and the permitted context tools. A gateway model needs its own exact transport opt-in. Missing or `null` uses the current model. An engaged Auto Mode gate must remain eligible on the target; otherwise the handoff is refused. Notes or model failures restore the original selection when still owned by Toolkit; your later explicit model selection wins. Reload only recovers selection, without automatically restarting inference.
146
-
147
- This does not generate a conversation summary. Pi's native compact operation is intentionally cancelled to launch the separate handoff, so an SDK/RPC caller can see a cancellation even while checkpoint duty starts. Pi can also reject an empty or already-compacted session before Toolkit's hook runs. Automatic threshold/overflow handling and internal trim maintenance keep their existing behavior. See the [handoff contract](docs/internals.md#managed-manual-compact).
148
-
149
- ### Continue a session with server-side compaction
150
-
151
- Use `context.mode: "remote-compaction"` for the Responses compaction path (the default), or `"pi"` to relinquish Toolkit context management. Set `context.remoteCompaction.model` only when a separate model should produce the checkpoint. These fields belong under `defaults` or an exact `models` override.
152
-
153
- `context.remoteCompaction.inputSource` defaults to `"legacy"`: first compaction uses Pi's current session context, with event preparation as a last resort; recursion uses the raw branch tail. This preserves existing behavior but can diverge from provider-visible context when other extensions rewrite messages.
138
+ ### Use server-side compaction
154
139
 
155
- Opt into `"pi-context-hook"` to use the ordered Pi context-hook projection. If that bridge is unavailable, compaction cancels rather than sending unprojected history. Checkpoints are source-specific; switching input sources requires a new checkpoint. See [protocol details](docs/internals.md#remote-compaction-v2-wire-contract).
140
+ Set `context.mode` to `"remote-compaction"` (the default) for encrypted Responses checkpoints, or `"pi"` to leave context management to Pi. Advanced input and fallback options are in the [configuration reference](docs/configuration.md#context).
156
141
 
157
142
  ### Choose a Web Search route
158
143
 
159
- Configure a global default and exact model overrides:
144
+ Set a default route and override it for individual models:
160
145
 
161
146
  ```json
162
147
  {
@@ -172,16 +157,18 @@ Configure a global default and exact model overrides:
172
157
  }
173
158
  ```
174
159
 
175
- - `unmanaged` releases Toolkit ownership. It does not disable third-party search or all Internet access.
176
- - `local` preserves the original local `pi-web-access` tool state and removes conflicting hosted/standalone tools from provider payloads. It never activates a local tool that was inactive.
177
- - `hosted` replaces the local `web_search` function with the native Responses search tool and source annotations.
178
- - `standalone-alpha` deliberately opts into experimental CPA/Codex gateway search. It exposes sequential `web_run` and sends one isolated request per call to the provider-relative `/alpha/search`, using the current Pi model and credentials. The gateway must actually support that endpoint and capability; Toolkit does not probe or enable it.
160
+ | Route | Behavior |
161
+ | --- | --- |
162
+ | `unmanaged` | Leave search to Pi and other extensions. |
163
+ | `local` | Keep the existing local `pi-web-access` tools. |
164
+ | `hosted` | Use Responses `web_search` with source annotations. |
165
+ | `standalone-alpha` | Use experimental `web_run` on a CPA/Codex gateway that supports `/alpha/search`. |
179
166
 
180
- Exact overrides win over defaults. There are no patterns, inferred capabilities, or fallback between routes. Invalid selected policy blocks the affected operation instead of choosing another route. The legacy hosted model list remains readable, but its permissive failure behavior is not identical to explicit v2 `hosted`; migration preview flags that difference.
167
+ Toolkit does not fall back between routes. See [search configuration](docs/configuration.md#web-search) for backend requirements.
181
168
 
182
169
  ### Generate an image
183
170
 
184
- Image generation requires a Responses session and may incur provider charges. Enable it globally:
171
+ Requires a Responses session and may incur provider charges. Enable it with an image model your provider supports:
185
172
 
186
173
  ```json
187
174
  {
@@ -190,19 +177,17 @@ Image generation requires a Responses session and may incur provider charges. En
190
177
  "imageGeneration": {
191
178
  "enabled": true,
192
179
  "defaultModel": "gpt-image-2.5",
193
- "allowedModels": ["gpt-image-2.5", "grok-imagine-image-2.0"]
180
+ "allowedModels": ["gpt-image-2.5"]
194
181
  }
195
182
  }
196
183
  }
197
184
  ```
198
185
 
199
- These are bare output-model IDs for the nested Responses `image_generation` tool. List order does not choose the default. `defaultModel` must belong to the nonempty `allowedModels` list; an optional one-call `model` must also be allowed. Invalid policy or a disallowed choice fails before authentication, reference upload, and paid dispatch. The provider must support the chosen model.
200
-
201
- `openai_generate_image` accepts text-to-image requests and edits using explicitly supplied local references. Image policy cannot be overridden per session model.
186
+ Ask Pi to generate an image or edit explicitly supplied local references using `openai_generate_image`. The default model must be in `allowedModels`; image settings are global. See [image configuration](docs/configuration.md#images).
202
187
 
203
188
  ### Review tool calls automatically
204
189
 
205
- Make Auto Mode available to an exact model and choose its reviewer:
190
+ Enable Auto Mode for a model and choose its reviewer:
206
191
 
207
192
  ```json
208
193
  {
@@ -218,38 +203,23 @@ Make Auto Mode available to an exact model and choose its reviewer:
218
203
  }
219
204
  ```
220
205
 
221
- `available` permits engagement; use `/auto on` or `--auto` to engage. `/auto off` explicitly disengages it. The default `side-effect` gate covers `bash`, `write`, `edit`, and configured extra tools; `gate: "all"` reviews every tool call. A reviewer timeout does not approve a call. Invalid configuration while engaged keeps a blocking gate until corrected or explicitly turned off.
222
-
223
- The TUI shows activation, review activity, and a footer status. Compatible Pi tool renderers also show per-call allowed, denied, blocked, or unreviewed states. If that renderer seam is unavailable, Toolkit warns once and retains the footer display. Reviewer, classifier, and circuit-breaker controls are in the [configuration reference](docs/configuration.md).
206
+ Turn it on with `/auto on` or `--auto`, and off with `/auto off`. By default it reviews `bash`, `write`, `edit`, and configured extra tools; `gate: "all"` reviews every call. A reviewer timeout never approves a call. See [Auto Mode settings](docs/configuration.md#auto-mode).
224
207
 
225
208
  ## Common configuration
226
209
 
227
- The global file uses built-ins, then `defaults`, then exact `models["provider/model-id"]` overrides. Missing fields inherit; arrays replace; `false` and `0` are retained. Supported optional model references accept `null` to clear inheritance. Defaults are not master switches: an exact override can enable a feature whose default is disabled.
228
-
229
- | Field | Default | Use |
230
- | --- | --- | --- |
231
- | `defaults.context.mode` | `"remote-compaction"` | `"pi"`, `"remote-compaction"`, or `"remote-windows"`. |
232
- | `models[exact].compatibility.transport` | `"standard"` | Exact gateway opt-in with `"codex-gateway"`. |
233
- | `defaults.context.remoteCompaction.model` | `null` | Checkpoint producer, or manual checkpoint-duty model in `remote-windows`. |
234
- | `defaults.context.remoteCompaction.inputSource` | `"legacy"` | Source-specific checkpoint input policy. |
235
- | `defaults.context.remoteWindows.reminderThresholdPercent` | `5` | `0` disables reminders and exhausted-window fallback. |
236
- | `defaults.webSearch.route` | `"unmanaged"` | Toolkit search ownership policy. |
237
- | `defaults.imageGeneration.enabled` | `false` | Global image-generation switch. |
238
- | `defaults.imageGeneration.defaultModel` | `"gpt-image-2.5"` | Explicit default output model. |
239
- | `defaults.imageGeneration.allowedModels` | `["gpt-image-2.5"]` | Allowed output models, global only. |
240
- | `defaults.autoMode.available` | `false` | Permission to engage tool review. |
241
- | `defaults.autoMode.gate` | `"side-effect"` | Selected side-effect tools or `"all"`. |
242
- | `defaults.autoMode.timeoutMs` | `30000` | Reviewer timeout in milliseconds. |
243
- | `diagnostics.level` | `"info"` | `"debug"` enables debug artifacts. |
244
- | `diagnostics.captureRequests` / `captureResponses` | `false` | Independent request/compact-response capture opt-ins. |
245
-
246
- Use `/toolkit-config` for effective values and origins, `/toolkit-config validate` for document issues, and `/toolkit-config migration-preview` for a read-only legacy candidate. These commands require a UI and perform no network/auth lookup, tool activation, or file writes. Configuration selection does not verify backend support.
247
-
248
- Unknown v2 policy keys and malformed values produce visible scoped errors, independent of debug mode. Each public callback or tool execution uses one immutable snapshot across its awaited helpers; later independent operations reread the file. A managed `/compact` handoff retains its initiating context-policy snapshot until selection is restored. Separate Pi events are not a general atomic transaction. See the [complete configuration reference](docs/configuration.md) and [editor schema](config.schema.json).
210
+ Use `defaults` for shared settings and `models["provider/model-id"]` for exact model overrides. Toolkit reads only the global config file.
211
+
212
+ | Command | Purpose |
213
+ | --- | --- |
214
+ | `/toolkit-config` | Show effective settings and their origins. |
215
+ | `/toolkit-config validate` | Check for configuration errors. |
216
+ | `/toolkit-config migration-preview` | Preview a legacy-to-v2 migration without writing files. |
217
+
218
+ These commands require Pi's interactive UI. See the [full configuration reference](docs/configuration.md), [editor schema](config.schema.json), and [implementation details](docs/internals.md).
249
219
 
250
220
  ## Development
251
221
 
252
- From the repository root, after dependencies are installed:
222
+ After installing dependencies in the repository root:
253
223
 
254
224
  ```bash
255
225
  npm run typecheck
package/README.zh.md CHANGED
@@ -11,39 +11,37 @@
11
11
 
12
12
  | 功能 | 用途 |
13
13
  | --- | --- |
14
- | Codex 远程上下文 | 使用适配的 Codex 窗口协议切换上下文窗口,并通过 `history` 检索较早窗口。 |
15
- | 远程压缩 v2 | 使用服务端返回的加密检查点继续符合条件的 Responses 会话。 |
16
- | 联网搜索路由 | 按精确模型选择本地 `pi-web-access`、Responses 托管 `web_search` 或实验性的 CPA 独立 `web_run`。 |
17
- | 图像生成 | 调用 Responses 托管生图工具生成图片,或编辑明确传入的本地参考图片。 |
18
- | 工具调用审查 | 使用 Toolkit 的审批门禁,由审查模型判断指定工具调用是否可以执行。 |
14
+ | Codex 远程上下文 | 通过适配的 Codex 协议切换窗口,用 `history` 找回较早的工作内容。 |
15
+ | 远程压缩 v2 | 使用加密的服务端检查点继续 Responses 会话。 |
16
+ | 联网搜索 | 按模型选择本地 `pi-web-access`、Responses `web_search` 或实验性的 CPA `web_run`。 |
17
+ | 图像生成 | 使用 Responses 托管生图工具生成或编辑图片。 |
18
+ | 工具调用审查 | 通过 Toolkit 审批门禁,让审查模型判断指定调用是否可以执行。 |
19
19
 
20
- 本包沿用 Pi 已有的模型、认证和会话配置,不新增提供商或模型。
20
+ 本包沿用 Pi 已有的模型、认证和会话,不新增提供商或模型。
21
21
 
22
22
  ## 安装
23
23
 
24
- 需要 Pi 0.85.1 或更高版本,以及 Node.js 22.19.0 或更高版本。
24
+ 需要 Pi 0.87.0+ 和 Node.js 22.19.0+。
25
25
 
26
26
  ```bash
27
27
  pi install npm:pi-openai-toolkit
28
28
  ```
29
29
 
30
- 在当前项目中安装扩展时加上 `--local`。Toolkit 策略仍然只使用全局配置,不支持项目配置或环境变量策略覆盖。
31
-
32
- 没有配置文件时,符合条件的模型启用远程压缩 v2,远程上下文窗口关闭,搜索不由 Toolkit 管理,图像生成关闭,自动模式不可用。这些默认值不表示后端能力已经验证。
33
-
34
- Toolkit 唯一的配置文件是:
30
+ 加上 `--local` 可安装到当前项目。无论安装位置如何,Toolkit 都使用全局配置:
35
31
 
36
32
  `~/.pi/agent/extensions/pi-openai-toolkit/config.json`
37
33
 
38
- 下文除 Pi 模型注册示例外,其他 JSON 示例均使用**配置格式 v2**。文件不存在时,创建文件及所需目录;已有 v2 配置时,将示例合并到对应对象并保留其他设置。无版本号的旧格式仍受支持,请勿直接加入 v2 字段,也不要用示例覆盖原文件。先运行 `/toolkit-config migration-preview`;[配置参考](docs/configuration.md)说明了审查和已安装版本检查要求。读取配置和预览迁移都不会改写源文件。
34
+ 文件不存在时,创建文件及所需目录。将下方 Toolkit 示例合并到已有的**配置格式 v2** 文件,不要覆盖其他设置。无版本号的旧配置请先运行 `/toolkit-config migration-preview`,详见[迁移指南](docs/configuration.md#legacy-compatibility-and-migration)。
35
+
36
+ 默认情况下,符合条件的模型启用远程压缩 v2;远程上下文窗口、生图和自动模式关闭,搜索不由 Toolkit 管理。使用相关功能仍需后端支持。
39
37
 
40
38
  ## 快速开始:启用远程上下文
41
39
 
42
- 本节适用于想使用 Codex 风格上下文窗口的用户。如果只需要搜索、生图或工具调用审查,请跳到[常见用法](#常见用法)。
40
+ 如果只需要搜索、生图或工具调用审查,可跳到[常见用法](#常见用法)。
43
41
 
44
42
  ### 使用 Pi 内置的 Codex 提供商
45
43
 
46
- 你需要先登录 Pi 内置的 `openai-codex` 提供商。创建或合并以下 v2 Toolkit 配置:
44
+ 先登录 Pi 的 `openai-codex` 提供商,再添加以下 Toolkit 配置:
47
45
 
48
46
  ```json
49
47
  {
@@ -54,19 +52,24 @@ Toolkit 唯一的配置文件是:
54
52
  }
55
53
  ```
56
54
 
57
- 使用已有 Codex 模型目录中的模型启动 Pi:
55
+ 使用 Codex 模型目录中的实际模型 ID 启动 Pi:
58
56
 
59
57
  ```bash
60
58
  pi --model openai-codex/<model-id>
61
59
  ```
62
60
 
63
- 将 `<model-id>` 换成 Pi 配置中实际显示的模型 ID。会话中出现 `new_context`、`get_context_remaining`、`history` 和 `notes`,说明已经激活;这不代表已完成后端往返验证。
61
+ 检查是否出现 `new_context`、`get_context_remaining`、`history` 和 `notes`。工具出现表示已激活,不代表后端兼容性已经验证。较早窗口可通过 `history` 检索,不会加载到每次请求中。
64
62
 
65
63
  ### 使用兼容网关
66
64
 
67
- 本路线要求使用 `openai-responses`,并且网关保留远程上下文所需的 Codex 协议字段。普通对话请求成功,不代表远程上下文兼容性已经验证。
65
+ 网关必须支持 `openai-responses` 和 Codex 远程上下文协议,仅支持普通对话还不够。
66
+
67
+ 如果模型已在 `~/.pi/agent/models.json` 注册,可跳过注册示例。
68
68
 
69
- 如果 `~/.pi/agent/models.json` 中已有兼容模型,可以跳过注册,直接设置下方 Toolkit 精确模型覆盖。否则,添加或合并下面的 Pi 提供商配置。请替换提供商名称、地址、环境变量和模型字段。示例数字不是项目默认值。
69
+ <details>
70
+ <summary>在 Pi 中注册网关模型</summary>
71
+
72
+ 将以下内容合并到 Pi 的模型文件。按实际情况替换提供商名称、URL、密钥变量、模型 ID 和限制值;其中的数字仅为示例。
70
73
 
71
74
  ```json
72
75
  {
@@ -88,19 +91,23 @@ pi --model openai-codex/<model-id>
88
91
  }
89
92
  ```
90
93
 
91
- 启动 Pi 前设置配置中引用的密钥。PowerShell 使用:
94
+ PowerShell 中设置密钥:
92
95
 
93
96
  ```powershell
94
97
  $env:MY_GATEWAY_KEY = "replace-with-your-gateway-key"
95
98
  ```
96
99
 
97
- POSIX shell 使用:
100
+ 或使用 POSIX shell:
98
101
 
99
102
  ```bash
100
103
  export MY_GATEWAY_KEY="replace-with-your-gateway-key"
101
104
  ```
102
105
 
103
- 使用同一个终端启动 Pi。在 Toolkit 配置中,确保键与已注册的提供商名称和模型 ID 完全一致:
106
+ 在同一个终端中启动 Pi。
107
+
108
+ </details>
109
+
110
+ 在 Toolkit 配置中填写已注册的精确 `提供商/模型 ID`:
104
111
 
105
112
  ```json
106
113
  {
@@ -118,45 +125,23 @@ export MY_GATEWAY_KEY="replace-with-your-gateway-key"
118
125
  pi --model my-gateway/gpt-5.6-luna
119
126
  ```
120
127
 
121
- 检查是否出现 `new_context`、`get_context_remaining`、`history` 和 `notes`。如果没有,查看通知与 `/toolkit-config`,再检查精确模型键、Pi API、认证和基础 URL。传输设置只是显式选择协议适配,不会注册模型,也不能证明后端支持。
122
-
123
- 较早窗口仍可通过 `history` 检索,但不会全部自动加入当前上下文。
128
+ 检查是否出现上面列出的上下文工具。如果没有,使用 `/toolkit-config`,并检查通知、模型键、API、认证和 URL。
124
129
 
125
130
  ## 常见用法
126
131
 
127
- ### 用 `/compact` 为托管会话保存检查点
132
+ ### 用 `/compact` 保存工作并切换窗口
128
133
 
129
- Remote Context 已激活时,运行 `/compact`,后面可附加检查点要求,即可把工作状态保存到 notes,并在同一 Pi 会话中进入一个新窗口。Toolkit 临时选择 `context.remoteCompaction.model`,等待新的 notes 检查点和窗口标记成功持久化,再在新窗口首次请求前恢复原模型及思考等级,并要求原模型先读取检查点回执再继续工作。
134
+ 远程上下文已激活时,运行 `/compact` 可将工作状态保存到 notes,再进入新窗口,不生成会话摘要。命令后可以附加要求。
130
135
 
131
- 要指定检查点模型,将以下内容合并到 v2 配置,并把模型引用替换为 Pi 中已有的模型:
136
+ 如需独立的检查点模型,在 `defaults` 或精确模型覆盖下设置 `context.remoteCompaction.model`。目标模型须在同一后端及账号下支持远程上下文;不设置则使用当前模型。Toolkit 会在继续工作前恢复原模型和思考等级。详见[上下文配置与要求](docs/configuration.md#context)。
132
137
 
133
- ```json
134
- {
135
- "schemaVersion": 2,
136
- "defaults": {
137
- "context": {
138
- "mode": "remote-windows",
139
- "remoteCompaction": { "model": "my-gateway/gpt-5.6-luna" }
140
- }
141
- }
142
- }
143
- ```
144
-
145
- 检查点模型也必须支持 Remote Context,使用相同后端及账号,有足够的上下文容量,并允许使用上下文工具。网关模型还需要单独配置精确的传输协议选项。未设置或为 `null` 时使用当前模型。若 Auto Mode 已启用,目标模型也必须符合其准入条件,否则交接会被拒绝。notes 或模型操作失败时,只要临时选择仍由 Toolkit 管理,就会恢复原模型;用户后来明确选择的模型优先。重载只恢复模型选择,不会自动重新发起推理。
146
-
147
- 该操作不生成会话摘要。Toolkit 会有意取消 Pi 原生压缩操作,再单独启动检查点交接,因此 SDK/RPC 调用方可能收到取消结果,同时看到检查点任务开始。空会话或刚压缩过的会话也可能在进入 Toolkit 钩子前就被 Pi 拒绝。自动阈值、溢出处理及内部旧窗口清理保持原有行为。详见[交接约定](docs/internals.md#managed-manual-compact)。
148
-
149
- ### 使用服务端压缩继续会话
150
-
151
- 使用 `context.mode: "remote-compaction"` 选择 Responses 压缩路径(默认值),或设为 `"pi"` 交回 Toolkit 的上下文管理权。只有需要独立模型生成检查点时才设置 `context.remoteCompaction.model`。这些字段放在 `defaults` 或精确的 `models` 覆盖下。
152
-
153
- `context.remoteCompaction.inputSource` 默认为 `"legacy"`:首次压缩使用 Pi 当前会话上下文,最后才回退到事件提供的数据;递归压缩使用原始分支尾部。这保留了既有行为,但当其他扩展改写消息时,可能与提供商实际看到的上下文不同。
138
+ ### 使用服务端压缩
154
139
 
155
- 主动选择 `"pi-context-hook"` 后,使用 Pi 有序上下文钩子投影。如果桥接不可用,压缩会取消,不会发送未投影的历史。检查点与来源绑定,切换来源后必须创建新检查点。详见[协议说明](docs/internals.md#remote-compaction-v2-wire-contract)。
140
+ 将 `context.mode` 设为 `"remote-compaction"`(默认值),使用加密的 Responses 检查点;设为 `"pi"` 则由 Pi 管理上下文。高级输入和回退选项见[配置参考](docs/configuration.md#context)。
156
141
 
157
142
  ### 选择联网搜索路由
158
143
 
159
- 设置全局默认值和精确模型覆盖:
144
+ 设置默认路由,并按模型覆盖:
160
145
 
161
146
  ```json
162
147
  {
@@ -172,16 +157,18 @@ Remote Context 已激活时,运行 `/compact`,后面可附加检查点要求
172
157
  }
173
158
  ```
174
159
 
175
- - `unmanaged` 释放 Toolkit 的管理权,不会关闭第三方搜索或所有网络访问。
176
- - `local` 保留本地 `pi-web-access` 工具原先的激活状态,并从提供商请求中移除冲突的托管或独立搜索工具。原先未激活的本地工具不会被自动激活。
177
- - `hosted` 用原生 Responses 搜索工具和来源标注替换本地 `web_search` 函数。
178
- - `standalone-alpha` 显式启用实验性的 CPA/Codex 网关搜索。它暴露顺序执行的 `web_run`,每次调用向提供商相对路径 `/alpha/search` 发送一次隔离请求,使用当前 Pi 模型及认证。网关必须实际支持该端点和能力,Toolkit 不会探测或替你开启。
160
+ | 路由 | 行为 |
161
+ | --- | --- |
162
+ | `unmanaged` | 由 Pi 和其他扩展管理搜索。 |
163
+ | `local` | 保留现有本地 `pi-web-access` 工具。 |
164
+ | `hosted` | 使用带来源标注的 Responses `web_search`。 |
165
+ | `standalone-alpha` | 在支持 `/alpha/search` 的 CPA/Codex 网关上使用实验性的 `web_run`。 |
179
166
 
180
- 精确覆盖优先于默认值。不支持模式匹配、猜测能力或路由间回退。选中策略无效时,会阻止受影响操作,不会改选其他路由。旧版托管模型列表仍可读取,但其宽松失败处理与 v2 显式 `hosted` 不完全相同;迁移预览会标出差异。
167
+ Toolkit 不会在路由之间自动回退。后端要求见[搜索配置](docs/configuration.md#web-search)。
181
168
 
182
169
  ### 生成图片
183
170
 
184
- 图像生成需要 Responses 会话,并且可能产生服务商费用。全局启用方式如下:
171
+ 需要 Responses 会话,并可能产生服务商费用。选择服务商支持的生图模型后启用:
185
172
 
186
173
  ```json
187
174
  {
@@ -190,19 +177,17 @@ Remote Context 已激活时,运行 `/compact`,后面可附加检查点要求
190
177
  "imageGeneration": {
191
178
  "enabled": true,
192
179
  "defaultModel": "gpt-image-2.5",
193
- "allowedModels": ["gpt-image-2.5", "grok-imagine-image-2.0"]
180
+ "allowedModels": ["gpt-image-2.5"]
194
181
  }
195
182
  }
196
183
  }
197
184
  ```
198
185
 
199
- 这里填写嵌套 Responses `image_generation` 工具使用的裸输出模型 ID。列表顺序不决定默认模型。`defaultModel` 必须属于非空的 `allowedModels` 列表,单次调用的可选 `model` 也必须在列表中。无效策略或不允许的选择会在认证、参考图上传及付费请求前被拒绝。提供商必须支持所选模型。
200
-
201
- `openai_generate_image` 支持文生图和使用明确传入的本地参考图进行编辑。生图策略不能按当前会话模型覆盖。
186
+ 让 Pi 通过 `openai_generate_image` 生成图片,或编辑明确提供的本地参考图。默认模型必须在 `allowedModels` 中;生图设置全局生效。详见[生图配置](docs/configuration.md#images)。
202
187
 
203
188
  ### 启用工具调用自动审查
204
189
 
205
- 为精确模型开放自动模式,并选择审查模型:
190
+ 为模型开放自动模式,并选择审查模型:
206
191
 
207
192
  ```json
208
193
  {
@@ -218,34 +203,19 @@ Remote Context 已激活时,运行 `/compact`,后面可附加检查点要求
218
203
  }
219
204
  ```
220
205
 
221
- `available` 表示允许开启,使用 `/auto on` 或 `--auto` 才会实际开启;`/auto off` 明确关闭。默认 `side-effect` 范围覆盖 `bash`、`write`、`edit` 和额外配置的工具;`gate: "all"` 审查所有工具调用。审查超时不会自动放行。运行中配置变为无效时,已开启的门禁继续阻止调用,直到修正配置或明确关闭。
222
-
223
- TUI 会显示开启提示、审查活动和底部状态。兼容的 Pi 工具渲染器还会显示每次调用的允许、拒绝、阻止或未审查状态。如果渲染接口不可用,Toolkit 告警一次并保留底部显示。审查器、分类器和熔断器的高级配置见[配置参考](docs/configuration.md)。
206
+ 使用 `/auto on` 或 `--auto` 开启,`/auto off` 关闭。默认审查 `bash`、`write`、`edit` 和额外配置的工具;`gate: "all"` 审查所有调用。审查超时不会自动放行。详见[自动模式配置](docs/configuration.md#auto-mode)。
224
207
 
225
208
  ## 常用配置
226
209
 
227
- 全局文件按内置值、`defaults`、精确 `models["provider/model-id"]` 的顺序解析。缺失字段继承,数组整体替换,`false` 和 `0` 保持含义。支持的可选模型引用可以用 `null` 清除继承。默认值不是总开关:精确覆盖可以开启默认关闭的功能。
228
-
229
- | 配置项 | 默认值 | 用途 |
230
- | --- | --- | --- |
231
- | `defaults.context.mode` | `"remote-compaction"` | `"pi"`、`"remote-compaction"` 或 `"remote-windows"`。 |
232
- | `models[exact].compatibility.transport` | `"standard"` | 用 `"codex-gateway"` 显式选择网关协议。 |
233
- | `defaults.context.remoteCompaction.model` | `null` | 检查点生成模型,或 `remote-windows` 下手动检查点任务使用的模型。 |
234
- | `defaults.context.remoteCompaction.inputSource` | `"legacy"` | 与检查点来源绑定的输入策略。 |
235
- | `defaults.context.remoteWindows.reminderThresholdPercent` | `5` | `0` 关闭提醒和窗口耗尽兜底。 |
236
- | `defaults.webSearch.route` | `"unmanaged"` | Toolkit 搜索管理策略。 |
237
- | `defaults.imageGeneration.enabled` | `false` | 全局生图开关。 |
238
- | `defaults.imageGeneration.defaultModel` | `"gpt-image-2.5"` | 明确指定默认输出模型。 |
239
- | `defaults.imageGeneration.allowedModels` | `["gpt-image-2.5"]` | 允许的输出模型,仅支持全局设置。 |
240
- | `defaults.autoMode.available` | `false` | 是否允许开启工具审查。 |
241
- | `defaults.autoMode.gate` | `"side-effect"` | 指定副作用工具,或 `"all"`。 |
242
- | `defaults.autoMode.timeoutMs` | `30000` | 审查超时,单位为毫秒。 |
243
- | `diagnostics.level` | `"info"` | `"debug"` 开启调试产物。 |
244
- | `diagnostics.captureRequests` / `captureResponses` | `false` | 分别开启请求或压缩响应捕获。 |
245
-
246
- 使用 `/toolkit-config` 查看有效值及来源,`/toolkit-config validate` 查看文档问题,`/toolkit-config migration-preview` 预览只读的旧格式迁移候选。这些命令需要 UI,不查询网络或认证,不激活工具,也不写文件。选中配置不表示后端支持已验证。
247
-
248
- 未知 v2 策略键和畸形值会产生可见的分范围错误,不依赖调试模式。每个公开回调或工具执行及其等待的辅助操作使用同一份不可变快照,后续独立操作重新读取文件;托管 `/compact` 交接会保留发起时的上下文策略快照,直到模型恢复。独立 Pi 事件之间不保证通用的原子事务。完整选项见[配置参考](docs/configuration.md)和[编辑器 schema](config.schema.json)。
210
+ `defaults` 设置通用值,`models["provider/model-id"]` 按精确模型覆盖。Toolkit 只读取全局配置文件。
211
+
212
+ | 命令 | 用途 |
213
+ | --- | --- |
214
+ | `/toolkit-config` | 查看生效设置及其来源。 |
215
+ | `/toolkit-config validate` | 检查配置错误。 |
216
+ | `/toolkit-config migration-preview` | 预览旧格式到 v2 的迁移,不写入文件。 |
217
+
218
+ 这些命令需要 Pi 交互界面。完整说明见[配置参考](docs/configuration.md)、[编辑器 schema](config.schema.json)和[实现细节](docs/internals.md)。
249
219
 
250
220
  ## 开发
251
221
 
package/docs/internals.md CHANGED
@@ -76,7 +76,7 @@ State invariants the lifecycle code must keep honest:
76
76
  - The gate uses the live session/branch identity from `ctx.sessionManager`; cached manager identity must not hide a valid current-window pair, and old or foreign-session evidence must remain rejected.
77
77
  - Every rollover carries the most recent successful checkpoint path in its handoff message. The optional `thread_hint` is supplemental; a hint failure must not erase the local receipt.
78
78
  - A successful rollover closes the pre-rollover checkpoint phase. The first turn in the new window reads the receipt once and resumes the active user task; it must not immediately create another checkpoint or call `new_context` as part of that handoff. A later checkpoint is only for a later rollover, and `canRolloverFromCurrentWindow()` enforces it: a window entered by a rollover has to produce a tool result from a tool that is not one of `new_context`, `get_context_remaining`, `history`, or `notes` before it can roll over again.
79
- - Pi 0.86 sends the system prompt and the entire tool loadout as `system` messages inside the transcript: `Agent#createContextSnapshot()` passes messages only, and the Responses transport rebuilds `params.tools` from the leading system message. Trimming everything before a window boundary therefore deletes the model's only tool declaration, and Pi never re-declares it because the persisted transcript still looks unchanged. `preserveSystemHead()` re-anchors the merged pre-boundary state as the leading message of every trimmed window.
79
+ - Pi 0.87 separates conversation-only `context` handlers from full-transcript `context_with_system` handlers. Toolkit uses the latter to trim windows and re-anchor the merged prompt/tool state with `preserveSystemHead()`. The output owns its leading system message; Pi does not repair it after this phase.
80
80
  - When even that rebuild would lose declared tools, the trim is refused and the full message list is sent: a window that still carries the previous turns is usable, a window with no tools silently degrades into a chat the model can only narrate. Each refusal is reported once per window and lost-tool set through `takeProjectionDiagnostics()`, which writes a `context.projection.tool_loadout_shrink` compaction artifact and warns in the UI.
81
81
  - The manager also remembers the head it last anchored. When a boundary compaction retires the transcript entry that declared the tools, the branch itself no longer contains a system message, and the remembered head is re-anchored instead of sending a tool-less window. The cache is request-time only, refreshed by every projection, and cleared by `reset()`.
82
82
  - `new_context` has no force escape hatch: a persisted successful notes checkpoint in the current window is always required before rollover. The model must wait for the notes result to be persisted before retrying.
@@ -98,7 +98,7 @@ State invariants the lifecycle code must keep honest:
98
98
  2. The matching `session_compact_failed` callback launches duty only after Pi is idle. Public `setModel` changes the session selection, and a visible `sendUserMessage` rebuilds the target's normal prompt/tools. Delivery is observed through `message_start`; return from the fire-and-forget send API is not proof. A delivery timeout restores an abandoned idle selection.
99
99
  3. The existing `new_context` gate additionally requires a successful paired notes result after this operation and any later delivered user message. It records the exact target window before queueing a context-only marker, and returns a terminating result. Ordinary tools are blocked during checkpoint duty. Normal `new_context` outside this operation is unchanged.
100
100
  4. A terminating result alone does not stop mixed batches or queued messages. At `turn_end`, Toolkit requests abort without awaiting idle; context/request guards also refuse an unsafe continuation. The standard Responses and native Codex transports must not send an already-aborted continuation. Pi can deliver queued user content during that stop; it remains persisted and ordered for the original model.
101
- 5. At `agent_settled`, after Pi has flushed custom messages, the controller verifies the exact durable marker and fresh checkpoint, restores original model/thinking, and sends one visible receipt-reading continuation. Restoring inside marker `message_end` would be too early for persistence and too late for a model snapshot already captured by `prepareNextTurn`.
101
+ 5. At `agent_settled`, after Pi has flushed custom messages, the controller verifies the exact durable marker and fresh checkpoint, restores original model/thinking, and sends one visible receipt-reading continuation. Pi 0.87 defers that continuation until every settled handler has returned, so later observers still see an idle session. Restoration finishes before the next provider request; marker `message_end` alone is not proof of persistence.
102
102
 
103
103
  Versioned `pi-openai-toolkit:manual-compact` custom entries record operation/session/branch/window identities, exact model keys, original thinking and phase. They contain no credentials, transcript copy or note contents. Reload/navigation reads the current branch and only recovers still-owned selection; it never retries notes, rollover or paid inference. A newer user model/thinking selection ends ownership. Failed restoration stays durable and blocks further provider requests until recovery or an explicit user selection.
104
104
 
@@ -118,9 +118,11 @@ The incremental SSE consumer validates the first terminal event and reconciles i
118
118
 
119
119
  A v2 response with a missing or empty checkpoint is never stored, and the `nativeFallback` tier is skipped. Pi's own threshold drives the next attempt, which may use `remoteCompactModel` when configured. `remoteCompactModel` must resolve to the same effective base URL as the active model.
120
120
 
121
- Pi 0.85.1's `session_before_compact` event supplies preparation and branch data, while the direct Remote V2 client can bypass the provider `context` hook chain. To preserve compatibility, an omitted `context.remoteCompaction.inputSource` keeps the original `"legacy"` behavior: first compaction uses `buildSessionContext()` and falls back to `SessionBeforeCompactEvent.preparation`, while recursion uses the opaque window plus the raw branch tail.
121
+ Pi 0.87 makes `SessionManager` the source of model-visible context. An omitted `context.remoteCompaction.inputSource` keeps `"legacy"` behavior: first compaction reads `buildSessionProjection()` (falling back to Pi's preparation if unavailable), and recursion uses the opaque window plus the canonically projected branch tail. Both paths honor persisted context edits. Legacy mode still bypasses request-time extension transforms.
122
122
 
123
- Setting `context.remoteCompaction.inputSource: "pi-context-hook"` opts into a narrow runtime bridge around Pi's public `ExtensionRunner.createContext()`. It adds a non-enumerable `ctx.projectContextForCompaction(messages)` method backed by `ExtensionRunner.emitContext()`. In this opt-in mode, Remote V2 first reads `buildSessionContext()` and then runs the ordered projection. If the bridge, session context, or recursive summary anchor is unavailable, the extension cancels rather than sending an unprojected history. The legacy mode is an intentional compatibility trade-off and does not claim parity with provider-visible context hooks.
123
+ Setting `context.remoteCompaction.inputSource: "pi-context-hook"` opts into a narrow runtime bridge around Pi's public `ExtensionRunner.createContext()`. It adds a non-enumerable `ctx.projectContextForCompaction(messages)` method backed by `ExtensionRunner.emitContext()`. This runs the ordered conversation-only and full-transcript phases on the canonical projection. If the bridge, session projection, or recursive summary anchor is unavailable, the extension cancels rather than sending unprojected history.
124
+
125
+ Retained-message matching uses the SDK projection and source entry IDs, including omissions and replacements made before compaction. Older retained compaction entries contribute no second summary. A compaction whose kept boundary is its own ID retains no earlier messages. Edits to post-checkpoint input are applied to the live tail; an edit targeting history already sealed in an opaque checkpoint blocks replay and recursive compaction with `checkpoint-context-edited`. The encrypted checkpoint cannot be selectively rewritten. Start a new session or navigate before that checkpoint to rebuild from editable history; the raw log remains unchanged.
124
126
 
125
127
  New checkpoints record `inputProvenance: "pi-context-hook-v1"` or `"legacy-raw-context-v1"`, and replay/recursion reject missing or mode-mismatched markers without searching past the latest compaction. Retained `role: "custom"` messages are optional during replay because they may be changed or removed by context hooks; required user, assistant, and complete tool-call/result content remains ordered and fail-closed.
126
128
 
@@ -130,7 +132,7 @@ New checkpoints record `inputProvenance: "pi-context-hook-v1"` or `"legacy-raw-c
130
132
 
131
133
  Approval state is scoped to actual user-message delivery. Full structured user content is SHA-256 hashed independently of transcript truncation, and generation-owned classifier completions cannot overwrite newer samples. The bounded transcript selects recent user instructions first and reports omissions. The blocking reviewer and its read-only evidence loop share one deadline that also races uncooperative dependencies; timeout/cancellation is an unavailable review, never approval or a safety denial.
132
134
 
133
- Auto Mode owns the review decision, but Pi owns the built-in tool-block component. Pi 0.85.1 exports `ToolExecutionComponent` without a public decorator interface, so `src/auto-mode/tool-review-tui.ts` installs a narrow, idempotent compatibility patch on its `render()` method. The patch only reads the component's existing tool-call ID and appends one bounded, single-line status after the normal block output; it never changes tool execution, event ordering, or provider payloads.
135
+ Auto Mode owns the review decision, but Pi owns the built-in tool-block component. Pi 0.87 exports `ToolExecutionComponent` without a public decorator interface, so `src/auto-mode/tool-review-tui.ts` installs a narrow, idempotent compatibility patch on its `render()` method. The patch only reads the component's existing tool-call ID and appends one bounded, single-line status after the normal block output; it never changes tool execution, event ordering, or provider payloads.
134
136
 
135
137
  The extension updates an ephemeral per-call state through the same `tool_call` lifecycle that performs the review:
136
138
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-openai-toolkit",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -120,16 +120,16 @@
120
120
  "typecheck": "tsc -p tsconfig.check.json"
121
121
  },
122
122
  "peerDependencies": {
123
- "@earendil-works/pi-agent-core": ">=0.85.1",
124
- "@earendil-works/pi-ai": ">=0.85.1",
125
- "@earendil-works/pi-coding-agent": ">=0.85.1",
126
- "@earendil-works/pi-tui": ">=0.85.1"
123
+ "@earendil-works/pi-agent-core": ">=0.87.0",
124
+ "@earendil-works/pi-ai": ">=0.87.0",
125
+ "@earendil-works/pi-coding-agent": ">=0.87.0",
126
+ "@earendil-works/pi-tui": ">=0.87.0"
127
127
  },
128
128
  "devDependencies": {
129
- "@earendil-works/pi-agent-core": "0.86.0",
130
- "@earendil-works/pi-ai": "0.86.0",
131
- "@earendil-works/pi-coding-agent": "0.86.0",
132
- "@earendil-works/pi-tui": "0.86.0",
129
+ "@earendil-works/pi-agent-core": "0.87.0",
130
+ "@earendil-works/pi-ai": "0.87.0",
131
+ "@earendil-works/pi-coding-agent": "0.87.0",
132
+ "@earendil-works/pi-tui": "0.87.0",
133
133
  "@types/node": "24.12.4",
134
134
  "bun": "1.2.23",
135
135
  "typescript": "5.9.3"
@@ -1,31 +1,35 @@
1
- import type {
2
- NativeCompactionDetails,
3
- NativeCompactionInputProvenance,
4
- } from "./types";
5
-
6
- /** The configured Pi context-hook projector is unavailable or failed. */
7
- export const COMPACTION_PROJECTION_UNAVAILABLE = "no-supported-projection-seam" as const;
8
- /** The current session context could not be read before projection. */
9
- export const COMPACTION_SESSION_CONTEXT_UNAVAILABLE = "session-context-unavailable" as const;
10
- /** A projected recursive context did not contain the current compaction summary. */
11
- export const COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE = "projected-compaction-context-unavailable" as const;
12
- /** The latest opaque checkpoint was created with no or a different input marker. */
13
- export const COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE = "unverified-compaction-checkpoint" as const;
14
-
15
- export type UnprojectedCompactionReason =
16
- | typeof COMPACTION_PROJECTION_UNAVAILABLE
17
- | typeof COMPACTION_SESSION_CONTEXT_UNAVAILABLE
18
- | typeof COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE
19
- | typeof COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE;
20
-
21
- /**
22
- * A Remote V2 checkpoint is safe for the current replay chain only when its
23
- * marker matches the configured input source. Structural persistence validation
24
- * intentionally remains more permissive for old session files.
25
- */
26
- export function hasVerifiedCompactionInputProvenance(
27
- details: Pick<NativeCompactionDetails, "inputProvenance"> | undefined,
28
- expected: NativeCompactionInputProvenance,
29
- ): boolean {
30
- return details?.inputProvenance === expected;
31
- }
1
+ import type {
2
+ NativeCompactionDetails,
3
+ NativeCompactionInputProvenance,
4
+ } from "./types";
5
+
6
+ /** The configured Pi context-hook projector is unavailable or failed. */
7
+ export const COMPACTION_PROJECTION_UNAVAILABLE = "no-supported-projection-seam" as const;
8
+ /** The current session context could not be read before projection. */
9
+ export const COMPACTION_SESSION_CONTEXT_UNAVAILABLE = "session-context-unavailable" as const;
10
+ /** A projected recursive context did not contain the current compaction summary. */
11
+ export const COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE = "projected-compaction-context-unavailable" as const;
12
+ /** The latest opaque checkpoint was created with no or a different input marker. */
13
+ export const COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE = "unverified-compaction-checkpoint" as const;
14
+
15
+ /** Persisted edits changed input already sealed inside an opaque checkpoint. */
16
+ export const COMPACTION_CHECKPOINT_CONTEXT_EDITED = "checkpoint-context-edited" as const;
17
+
18
+ export type UnprojectedCompactionReason =
19
+ | typeof COMPACTION_CHECKPOINT_CONTEXT_EDITED
20
+ | typeof COMPACTION_PROJECTION_UNAVAILABLE
21
+ | typeof COMPACTION_SESSION_CONTEXT_UNAVAILABLE
22
+ | typeof COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE
23
+ | typeof COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE;
24
+
25
+ /**
26
+ * A Remote V2 checkpoint is safe for the current replay chain only when its
27
+ * marker matches the configured input source. Structural persistence validation
28
+ * intentionally remains more permissive for old session files.
29
+ */
30
+ export function hasVerifiedCompactionInputProvenance(
31
+ details: Pick<NativeCompactionDetails, "inputProvenance"> | undefined,
32
+ expected: NativeCompactionInputProvenance,
33
+ ): boolean {
34
+ return details?.inputProvenance === expected;
35
+ }
@@ -1,7 +1,7 @@
1
1
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
2
  import type {
3
3
  BeforeProviderRequestEvent,
4
- ContextEvent,
4
+ ContextWithSystemEvent,
5
5
  CompactionResult,
6
6
  ExtensionAPI,
7
7
  ExtensionContext,
@@ -33,6 +33,7 @@ import {
33
33
  import { writeDebugArtifact, writeReplayFailureArtifact } from "./debug";
34
34
  import {
35
35
  COMPACTION_CHECKPOINT_PROVENANCE_UNAVAILABLE,
36
+ COMPACTION_CHECKPOINT_CONTEXT_EDITED,
36
37
  COMPACTION_PROJECTION_UNAVAILABLE,
37
38
  COMPACTION_SESSION_CONTEXT_UNAVAILABLE,
38
39
  COMPACTION_PROJECTED_CONTEXT_UNAVAILABLE,
@@ -50,6 +51,7 @@ import {
50
51
  import { runNativeFallbackCompaction } from "./native-fallback";
51
52
  import {
52
53
  rewriteResponsesPayloadWithNativeReplay,
54
+ hasInvalidatedCompactionContext,
53
55
  removeNativeCompactionRetainedMessages,
54
56
  serializeLiveTailToResponsesInput,
55
57
  } from "./payload-rewrite";
@@ -260,22 +262,11 @@ function findLatestProjectedCompactionSummaryIndex(
260
262
  return -1;
261
263
  }
262
264
 
263
- type SessionManagerWithOptionalContext = ExtensionContext["sessionManager"] & {
264
- buildSessionContext?: () => { messages?: readonly AgentMessage[] };
265
- };
266
-
267
265
  function readSessionContextMessages(ctx: ExtensionContext): AgentMessage[] | undefined {
268
- const sessionManager = ctx.sessionManager as SessionManagerWithOptionalContext;
269
- if (typeof sessionManager.buildSessionContext !== "function") {
270
- return undefined;
271
- }
272
-
273
266
  try {
274
- const sessionContext = sessionManager.buildSessionContext();
275
- if (!sessionContext || !Array.isArray(sessionContext.messages)) {
276
- return undefined;
277
- }
278
- return structuredClone([...sessionContext.messages]);
267
+ const projection = ctx.sessionManager.buildSessionProjection();
268
+ if (!projection || !Array.isArray(projection.messages)) return undefined;
269
+ return structuredClone(projection.messages);
279
270
  } catch {
280
271
  return undefined;
281
272
  }
@@ -339,6 +330,9 @@ async function runResponsesNativeCompact(
339
330
  let requestSource: "session-context" | "non-native-session-context" | "latest-native-replay";
340
331
  let request: NativeCompactionRequestBody;
341
332
  if (latestNativeCompaction.ok) {
333
+ if (hasInvalidatedCompactionContext(branchEntries, latestNativeCompaction.entry.id)) {
334
+ return { outcome: "unprojected-input", reason: COMPACTION_CHECKPOINT_CONTEXT_EDITED };
335
+ }
342
336
  const details = latestNativeCompaction.entry.details;
343
337
  if (!details) {
344
338
  return { outcome: "failed" };
@@ -658,7 +652,9 @@ async function handleSessionBeforeCompact(
658
652
  return { cancel: true };
659
653
  }
660
654
  if (responsesOutcome.outcome === "unprojected-input") {
661
- const message = responsesOutcome.reason === COMPACTION_PROJECTION_UNAVAILABLE
655
+ const message = responsesOutcome.reason === COMPACTION_CHECKPOINT_CONTEXT_EDITED
656
+ ? "A context edit changed history sealed in the opaque checkpoint. Start a new session or navigate before that checkpoint; its encrypted content cannot be selectively rewritten."
657
+ : responsesOutcome.reason === COMPACTION_PROJECTION_UNAVAILABLE
662
658
  ? "Pi's ordered context-hook projection is unavailable; Remote V2 was not sent raw session history."
663
659
  : responsesOutcome.reason === COMPACTION_SESSION_CONTEXT_UNAVAILABLE
664
660
  ? "Pi did not provide the current session context required by its context-hook projection."
@@ -680,7 +676,7 @@ async function handleSessionBeforeCompact(
680
676
  );
681
677
  notifyWarning(
682
678
  ctx,
683
- `Remote V2 compaction cancelled (${responsesOutcome.reason}); ${message} Keep the context-source setting aligned with the checkpoint or choose legacy mode explicitly.`,
679
+ `Remote V2 compaction cancelled (${responsesOutcome.reason}); ${message} The existing checkpoint and session history were left unchanged.`,
684
680
  );
685
681
  return { cancel: true };
686
682
  }
@@ -850,7 +846,7 @@ async function handleWindowBulk(
850
846
  }
851
847
 
852
848
  async function handleContextInternal(
853
- event: ContextEvent,
849
+ event: ContextWithSystemEvent,
854
850
  ctx: ExtensionContext,
855
851
  pi: ExtensionAPI,
856
852
  loadConfig: typeof loadToolkitConfig,
@@ -953,7 +949,7 @@ async function handleContextInternal(
953
949
  * caught exception for a successful projection.
954
950
  */
955
951
  async function handleContext(
956
- event: ContextEvent,
952
+ event: ContextWithSystemEvent,
957
953
  ctx: ExtensionContext,
958
954
  pi: ExtensionAPI,
959
955
  loadConfig: typeof loadToolkitConfig,
@@ -1308,7 +1304,7 @@ export default function registerCompactionExtension(
1308
1304
  }
1309
1305
  });
1310
1306
 
1311
- pi.on("context", (event, ctx) => {
1307
+ pi.on("context_with_system", (event, ctx) => {
1312
1308
  manualCompact.guardRequest(ctx);
1313
1309
  return handleContext(event, ctx, pi, dependencies.loadConfig, contextWindows, remoteContextActive);
1314
1310
  });
@@ -1,12 +1,7 @@
1
- import { sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
1
+ import { buildSessionProjection, sessionEntryToContextMessages } from "@earendil-works/pi-coding-agent";
2
2
  import type { AgentMessage } from "@earendil-works/pi-agent-core";
3
3
  import type { Api, Model } from "@earendil-works/pi-ai";
4
- import type {
5
- BranchSummaryEntry,
6
- CustomMessageEntry,
7
- SessionEntry,
8
- SessionMessageEntry,
9
- } from "@earendil-works/pi-coding-agent";
4
+ import type { SessionEntry } from "@earendil-works/pi-coding-agent";
10
5
  import type { ResponsesCompatibleRequestPayload } from "./runtime";
11
6
  import { hasVerifiedCompactionInputProvenance } from "./compaction-projection";
12
7
  import { rewritePayloadWithDeferredToolCarryover } from "./deferred-tool-carryover";
@@ -40,7 +35,8 @@ export type NativeReplayPayloadRewriteFailureReason =
40
35
  | "unexpected-compaction-after-boundary"
41
36
  | "compaction-summary-not-found"
42
37
  | "retained-context-mismatch"
43
- | "unverified-compaction-input";
38
+ | "unverified-compaction-input"
39
+ | "checkpoint-context-edited";
44
40
 
45
41
  export type NativeReplayPayloadRewriteFailure = {
46
42
  ok: false;
@@ -200,46 +196,6 @@ function areEquivalentValues(left: unknown, right: unknown): boolean {
200
196
  return false;
201
197
  }
202
198
 
203
- function toBranchSummaryMessage(entry: BranchSummaryEntry): AgentMessage {
204
- return {
205
- role: "branchSummary",
206
- summary: entry.summary,
207
- fromId: entry.fromId,
208
- timestamp: new Date(entry.timestamp).getTime(),
209
- } as AgentMessage;
210
- }
211
-
212
- function toCustomMessage(entry: CustomMessageEntry): AgentMessage {
213
- return {
214
- role: "custom",
215
- customType: entry.customType,
216
- content: entry.content,
217
- display: entry.display,
218
- details: entry.details,
219
- timestamp: new Date(entry.timestamp).getTime(),
220
- } as AgentMessage;
221
- }
222
-
223
- function toSessionMessage(entry: SessionMessageEntry): AgentMessage {
224
- return entry.message;
225
- }
226
-
227
- function toReplayAgentMessage(entry: SessionEntry): AgentMessage | undefined {
228
- if (entry.type === "message") {
229
- return toSessionMessage(entry);
230
- }
231
-
232
- if (entry.type === "custom_message") {
233
- return toCustomMessage(entry);
234
- }
235
-
236
- if (entry.type === "branch_summary") {
237
- return toBranchSummaryMessage(entry);
238
- }
239
-
240
- return undefined;
241
- }
242
-
243
199
  function extractUserItemText(item: Record<string, unknown>): unknown {
244
200
  const { content } = item;
245
201
  if (typeof content === "string") {
@@ -273,23 +229,29 @@ function findCompactionSummaryIndex(input: readonly unknown[], summaryMarker: st
273
229
  }
274
230
 
275
231
  function collectReplayMessages(entries: readonly SessionEntry[]): AgentMessage[] {
276
- const messages: AgentMessage[] = [];
277
-
278
- for (const entry of entries) {
279
- const message = toReplayAgentMessage(entry);
280
- if (message) {
281
- messages.push(message);
282
- }
283
- }
232
+ return buildSessionProjection([...entries]).messages;
233
+ }
284
234
 
285
- return messages;
235
+ /** An opaque checkpoint cannot be selectively edited after it was produced. */
236
+ export function hasInvalidatedCompactionContext(
237
+ entries: readonly SessionEntry[],
238
+ compactionEntryId: string,
239
+ ): boolean {
240
+ const boundary = findCompactionBoundaryIndex(entries, compactionEntryId);
241
+ if (boundary === undefined) return false;
242
+ const coveredIds = new Set(entries.slice(0, boundary).map((entry) => entry.id));
243
+ return entries.slice(boundary + 1).some((entry) =>
244
+ entry.type === "context_edit" && coveredIds.has(entry.targetId),
245
+ );
286
246
  }
287
247
 
288
- function findEntryIndexByIdBeforeBoundary(
248
+ function findKeptEntryIndex(
289
249
  entries: readonly SessionEntry[],
290
250
  entryId: string,
291
251
  boundaryIndex: number,
292
252
  ): number | undefined {
253
+ // Pi 0.87 uses the compaction itself as the retained boundary for retain-none.
254
+ if (entries[boundaryIndex]?.id === entryId) return boundaryIndex;
293
255
  const index = entries.findIndex((entry, candidateIndex) => candidateIndex < boundaryIndex && entry.id === entryId);
294
256
  return index >= 0 ? index : undefined;
295
257
  }
@@ -319,7 +281,7 @@ export function collectLiveTailMessages(entries: readonly SessionEntry[]): Agent
319
281
  }
320
282
 
321
283
  /**
322
- * Remote V2 covers the complete pre-compaction context. Pi 0.85.1 also retains
284
+ * Remote V2 covers the complete pre-compaction context. Pi also retains
323
285
  * recent messages, so remove those verified copies before provider conversion
324
286
  * (where IDs/fields may change across models). Persisted session entries are
325
287
  * untouched. Preserve transient additions and all post-compaction messages.
@@ -335,7 +297,10 @@ export function removeNativeCompactionRetainedMessages(args: {
335
297
  if (!hasVerifiedCompactionInputProvenance(args.compactionEntry.details, args.expectedInputProvenance)) {
336
298
  return { ok: false, reason: "unverified-compaction-input" };
337
299
  }
338
- const firstKept = findEntryIndexByIdBeforeBoundary(args.branchEntries, args.compactionEntry.firstKeptEntryId, boundary);
300
+ if (hasInvalidatedCompactionContext(args.branchEntries, args.compactionEntry.id)) {
301
+ return { ok: false, reason: "checkpoint-context-edited" };
302
+ }
303
+ const firstKept = findKeptEntryIndex(args.branchEntries, args.compactionEntry.firstKeptEntryId, boundary);
339
304
  if (firstKept === undefined) return { ok: false, reason: "first-kept-entry-not-found" };
340
305
  const summaryMessage = sessionEntryToContextMessages(args.compactionEntry).find(
341
306
  (message) => message.role === "compactionSummary",
@@ -344,9 +309,10 @@ export function removeNativeCompactionRetainedMessages(args: {
344
309
  const summary = structuredClone(summaryMessage);
345
310
  const summaryIndex = args.messages.findIndex((message) => areEquivalentValues(message, summary));
346
311
  if (summaryIndex < 0) return { ok: false, reason: "compaction-summary-not-found" };
347
- const retained = args.branchEntries
348
- .slice(firstKept, boundary)
349
- .flatMap(sessionEntryToContextMessages)
312
+ const retainedIds = new Set(args.branchEntries.slice(firstKept, boundary).map((entry) => entry.id));
313
+ const retained = buildSessionProjection([...args.branchEntries]).entries
314
+ .filter((entry) => retainedIds.has(entry.sourceEntry.id))
315
+ .flatMap((entry) => entry.messages)
350
316
  .map((message) => structuredClone(message));
351
317
  // Custom messages are optional context-hook entries: a hook may filter or
352
318
  // rewrite them before provider serialization. An exact custom copy is still
@@ -418,7 +384,11 @@ function buildNativeReplaySegmentsInternal<TApi extends Api>(args: {
418
384
  };
419
385
  }
420
386
 
421
- const firstKeptEntryIndex = findEntryIndexByIdBeforeBoundary(
387
+ if (hasInvalidatedCompactionContext(args.branchEntries, args.compactionEntry.id)) {
388
+ return { ok: false, reason: "checkpoint-context-edited" };
389
+ }
390
+
391
+ const firstKeptEntryIndex = findKeptEntryIndex(
422
392
  args.branchEntries,
423
393
  args.compactionEntry.firstKeptEntryId,
424
394
  boundaryIndex,
@@ -1,116 +1,117 @@
1
- import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
- import { ExtensionRunner, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
-
4
- /** Context property added by the toolkit's host patch. */
5
- export const PI_CONTEXT_PROJECTOR_FIELD = "projectContextForCompaction" as const;
6
- const PI_CONTEXT_PROJECTION_REPORTER_FIELD = "__piOpenAiToolkitReportContextProjectionFailure" as const;
7
- const PATCH_MARKER_FIELD = "__piOpenAiToolkitContextHookPatch" as const;
8
-
9
- type ProjectionState = {
10
- failure?: string;
11
- };
12
-
13
- const activeProjectionByRunner = new WeakMap<ExtensionRunner, ProjectionState>();
14
-
15
- type PatchedExtensionContext = ExtensionContext & {
16
- [PI_CONTEXT_PROJECTOR_FIELD]?: PiContextHookProjector;
17
- [PI_CONTEXT_PROJECTION_REPORTER_FIELD]?: (reason: string) => void;
18
- };
19
-
20
- type MarkedCreateContext = typeof ExtensionRunner.prototype.createContext & {
21
- [PATCH_MARKER_FIELD]?: true;
22
- };
23
-
24
- /** The ordered context-hook chain, applied to a cloned compaction context. */
25
- export type PiContextHookProjector = (
26
- messages: readonly AgentMessage[],
27
- ) => Promise<readonly AgentMessage[]>;
28
-
29
- export type PiContextHookPatchResult =
30
- | { ok: true; installed: boolean; reason?: "already-installed" }
31
- | { ok: false; reason: "emit-context-unavailable" | "create-context-unavailable" };
32
-
33
- /**
34
- * Add a narrow projection method to every ExtensionContext created by Pi.
35
- *
36
- * Pi 0.85.1 already has the ordered `ExtensionRunner.emitContext()` method,
37
- * but the event-level ExtensionContext does not expose the runner. The patch
38
- * bridges only the method needed by Remote V2; it does not expose the runner
39
- * or alter Pi's scheduler.
40
- */
41
- export function installPiContextHookPatch(): PiContextHookPatchResult {
42
- const prototype = ExtensionRunner.prototype;
43
- if (typeof prototype.emitContext !== "function") {
44
- return { ok: false, reason: "emit-context-unavailable" };
45
- }
46
-
47
- const original = prototype.createContext;
48
- if (typeof original !== "function") {
49
- return { ok: false, reason: "create-context-unavailable" };
50
- }
51
-
52
- const markedOriginal = original as MarkedCreateContext;
53
- if (markedOriginal[PATCH_MARKER_FIELD] === true) {
54
- return { ok: true, installed: false, reason: "already-installed" };
55
- }
56
-
57
- const patched = function patchedCreateContext(this: ExtensionRunner): ExtensionContext {
58
- const context = original.call(this) as PatchedExtensionContext;
59
- if (!(PI_CONTEXT_PROJECTOR_FIELD in context)) {
60
- Object.defineProperty(context, PI_CONTEXT_PROJECTOR_FIELD, {
61
- configurable: false,
62
- enumerable: false,
63
- writable: false,
64
- value: async (messages: readonly AgentMessage[]) => {
65
- const state: ProjectionState = {};
66
- activeProjectionByRunner.set(this, state);
67
- try {
68
- const projected = await this.emitContext([...messages]);
69
- if (state.failure) {
70
- throw new Error(`context projection failed: ${state.failure}`);
71
- }
72
- return projected;
73
- } finally {
74
- if (activeProjectionByRunner.get(this) === state) {
75
- activeProjectionByRunner.delete(this);
76
- }
77
- }
78
- },
79
- });
80
- }
81
- if (!(PI_CONTEXT_PROJECTION_REPORTER_FIELD in context)) {
82
- Object.defineProperty(context, PI_CONTEXT_PROJECTION_REPORTER_FIELD, {
83
- configurable: false,
84
- enumerable: false,
85
- writable: false,
86
- value: (reason: string) => {
87
- const state = activeProjectionByRunner.get(this);
88
- if (state) state.failure = reason;
89
- },
90
- });
91
- }
92
- return context;
93
- };
94
-
95
- Object.defineProperty(patched, PATCH_MARKER_FIELD, {
96
- configurable: false,
97
- enumerable: false,
98
- writable: false,
99
- value: true,
100
- });
101
- prototype.createContext = patched;
102
- return { ok: true, installed: true };
103
- }
104
-
105
- /** Return the patched projection method, if this Pi process supports it. */
106
- export function getPiContextHookProjector(
107
- ctx: ExtensionContext,
108
- ): PiContextHookProjector | undefined {
109
- const candidate = (ctx as PatchedExtensionContext)[PI_CONTEXT_PROJECTOR_FIELD];
110
- return typeof candidate === "function" ? candidate : undefined;
111
- }
112
-
113
- /** Preserve fail-closed context-hook errors across Pi's emitContext boundary. */
114
- export function reportPiContextHookFailure(ctx: ExtensionContext, reason: string): void {
115
- (ctx as PatchedExtensionContext)[PI_CONTEXT_PROJECTION_REPORTER_FIELD]?.(reason);
116
- }
1
+ import type { AgentMessage } from "@earendil-works/pi-agent-core";
2
+ import { ExtensionRunner, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+
4
+ /** Context property added by the toolkit's host patch. */
5
+ export const PI_CONTEXT_PROJECTOR_FIELD = "projectContextForCompaction" as const;
6
+ const PI_CONTEXT_PROJECTION_REPORTER_FIELD = "__piOpenAiToolkitReportContextProjectionFailure" as const;
7
+ const PATCH_MARKER_FIELD = "__piOpenAiToolkitContextHookPatch" as const;
8
+
9
+ type ProjectionState = {
10
+ failure?: string;
11
+ };
12
+
13
+ const activeProjectionByRunner = new WeakMap<ExtensionRunner, ProjectionState>();
14
+
15
+ type PatchedExtensionContext = ExtensionContext & {
16
+ [PI_CONTEXT_PROJECTOR_FIELD]?: PiContextHookProjector;
17
+ [PI_CONTEXT_PROJECTION_REPORTER_FIELD]?: (reason: string) => void;
18
+ };
19
+
20
+ type MarkedCreateContext = typeof ExtensionRunner.prototype.createContext & {
21
+ [PATCH_MARKER_FIELD]?: true;
22
+ };
23
+
24
+ /** The ordered context-hook chain, applied to a cloned compaction context. */
25
+ export type PiContextHookProjector = (
26
+ messages: readonly AgentMessage[],
27
+ ) => Promise<readonly AgentMessage[]>;
28
+
29
+ export type PiContextHookPatchResult =
30
+ | { ok: true; installed: boolean; reason?: "already-installed" }
31
+ | { ok: false; reason: "emit-context-unavailable" | "create-context-unavailable" };
32
+
33
+ /**
34
+ * Add a narrow projection method to every ExtensionContext created by Pi.
35
+ *
36
+ * Pi 0.87's ordered `ExtensionRunner.emitContext()` runs both `context` and
37
+ * `context_with_system` phases,
38
+ * but the event-level ExtensionContext does not expose the runner. The patch
39
+ * bridges only the method needed by Remote V2; it does not expose the runner
40
+ * or alter Pi's scheduler.
41
+ */
42
+ export function installPiContextHookPatch(): PiContextHookPatchResult {
43
+ const prototype = ExtensionRunner.prototype;
44
+ if (typeof prototype.emitContext !== "function") {
45
+ return { ok: false, reason: "emit-context-unavailable" };
46
+ }
47
+
48
+ const original = prototype.createContext;
49
+ if (typeof original !== "function") {
50
+ return { ok: false, reason: "create-context-unavailable" };
51
+ }
52
+
53
+ const markedOriginal = original as MarkedCreateContext;
54
+ if (markedOriginal[PATCH_MARKER_FIELD] === true) {
55
+ return { ok: true, installed: false, reason: "already-installed" };
56
+ }
57
+
58
+ const patched = function patchedCreateContext(this: ExtensionRunner): ExtensionContext {
59
+ const context = original.call(this) as PatchedExtensionContext;
60
+ if (!(PI_CONTEXT_PROJECTOR_FIELD in context)) {
61
+ Object.defineProperty(context, PI_CONTEXT_PROJECTOR_FIELD, {
62
+ configurable: false,
63
+ enumerable: false,
64
+ writable: false,
65
+ value: async (messages: readonly AgentMessage[]) => {
66
+ const state: ProjectionState = {};
67
+ activeProjectionByRunner.set(this, state);
68
+ try {
69
+ const projected = await this.emitContext([...messages]);
70
+ if (state.failure) {
71
+ throw new Error(`context projection failed: ${state.failure}`);
72
+ }
73
+ return projected;
74
+ } finally {
75
+ if (activeProjectionByRunner.get(this) === state) {
76
+ activeProjectionByRunner.delete(this);
77
+ }
78
+ }
79
+ },
80
+ });
81
+ }
82
+ if (!(PI_CONTEXT_PROJECTION_REPORTER_FIELD in context)) {
83
+ Object.defineProperty(context, PI_CONTEXT_PROJECTION_REPORTER_FIELD, {
84
+ configurable: false,
85
+ enumerable: false,
86
+ writable: false,
87
+ value: (reason: string) => {
88
+ const state = activeProjectionByRunner.get(this);
89
+ if (state) state.failure = reason;
90
+ },
91
+ });
92
+ }
93
+ return context;
94
+ };
95
+
96
+ Object.defineProperty(patched, PATCH_MARKER_FIELD, {
97
+ configurable: false,
98
+ enumerable: false,
99
+ writable: false,
100
+ value: true,
101
+ });
102
+ prototype.createContext = patched;
103
+ return { ok: true, installed: true };
104
+ }
105
+
106
+ /** Return the patched projection method, if this Pi process supports it. */
107
+ export function getPiContextHookProjector(
108
+ ctx: ExtensionContext,
109
+ ): PiContextHookProjector | undefined {
110
+ const candidate = (ctx as PatchedExtensionContext)[PI_CONTEXT_PROJECTOR_FIELD];
111
+ return typeof candidate === "function" ? candidate : undefined;
112
+ }
113
+
114
+ /** Preserve fail-closed context-hook errors across Pi's emitContext boundary. */
115
+ export function reportPiContextHookFailure(ctx: ExtensionContext, reason: string): void {
116
+ (ctx as PatchedExtensionContext)[PI_CONTEXT_PROJECTION_REPORTER_FIELD]?.(reason);
117
+ }