@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 CHANGED
@@ -2,11 +2,13 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
- ## [4.8.1] - 2026-01-12
5
+ ## [4.9.0] - 2026-01-12
6
6
 
7
- ### Fixed
7
+ ## [4.8.3] - 2026-01-12
8
+
9
+ ### Changed
8
10
 
9
- - Prevent bun from statically resolving sharp import to fix runtime errors on arm64
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.8.1",
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-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.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 sharp if available, falls back to ImageMagick (magick/convert).
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
- // 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
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 sharp is unavailable.
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: Buffer; mimeType: string },
80
- b: { buffer: Buffer; mimeType: string },
81
- ): { buffer: Buffer; mimeType: string } {
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 sharp for image processing. If sharp is not available (e.g., in some
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
- // 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
110
- return resizeImageWithImageMagick(img, opts);
111
- }
112
-
113
- const sharpImg = sharp(buffer);
114
- const metadata = await sharpImg.metadata();
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
- const originalWidth = metadata.width ?? 0;
117
- const originalHeight = metadata.height ?? 0;
118
- const format = metadata.format ?? img.mimeType?.split("/")[1] ?? "png";
122
+ // Calculate initial dimensions respecting max limits
123
+ let targetWidth = originalWidth;
124
+ let targetHeight = originalHeight;
119
125
 
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
- }
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
- // Calculate initial dimensions respecting max limits
135
- let targetWidth = originalWidth;
136
- let targetHeight = originalHeight;
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
- 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
- }
144
+ const pngBuffer = resized.writeToBuffer(".png");
145
+ const jpegBuffer = resized.writeToBuffer(".jpg", { Q: jpegQuality });
146
146
 
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();
147
+ resized.delete();
154
148
 
155
- const [pngBuffer, jpegBuffer] = await Promise.all([
156
- sharp(resized).png({ compressionLevel: 9 }).toBuffer(),
157
- sharp(resized).jpeg({ quality: jpegQuality }).toBuffer(),
158
- ]);
149
+ return pickSmaller(
150
+ { buffer: pngBuffer, mimeType: "image/png" },
151
+ { buffer: jpegBuffer, mimeType: "image/jpeg" },
152
+ );
153
+ }
159
154
 
160
- return pickSmaller({ buffer: pngBuffer, mimeType: "image/png" }, { buffer: jpegBuffer, mimeType: "image/jpeg" });
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
- // 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];
159
+ let best: { buffer: Uint8Array; mimeType: string };
160
+ let finalWidth = targetWidth;
161
+ let finalHeight = targetHeight;
166
162
 
167
- let best: { buffer: Buffer; mimeType: string };
168
- let finalWidth = targetWidth;
169
- let finalHeight = targetHeight;
163
+ // First attempt: resize to target dimensions, try both formats
164
+ best = tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
170
165
 
171
- // First attempt: resize to target dimensions, try both formats
172
- best = await tryBothFormats(targetWidth, targetHeight, opts.jpegQuality);
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
- 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
- }
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
- // 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);
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
- if (best.buffer.length <= opts.maxBytes) {
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
+ }