@oh-my-pi/pi-coding-agent 4.8.0 → 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 CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [4.8.3] - 2026-01-12
6
+
7
+ ### Changed
8
+
9
+ - Replace sharp with wasm-vips for cross-platform image processing without native dependencies
10
+
5
11
  ## [4.8.0] - 2026-01-12
6
12
 
7
13
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oh-my-pi/pi-coding-agent",
3
- "version": "4.8.0",
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-ai": "4.8.0",
43
- "@oh-my-pi/pi-agent-core": "4.8.0",
44
- "@oh-my-pi/pi-git-tool": "4.8.0",
45
- "@oh-my-pi/pi-tui": "4.8.0",
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 sharp if available, falls back to ImageMagick (magick/convert).
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,17 +34,23 @@ export async function convertToPng(
14
34
  return { data: base64Data, mimeType };
15
35
  }
16
36
 
17
- // Try sharp first
18
- try {
19
- const sharp = (await import("sharp")).default;
20
- const buffer = Buffer.from(base64Data, "base64");
21
- const pngBuffer = await sharp(buffer).png().toBuffer();
22
- return {
23
- data: pngBuffer.toString("base64"),
24
- mimeType: "image/png",
25
- };
26
- } catch {
27
- // Sharp not available, try ImageMagick fallback
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
+ }
28
54
  }
29
55
 
30
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 sharp is unavailable.
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: Buffer; mimeType: string },
80
- b: { buffer: Buffer; mimeType: string },
81
- ): { buffer: Buffer; mimeType: string } {
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 sharp for image processing. If sharp is not available (e.g., in some
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,96 +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
- let sharp: typeof import("sharp") | undefined;
103
- try {
104
- sharp = (await import("sharp")).default;
105
- } catch {
106
- // Sharp not available - try ImageMagick fallback
118
+ const vips = await getVips();
119
+ if (!vips) {
107
120
  return resizeImageWithImageMagick(img, opts);
108
121
  }
109
122
 
110
- const sharpImg = sharp(buffer);
111
- const metadata = await sharpImg.metadata();
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
+ }
112
143
 
113
- const originalWidth = metadata.width ?? 0;
114
- const originalHeight = metadata.height ?? 0;
115
- const format = metadata.format ?? img.mimeType?.split("/")[1] ?? "png";
144
+ // Calculate initial dimensions respecting max limits
145
+ let targetWidth = originalWidth;
146
+ let targetHeight = originalHeight;
116
147
 
117
- // Check if already within all limits (dimensions AND size)
118
- const originalSize = buffer.length;
119
- if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
120
- return {
121
- data: img.data,
122
- mimeType: img.mimeType ?? `image/${format}`,
123
- originalWidth,
124
- originalHeight,
125
- width: originalWidth,
126
- height: originalHeight,
127
- wasResized: false,
128
- };
129
- }
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
+ }
130
156
 
131
- // Calculate initial dimensions respecting max limits
132
- let targetWidth = originalWidth;
133
- let targetHeight = originalHeight;
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);
134
165
 
135
- if (targetWidth > opts.maxWidth) {
136
- targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
137
- targetWidth = opts.maxWidth;
138
- }
139
- if (targetHeight > opts.maxHeight) {
140
- targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
141
- targetHeight = opts.maxHeight;
142
- }
166
+ const pngBuffer = resized.writeToBuffer(".png");
167
+ const jpegBuffer = resized.writeToBuffer(".jpg", { Q: jpegQuality });
143
168
 
144
- // Helper to resize and encode in both formats, returning the smaller one
145
- async function tryBothFormats(
146
- width: number,
147
- height: number,
148
- jpegQuality: number,
149
- ): Promise<{ buffer: Buffer; mimeType: string }> {
150
- const resized = await sharp!(buffer)
151
- .resize(width, height, { fit: "inside", withoutEnlargement: true })
152
- .toBuffer();
153
-
154
- const [pngBuffer, jpegBuffer] = await Promise.all([
155
- sharp!(resized).png({ compressionLevel: 9 }).toBuffer(),
156
- sharp!(resized).jpeg({ quality: jpegQuality }).toBuffer(),
157
- ]);
158
-
159
- return pickSmaller({ buffer: pngBuffer, mimeType: "image/png" }, { buffer: jpegBuffer, mimeType: "image/jpeg" });
160
- }
169
+ resized.delete();
161
170
 
162
- // Try to produce an image under maxBytes
163
- const qualitySteps = [85, 70, 55, 40];
164
- const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
171
+ return pickSmaller(
172
+ { buffer: pngBuffer, mimeType: "image/png" },
173
+ { buffer: jpegBuffer, mimeType: "image/jpeg" },
174
+ );
175
+ }
165
176
 
166
- let best: { buffer: Buffer; mimeType: string };
167
- let finalWidth = targetWidth;
168
- let finalHeight = targetHeight;
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];
169
180
 
170
- // First attempt: resize to target dimensions, try both formats
171
- best = await tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
181
+ let best: { buffer: Uint8Array; mimeType: string };
182
+ let finalWidth = targetWidth;
183
+ let finalHeight = targetHeight;
172
184
 
173
- if (best.buffer.length <= opts.maxBytes) {
174
- return {
175
- data: best.buffer.toString("base64"),
176
- mimeType: best.mimeType,
177
- originalWidth,
178
- originalHeight,
179
- width: finalWidth,
180
- height: finalHeight,
181
- wasResized: true,
182
- };
183
- }
184
-
185
- // Still too large - try JPEG with decreasing quality (and compare to PNG each time)
186
- for (const quality of qualitySteps) {
187
- best = await tryBothFormats(targetWidth, targetHeight, quality);
185
+ // First attempt: resize to target dimensions, try both formats
186
+ best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
188
187
 
189
188
  if (best.buffer.length <= opts.maxBytes) {
190
189
  return {
191
- data: best.buffer.toString("base64"),
190
+ data: Buffer.from(best.buffer).toString("base64"),
192
191
  mimeType: best.mimeType,
193
192
  originalWidth,
194
193
  originalHeight,
@@ -197,24 +196,14 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
197
196
  wasResized: true,
198
197
  };
199
198
  }
200
- }
201
-
202
- // Still too large - reduce dimensions progressively
203
- for (const scale of scaleSteps) {
204
- finalWidth = Math.round(targetWidth * scale);
205
- finalHeight = Math.round(targetHeight * scale);
206
-
207
- // Skip if dimensions are too small
208
- if (finalWidth < 100 || finalHeight < 100) {
209
- break;
210
- }
211
199
 
200
+ // Still too large - try JPEG with decreasing quality
212
201
  for (const quality of qualitySteps) {
213
- best = await tryBothFormats(finalWidth, finalHeight, quality);
202
+ best = tryBothFormats(targetWidth, targetHeight, quality);
214
203
 
215
204
  if (best.buffer.length <= opts.maxBytes) {
216
205
  return {
217
- data: best.buffer.toString("base64"),
206
+ data: Buffer.from(best.buffer).toString("base64"),
218
207
  mimeType: best.mimeType,
219
208
  originalWidth,
220
209
  originalHeight,
@@ -224,19 +213,49 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption
224
213
  };
225
214
  }
226
215
  }
227
- }
228
216
 
229
- // Last resort: return smallest version we produced even if over limit
230
- // (the API will reject it, but at least we tried everything)
231
- return {
232
- data: best.buffer.toString("base64"),
233
- mimeType: best.mimeType,
234
- originalWidth,
235
- originalHeight,
236
- width: finalWidth,
237
- height: finalHeight,
238
- wasResized: true,
239
- };
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
+ }
240
259
  }
241
260
 
242
261
  /**