pi-image-slim 0.1.0 → 0.2.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
@@ -2,8 +2,8 @@
2
2
 
3
3
  [中文文档](./README.zh-CN.md)
4
4
 
5
- A [Pi](https://pi.dev) extension that shrinks screenshots and stops old ones from being
6
- re-uploaded.
5
+ A [Pi](https://pi.dev) extension that shrinks screenshots, and optionally stops old ones from
6
+ being re-uploaded.
7
7
 
8
8
  ## The problem
9
9
 
@@ -16,26 +16,26 @@ minute of uploading before the model even starts thinking.
16
16
 
17
17
  ## The fix
18
18
 
19
- Two things happen, at two different moments. Each can be turned off on its own.
19
+ Two things can happen, at two different moments. The first is on by default; the second is
20
+ opt-in.
20
21
 
21
- **When an image arrives**, it is compressed and described:
22
+ **When an image arrives**, it is compressed:
22
23
 
23
24
  ```
24
- your screenshot → 6.4x smaller WebP + one-line description
25
+ your screenshot → 6.4x smaller WebP
25
26
  ```
26
27
 
27
- **Once it falls out of the window**, it is replaced by text:
28
+ **Once its turn is over**, it can be replaced by text (`"strip": true`):
28
29
 
29
30
  ```
30
31
  [image removed from history to save upload bandwidth. Re-read /proj/title.png if you need to see it again.]
31
32
  ```
32
33
 
33
- So each image is uploaded a small, bounded number of times, and what stays behind is a path
34
- plus a description — enough for the model to know what the picture was, and to fetch it again
35
- if it really needs to.
36
-
37
- If you would rather keep every image in view, set `"strip": false`. You still get the 6.4x
38
- compression, and the model never loses sight of anything.
34
+ Compression is invisible — same picture, fewer bytes, the model sees exactly what it saw
35
+ before. Replacement rewrites history, so it ships off and you decide. Switch it on when a slow
36
+ link is the real problem: each image is then uploaded a small, bounded number of times, and
37
+ what stays behind is a path plus a description — enough for the model to know what the picture
38
+ was, and to fetch it again if it really needs to.
39
39
 
40
40
  ## Install
41
41
 
@@ -62,7 +62,7 @@ Four real screenshots from Pi's own docs, compressed to 1280 px / WebP / quality
62
62
  | **total** | **2.4 MB** | **377 KB** | **6.4x** |
63
63
 
64
64
  That saves bandwidth on the turn the image is sent, **and** disk space in the session file.
65
- Stripping saves bandwidth on every turn after that.
65
+ Stripping, when you turn it on, saves bandwidth on every turn after that.
66
66
 
67
67
  ## Who this is for
68
68
 
@@ -79,8 +79,9 @@ Two hooks, nothing more.
79
79
  **`tool_result`** — runs when a tool returns an image.
80
80
 
81
81
  1. Re-encodes PNG/JPEG as WebP with the longest edge capped at `maxEdge` (`compress`).
82
- 2. Asks the current model to describe the stored bytes in one line (`caption`).
83
- 3. Writes both into the conversation as plain text:
82
+ 2. If stripping is on, asks the current model to describe the stored bytes in one line
83
+ (`caption`).
84
+ 3. Writes the path and that description into the conversation as plain text:
84
85
 
85
86
  ```
86
87
  Image file: /proj/docs/mockups/title.png
@@ -98,38 +99,44 @@ Image description: [2] Footer area: grey link list, copyright at the bottom.
98
99
  ```
99
100
 
100
101
  Because this is ordinary transcript text, it survives `/reload`, restarts, and compaction.
101
- The description is generated once, though its tokens are paid on every later request.
102
+ The description is generated once, though its tokens are paid on every later request. With
103
+ stripping off none of this is written: an image that never leaves context needs no address and
104
+ no description, so that round trip is skipped too.
102
105
 
103
106
  **`before_provider_request`** — runs on the finished request body, just before it goes out.
104
107
 
105
- Every image outside the newest `keepImages` image-bearing messages becomes a placeholder
106
- (`strip`). Nothing else is touched. This pass never decodes anything: it swaps object pointers, so a 30 MB body costs
107
- about a millisecond.
108
+ Every image whose turn is over becomes a placeholder (`strip`). Nothing else is touched. This
109
+ pass never decodes anything: it swaps object pointers, so a 30 MB body costs about a
110
+ millisecond.
108
111
 
109
112
  ### What the model sees
110
113
 
111
114
  | | Sent |
112
115
  | --- | --- |
113
- | The newest `keepImages` messages that carry an image | full image |
114
- | Anything older | placeholder + path |
116
+ | Every image read since you last spoke | full image |
117
+ | Images from earlier turns | placeholder + path |
115
118
  | The description note | always |
119
+ | Nothing at all, with `strip: false` (the default) | full image, forever |
116
120
 
117
- ### A window of messages, not of images
121
+ ### A window of turns, not of images
118
122
 
119
- The last two image-bearing messages are sent in full; everything older is a placeholder.
123
+ Everything the agent has produced since you last spoke is sent verbatim. When you speak again,
124
+ the previous turn's images become placeholders and the new turn starts clean. `keepTurns`
125
+ (default `1`) moves that boundary further back.
120
126
 
121
- - Ten screenshots from **one** tool call share one message, so all ten arrive in full and the
122
- model can compare them. The window counts messages, not pictures.
123
- - Each new screenshot pushes the oldest one out. The image is still there in the description
124
- note, so the model knows what it was.
127
+ - Ten screenshots in **one** turn stay visible for the whole turn, however many tool calls they
128
+ came from. A count-based window got this wrong: the third screenshot pushed the first one
129
+ out in the middle of the task that needed both.
125
130
  - The model needs another look → it `read`s the path, and that one image is uploaded again.
131
+ - Nothing changes *within* a turn, so the request prefix is byte-identical on every step of a
132
+ loop and the prompt cache keeps hitting. The cut lands on a turn boundary, which costs
133
+ exactly one cache miss — in the request where those images actually went stale.
126
134
 
127
- The window is why this also works inside a single long task. While the agent works on its own
128
- there is no new user message, so a boundary based on "the last thing you typed" would never
129
- move and every screenshot would be re-uploaded on every step. Measured over a 30-step loop at
130
- 68 KB per screenshot: **31 MB with a turn boundary, 4.1 MB with a window of 2** (ideal is 2 MB).
131
-
132
- Set `"keepImages": 1` for the strictest version — each image is then uploaded exactly once.
135
+ The catch: an agent working on its own never sees a new user message, so the turn never ends
136
+ and nothing would ever be stripped. Measured over a 30-step loop at 68 KB per screenshot that
137
+ is **31 MB** (ideal: 2 MB). `maxKeptImages` is the escape hatch, and it stays at `0` — no cap —
138
+ because cutting inside a turn buys bandwidth with a cache miss on every step, a trade only you
139
+ can price. Set it to 6–8 on a slow link and the same loop costs 11–14 MB.
133
140
 
134
141
  ### Why WebP
135
142
 
@@ -151,17 +158,19 @@ A JSON file, not environment variables. Two locations, most specific last:
151
158
  {
152
159
  "maxEdge": 1280,
153
160
  "quality": 75,
154
- "strip": true,
155
- "keepImages": 2
161
+ "strip": false,
162
+ "keepTurns": 1,
163
+ "maxKeptImages": 0
156
164
  }
157
165
  ```
158
166
 
159
167
  | Key | Default | Meaning |
160
168
  | --- | --- | --- |
161
169
  | `compress` | `true` | Re-encode PNG/JPEG as WebP |
162
- | `caption` | `true` | Describe each image before the pixels go away |
163
- | `strip` | `true` | Replace old images with a placeholder |
164
- | `keepImages` | `2` | How many recent image messages stay intact (1-20) |
170
+ | `caption` | `true` | Describe each image before the pixels go away (only used while `strip` is on) |
171
+ | `strip` | `false` | Replace old images with a placeholder |
172
+ | `keepTurns` | `1` | How many recent turns keep their images (1-20) |
173
+ | `maxKeptImages` | `0` | Cap on image-bearing messages, even inside a kept turn; `0` = no cap |
165
174
  | `maxEdge` | `1280` | Ceiling for the **longer** side |
166
175
  | `quality` | `75` | WebP quality, 0-100 |
167
176
  | `captionTimeoutMs` | `20000` | Fall back to a bare path after this long; `0` = no limit |
@@ -169,20 +178,23 @@ A JSON file, not environment variables. Two locations, most specific last:
169
178
  Every key is optional. Unknown keys and bad values are ignored, and a malformed file falls
170
179
  back to defaults instead of breaking tool results. Run `/reload` after editing.
171
180
 
181
+ 0.1.x's `keepImages` is gone. It counted messages rather than turns, so it is ignored instead
182
+ of being reinterpreted as `maxKeptImages` — that would quietly impose a cap nobody asked for.
183
+
172
184
  ### Pick your level
173
185
 
174
186
  | `compress` | `strip` | What happens |
175
187
  | --- | --- | --- |
176
- | `true` | `true` | **Default.** Smaller images, old ones become text. |
177
- | `true` | `false` | Images shrink ~6x, but the model keeps seeing all of them. |
188
+ | `true` | `false` | **Default.** Images shrink ~6x, and the model keeps seeing all of them. |
189
+ | `true` | `true` | Smaller images, and old ones become text. |
178
190
  | `false` | `true` | Full-size images, old ones become text. |
179
191
 
180
- `"strip": false` changes nothing about the conversation — the model can always look back at
181
- any image — while still cutting upload by about 84%. That is the safest setting if you are
182
- unsure.
192
+ The default changes nothing about the conversation — the model can always look back at any
193
+ image — while still cutting upload by about 84%. Turn `strip` on when that is not enough, on
194
+ a slow, metered, or proxied link.
183
195
 
184
196
  `caption` only does something while `strip` is on: an image that never leaves context does
185
- not need a description, so the extra round trip is skipped automatically. `keepImages` below 1
197
+ not need a description, so the extra round trip is skipped automatically. `keepTurns` below 1
186
198
  is ignored, because the model has to be able to see the image it just asked for.
187
199
 
188
200
  **Raise `maxEdge` if you work with small text** — 1568 is a common choice. Keep it at 2000 or
@@ -223,16 +235,18 @@ Images are detected in all of them, including images nested inside `tool_result`
223
235
  - **Reading an image costs one extra model round trip**, because the description is generated
224
236
  before the tool result is committed. Images are handled one at a time, so ten images means
225
237
  ten round trips.
226
- - **Prompt cache misses once**, on the turn after an image was read. The `tools` and `system`
227
- layers still hit; only `messages` is invalidated.
238
+ - **Prompt cache misses once per turn boundary**, when the previous turn's images are replaced.
239
+ The `tools` and `system` layers still hit; only `messages` is invalidated. With
240
+ `maxKeptImages` set, the cut moves every step and you pay that miss every step.
228
241
  - **A batch of images cannot be re-read.** When one call returns several images, Pi reports one
229
242
  `path`, so none of them can claim it. The descriptions survive; the pixels do not.
230
243
  - **The model may answer from the description instead of looking again.** For pixel-level
231
244
  work, ask it to re-`read` the file.
232
- - **Old images are invisible to the model within a long task.** With a window of 2, a
233
- screenshot taken ten steps ago is a placeholder by the time the model wants to re-check it.
234
- The description is still there; the pixels are one `read` away. Raise `keepImages` if you
235
- work across many screenshots at once.
245
+ - **A long task keeps every image it reads**, because the turn never ends. That is the price of
246
+ the cache-friendly cut. Lower `maxKeptImages` if the uploads hurt more than the misses.
247
+ - **Turn boundaries depend on how Pi serialises a turn**, i.e. on a real `user` message and on
248
+ the synthetic carrier Pi creates for tool images. If a future Pi changes that shape, the
249
+ extension degrades safely: it finds no boundary and strips nothing.
236
250
  - **Old sessions are not retrofitted.** Only images read while the extension is active get
237
251
  compressed and described.
238
252
  - **Session files still keep the compressed image**, not just the text. Stripping only
@@ -245,7 +259,7 @@ git clone https://github.com/wiidede/pi-image-slim
245
259
  cd pi-image-slim
246
260
  pnpm install
247
261
  pnpm check # lint + typecheck + tests
248
- pnpm test # 59 tests, no network, no fixtures
262
+ pnpm test # 69 tests, no network, no fixtures
249
263
  ```
250
264
 
251
265
  ## License
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](./README.md)
4
4
 
5
- 一个 [Pi](https://pi.dev) 扩展:压缩截图,并让旧图不再反复重传。
5
+ 一个 [Pi](https://pi.dev) 扩展:压缩截图;如果你需要,还能让旧图不再反复重传。
6
6
 
7
7
  ## 问题
8
8
 
@@ -12,23 +12,21 @@ Pi 每次请求都会把整个对话发一遍。prompt cache 让服务端算起
12
12
 
13
13
  ## 做法
14
14
 
15
- 两个时刻,各做一件事,两件都能单独关掉。
15
+ 两个时刻,各做一件事。第一件默认开着,第二件要你自己开。
16
16
 
17
- **图片进来时**,压缩并描述:
17
+ **图片进来时**,压缩:
18
18
 
19
19
  ```
20
- 你的截图 → 小 6.4 倍的 WebP + 一句描述
20
+ 你的截图 → 小 6.4 倍的 WebP
21
21
  ```
22
22
 
23
- **等它被挤出窗口后**,它被替换成文字:
23
+ **等它那一轮结束之后**,可以换成文字(`"strip": true`):
24
24
 
25
25
  ```
26
26
  [image removed from history to save upload bandwidth. Re-read /proj/title.png if you need to see it again.]
27
27
  ```
28
28
 
29
- 于是每张图的上传次数是有界的、很少的几次,留下的是路径加描述——够模型知道那张图是什么,也够它真需要时再取回来。
30
-
31
- 如果你更希望所有图片一直可见,设 `"strip": false`。照样能得到 6.4 倍的压缩,而且模型什么都不会丢。
29
+ 压缩是隐形的——同一张图,字节更少,模型看到的和以前完全一样。替换会改写对话历史,所以默认关着,由你决定。网络真的是瓶颈时再开:每张图的上传次数有界且很少,留下的是路径加描述——够模型知道那张图是什么,也够它真需要时再取回来。
32
30
 
33
31
  ## 安装
34
32
 
@@ -54,7 +52,7 @@ Pi 官方文档里的四张真实截图,压到 1280 px / WebP / 质量 75:
54
52
  | `doom-extension.png` | 167 KB | 44 KB | 3.7x |
55
53
  | **合计** | **2.4 MB** | **377 KB** | **6.4x** |
56
54
 
57
- 这既省了图片被发送那一轮的带宽,**也**省了会话文件的磁盘空间。剥离则省掉之后每一轮的带宽。
55
+ 这既省了图片被发送那一轮的带宽,**也**省了会话文件的磁盘空间。打开剥离后,还能省掉之后每一轮的带宽。
58
56
 
59
57
  ## 适合谁
60
58
 
@@ -71,8 +69,8 @@ Pi 官方文档里的四张真实截图,压到 1280 px / WebP / 质量 75:
71
69
  **`tool_result`** —— 工具返回图片时触发。
72
70
 
73
71
  1. 把 PNG/JPEG 重新编码成 WebP,最长边限制在 `maxEdge`(`compress`)。
74
- 2. 让当前模型对"真正会被存下来的那份字节"写一句描述(`caption`)。
75
- 3. 两者都作为普通文本写进对话:
72
+ 2. 如果开着剥离,让当前模型对“真正会被存下来的那份字节”写一句描述(`caption`)。
73
+ 3. 把路径和描述作为普通文本写进对话:
76
74
 
77
75
  ```
78
76
  Image file: /proj/docs/mockups/title.png
@@ -87,31 +85,30 @@ Image description: [1] Header 区域:深色导航栏,右侧一个登录按
87
85
  Image description: [2] Footer 区域:灰色链接列表,版权信息在底部。
88
86
  ```
89
87
 
90
- 因为这就是普通的对话记录,它能挺过 `/reload`、进程重启和上下文压缩。描述只生成一次,但它的 token 每轮都要重付。
88
+ 因为这就是普通的对话记录,它能挺过 `/reload`、进程重启和上下文压缩。描述只生成一次,但它的 token 每轮都要重付。没开剥离时这些一个字都不会写:永远不会离开上下文的图,既不需要地址也不需要描述,那次模型往返也一并省掉。
91
89
 
92
90
  **`before_provider_request`** —— 在请求体组装完、还没发出去的一刻触发。
93
91
 
94
- 最近 `keepImages` 条带图消息之外的图片都变成占位符,其它什么都不动。这一遍**不解码任何数据**,只是替换对象指针,30 MB 的请求体大约一毫秒。
92
+ 轮次已经结束的图片变成占位符,其它什么都不动。这一遍**不解码任何数据**,只是替换对象指针,30 MB 的请求体大约一毫秒。
95
93
 
96
94
  ### 模型每轮看到什么
97
95
 
98
96
  | | 发送内容 |
99
97
  | --- | --- |
100
- | 最近 `keepImages` 条带图消息 | 完整图片 |
101
- | 更早的 | 占位符 + 路径 |
98
+ | 你上次发言以来读进来的所有图 | 完整图片 |
99
+ | 更早轮次里的图 | 占位符 + 路径 |
102
100
  | 那条描述 note | 始终保留 |
101
+ | 默认(`strip: false`)时 | 完整图片,永远都在 |
103
102
 
104
- ### 窗口按消息算,不按图片算
103
+ ### 窗口按轮算,不按图片算
105
104
 
106
- 最近两条带图消息完整发送,更早的变成占位符。
105
+ 你上次发言之后 agent 产出的一切都完整发送。你再次开口时,上一轮的图片变成占位符,新一轮从干净的窗口开始。`keepTurns`(默认 `1`)把这个边界再往前推几轮。
107
106
 
108
- - **一次**工具调用返回的十张截图共享同一条消息,所以十张都完整送达,模型可以互相对比。窗口数的是消息,不是张数。
109
- - 每来一张新截图,最老的那条被挤出去。图虽然没了,描述还在,模型仍知道它是什么。
107
+ - **同一轮**里的十张截图在整个那一轮里都完整可见,不管它们来自几次工具调用。按条数计窗口在这里是错的:第三张截图会把第一张挤出去,而任务正需要同时看这两张。
110
108
  - 模型要再看 → 它对路径 `read` 一次,那一张图会被重新上传。
109
+ - **一轮之内什么都不变**,所以循环里每一步发出的前缀都逐字节相同,prompt cache 一直命中。切口落在轮次边界上,只花掉一次 miss——就发生在那些图真正过期的那次请求里。
111
110
 
112
- 这个窗口也是它能在一个长任务里生效的原因。agent 自己干活时不会有新的用户消息,所以"以你最后一次发言为界"这条规则永远不会移动,每一步都会把所有截图重传一遍。实测 30 步、每步一张 68 KB 截图:**按轮划分是 31 MB,窗口为 2 是 4.1 MB**(理想值 2 MB)。
113
-
114
- 想要最严格就把 `"keepImages"` 设成 `1`——那样每张图确实只上传一次。
111
+ 代价是:agent 自己干活时不会有新的用户消息,这一轮就不会结束,也就永远不会剥离。实测 30 步、每步一张 68 KB 截图:**31 MB**(理想值 2 MB)。`maxKeptImages` 就是为这个准备的,默认 `0`——不限,因为在一轮内部下刀等于每一步都拿一次缓存 miss 换带宽,这个価只有你自己能定。慢链路上把它设成 6–8,同样的循环降到 11–14 MB。
115
112
 
116
113
  ### 为什么是 WebP
117
114
 
@@ -130,34 +127,38 @@ Image description: [2] Footer 区域:灰色链接列表,版权信息在底
130
127
  {
131
128
  "maxEdge": 1280,
132
129
  "quality": 75,
133
- "strip": true,
134
- "keepImages": 2
130
+ "strip": false,
131
+ "keepTurns": 1,
132
+ "maxKeptImages": 0
135
133
  }
136
134
  ```
137
135
 
138
136
  | 键 | 默认 | 含义 |
139
137
  | --- | --- | --- |
140
138
  | `compress` | `true` | 把 PNG/JPEG 重新编码成 WebP |
141
- | `caption` | `true` | 像素丢掉之前先写一句描述 |
142
- | `strip` | `true` | 把旧图换成占位符 |
143
- | `keepImages` | `2` | 保留最近几条带图消息(1–20) |
139
+ | `caption` | `true` | 像素丢掉之前先写一句描述(只在 `strip` 开着时生效) |
140
+ | `strip` | `false` | 把旧图换成占位符 |
141
+ | `keepTurns` | `1` | 保留最近几轮里的图片(1–20) |
142
+ | `maxKeptImages` | `0` | 即使在保留的轮次里,也最多留下几条带图消息;`0` 表示不限 |
144
143
  | `maxEdge` | `1280` | **较长边**的上限 |
145
144
  | `quality` | `75` | WebP 质量,0–100 |
146
145
  | `captionTimeoutMs` | `20000` | 超过这么久就退回纯路径;`0` 表示不限时 |
147
146
 
148
147
  所有键都可选。未知的键和不合法的值会被忽略,文件格式坏了也只是退回默认值,不会弄坏工具结果。改完记得 `/reload`。
149
148
 
149
+ 0.1.x 的 `keepImages` 已经不存在了。它数的是消息条数而不是轮次,所以直接忽略,而不是悄悄当成 `maxKeptImages`——那会给用户强加一个他没要求的限制。
150
+
150
151
  ### 选一个档位
151
152
 
152
153
  | `compress` | `strip` | 效果 |
153
154
  | --- | --- | --- |
154
- | `true` | `true` | **默认。** 图片变小,旧的变成文字。 |
155
- | `true` | `false` | 图片小 6 倍左右,但模型始终能看到全部图片。 |
155
+ | `true` | `false` | **默认。** 图片小 6 倍左右,模型始终能看到全部图片。 |
156
+ | `true` | `true` | 图片变小,旧的变成文字。 |
156
157
  | `false` | `true` | 原图尺寸,旧的变成文字。 |
157
158
 
158
- `"strip": false` **完全不影响对话内容**——模型随时能回头看任何一张图——但上行带宽仍然能省大约 84%。拿不定主意时,选这个最稳。
159
+ 默认设置**完全不影响对话内容**——模型随时能回头看任何一张图——但上行带宽仍然能省大约 84%。这还不够、链路又慢又贵要走代理,再打开 `strip`。
159
160
 
160
- `caption` 只在 `strip` 开着时才有意义:永远不会离开上下文的图,不需要描述,所以那次额外的模型往返会自动跳过。`keepImages` 小于 1 会被忽略——模型总得能看见它刚要求的那张图。
161
+ `caption` 只在 `strip` 开着时才有意义:永远不会离开上下文的图,不需要描述,所以那次额外的模型往返会自动跳过。`keepTurns` 小于 1 会被忽略——模型总得能看见它刚要求的那张图。
161
162
 
162
163
  **如果常看小字,把 `maxEdge` 调大** —— 1568 是常见选择。但别超过 2000:超过之后 Pi 自己的图片归一化会把结果重新编码,我们生成的 WebP 就白做了。
163
164
 
@@ -184,12 +185,12 @@ Image description: [2] Footer 区域:灰色链接列表,版权信息在底
184
185
  - **压缩会永久降低分辨率。** 重新 `read` 也找不回细节——Pi 每次读都会应用同样的缩放。需要看清小字就调大 `maxEdge`。
185
186
  - **描述可能是错的。** 一句自信但错误的描述比没有更糟,因为模型会照着它推理。描述由本来就要看这张图的模型当场生成,也没发给任何新的端点,但**没有被验证过**。设 `"caption": false` 可以关掉。
186
187
  - **批量返回的图无法重新读取。** 一次调用返回多张图时,Pi 只报一个 `path`,所以它们谁也用不上。描述还在,像素没了。
187
- - **长任务里旧的图对模型是不可见的。** 窗口为 2 时,十步之前的截图早就变成占位符了。描述还在,像素只差一次 `read`。如果你经常要同时对照很多张图,把 `keepImages` 调大。
188
+ - **旧图在长任务里仍然保留**,因为那一轮没结束。这是缓存友好的代价。如果上传比 miss 更痛,把 `maxKeptImages` 调小。
188
189
  - **描述只生成一次,不会重试。** 如果那次请求超时,占位符就永远是纯路径。调大 `captionTimeoutMs` 可以降低概率。
189
190
  - **读一张图会多一次模型往返**,因为描述要在工具结果落盘前生成。图片是一张一张处理的,读十张就是十次往返。
190
- - **prompt cache 会 miss 一次**,发生在图片被读进来的下一轮。`tools` 和 `system` 层仍然命中,只有 `messages` 失效。
191
+ - **prompt cache 每个轮次边界 miss 一次**,就是上一轮的图被替换的那一次。`tools` 和 `system` 层仍然命中,只有 `messages` 失效。一旦你设了 `maxKeptImages`,切口每一步都在动,那就每一步都要付这次 miss。
191
192
  - **模型可能照描述回答,而不是重新看图。** 做像素级的工作时,直接让它再 `read` 一次那个文件。
192
- - **轮次边界的判断依赖 Pi 的序列化细节**(那条合成的 `Attached image(s) from tool result:` 消息)。如果未来的 Pi 改了形状,扩展会安全降级:找不到边界,就什么都不剥离。
193
+ - **轮次边界的判断依赖 Pi 的序列化细节**(真实的 `user` 消息,以及 Pi 为工具图片合成的那条 `Attached image(s) from tool result:` 消息)。如果未来的 Pi 改了形状,扩展会安全降级:找不到边界,就什么都不剥离。
193
194
  - **旧会话不会被追溯处理。** 只有扩展启用期间读进来的图才会被压缩和描述。
194
195
  - **会话文件里仍然保留压缩后的图片**,不只是文字。剥离只影响线上传输的部分。
195
196
 
@@ -200,7 +201,7 @@ git clone https://github.com/wiidede/pi-image-slim
200
201
  cd pi-image-slim
201
202
  pnpm install
202
203
  pnpm check # lint + typecheck + 测试
203
- pnpm test # 59 个测试,不联网,不依赖磁盘文件
204
+ pnpm test # 69 个测试,不联网,不依赖磁盘文件
204
205
  ```
205
206
 
206
207
  ## License
package/config.ts CHANGED
@@ -25,14 +25,22 @@ export interface ImageSlimConfig {
25
25
  compress: boolean
26
26
  /** Ask the current model to describe each image before the pixels are dropped. */
27
27
  caption: boolean
28
- /** Replace images from earlier turns with a text placeholder. */
28
+ /** Replace images from earlier turns with a text placeholder. Off by default. */
29
29
  strip: boolean
30
30
  /**
31
- * How many of the newest image-bearing messages stay in full. Everything older
32
- * becomes a placeholder. At least one is always kept: the model has to be able
33
- * to see the image it just asked for.
31
+ * How many of the newest *turns* stay in full. A turn starts at a real user
32
+ * message, so everything the agent produced since the user last spoke is kept
33
+ * verbatim. At least one is always kept: the model has to be able to see the
34
+ * image it just asked for.
34
35
  */
35
- keepImages: number
36
+ keepTurns: number
37
+ /**
38
+ * Hard cap on how many image-bearing messages survive, across all kept turns.
39
+ * `0` disables the cap. It exists for the one case a turn boundary never closes:
40
+ * an agent working on its own. Cutting inside a turn costs a cache miss per
41
+ * step, so this stays opt-in — lower it on a slow link.
42
+ */
43
+ maxKeptImages: number
36
44
  /** Longest edge kept when re-encoding. Applies to the longer of width/height. */
37
45
  maxEdge: number
38
46
  /** WebP quality, 0-100. */
@@ -44,8 +52,13 @@ export interface ImageSlimConfig {
44
52
  export const DEFAULTS: ImageSlimConfig = {
45
53
  compress: true,
46
54
  caption: true,
47
- strip: true,
48
- keepImages: 2,
55
+ // Stripping rewrites history, which is not something to do to someone who did
56
+ // not ask for it. Compression alone is invisible: same pixels, fewer bytes.
57
+ strip: false,
58
+ keepTurns: 1,
59
+ // No cap by default: an arbitrary cap inside a turn trades prompt cache for
60
+ // upload bandwidth, and only the user knows which one their link needs.
61
+ maxKeptImages: 0,
49
62
  maxEdge: 1280,
50
63
  quality: 75,
51
64
  captionTimeoutMs: 20_000,
@@ -88,7 +101,8 @@ function apply(config: ImageSlimConfig, raw: RawConfig): ImageSlimConfig {
88
101
  compress: asBoolean(raw.compress) ?? config.compress,
89
102
  caption: asBoolean(raw.caption) ?? config.caption,
90
103
  strip: asBoolean(raw.strip) ?? config.strip,
91
- keepImages: asInteger(raw.keepImages, 1, 20) ?? config.keepImages,
104
+ keepTurns: asInteger(raw.keepTurns, 1, 20) ?? config.keepTurns,
105
+ maxKeptImages: asInteger(raw.maxKeptImages, 0, 200) ?? config.maxKeptImages,
92
106
  maxEdge: asInteger(raw.maxEdge, 1, 20_000) ?? config.maxEdge,
93
107
  quality: asInteger(raw.quality, 0, 100) ?? config.quality,
94
108
  captionTimeoutMs: asInteger(raw.captionTimeoutMs, 0, 600_000) ?? config.captionTimeoutMs,
package/index.ts CHANGED
@@ -11,16 +11,15 @@
11
11
  *
12
12
  * The fix (two hooks)
13
13
  * 1. `tool_result` — re-encode every image as WebP (capped longest
14
- * edge), then ask the current model for a
15
- * one-line description while the pixels are
16
- * still visible. Write both the path and the
17
- * description into a text note that stays in
14
+ * edge). When stripping is on, also ask the
15
+ * current model for a one-line description while
16
+ * the pixels are still visible, and write the path
17
+ * and description into a text note that stays in
18
18
  * history.
19
19
  * 2. `before_provider_request` — the final serialized request body, one step
20
- * before it goes on the wire. Images outside
21
- * the newest `keepImages` image-bearing
22
- * messages are replaced with a one-line text
23
- * placeholder.
20
+ * before it goes on the wire. Images from turns
21
+ * older than the newest `keepTurns` are replaced
22
+ * with a one-line text placeholder.
24
23
  *
25
24
  * Two stages of reduction
26
25
  * Compression shrinks each image by roughly 3-8x, which cuts upload bandwidth
@@ -36,18 +35,26 @@
36
35
  * bare path. It also means the strip pass never has to read pixels back out of
37
36
  * a provider payload, so it stays a pure pointer swap.
38
37
  *
39
- * A sliding window of messages
40
- * The newest `keepImages` messages that carry an image are sent verbatim;
41
- * everything older becomes a placeholder. Three consequences:
38
+ * A window of turns, not of messages
39
+ * Everything the agent produced since the newest `keepTurns` user messages were
40
+ * spoken is sent verbatim; images from older turns become placeholders. Four
41
+ * consequences:
42
42
  *
43
- * · A tool that returns ten screenshots puts them in one message, so they
44
- * travel together — reading ten images in one call still shows all ten.
45
- * · The window does not care whether the user spoke. That matters: an agent
46
- * working on its own keeps one user message at index 0 for the whole loop,
47
- * so a turn-based rule strips nothing there and re-uploads every screenshot
48
- * on every step. Measured over a 30-step loop, that is 31 MB instead of 2 MB.
49
- * · Nothing depends on how Pi serialises a turn. The window is just "messages
50
- * that contain an image", counted from the end.
43
+ * · Reading ten screenshots in one turn keeps all ten visible for the rest of
44
+ * that turn. A count-based window got this wrong: the third screenshot pushed
45
+ * the first one out, in the middle of the task that needed both.
46
+ * · Nothing changes *within* a turn, so the request prefix is byte-identical on
47
+ * every step of a loop and the prompt cache keeps hitting. Stripping lands on
48
+ * a turn boundary, which costs exactly one cache miss — in the request where
49
+ * the images actually went stale.
50
+ * · A turn boundary is a real user message. A tool that returns ten screenshots
51
+ * hoists them into a synthetic user message of their own, and turning that
52
+ * into a boundary would cut the turn in half, so carriers are skipped.
53
+ * · An agent working on its own never sees a new user message: the turn never
54
+ * ends, and a rule that only cuts at boundaries would strip nothing and
55
+ * re-upload every screenshot on every step (31 MB over a 30-step loop,
56
+ * measured). `maxKeptImages` is the answer, and it stays off by default — it
57
+ * buys bandwidth with cache misses, which is a trade only the user can price.
51
58
  *
52
59
  * Properties
53
60
  * · Compression is best-effort: a failure, an unsupported format, or a result
@@ -71,6 +78,7 @@ import { DEFAULTS, loadConfig } from './config.ts'
71
78
  import { compressImage } from './image.ts'
72
79
  import {
73
80
  collectImageParts,
81
+ isImageCarrier,
74
82
  isRecord,
75
83
  textPartFor,
76
84
  topLevelMessages,
@@ -188,25 +196,41 @@ function nearbyNote(messages: unknown[], index: number): ImageNote | undefined {
188
196
  // ─────────────────────────────────────────────────────────────
189
197
 
190
198
  /**
191
- * Replace every image outside the newest `keepImages` image-bearing messages
192
- * with a text placeholder, in place. Returns the number of images replaced.
193
- *
194
- * The unit is the *message*, not the image. A tool that returns ten screenshots
195
- * puts them all in one message, and they live or die together — so reading ten
196
- * images in one call still shows the model all ten.
199
+ * Index of the user message that opens the `keepTurns`-th newest turn. Everything
200
+ * at or after it is sent verbatim. Returns 0 — strip nothing — when the history
201
+ * holds fewer turns than that, which is also the safe answer when Pi's
202
+ * serialisation stops looking the way this expects.
203
+ */
204
+ export function turnStart(messages: unknown[], keepTurns: number): number {
205
+ const starts: number[] = []
206
+ for (let i = 0; i < messages.length; i++) {
207
+ const message = messages[i]
208
+ if (isRecord(message) && message.role === 'user' && !isImageCarrier(message))
209
+ starts.push(i)
210
+ }
211
+ return starts.length < keepTurns
212
+ ? 0
213
+ : starts[starts.length - keepTurns]!
214
+ }
215
+
216
+ /**
217
+ * Replace every image older than the kept turns with a text placeholder, in
218
+ * place. Returns the number of images replaced.
197
219
  *
198
- * Counting messages instead of locating turn boundaries is what makes this hold
199
- * inside a single agent loop. `turnStart()` used to key on the last real user
200
- * message, which never moves while the agent works on its own, so a 30-step loop
201
- * kept every screenshot and re-uploaded the lot on every request. With a window,
202
- * each image is uploaded once and then dropped, whether or not the user spoke.
220
+ * The unit is the *message*: a tool that returns ten screenshots puts them all in
221
+ * one message, and they live or die together — reading ten images in one call
222
+ * still shows the model all ten.
203
223
  */
204
- export function stripOldImages(payload: unknown, api?: string, keepImages = DEFAULTS.keepImages): number {
224
+ export function stripOldImages(
225
+ payload: unknown,
226
+ api?: string,
227
+ keepTurns = DEFAULTS.keepTurns,
228
+ maxKeptImages = DEFAULTS.maxKeptImages,
229
+ ): number {
205
230
  const messages = topLevelMessages(payload)
206
231
  if (!messages)
207
232
  return 0
208
233
 
209
- // One pass to find where the kept window begins.
210
234
  const carriers: number[] = []
211
235
  for (let i = 0; i < messages.length; i++) {
212
236
  const refs: ImageRef[] = []
@@ -214,10 +238,19 @@ export function stripOldImages(payload: unknown, api?: string, keepImages = DEFA
214
238
  if (refs.length > 0)
215
239
  carriers.push(i)
216
240
  }
217
- if (carriers.length <= keepImages)
241
+ if (carriers.length === 0)
218
242
  return 0
219
243
 
220
- const keepFrom = carriers[carriers.length - keepImages]!
244
+ let keepFrom = turnStart(messages, keepTurns)
245
+
246
+ // The cap is off by default. When it is on it can only ever move the cut
247
+ // forward, never back — it exists to bound a turn that never ends, not to
248
+ // overrule the turns the user asked to keep.
249
+ if (maxKeptImages > 0 && carriers.length > maxKeptImages)
250
+ keepFrom = Math.max(keepFrom, carriers[carriers.length - maxKeptImages]!)
251
+
252
+ if (keepFrom <= 0)
253
+ return 0
221
254
 
222
255
  let removed = 0
223
256
  for (let i = 0; i < keepFrom; i++) {
@@ -383,7 +416,12 @@ export default function (pi: ExtensionAPI): void {
383
416
  if (!config.strip)
384
417
  return undefined
385
418
 
386
- const removed = stripOldImages(event.payload, ctx.model?.api, config.keepImages)
419
+ const removed = stripOldImages(
420
+ event.payload,
421
+ ctx.model?.api,
422
+ config.keepTurns,
423
+ config.maxKeptImages,
424
+ )
387
425
  // Return undefined when nothing changed, so the body object is left alone.
388
426
  return removed > 0
389
427
  ? event.payload
package/package.json CHANGED
@@ -1,8 +1,7 @@
1
1
  {
2
2
  "name": "pi-image-slim",
3
3
  "type": "module",
4
- "version": "0.1.0",
5
- "packageManager": "pnpm@12.15.0",
4
+ "version": "0.2.0",
6
5
  "description": "Pi extension that stops historical images from being re-uploaded on every request — each image is sent in full exactly once, then replaced by a path-carrying placeholder.",
7
6
  "author": "wiidede",
8
7
  "license": "MIT",
@@ -50,15 +49,6 @@
50
49
  "engines": {
51
50
  "node": ">=22.19.0"
52
51
  },
53
- "scripts": {
54
- "lint": "eslint .",
55
- "typecheck": "tsc --noEmit",
56
- "test": "vitest run",
57
- "test:watch": "vitest",
58
- "check": "pnpm lint && pnpm typecheck && pnpm test",
59
- "release": "pnpm check && bumpp",
60
- "prepublishOnly": "pnpm check"
61
- },
62
52
  "peerDependencies": {
63
53
  "@earendil-works/pi-coding-agent": "*"
64
54
  },
@@ -77,5 +67,13 @@
77
67
  "pnpm": "^12.15.0",
78
68
  "typescript": "^6.0.3",
79
69
  "vitest": "^5.0.5"
70
+ },
71
+ "scripts": {
72
+ "lint": "eslint .",
73
+ "typecheck": "tsc --noEmit",
74
+ "test": "vitest run",
75
+ "test:watch": "vitest",
76
+ "check": "pnpm lint && pnpm typecheck && pnpm test",
77
+ "release": "pnpm check && bumpp"
80
78
  }
81
- }
79
+ }
package/protocol.ts CHANGED
@@ -59,6 +59,39 @@ export function isImagePart(part: unknown): boolean {
59
59
  return false
60
60
  }
61
61
 
62
+ /**
63
+ * Is this user message an artefact of a tool returning images rather than the
64
+ * user actually speaking? Turn boundaries must never land on one of these.
65
+ *
66
+ * Pi hoists tool images out of the result: `openai-completions` pushes a
67
+ * synthetic user message with a fixed header, `anthropic-messages` wraps them in
68
+ * a `tool_result` block, google and bedrock leave a bare image part. A false
69
+ * positive here (calling a tool artefact a real turn) cuts the current turn in
70
+ * half, so the test is deliberately generous — mistaking a user's own pasted
71
+ * image for an artefact is harmless, because it sits *after* the turn start and
72
+ * is therefore kept either way.
73
+ */
74
+ export function isImageCarrier(message: unknown): boolean {
75
+ if (!isRecord(message) || message.role !== 'user')
76
+ return false
77
+ // `content` for openai/anthropic/bedrock, `parts` for google.
78
+ for (const list of [message.content, message.parts]) {
79
+ if (!Array.isArray(list))
80
+ continue
81
+ for (const part of list) {
82
+ if (!isRecord(part))
83
+ continue
84
+ if (isImagePart(part) || part.type === 'tool_result')
85
+ return true
86
+ if (part.type === 'text' && typeof part.text === 'string'
87
+ && part.text.startsWith('Attached image(s) from tool result')) {
88
+ return true
89
+ }
90
+ }
91
+ }
92
+ return false
93
+ }
94
+
62
95
  /** Top-level message array: `messages` (openai), `input` (responses), `contents` (google). */
63
96
  export function topLevelMessages(payload: unknown): unknown[] | undefined {
64
97
  if (!isRecord(payload))