@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 CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [4.8.1] - 2026-01-12
5
+ ## [4.8.3] - 2026-01-12
6
6
 
7
- ### Fixed
7
+ ### Changed
8
8
 
9
- - Prevent bun from statically resolving sharp import to fix runtime errors on arm64
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.1",
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.1",
43
- "@oh-my-pi/pi-agent-core": "4.8.1",
44
- "@oh-my-pi/pi-git-tool": "4.8.1",
45
- "@oh-my-pi/pi-tui": "4.8.1",
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,19 +34,23 @@ export async function convertToPng(
14
34
  return { data: base64Data, mimeType };
15
35
  }
16
36
 
17
- // Try sharp first
18
- try {
19
- // Use variable to prevent bun from statically analyzing the import
20
- const sharpModule = "sharp";
21
- const sharp = (await import(/* @vite-ignore */ sharpModule)).default;
22
- const buffer = Buffer.from(base64Data, "base64");
23
- const pngBuffer = await sharp(buffer).png().toBuffer();
24
- return {
25
- data: pngBuffer.toString("base64"),
26
- mimeType: "image/png",
27
- };
28
- } catch {
29
- // 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
+ }
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 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,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
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
103
- let sharp: any;
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
- const sharpImg = sharp(buffer);
114
- 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
+ }
115
143
 
116
- const originalWidth = metadata.width ?? 0;
117
- const originalHeight = metadata.height ?? 0;
118
- const format = metadata.format ?? img.mimeType?.split("/")[1] ?? "png";
144
+ // Calculate initial dimensions respecting max limits
145
+ let targetWidth = originalWidth;
146
+ let targetHeight = originalHeight;
119
147
 
120
- // Check if already within all limits (dimensions AND size)
121
- const originalSize = buffer.length;
122
- if (originalWidth <= opts.maxWidth && originalHeight <= opts.maxHeight && originalSize <= opts.maxBytes) {
123
- return {
124
- data: img.data,
125
- mimeType: img.mimeType ?? `image/${format}`,
126
- originalWidth,
127
- originalHeight,
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
- if (targetWidth > opts.maxWidth) {
139
- targetHeight = Math.round((targetHeight * opts.maxWidth) / targetWidth);
140
- targetWidth = opts.maxWidth;
141
- }
142
- if (targetHeight > opts.maxHeight) {
143
- targetWidth = Math.round((targetWidth * opts.maxHeight) / targetHeight);
144
- targetHeight = opts.maxHeight;
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
- // Helper to resize and encode in both formats, returning the smaller one
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();
166
+ const pngBuffer = resized.writeToBuffer(".png");
167
+ const jpegBuffer = resized.writeToBuffer(".jpg", { Q: jpegQuality });
154
168
 
155
- const [pngBuffer, jpegBuffer] = await Promise.all([
156
- sharp(resized).png({ compressionLevel: 9 }).toBuffer(),
157
- sharp(resized).jpeg({ quality: jpegQuality }).toBuffer(),
158
- ]);
169
+ resized.delete();
159
170
 
160
- return pickSmaller({ buffer: pngBuffer, mimeType: "image/png" }, { buffer: jpegBuffer, mimeType: "image/jpeg" });
161
- }
171
+ return pickSmaller(
172
+ { buffer: pngBuffer, mimeType: "image/png" },
173
+ { buffer: jpegBuffer, mimeType: "image/jpeg" },
174
+ );
175
+ }
162
176
 
163
- // Try to produce an image under maxBytes
164
- const qualitySteps = [85, 70, 55, 40];
165
- const scaleSteps = [1.0, 0.75, 0.5, 0.35, 0.25];
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
- let best: { buffer: Buffer; mimeType: string };
168
- let finalWidth = targetWidth;
169
- let finalHeight = targetHeight;
181
+ let best: { buffer: Uint8Array; mimeType: string };
182
+ let finalWidth = targetWidth;
183
+ let finalHeight = targetHeight;
170
184
 
171
- // First attempt: resize to target dimensions, try both formats
172
- best = await tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
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 = await tryBothFormats(finalWidth, finalHeight, quality);
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
- // 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
- };
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
  /**