opencode-image-compressor 0.1.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/README.md ADDED
@@ -0,0 +1,72 @@
1
+ # Opencode Image Compressor plugin
2
+
3
+ `opencode-image-compressor` shrinks large image attachments into smaller JPEG payloads before they are passed into the model.
4
+
5
+ ## What it does
6
+
7
+ - checks supported images larger than `200 KB`
8
+ - rewrites successful outputs as `image/jpeg`
9
+ - aims for roughly `50 KB`
10
+ - accepts results up to `200 KB`
11
+ - supports `image/jpeg`, `image/jpg`, `image/pjpeg`, `image/png`, and `image/webp`
12
+ - does not process GIF input
13
+ - keeps the original file when recompression fails or would not reduce size
14
+
15
+ ## Installation
16
+
17
+ Requirements:
18
+
19
+ - OpenCode plugin support with `@opencode-ai/plugin >= 1.2.27`
20
+ - a working `sharp` install in the runtime environment
21
+
22
+ This plugin is intended to work on macOS, Linux and Windows as long as `sharp` installs and loads correctly on that machine.
23
+
24
+ ```bash
25
+ bun add opencode-image-compressor
26
+ ```
27
+
28
+ Then add it to `opencode.json`:
29
+
30
+ ```json
31
+ {
32
+ "plugin": ["opencode-image-compressor"]
33
+ }
34
+ ```
35
+
36
+ If installing from source:
37
+
38
+ ```bash
39
+ bun install
40
+ bun run build
41
+ ```
42
+
43
+ ## Development
44
+
45
+ ```bash
46
+ bun install
47
+ bun run typecheck
48
+ bun run build
49
+ ```
50
+
51
+ ## Debug logging
52
+
53
+ Enable verbose logging:
54
+
55
+ ```bash
56
+ OPENCODE_IMAGE_COMPRESSOR_DEBUG=true
57
+ ```
58
+
59
+ Optional custom log file path:
60
+
61
+ ```bash
62
+ OPENCODE_IMAGE_COMPRESSOR_DEBUG_LOG_PATH=/tmp/opencode-image-compressor.log
63
+ ```
64
+
65
+ When enabled, the plugin:
66
+
67
+ - appends trace lines to the log file for each inspected attachment
68
+ - emits runtime status lines to stderr
69
+
70
+ When debug is disabled, routine status messages stay silent and only actual errors are written to stderr.
71
+
72
+ On macOS, `/tmp` maps to `/private/tmp`, so check both locations if needed.
@@ -0,0 +1,5 @@
1
+ import { Plugin } from '@opencode-ai/plugin';
2
+
3
+ declare const imageCompressor: Plugin;
4
+
5
+ export { imageCompressor as default };
package/dist/index.js ADDED
@@ -0,0 +1,327 @@
1
+ // src/config.ts
2
+ function envBool(name, fallback) {
3
+ const value = process.env[name]?.trim().toLowerCase();
4
+ if (!value) return fallback;
5
+ return value === "1" || value === "true" || value === "yes" || value === "on";
6
+ }
7
+ var DEFAULT_CONFIG = {
8
+ minBytesForCompression: 200 * 1024,
9
+ targetBytes: 50 * 1024,
10
+ maxOutputBytes: 200 * 1024,
11
+ debug: envBool("OPENCODE_IMAGE_COMPRESSOR_DEBUG", false),
12
+ debugLogPath: process.env.OPENCODE_IMAGE_COMPRESSOR_DEBUG_LOG_PATH || "/tmp/opencode-image-compressor.log",
13
+ background: { r: 255, g: 255, b: 255 },
14
+ supportedMimes: ["image/jpeg", "image/jpg", "image/pjpeg", "image/png", "image/webp"],
15
+ compressionPasses: [
16
+ { maxEdge: null, quality: 55 },
17
+ { maxEdge: null, quality: 45 },
18
+ { maxEdge: 1600, quality: 40 },
19
+ { maxEdge: 1280, quality: 35 },
20
+ { maxEdge: 1024, quality: 32 },
21
+ { maxEdge: 768, quality: 30 },
22
+ { maxEdge: 640, quality: 28 }
23
+ ],
24
+ cacheEntries: 128
25
+ };
26
+ function createConfig(overrides) {
27
+ return {
28
+ ...DEFAULT_CONFIG,
29
+ ...overrides,
30
+ debug: overrides?.debug ?? DEFAULT_CONFIG.debug,
31
+ debugLogPath: overrides?.debugLogPath ?? DEFAULT_CONFIG.debugLogPath,
32
+ background: overrides?.background ?? DEFAULT_CONFIG.background,
33
+ supportedMimes: overrides?.supportedMimes ?? DEFAULT_CONFIG.supportedMimes,
34
+ compressionPasses: overrides?.compressionPasses ?? DEFAULT_CONFIG.compressionPasses
35
+ };
36
+ }
37
+
38
+ // src/compression/cache.ts
39
+ import { createHash } from "crypto";
40
+ var AttachmentCache = class {
41
+ constructor(maxEntries) {
42
+ this.maxEntries = maxEntries;
43
+ }
44
+ maxEntries;
45
+ entries = /* @__PURE__ */ new Map();
46
+ keyFor(buffer) {
47
+ return createHash("sha1").update(buffer).digest("hex");
48
+ }
49
+ get(key) {
50
+ const value = this.entries.get(key);
51
+ if (!value) return void 0;
52
+ this.entries.delete(key);
53
+ this.entries.set(key, value);
54
+ return value;
55
+ }
56
+ set(key, value) {
57
+ if (this.entries.has(key)) {
58
+ this.entries.delete(key);
59
+ }
60
+ this.entries.set(key, value);
61
+ if (this.entries.size > this.maxEntries) {
62
+ const oldest = this.entries.keys().next().value;
63
+ if (oldest) this.entries.delete(oldest);
64
+ }
65
+ }
66
+ };
67
+
68
+ // src/compression/compress.ts
69
+ import sharp from "sharp";
70
+ var LIMIT_INPUT_PIXELS = 16383 * 16383;
71
+ async function readMeta(buffer) {
72
+ const meta = await sharp(buffer, { limitInputPixels: LIMIT_INPUT_PIXELS }).metadata();
73
+ return {
74
+ width: meta.width ?? 0,
75
+ height: meta.height ?? 0
76
+ };
77
+ }
78
+ function unchanged(buffer, reason, meta) {
79
+ return {
80
+ changed: false,
81
+ buffer,
82
+ originalBytes: buffer.length,
83
+ outputBytes: buffer.length,
84
+ width: meta?.width ?? 0,
85
+ height: meta?.height ?? 0,
86
+ quality: null,
87
+ maxEdge: null,
88
+ reason
89
+ };
90
+ }
91
+ async function encodeCandidate(input, step, background) {
92
+ let pipeline = sharp(input, { limitInputPixels: LIMIT_INPUT_PIXELS }).rotate();
93
+ if (step.maxEdge) {
94
+ pipeline = pipeline.resize({
95
+ width: step.maxEdge,
96
+ height: step.maxEdge,
97
+ fit: "inside",
98
+ withoutEnlargement: true
99
+ });
100
+ }
101
+ pipeline = pipeline.flatten({ background }).jpeg({
102
+ quality: step.quality,
103
+ mozjpeg: true,
104
+ progressive: true
105
+ });
106
+ const { data, info } = await pipeline.toBuffer({ resolveWithObject: true });
107
+ return { buffer: data, width: info.width, height: info.height };
108
+ }
109
+ async function compressAggressively(buffer, config = DEFAULT_CONFIG) {
110
+ if (buffer.length <= config.minBytesForCompression) {
111
+ return unchanged(buffer, "under threshold");
112
+ }
113
+ try {
114
+ const meta = await readMeta(buffer);
115
+ let best = null;
116
+ for (const step of config.compressionPasses) {
117
+ const candidate = await encodeCandidate(buffer, step, config.background);
118
+ const result = {
119
+ changed: true,
120
+ buffer: candidate.buffer,
121
+ originalBytes: buffer.length,
122
+ outputBytes: candidate.buffer.length,
123
+ width: candidate.width,
124
+ height: candidate.height,
125
+ quality: step.quality,
126
+ maxEdge: step.maxEdge,
127
+ reason: "optimized"
128
+ };
129
+ if (result.outputBytes >= result.originalBytes) {
130
+ continue;
131
+ }
132
+ if (result.outputBytes <= config.targetBytes) {
133
+ return result;
134
+ }
135
+ if (result.outputBytes <= config.maxOutputBytes) {
136
+ if (!best || result.outputBytes < best.outputBytes) {
137
+ best = result;
138
+ }
139
+ }
140
+ }
141
+ return best ?? unchanged(buffer, "could not reach output policy", meta);
142
+ } catch {
143
+ return unchanged(buffer, "compression failed");
144
+ }
145
+ }
146
+
147
+ // src/logger.ts
148
+ import fs from "fs/promises";
149
+ var PREFIX = "[image-compressor]";
150
+ function formatBytes(bytes) {
151
+ if (bytes < 1024) return `${bytes} B`;
152
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
153
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
154
+ }
155
+ function logConsole(config, message) {
156
+ if (!config.debug) return;
157
+ console.error(`${PREFIX} ${message}`);
158
+ }
159
+ function logReady(config) {
160
+ logConsole(config, "Ready to recompress large image attachments");
161
+ }
162
+ function logCompressed(config, result) {
163
+ logConsole(
164
+ config,
165
+ `Compressed ${formatBytes(result.originalBytes)} \u2192 ${formatBytes(result.outputBytes)} (jpeg, ${result.width}x${result.height}, q=${result.quality ?? "-"}, edge=${result.maxEdge ?? "orig"})`
166
+ );
167
+ }
168
+ function logBypassed(config, reason) {
169
+ logConsole(config, `Bypassed attachment: ${reason}`);
170
+ }
171
+ function logError(error) {
172
+ console.error(`${PREFIX} Error: ${error.message}`);
173
+ }
174
+ async function logDebug(config, message) {
175
+ if (!config.debug) return;
176
+ const line = `[${(/* @__PURE__ */ new Date()).toISOString()}] ${message}
177
+ `;
178
+ try {
179
+ await fs.appendFile(config.debugLogPath, line, "utf8");
180
+ } catch {
181
+ console.error(`${PREFIX} Failed writing debug log: ${config.debugLogPath}`);
182
+ }
183
+ }
184
+
185
+ // src/attachments/resolve.ts
186
+ import fs2 from "fs/promises";
187
+ import { fileURLToPath } from "url";
188
+ var DATA_URI_PREFIX = "data:";
189
+ var BASE64_MARKER = ";base64,";
190
+ function isSupportedMime(mime, config) {
191
+ return config.supportedMimes.includes(mime);
192
+ }
193
+ function parseDataUri(url, config) {
194
+ if (!url.startsWith(DATA_URI_PREFIX)) return null;
195
+ const markerIndex = url.indexOf(BASE64_MARKER);
196
+ if (markerIndex === -1) return null;
197
+ const mime = url.slice(DATA_URI_PREFIX.length, markerIndex);
198
+ if (!isSupportedMime(mime, config)) return null;
199
+ const base64 = url.slice(markerIndex + BASE64_MARKER.length);
200
+ if (!base64) return null;
201
+ return Buffer.from(base64, "base64");
202
+ }
203
+ async function resolveImageBuffer(url, mime, config) {
204
+ if (!isSupportedMime(mime, config)) return null;
205
+ const parsed = parseDataUri(url, config);
206
+ if (parsed) return parsed;
207
+ try {
208
+ if (url.startsWith("file://")) {
209
+ return await fs2.readFile(fileURLToPath(url));
210
+ }
211
+ if (url.startsWith("/")) {
212
+ return await fs2.readFile(url);
213
+ }
214
+ } catch {
215
+ return null;
216
+ }
217
+ return null;
218
+ }
219
+ function toJpegDataUri(buffer) {
220
+ return `data:image/jpeg;base64,${buffer.toString("base64")}`;
221
+ }
222
+
223
+ // src/attachments/rewriter.ts
224
+ function createAttachmentRewriter(config) {
225
+ const cache = new AttachmentCache(config.cacheEntries);
226
+ return async function rewriteAttachment(attachment) {
227
+ await logDebug(config, `inspect attachment mime=${attachment.mime} urlPrefix=${attachment.url.slice(0, 48)}`);
228
+ const sourceBuffer = await resolveImageBuffer(attachment.url, attachment.mime, config);
229
+ if (!sourceBuffer) {
230
+ await logDebug(config, `ignore attachment reason=unresolved-or-unsupported mime=${attachment.mime}`);
231
+ logBypassed(config, "unsupported or unreadable image payload");
232
+ return;
233
+ }
234
+ const key = cache.keyFor(sourceBuffer);
235
+ const cached = cache.get(key);
236
+ await logDebug(
237
+ config,
238
+ `resolved bytes=${sourceBuffer.length} cache=${cached ? "hit" : "miss"} mime=${attachment.mime}`
239
+ );
240
+ const result = cached ?? await compressAggressively(sourceBuffer, config);
241
+ if (!cached) cache.set(key, result);
242
+ if (!result.changed) {
243
+ await logDebug(config, `leave-original reason=${result.reason} bytes=${result.outputBytes}`);
244
+ logBypassed(config, result.reason);
245
+ return;
246
+ }
247
+ try {
248
+ attachment.url = toJpegDataUri(result.buffer);
249
+ attachment.mime = "image/jpeg";
250
+ await logDebug(
251
+ config,
252
+ `rewrite attachment original=${result.originalBytes} output=${result.outputBytes} q=${result.quality ?? "-"} edge=${result.maxEdge ?? "orig"}`
253
+ );
254
+ logCompressed(config, result);
255
+ } catch (error) {
256
+ await logDebug(config, `error applying optimized payload: ${String(error)}`);
257
+ logError(error instanceof Error ? error : new Error(String(error)));
258
+ }
259
+ };
260
+ }
261
+
262
+ // src/hooks/run-hook-safely.ts
263
+ async function runHookSafely(task) {
264
+ try {
265
+ await task();
266
+ } catch (error) {
267
+ logError(error instanceof Error ? error : new Error(String(error)));
268
+ }
269
+ }
270
+
271
+ // src/hooks/tool-hook.ts
272
+ function createToolHook(config) {
273
+ const rewriteAttachment = createAttachmentRewriter(config);
274
+ return async (input, output) => {
275
+ await runHookSafely(async () => {
276
+ if (input.tool !== "read") return;
277
+ const attachments = output.attachments;
278
+ if (!attachments?.length) return;
279
+ for (const attachment of attachments) {
280
+ if (attachment.type !== "file") continue;
281
+ await rewriteAttachment(attachment);
282
+ }
283
+ });
284
+ };
285
+ }
286
+
287
+ // src/hooks/message-hook.ts
288
+ function isDirectAttachment(part) {
289
+ return typeof part.mime === "string" && typeof part.url === "string";
290
+ }
291
+ function createMessageHook(config) {
292
+ const rewriteAttachment = createAttachmentRewriter(config);
293
+ return async (_input, output) => {
294
+ await runHookSafely(async () => {
295
+ for (const message of output.messages) {
296
+ for (const part of message.parts) {
297
+ if (part.type === "tool") {
298
+ if (part.state?.status !== "completed" || !part.state.attachments?.length) {
299
+ continue;
300
+ }
301
+ for (const attachment of part.state.attachments) {
302
+ await rewriteAttachment(attachment);
303
+ }
304
+ continue;
305
+ }
306
+ if (isDirectAttachment(part)) {
307
+ await rewriteAttachment(part);
308
+ }
309
+ }
310
+ }
311
+ });
312
+ };
313
+ }
314
+
315
+ // src/index.ts
316
+ var imageCompressor = async () => {
317
+ const config = createConfig();
318
+ logReady(config);
319
+ return {
320
+ "tool.execute.after": createToolHook(config),
321
+ "experimental.chat.messages.transform": createMessageHook(config)
322
+ };
323
+ };
324
+ var index_default = imageCompressor;
325
+ export {
326
+ index_default as default
327
+ };
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "opencode-image-compressor",
3
+ "version": "0.1.0",
4
+ "description": "OpenCode plugin that recompresses large image attachments into model-friendly JPEG payloads",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "exports": {
8
+ ".": "./dist/index.js"
9
+ },
10
+ "files": [
11
+ "dist"
12
+ ],
13
+ "keywords": [
14
+ "opencode",
15
+ "plugin",
16
+ "image",
17
+ "jpeg",
18
+ "compression",
19
+ "attachments",
20
+ "images"
21
+ ],
22
+ "license": "MIT",
23
+ "dependencies": {
24
+ "sharp": "^0.33.0"
25
+ },
26
+ "peerDependencies": {
27
+ "@opencode-ai/plugin": ">=1.2.27"
28
+ },
29
+ "devDependencies": {
30
+ "@opencode-ai/plugin": "^1.2.27",
31
+ "@types/bun": "latest",
32
+ "tsup": "^8.0.0",
33
+ "typescript": "^5.0.0"
34
+ },
35
+ "scripts": {
36
+ "build": "tsup",
37
+ "typecheck": "tsc --noEmit",
38
+ "prepublishOnly": "bun run typecheck && bun run build"
39
+ }
40
+ }