pi-image-slim 0.1.1 → 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 +65 -51
- package/README.zh-CN.md +36 -35
- package/config.ts +22 -8
- package/index.ts +73 -35
- package/package.json +1 -1
- package/protocol.ts +33 -0
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
|
|
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.
|
|
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
|
|
22
|
+
**When an image arrives**, it is compressed:
|
|
22
23
|
|
|
23
24
|
```
|
|
24
|
-
your screenshot → 6.4x smaller WebP
|
|
25
|
+
your screenshot → 6.4x smaller WebP
|
|
25
26
|
```
|
|
26
27
|
|
|
27
|
-
**Once
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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.
|
|
83
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
|
114
|
-
|
|
|
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
|
|
121
|
+
### A window of turns, not of images
|
|
118
122
|
|
|
119
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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":
|
|
155
|
-
"
|
|
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` | `
|
|
164
|
-
| `
|
|
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` | `
|
|
177
|
-
| `true` | `
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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. `
|
|
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
|
|
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
|
-
- **
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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 #
|
|
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.
|
|
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
|
-
|
|
92
|
+
轮次已经结束的图片变成占位符,其它什么都不动。这一遍**不解码任何数据**,只是替换对象指针,30 MB 的请求体大约一毫秒。
|
|
95
93
|
|
|
96
94
|
### 模型每轮看到什么
|
|
97
95
|
|
|
98
96
|
| | 发送内容 |
|
|
99
97
|
| --- | --- |
|
|
100
|
-
|
|
|
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
|
-
|
|
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":
|
|
134
|
-
"
|
|
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` | `
|
|
143
|
-
| `
|
|
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` | `
|
|
155
|
-
| `true` | `
|
|
155
|
+
| `true` | `false` | **默认。** 图片小 6 倍左右,模型始终能看到全部图片。 |
|
|
156
|
+
| `true` | `true` | 图片变小,旧的变成文字。 |
|
|
156
157
|
| `false` | `true` | 原图尺寸,旧的变成文字。 |
|
|
157
158
|
|
|
158
|
-
`
|
|
159
|
+
默认设置**完全不影响对话内容**——模型随时能回头看任何一张图——但上行带宽仍然能省大约 84%。这还不够、链路又慢又贵要走代理,再打开 `strip`。
|
|
159
160
|
|
|
160
|
-
`caption` 只在 `strip` 开着时才有意义:永远不会离开上下文的图,不需要描述,所以那次额外的模型往返会自动跳过。`
|
|
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
|
-
-
|
|
188
|
+
- **旧图在长任务里仍然保留**,因为那一轮没结束。这是缓存友好的代价。如果上传比 miss 更痛,把 `maxKeptImages` 调小。
|
|
188
189
|
- **描述只生成一次,不会重试。** 如果那次请求超时,占位符就永远是纯路径。调大 `captionTimeoutMs` 可以降低概率。
|
|
189
190
|
- **读一张图会多一次模型往返**,因为描述要在工具结果落盘前生成。图片是一张一张处理的,读十张就是十次往返。
|
|
190
|
-
- **prompt cache
|
|
191
|
+
- **prompt cache 每个轮次边界 miss 一次**,就是上一轮的图被替换的那一次。`tools` 和 `system` 层仍然命中,只有 `messages` 失效。一旦你设了 `maxKeptImages`,切口每一步都在动,那就每一步都要付这次 miss。
|
|
191
192
|
- **模型可能照描述回答,而不是重新看图。** 做像素级的工作时,直接让它再 `read` 一次那个文件。
|
|
192
|
-
- **轮次边界的判断依赖 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 #
|
|
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
|
|
32
|
-
*
|
|
33
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
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)
|
|
15
|
-
* one-line description while
|
|
16
|
-
* still visible
|
|
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
|
|
21
|
-
* the newest `
|
|
22
|
-
*
|
|
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
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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
|
-
* ·
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
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(
|
|
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
|
|
241
|
+
if (carriers.length === 0)
|
|
218
242
|
return 0
|
|
219
243
|
|
|
220
|
-
|
|
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(
|
|
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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-image-slim",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.2.0",
|
|
5
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.",
|
|
6
6
|
"author": "wiidede",
|
|
7
7
|
"license": "MIT",
|
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))
|