@oh-my-pi/pi-coding-agent 4.8.1 → 4.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +3 -3
- package/package.json +6 -8
- package/src/utils/image-convert.ts +38 -14
- package/src/utils/image-resize.ts +128 -110
package/CHANGELOG.md
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [4.8.
|
|
5
|
+
## [4.8.3] - 2026-01-12
|
|
6
6
|
|
|
7
|
-
###
|
|
7
|
+
### Changed
|
|
8
8
|
|
|
9
|
-
-
|
|
9
|
+
- Replace sharp with wasm-vips for cross-platform image processing without native dependencies
|
|
10
10
|
|
|
11
11
|
## [4.8.0] - 2026-01-12
|
|
12
12
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
3
|
-
"version": "4.8.
|
|
3
|
+
"version": "4.8.3",
|
|
4
4
|
"description": "Coding agent CLI with read, bash, edit, write tools and session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"ompConfig": {
|
|
@@ -39,10 +39,10 @@
|
|
|
39
39
|
"prepublishOnly": "bun run generate-template && bun run clean && bun run build"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@oh-my-pi/pi-
|
|
43
|
-
"@oh-my-pi/pi-
|
|
44
|
-
"@oh-my-pi/pi-git-tool": "4.8.
|
|
45
|
-
"@oh-my-pi/pi-tui": "4.8.
|
|
42
|
+
"@oh-my-pi/pi-agent-core": "4.8.3",
|
|
43
|
+
"@oh-my-pi/pi-ai": "4.8.3",
|
|
44
|
+
"@oh-my-pi/pi-git-tool": "4.8.3",
|
|
45
|
+
"@oh-my-pi/pi-tui": "4.8.3",
|
|
46
46
|
"@openai/agents": "^0.3.7",
|
|
47
47
|
"@sinclair/typebox": "^0.34.46",
|
|
48
48
|
"ajv": "^8.17.1",
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"node-html-parser": "^6.1.13",
|
|
61
61
|
"smol-toml": "^1.6.0",
|
|
62
62
|
"strip-ansi": "^7.1.2",
|
|
63
|
+
"wasm-vips": "^0.0.16",
|
|
63
64
|
"winston": "^3.17.0",
|
|
64
65
|
"winston-daily-rotate-file": "^5.0.0",
|
|
65
66
|
"zod": "^4.3.5"
|
|
@@ -71,9 +72,6 @@
|
|
|
71
72
|
"@types/node": "^24.3.0",
|
|
72
73
|
"ms": "^2.1.3"
|
|
73
74
|
},
|
|
74
|
-
"optionalDependencies": {
|
|
75
|
-
"sharp": "^0.34.2"
|
|
76
|
-
},
|
|
77
75
|
"keywords": [
|
|
78
76
|
"coding-agent",
|
|
79
77
|
"ai",
|
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
import { convertToPngWithImageMagick } from "./image-magick";
|
|
2
2
|
|
|
3
|
+
// Cached vips instance
|
|
4
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
5
|
+
let vipsInstance: any;
|
|
6
|
+
let vipsLoadFailed = false;
|
|
7
|
+
|
|
8
|
+
async function getVips() {
|
|
9
|
+
if (vipsLoadFailed) return undefined;
|
|
10
|
+
if (vipsInstance) return vipsInstance;
|
|
11
|
+
|
|
12
|
+
try {
|
|
13
|
+
const wasmVips = await import("wasm-vips");
|
|
14
|
+
const Vips = wasmVips.default ?? wasmVips;
|
|
15
|
+
vipsInstance = await Vips();
|
|
16
|
+
return vipsInstance;
|
|
17
|
+
} catch {
|
|
18
|
+
vipsLoadFailed = true;
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
3
23
|
/**
|
|
4
24
|
* Convert image to PNG format for terminal display.
|
|
5
25
|
* Kitty graphics protocol requires PNG format (f=100).
|
|
6
|
-
* Uses
|
|
26
|
+
* Uses wasm-vips if available, falls back to ImageMagick (magick/convert).
|
|
7
27
|
*/
|
|
8
28
|
export async function convertToPng(
|
|
9
29
|
base64Data: string,
|
|
@@ -14,19 +34,23 @@ export async function convertToPng(
|
|
|
14
34
|
return { data: base64Data, mimeType };
|
|
15
35
|
}
|
|
16
36
|
|
|
17
|
-
// Try
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
37
|
+
// Try wasm-vips first
|
|
38
|
+
const vips = await getVips();
|
|
39
|
+
if (vips) {
|
|
40
|
+
let image: ReturnType<typeof vips.Image.newFromBuffer> | undefined;
|
|
41
|
+
try {
|
|
42
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
43
|
+
image = vips.Image.newFromBuffer(buffer);
|
|
44
|
+
const pngBuffer = image.writeToBuffer(".png");
|
|
45
|
+
return {
|
|
46
|
+
data: Buffer.from(pngBuffer).toString("base64"),
|
|
47
|
+
mimeType: "image/png",
|
|
48
|
+
};
|
|
49
|
+
} catch {
|
|
50
|
+
// wasm-vips failed, try ImageMagick fallback
|
|
51
|
+
} finally {
|
|
52
|
+
image?.delete();
|
|
53
|
+
}
|
|
30
54
|
}
|
|
31
55
|
|
|
32
56
|
// Fall back to ImageMagick
|
|
@@ -29,18 +29,16 @@ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
|
|
|
29
29
|
};
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Fallback resize using ImageMagick when
|
|
32
|
+
* Fallback resize using ImageMagick when wasm-vips is unavailable.
|
|
33
33
|
*/
|
|
34
34
|
async function resizeImageWithImageMagick(
|
|
35
35
|
img: ImageContent,
|
|
36
36
|
opts: Required<ImageResizeOptions>,
|
|
37
37
|
): Promise<ResizedImage> {
|
|
38
|
-
// Try to get dimensions first
|
|
39
38
|
const dims = await getImageDimensionsWithImageMagick(img.data);
|
|
40
39
|
const originalWidth = dims?.width ?? 0;
|
|
41
40
|
const originalHeight = dims?.height ?? 0;
|
|
42
41
|
|
|
43
|
-
// Try to resize
|
|
44
42
|
const result = await resizeWithImageMagick(
|
|
45
43
|
img.data,
|
|
46
44
|
img.mimeType,
|
|
@@ -62,7 +60,6 @@ async function resizeImageWithImageMagick(
|
|
|
62
60
|
};
|
|
63
61
|
}
|
|
64
62
|
|
|
65
|
-
// ImageMagick not available or resize not needed - return original
|
|
66
63
|
return {
|
|
67
64
|
data: img.data,
|
|
68
65
|
mimeType: img.mimeType,
|
|
@@ -76,18 +73,37 @@ async function resizeImageWithImageMagick(
|
|
|
76
73
|
|
|
77
74
|
/** Helper to pick the smaller of two buffers */
|
|
78
75
|
function pickSmaller(
|
|
79
|
-
a: { buffer:
|
|
80
|
-
b: { buffer:
|
|
81
|
-
): { buffer:
|
|
76
|
+
a: { buffer: Uint8Array; mimeType: string },
|
|
77
|
+
b: { buffer: Uint8Array; mimeType: string },
|
|
78
|
+
): { buffer: Uint8Array; mimeType: string } {
|
|
82
79
|
return a.buffer.length <= b.buffer.length ? a : b;
|
|
83
80
|
}
|
|
84
81
|
|
|
82
|
+
// Cached vips instance
|
|
83
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
84
|
+
let vipsInstance: any;
|
|
85
|
+
let vipsLoadFailed = false;
|
|
86
|
+
|
|
87
|
+
async function getVips() {
|
|
88
|
+
if (vipsLoadFailed) return undefined;
|
|
89
|
+
if (vipsInstance) return vipsInstance;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const wasmVips = await import("wasm-vips");
|
|
93
|
+
const Vips = wasmVips.default ?? wasmVips;
|
|
94
|
+
vipsInstance = await Vips();
|
|
95
|
+
return vipsInstance;
|
|
96
|
+
} catch {
|
|
97
|
+
vipsLoadFailed = true;
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
85
102
|
/**
|
|
86
103
|
* Resize an image to fit within the specified max dimensions and file size.
|
|
87
104
|
* Returns the original image if it already fits within the limits.
|
|
88
105
|
*
|
|
89
|
-
* Uses
|
|
90
|
-
* environments), returns the original image unchanged.
|
|
106
|
+
* Uses wasm-vips for image processing. Falls back to ImageMagick if unavailable.
|
|
91
107
|
*
|
|
92
108
|
* Strategy for staying under maxBytes:
|
|
93
109
|
* 1. First resize to maxWidth/maxHeight
|
|
@@ -99,97 +115,79 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
99
115
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
100
116
|
const buffer = Buffer.from(img.data, "base64");
|
|
101
117
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
try {
|
|
105
|
-
// Use variable to prevent bun from statically analyzing the import
|
|
106
|
-
const sharpModule = "sharp";
|
|
107
|
-
sharp = (await import(/* @vite-ignore */ sharpModule)).default;
|
|
108
|
-
} catch {
|
|
109
|
-
// Sharp not available - try ImageMagick fallback
|
|
118
|
+
const vips = await getVips();
|
|
119
|
+
if (!vips) {
|
|
110
120
|
return resizeImageWithImageMagick(img, opts);
|
|
111
121
|
}
|
|
112
122
|
|
|
113
|
-
|
|
114
|
-
|
|
123
|
+
let image: ReturnType<typeof vips.Image.newFromBuffer> | undefined;
|
|
124
|
+
try {
|
|
125
|
+
image = vips.Image.newFromBuffer(buffer);
|
|
126
|
+
const originalWidth = image.width;
|
|
127
|
+
const originalHeight = image.height;
|
|
128
|
+
const format = img.mimeType?.split("/")[1] ?? "png";
|
|
129
|
+
|
|
130
|
+
// Check if already within all limits (dimensions AND size)
|
|
131
|
+
const originalSize = buffer.length;
|
|
132
|
+
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
|
|
133
|
+
return {
|
|
134
|
+
data: img.data,
|
|
135
|
+
mimeType: img.mimeType ?? `image/${format}`,
|
|
136
|
+
originalWidth,
|
|
137
|
+
originalHeight,
|
|
138
|
+
width: originalWidth,
|
|
139
|
+
height: originalHeight,
|
|
140
|
+
wasResized: false,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
115
143
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
144
|
+
// Calculate initial dimensions respecting max limits
|
|
145
|
+
let targetWidth = originalWidth;
|
|
146
|
+
let targetHeight = originalHeight;
|
|
119
147
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
width: originalWidth,
|
|
129
|
-
height: originalHeight,
|
|
130
|
-
wasResized: false,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// Calculate initial dimensions respecting max limits
|
|
135
|
-
let targetWidth = originalWidth;
|
|
136
|
-
let targetHeight = originalHeight;
|
|
148
|
+
if (targetWidth > opts.maxWidth) {
|
|
149
|
+
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
|
|
150
|
+
targetWidth = opts.maxWidth;
|
|
151
|
+
}
|
|
152
|
+
if (targetHeight > opts.maxHeight) {
|
|
153
|
+
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
|
|
154
|
+
targetHeight = opts.maxHeight;
|
|
155
|
+
}
|
|
137
156
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
157
|
+
// Helper to resize and encode in both formats, returning the smaller one
|
|
158
|
+
function tryBothFormats(
|
|
159
|
+
width: number,
|
|
160
|
+
height: number,
|
|
161
|
+
jpegQuality: number,
|
|
162
|
+
): { buffer: Uint8Array; mimeType: string } {
|
|
163
|
+
const scale = Math.min(width / originalWidth, height / originalHeight);
|
|
164
|
+
const resized = image!.resize(scale);
|
|
146
165
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
width: number,
|
|
150
|
-
height: number,
|
|
151
|
-
jpegQuality: number,
|
|
152
|
-
): Promise<{ buffer: Buffer; mimeType: string }> {
|
|
153
|
-
const resized = await sharp(buffer).resize(width, height, { fit: "inside", withoutEnlargement: true }).toBuffer();
|
|
166
|
+
const pngBuffer = resized.writeToBuffer(".png");
|
|
167
|
+
const jpegBuffer = resized.writeToBuffer(".jpg", { Q: jpegQuality });
|
|
154
168
|
|
|
155
|
-
|
|
156
|
-
sharp(resized).png({ compressionLevel: 9 }).toBuffer(),
|
|
157
|
-
sharp(resized).jpeg({ quality: jpegQuality }).toBuffer(),
|
|
158
|
-
]);
|
|
169
|
+
resized.delete();
|
|
159
170
|
|
|
160
|
-
|
|
161
|
-
|
|
171
|
+
return pickSmaller(
|
|
172
|
+
{ buffer: pngBuffer, mimeType: "image/png" },
|
|
173
|
+
{ buffer: jpegBuffer, mimeType: "image/jpeg" },
|
|
174
|
+
);
|
|
175
|
+
}
|
|
162
176
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
177
|
+
// Try to produce an image under maxBytes
|
|
178
|
+
const qualitySteps = [85, 70, 55, 40];
|
|
179
|
+
const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
|
|
166
180
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
181
|
+
let best: { buffer: Uint8Array; mimeType: string };
|
|
182
|
+
let finalWidth = targetWidth;
|
|
183
|
+
let finalHeight = targetHeight;
|
|
170
184
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (best.buffer.length <= opts.maxBytes) {
|
|
175
|
-
return {
|
|
176
|
-
data: best.buffer.toString("base64"),
|
|
177
|
-
mimeType: best.mimeType,
|
|
178
|
-
originalWidth,
|
|
179
|
-
originalHeight,
|
|
180
|
-
width: finalWidth,
|
|
181
|
-
height: finalHeight,
|
|
182
|
-
wasResized: true,
|
|
183
|
-
};
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Still too large - try JPEG with decreasing quality (and compare to PNG each time)
|
|
187
|
-
for (const quality of qualitySteps) {
|
|
188
|
-
best = await tryBothFormats(targetWidth, targetHeight, quality);
|
|
185
|
+
// First attempt: resize to target dimensions, try both formats
|
|
186
|
+
best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
|
|
189
187
|
|
|
190
188
|
if (best.buffer.length <= opts.maxBytes) {
|
|
191
189
|
return {
|
|
192
|
-
data: best.buffer.toString("base64"),
|
|
190
|
+
data: Buffer.from(best.buffer).toString("base64"),
|
|
193
191
|
mimeType: best.mimeType,
|
|
194
192
|
originalWidth,
|
|
195
193
|
originalHeight,
|
|
@@ -198,24 +196,14 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
198
196
|
wasResized: true,
|
|
199
197
|
};
|
|
200
198
|
}
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Still too large - reduce dimensions progressively
|
|
204
|
-
for (const scale of scaleSteps) {
|
|
205
|
-
finalWidth = Math.round(targetWidth * scale);
|
|
206
|
-
finalHeight = Math.round(targetHeight * scale);
|
|
207
|
-
|
|
208
|
-
// Skip if dimensions are too small
|
|
209
|
-
if (finalWidth < 100 || finalHeight < 100) {
|
|
210
|
-
break;
|
|
211
|
-
}
|
|
212
199
|
|
|
200
|
+
// Still too large - try JPEG with decreasing quality
|
|
213
201
|
for (const quality of qualitySteps) {
|
|
214
|
-
best =
|
|
202
|
+
best = tryBothFormats(targetWidth, targetHeight, quality);
|
|
215
203
|
|
|
216
204
|
if (best.buffer.length <= opts.maxBytes) {
|
|
217
205
|
return {
|
|
218
|
-
data: best.buffer.toString("base64"),
|
|
206
|
+
data: Buffer.from(best.buffer).toString("base64"),
|
|
219
207
|
mimeType: best.mimeType,
|
|
220
208
|
originalWidth,
|
|
221
209
|
originalHeight,
|
|
@@ -225,19 +213,49 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
225
213
|
};
|
|
226
214
|
}
|
|
227
215
|
}
|
|
228
|
-
}
|
|
229
216
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
217
|
+
// Still too large - reduce dimensions progressively
|
|
218
|
+
for (const scale of scaleSteps) {
|
|
219
|
+
finalWidth = Math.round(targetWidth * scale);
|
|
220
|
+
finalHeight = Math.round(targetHeight * scale);
|
|
221
|
+
|
|
222
|
+
if (finalWidth < 100 || finalHeight < 100) {
|
|
223
|
+
break;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
for (const quality of qualitySteps) {
|
|
227
|
+
best = tryBothFormats(finalWidth, finalHeight, quality);
|
|
228
|
+
|
|
229
|
+
if (best.buffer.length <= opts.maxBytes) {
|
|
230
|
+
return {
|
|
231
|
+
data: Buffer.from(best.buffer).toString("base64"),
|
|
232
|
+
mimeType: best.mimeType,
|
|
233
|
+
originalWidth,
|
|
234
|
+
originalHeight,
|
|
235
|
+
width: finalWidth,
|
|
236
|
+
height: finalHeight,
|
|
237
|
+
wasResized: true,
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Last resort: return smallest version we produced
|
|
244
|
+
return {
|
|
245
|
+
data: Buffer.from(best.buffer).toString("base64"),
|
|
246
|
+
mimeType: best.mimeType,
|
|
247
|
+
originalWidth,
|
|
248
|
+
originalHeight,
|
|
249
|
+
width: finalWidth,
|
|
250
|
+
height: finalHeight,
|
|
251
|
+
wasResized: true,
|
|
252
|
+
};
|
|
253
|
+
} catch {
|
|
254
|
+
// wasm-vips failed - try ImageMagick fallback
|
|
255
|
+
return resizeImageWithImageMagick(img, opts);
|
|
256
|
+
} finally {
|
|
257
|
+
image?.delete();
|
|
258
|
+
}
|
|
241
259
|
}
|
|
242
260
|
|
|
243
261
|
/**
|