@oh-my-pi/pi-coding-agent 4.8.1 → 4.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -3
- package/package.json +6 -8
- package/src/utils/image-convert.ts +19 -13
- package/src/utils/image-resize.ts +123 -124
- package/src/utils/vips.ts +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [4.
|
|
5
|
+
## [4.9.0] - 2026-01-12
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## [4.8.3] - 2026-01-12
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
8
10
|
|
|
9
|
-
-
|
|
11
|
+
- Replace sharp with wasm-vips for cross-platform image processing without native dependencies
|
|
10
12
|
|
|
11
13
|
## [4.8.0] - 2026-01-12
|
|
12
14
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@oh-my-pi/pi-coding-agent",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.9.0",
|
|
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.
|
|
45
|
-
"@oh-my-pi/pi-tui": "4.
|
|
42
|
+
"@oh-my-pi/pi-agent-core": "4.9.0",
|
|
43
|
+
"@oh-my-pi/pi-ai": "4.9.0",
|
|
44
|
+
"@oh-my-pi/pi-git-tool": "4.9.0",
|
|
45
|
+
"@oh-my-pi/pi-tui": "4.9.0",
|
|
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,11 @@
|
|
|
1
|
+
import { logger } from "../core/logger";
|
|
1
2
|
import { convertToPngWithImageMagick } from "./image-magick";
|
|
3
|
+
import { Vips } from "./vips";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Convert image to PNG format for terminal display.
|
|
5
7
|
* Kitty graphics protocol requires PNG format (f=100).
|
|
6
|
-
* Uses
|
|
8
|
+
* Uses wasm-vips if available, falls back to ImageMagick (magick/convert).
|
|
7
9
|
*/
|
|
8
10
|
export async function convertToPng(
|
|
9
11
|
base64Data: string,
|
|
@@ -14,19 +16,23 @@ export async function convertToPng(
|
|
|
14
16
|
return { data: base64Data, mimeType };
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
// Try sharp first
|
|
18
19
|
try {
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
const { Image } = await Vips();
|
|
21
|
+
const image = Image.newFromBuffer(Buffer.from(base64Data, "base64"));
|
|
22
|
+
try {
|
|
23
|
+
const pngBuffer = image.writeToBuffer(".png");
|
|
24
|
+
return {
|
|
25
|
+
data: Buffer.from(pngBuffer).toString("base64"),
|
|
26
|
+
mimeType: "image/png",
|
|
27
|
+
};
|
|
28
|
+
} finally {
|
|
29
|
+
image.delete();
|
|
30
|
+
}
|
|
31
|
+
} catch (error) {
|
|
32
|
+
// wasm-vips failed, try ImageMagick fallback
|
|
33
|
+
logger.error("Failed to convert image to PNG with wasm-vips", {
|
|
34
|
+
error: error instanceof Error ? error.message : String(error),
|
|
35
|
+
});
|
|
30
36
|
}
|
|
31
37
|
|
|
32
38
|
// Fall back to ImageMagick
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
|
2
|
+
import { logger } from "../core/logger";
|
|
2
3
|
import { getImageDimensionsWithImageMagick, resizeWithImageMagick } from "./image-magick";
|
|
4
|
+
import { Vips } from "./vips";
|
|
3
5
|
|
|
4
6
|
export interface ImageResizeOptions {
|
|
5
7
|
maxWidth?: number; // Default: 2000
|
|
@@ -29,18 +31,16 @@ const DEFAULT_OPTIONS: Required<ImageResizeOptions> = {
|
|
|
29
31
|
};
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
|
-
* Fallback resize using ImageMagick when
|
|
34
|
+
* Fallback resize using ImageMagick when wasm-vips is unavailable.
|
|
33
35
|
*/
|
|
34
36
|
async function resizeImageWithImageMagick(
|
|
35
37
|
img: ImageContent,
|
|
36
38
|
opts: Required<ImageResizeOptions>,
|
|
37
39
|
): Promise<ResizedImage> {
|
|
38
|
-
// Try to get dimensions first
|
|
39
40
|
const dims = await getImageDimensionsWithImageMagick(img.data);
|
|
40
41
|
const originalWidth = dims?.width ?? 0;
|
|
41
42
|
const originalHeight = dims?.height ?? 0;
|
|
42
43
|
|
|
43
|
-
// Try to resize
|
|
44
44
|
const result = await resizeWithImageMagick(
|
|
45
45
|
img.data,
|
|
46
46
|
img.mimeType,
|
|
@@ -62,7 +62,6 @@ async function resizeImageWithImageMagick(
|
|
|
62
62
|
};
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
// ImageMagick not available or resize not needed - return original
|
|
66
65
|
return {
|
|
67
66
|
data: img.data,
|
|
68
67
|
mimeType: img.mimeType,
|
|
@@ -76,9 +75,9 @@ async function resizeImageWithImageMagick(
|
|
|
76
75
|
|
|
77
76
|
/** Helper to pick the smaller of two buffers */
|
|
78
77
|
function pickSmaller(
|
|
79
|
-
a: { buffer:
|
|
80
|
-
b: { buffer:
|
|
81
|
-
): { buffer:
|
|
78
|
+
a: { buffer: Uint8Array; mimeType: string },
|
|
79
|
+
b: { buffer: Uint8Array; mimeType: string },
|
|
80
|
+
): { buffer: Uint8Array; mimeType: string } {
|
|
82
81
|
return a.buffer.length <= b.buffer.length ? a : b;
|
|
83
82
|
}
|
|
84
83
|
|
|
@@ -86,8 +85,7 @@ function pickSmaller(
|
|
|
86
85
|
* Resize an image to fit within the specified max dimensions and file size.
|
|
87
86
|
* Returns the original image if it already fits within the limits.
|
|
88
87
|
*
|
|
89
|
-
* Uses
|
|
90
|
-
* environments), returns the original image unchanged.
|
|
88
|
+
* Uses wasm-vips for image processing. Falls back to ImageMagick if unavailable.
|
|
91
89
|
*
|
|
92
90
|
* Strategy for staying under maxBytes:
|
|
93
91
|
* 1. First resize to maxWidth/maxHeight
|
|
@@ -99,97 +97,130 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
99
97
|
const opts = { ...DEFAULT_OPTIONS, ...options };
|
|
100
98
|
const buffer = Buffer.from(img.data, "base64");
|
|
101
99
|
|
|
102
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
103
|
-
let sharp: any;
|
|
104
100
|
try {
|
|
105
|
-
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
101
|
+
const { Image } = await Vips();
|
|
102
|
+
const image = Image.newFromBuffer(buffer);
|
|
103
|
+
try {
|
|
104
|
+
const originalWidth = image.width;
|
|
105
|
+
const originalHeight = image.height;
|
|
106
|
+
const format = img.mimeType?.split("/")[1] ?? "png";
|
|
107
|
+
|
|
108
|
+
// Check if already within all limits (dimensions AND size)
|
|
109
|
+
const originalSize = buffer.length;
|
|
110
|
+
if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
|
|
111
|
+
return {
|
|
112
|
+
data: img.data,
|
|
113
|
+
mimeType: img.mimeType ?? `image/${format}`,
|
|
114
|
+
originalWidth,
|
|
115
|
+
originalHeight,
|
|
116
|
+
width: originalWidth,
|
|
117
|
+
height: originalHeight,
|
|
118
|
+
wasResized: false,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
115
121
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
122
|
+
// Calculate initial dimensions respecting max limits
|
|
123
|
+
let targetWidth = originalWidth;
|
|
124
|
+
let targetHeight = originalHeight;
|
|
119
125
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
width: originalWidth,
|
|
129
|
-
height: originalHeight,
|
|
130
|
-
wasResized: false,
|
|
131
|
-
};
|
|
132
|
-
}
|
|
126
|
+
if (targetWidth > opts.maxWidth) {
|
|
127
|
+
targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
|
|
128
|
+
targetWidth = opts.maxWidth;
|
|
129
|
+
}
|
|
130
|
+
if (targetHeight > opts.maxHeight) {
|
|
131
|
+
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
|
|
132
|
+
targetHeight = opts.maxHeight;
|
|
133
|
+
}
|
|
133
134
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
135
|
+
// Helper to resize and encode in both formats, returning the smaller one
|
|
136
|
+
function tryBothFormats(
|
|
137
|
+
width: number,
|
|
138
|
+
height: number,
|
|
139
|
+
jpegQuality: number,
|
|
140
|
+
): { buffer: Uint8Array; mimeType: string } {
|
|
141
|
+
const scale = Math.min(width / originalWidth, height / originalHeight);
|
|
142
|
+
const resized = image!.resize(scale);
|
|
137
143
|
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
targetWidth = opts.maxWidth;
|
|
141
|
-
}
|
|
142
|
-
if (targetHeight > opts.maxHeight) {
|
|
143
|
-
targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
|
|
144
|
-
targetHeight = opts.maxHeight;
|
|
145
|
-
}
|
|
144
|
+
const pngBuffer = resized.writeToBuffer(".png");
|
|
145
|
+
const jpegBuffer = resized.writeToBuffer(".jpg", { Q: jpegQuality });
|
|
146
146
|
|
|
147
|
-
|
|
148
|
-
async function tryBothFormats(
|
|
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();
|
|
147
|
+
resized.delete();
|
|
154
148
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
149
|
+
return pickSmaller(
|
|
150
|
+
{ buffer: pngBuffer, mimeType: "image/png" },
|
|
151
|
+
{ buffer: jpegBuffer, mimeType: "image/jpeg" },
|
|
152
|
+
);
|
|
153
|
+
}
|
|
159
154
|
|
|
160
|
-
|
|
161
|
-
|
|
155
|
+
// Try to produce an image under maxBytes
|
|
156
|
+
const qualitySteps = [85, 70, 55, 40];
|
|
157
|
+
const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
|
|
162
158
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
159
|
+
let best: { buffer: Uint8Array; mimeType: string };
|
|
160
|
+
let finalWidth = targetWidth;
|
|
161
|
+
let finalHeight = targetHeight;
|
|
166
162
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
let finalHeight = targetHeight;
|
|
163
|
+
// First attempt: resize to target dimensions, try both formats
|
|
164
|
+
best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
|
|
170
165
|
|
|
171
|
-
|
|
172
|
-
|
|
166
|
+
if (best.buffer.length <= opts.maxBytes) {
|
|
167
|
+
return {
|
|
168
|
+
data: Buffer.from(best.buffer).toString("base64"),
|
|
169
|
+
mimeType: best.mimeType,
|
|
170
|
+
originalWidth,
|
|
171
|
+
originalHeight,
|
|
172
|
+
width: finalWidth,
|
|
173
|
+
height: finalHeight,
|
|
174
|
+
wasResized: true,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
173
177
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
178
|
+
// Still too large - try JPEG with decreasing quality
|
|
179
|
+
for (const quality of qualitySteps) {
|
|
180
|
+
best = tryBothFormats(targetWidth, targetHeight, quality);
|
|
181
|
+
|
|
182
|
+
if (best.buffer.length <= opts.maxBytes) {
|
|
183
|
+
return {
|
|
184
|
+
data: Buffer.from(best.buffer).toString("base64"),
|
|
185
|
+
mimeType: best.mimeType,
|
|
186
|
+
originalWidth,
|
|
187
|
+
originalHeight,
|
|
188
|
+
width: finalWidth,
|
|
189
|
+
height: finalHeight,
|
|
190
|
+
wasResized: true,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
}
|
|
185
194
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
195
|
+
// Still too large - reduce dimensions progressively
|
|
196
|
+
for (const scale of scaleSteps) {
|
|
197
|
+
finalWidth = Math.round(targetWidth * scale);
|
|
198
|
+
finalHeight = Math.round(targetHeight * scale);
|
|
199
|
+
|
|
200
|
+
if (finalWidth < 100 || finalHeight < 100) {
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
for (const quality of qualitySteps) {
|
|
205
|
+
best = tryBothFormats(finalWidth, finalHeight, quality);
|
|
206
|
+
|
|
207
|
+
if (best.buffer.length <= opts.maxBytes) {
|
|
208
|
+
return {
|
|
209
|
+
data: Buffer.from(best.buffer).toString("base64"),
|
|
210
|
+
mimeType: best.mimeType,
|
|
211
|
+
originalWidth,
|
|
212
|
+
originalHeight,
|
|
213
|
+
width: finalWidth,
|
|
214
|
+
height: finalHeight,
|
|
215
|
+
wasResized: true,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
189
220
|
|
|
190
|
-
|
|
221
|
+
// Last resort: return smallest version we produced
|
|
191
222
|
return {
|
|
192
|
-
data: best.buffer.toString("base64"),
|
|
223
|
+
data: Buffer.from(best.buffer).toString("base64"),
|
|
193
224
|
mimeType: best.mimeType,
|
|
194
225
|
originalWidth,
|
|
195
226
|
originalHeight,
|
|
@@ -197,47 +228,15 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
|
|
|
197
228
|
height: finalHeight,
|
|
198
229
|
wasResized: true,
|
|
199
230
|
};
|
|
231
|
+
} finally {
|
|
232
|
+
image.delete();
|
|
200
233
|
}
|
|
234
|
+
} catch (error) {
|
|
235
|
+
logger.error("Failed to resize image with wasm-vips", {
|
|
236
|
+
error: error instanceof Error ? error.message : String(error),
|
|
237
|
+
});
|
|
238
|
+
return resizeImageWithImageMagick(img, opts);
|
|
201
239
|
}
|
|
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
|
-
|
|
213
|
-
for (const quality of qualitySteps) {
|
|
214
|
-
best = await tryBothFormats(finalWidth, finalHeight, quality);
|
|
215
|
-
|
|
216
|
-
if (best.buffer.length <= opts.maxBytes) {
|
|
217
|
-
return {
|
|
218
|
-
data: best.buffer.toString("base64"),
|
|
219
|
-
mimeType: best.mimeType,
|
|
220
|
-
originalWidth,
|
|
221
|
-
originalHeight,
|
|
222
|
-
width: finalWidth,
|
|
223
|
-
height: finalHeight,
|
|
224
|
-
wasResized: true,
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
}
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
// Last resort: return smallest version we produced even if over limit
|
|
231
|
-
// (the API will reject it, but at least we tried everything)
|
|
232
|
-
return {
|
|
233
|
-
data: best.buffer.toString("base64"),
|
|
234
|
-
mimeType: best.mimeType,
|
|
235
|
-
originalWidth,
|
|
236
|
-
originalHeight,
|
|
237
|
-
width: finalWidth,
|
|
238
|
-
height: finalHeight,
|
|
239
|
-
wasResized: true,
|
|
240
|
-
};
|
|
241
240
|
}
|
|
242
241
|
|
|
243
242
|
/**
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type realVips from "wasm-vips";
|
|
2
|
+
import { logger } from "../core/logger";
|
|
3
|
+
|
|
4
|
+
// Cached vips instance
|
|
5
|
+
let _vips: Promise<typeof realVips> | undefined;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Get the vips instance.
|
|
9
|
+
* @returns The vips instance.
|
|
10
|
+
*/
|
|
11
|
+
export function Vips(): Promise<typeof realVips> {
|
|
12
|
+
if (_vips) return _vips;
|
|
13
|
+
|
|
14
|
+
let instance: Promise<typeof realVips> | undefined;
|
|
15
|
+
try {
|
|
16
|
+
instance = import("wasm-vips").then((mod) => (mod.default ?? mod)());
|
|
17
|
+
} catch (error) {
|
|
18
|
+
logger.error("Failed to import wasm-vips", { error: error instanceof Error ? error.message : String(error) });
|
|
19
|
+
instance = Promise.reject(error);
|
|
20
|
+
}
|
|
21
|
+
_vips = instance;
|
|
22
|
+
return instance;
|
|
23
|
+
}
|