From 6918d29e79c5fdcf782751d24834d2f8bf86559f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Tue, 5 May 2026 10:28:23 -0500 Subject: [PATCH 01/13] wip --- packages/opencode/src/config/attachment.ts | 30 +++++++++++ packages/opencode/src/config/config.ts | 4 ++ packages/opencode/src/image/image.ts | 58 ++++++++++++++++++++++ packages/opencode/src/session/processor.ts | 11 +++- packages/opencode/src/session/prompt.ts | 12 +++-- 5 files changed, 110 insertions(+), 5 deletions(-) create mode 100644 packages/opencode/src/config/attachment.ts create mode 100644 packages/opencode/src/image/image.ts diff --git a/packages/opencode/src/config/attachment.ts b/packages/opencode/src/config/attachment.ts new file mode 100644 index 000000000000..d2563f8b325e --- /dev/null +++ b/packages/opencode/src/config/attachment.ts @@ -0,0 +1,30 @@ +export * as ConfigAttachment from "./attachment" + +import { Schema } from "effect" +import { zod } from "@/util/effect-zod" +import { PositiveInt, withStatics } from "@/util/schema" + +export const Image = Schema.Struct({ + auto_resize: Schema.optional(Schema.Boolean).annotate({ + description: "Resize images before sending them to the model when they exceed configured limits (default: true)", + }), + max_width: Schema.optional(PositiveInt).annotate({ + description: "Maximum image width before resizing or rejecting the attachment (default: 2000)", + }), + max_height: Schema.optional(PositiveInt).annotate({ + description: "Maximum image height before resizing or rejecting the attachment (default: 2000)", + }), + max_base64_bytes: Schema.optional(PositiveInt).annotate({ + description: "Maximum base64 payload bytes for an image attachment (default: 4718592)", + }), +}) + .annotate({ identifier: "ImageAttachmentConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Image = Schema.Schema.Type + +export const Info = Schema.Struct({ + image: Schema.optional(Image).annotate({ description: "Image attachment configuration" }), +}) + .annotate({ identifier: "AttachmentConfig" }) + .pipe(withStatics((s) => ({ zod: zod(s) }))) +export type Info = Schema.Schema.Type diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index 3a933f81e967..d14b41ef472a 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -25,6 +25,7 @@ import { containsPath } from "../project/instance-context" import { zod } from "@/util/effect-zod" import { NonNegativeInt, PositiveInt, withStatics, type DeepMutable } from "@/util/schema" import { ConfigAgent } from "./agent" +import { ConfigAttachment } from "./attachment" import { ConfigCommand } from "./command" import { ConfigFormatter } from "./formatter" import { ConfigLayout } from "./layout" @@ -206,6 +207,9 @@ export const Info = Schema.Struct({ layout: Schema.optional(ConfigLayout.Layout).annotate({ description: "@deprecated Always uses stretch layout." }), permission: Schema.optional(ConfigPermission.Info), tools: Schema.optional(Schema.Record(Schema.String, Schema.Boolean)), + attachment: Schema.optional(ConfigAttachment.Info).annotate({ + description: "Attachment processing configuration, including image size limits and resizing behavior", + }), enterprise: Schema.optional( Schema.Struct({ url: Schema.optional(Schema.String).annotate({ description: "Enterprise URL" }), diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts new file mode 100644 index 000000000000..06856ce6d93e --- /dev/null +++ b/packages/opencode/src/image/image.ts @@ -0,0 +1,58 @@ +import { Config } from "@/config/config" +import type { MessageV2 } from "@/session/message-v2" +import { Context, Effect, Layer } from "effect" + +export const MAX_BASE64_BYTES = 4.5 * 1024 * 1024 +export const MAX_WIDTH = 2000 +export const MAX_HEIGHT = 2000 +export const AUTO_RESIZE = true + +export interface Info { + autoResize: boolean + maxWidth: number + maxHeight: number + maxBase64Bytes: number +} + +export interface Interface { + readonly get: () => Effect.Effect + readonly checkBase64Size: (input: string) => Effect.Effect<{ ok: boolean; bytes: number }> + readonly sanitize: (input: MessageV2.FilePart) => Effect.Effect +} + +export class Service extends Context.Service()("@opencode/Image") {} + +export const layer = Layer.effect( + Service, + Effect.gen(function* () { + const config = yield* Config.Service + + const get = Effect.fn("Image.get")(function* () { + const image = (yield* config.get()).attachment?.image + return { + autoResize: image?.auto_resize ?? AUTO_RESIZE, + maxWidth: image?.max_width ?? MAX_WIDTH, + maxHeight: image?.max_height ?? MAX_HEIGHT, + maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES, + } + }) + + const checkBase64Size = Effect.fn("Image.checkBase64Size")(function* (input: string) { + const bytes = Buffer.byteLength(input, "utf8") + return { + ok: bytes <= (yield* get()).maxBase64Bytes, + bytes, + } + }) + + const sanitize = Effect.fn("Image.sanitize")(function* (input: MessageV2.FilePart) { + return input + }) + + return Service.of({ get, checkBase64Size, sanitize }) + }), +) + +export const defaultLayer = layer.pipe(Layer.provide(Config.defaultLayer)) + +export * as Image from "./image" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index cf1a7e0ae921..13d6b98782d1 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -9,6 +9,7 @@ import { Snapshot } from "@/snapshot" import * as Session from "./session" import { LLM } from "./llm" import { MessageV2 } from "./message-v2" +import { Image } from "@/image/image" import { isOverflow } from "./overflow" import { PartID } from "./schema" import type { SessionID } from "./schema" @@ -107,6 +108,7 @@ export const layer: Layer.Layer< const summary = yield* SessionSummary.Service const scope = yield* Scope.Scope const status = yield* SessionStatus.Service + const image = yield* Image.Service const create = Effect.fn("SessionProcessor.create")(function* (input: Input) { // Pre-capture snapshot before the LLM stream starts. The AI SDK @@ -182,6 +184,11 @@ export const layer: Layer.Layer< ) { const match = yield* readToolCall(toolCallID) if (!match || match.part.state.status !== "running") return + const attachments = output.attachments + ? yield* Effect.forEach(output.attachments, (attachment) => + attachment.mime.startsWith("image/") ? image.sanitize(attachment) : Effect.succeed(attachment), + ) + : undefined yield* session.updatePart({ ...match.part, state: { @@ -191,7 +198,7 @@ export const layer: Layer.Layer< metadata: output.metadata, title: output.title, time: { start: match.part.state.time.start, end: Date.now() }, - attachments: output.attachments, + attachments, }, }) yield* settleToolCall(toolCallID) @@ -743,7 +750,7 @@ export const layer: Layer.Layer< return Service.of({ create }) }), -) +).pipe(Layer.provide(Image.layer)) export const defaultLayer = Layer.suspend(() => layer.pipe( diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 0590fc38274c..f100940bebea 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -44,6 +44,7 @@ import { Shell } from "@/shell/shell" import { ShellID } from "@/tool/shell/id" import { AppFileSystem } from "@opencode-ai/core/filesystem" import { Truncate } from "@/tool/truncate" +import { Image } from "@/image/image" import { decodeDataUrl } from "@/util/data-url" import { Process } from "@/util/process" import { Cause, Effect, Exit, Latch, Layer, Option, Scope, Context, Schema, Types } from "effect" @@ -108,6 +109,7 @@ export const layer = Layer.effect( const lsp = yield* LSP.Service const registry = yield* ToolRegistry.Service const truncate = yield* Truncate.Service + const image = yield* Image.Service const spawner = yield* ChildProcessSpawner.ChildProcessSpawner const scope = yield* Scope.Scope const instruction = yield* Instruction.Service @@ -1258,7 +1260,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return [{ ...part, messageID: info.id, sessionID: input.sessionID }] }) - const parts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( + const resolvedParts = yield* Effect.forEach(input.parts, resolvePart, { concurrency: "unbounded" }).pipe( Effect.map((x) => x.flat().map(assign)), ) @@ -1271,7 +1273,11 @@ NOTE: At any point in time through this workflow you should feel free to ask the messageID: input.messageID, variant: input.variant, }, - { message: info, parts }, + { message: info, parts: resolvedParts }, + ) + + const parts = yield* Effect.forEach(resolvedParts, (part) => + part.type === "file" && part.mime.startsWith("image/") ? image.sanitize(part) : Effect.succeed(part), ) const parsed = MessageV2.Info.zod.safeParse(info) @@ -1765,7 +1771,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the resolvePromptParts, }) }), -) +).pipe(Layer.provide(Image.layer)) export const defaultLayer = Layer.suspend(() => layer.pipe( From c455e9f07eff2e84ad0c8fd0f92f5c84840c96c0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 8 May 2026 22:40:18 -0500 Subject: [PATCH 02/13] add photon image resizing --- AGENTS.md | 1 + bun.lock | 3 + packages/opencode/package.json | 1 + packages/opencode/src/audio.d.ts | 5 ++ packages/opencode/src/image/image.ts | 98 +++++++++++++++++++++++++++- 5 files changed, 107 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 44d08ae955eb..7913ddabd28e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,6 +9,7 @@ ### General Principles - Keep things in one function unless composable or reusable +- Do not extract single-use helpers preemptively. Inline the logic at the call site unless the helper is reused, hides a genuinely complex boundary, or has a clear independent name that improves the caller. - Avoid `try`/`catch` where possible - Avoid using the `any` type - Use Bun APIs when possible, like `Bun.file()` diff --git a/bun.lock b/bun.lock index 3e73e0c236f5..e8ca0ba10274 100644 --- a/bun.lock +++ b/bun.lock @@ -383,6 +383,7 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", + "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", @@ -2008,6 +2009,8 @@ "@sigstore/verify": ["@sigstore/verify@3.1.0", "", { "dependencies": { "@sigstore/bundle": "^4.0.0", "@sigstore/core": "^3.1.0", "@sigstore/protobuf-specs": "^0.5.0" } }, "sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag=="], + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + "@sindresorhus/is": ["@sindresorhus/is@4.6.0", "", {}, "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="], "@slack/bolt": ["@slack/bolt@3.22.0", "", { "dependencies": { "@slack/logger": "^4.0.0", "@slack/oauth": "^2.6.3", "@slack/socket-mode": "^1.3.6", "@slack/types": "^2.13.0", "@slack/web-api": "^6.13.0", "@types/express": "^4.16.1", "@types/promise.allsettled": "^1.0.3", "@types/tsscmp": "^1.0.0", "axios": "^1.7.4", "express": "^4.21.0", "path-to-regexp": "^8.1.0", "promise.allsettled": "^1.0.2", "raw-body": "^2.3.3", "tsscmp": "^1.0.6" } }, "sha512-iKDqGPEJDnrVwxSVlFW6OKTkijd7s4qLBeSufoBsTM0reTyfdp/5izIQVkxNfzjHi3o6qjdYbRXkYad5HBsBog=="], diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 06c1ac73710a..85f10f4b15e2 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -128,6 +128,7 @@ "@opentui/solid": "catalog:", "@parcel/watcher": "2.5.1", "@pierre/diffs": "catalog:", + "@silvia-odwyer/photon-node": "0.3.4", "@solid-primitives/event-bus": "1.1.2", "@solid-primitives/scheduled": "1.5.2", "@standard-schema/spec": "1.0.0", diff --git a/packages/opencode/src/audio.d.ts b/packages/opencode/src/audio.d.ts index 54a86efa3044..c7c947450dcf 100644 --- a/packages/opencode/src/audio.d.ts +++ b/packages/opencode/src/audio.d.ts @@ -2,3 +2,8 @@ declare module "*.wav" { const file: string export default file } + +declare module "*.wasm" { + const file: string + export default file +} diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index 06856ce6d93e..b80e282e4717 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -1,11 +1,44 @@ import { Config } from "@/config/config" import type { MessageV2 } from "@/session/message-v2" import { Context, Effect, Layer } from "effect" +import fs from "fs" + +import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" } export const MAX_BASE64_BYTES = 4.5 * 1024 * 1024 export const MAX_WIDTH = 2000 export const MAX_HEIGHT = 2000 export const AUTO_RESIZE = true +const JPEG_QUALITIES = [80, 85, 70, 55, 40] + +type Photon = typeof import("@silvia-odwyer/photon-node") + +let photonModule: Photon | null = null +let photonPromise: Promise | null = null + +function loadPhoton() { + if (photonModule) return Promise.resolve(photonModule) + if (photonPromise) return photonPromise + + photonPromise = (async () => { + const original = fs.readFileSync + fs.readFileSync = ((file: fs.PathOrFileDescriptor, options?: Parameters[1]) => { + if (typeof file === "string" && file.endsWith("photon_rs_bg.wasm")) return original(photonWasm, options) + return original(file, options) + }) as typeof fs.readFileSync + try { + photonModule = await import("@silvia-odwyer/photon-node") + return photonModule + } catch { + photonModule = null + return null + } finally { + fs.readFileSync = original + } + })() + + return photonPromise +} export interface Info { autoResize: boolean @@ -46,7 +79,70 @@ export const layer = Layer.effect( }) const sanitize = Effect.fn("Image.sanitize")(function* (input: MessageV2.FilePart) { - return input + const info = yield* get() + if (!info.autoResize) return input + if (!input.url.startsWith("data:") || !input.url.includes(";base64,")) return input + const data = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length) + + const photon = yield* Effect.promise(loadPhoton) + if (!photon) return input + + const image = yield* Effect.sync(() => { + try { + return photon.PhotonImage.new_from_byteslice(Buffer.from(data, "base64")) + } catch { + return undefined + } + }) + if (!image) return input + + try { + const originalWidth = image.get_width() + const originalHeight = image.get_height() + if ( + originalWidth <= info.maxWidth && + originalHeight <= info.maxHeight && + Buffer.byteLength(data, "utf8") <= info.maxBase64Bytes + ) + return input + + const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight) + let current = { + width: Math.max(1, Math.round(originalWidth * scale)), + height: Math.max(1, Math.round(originalHeight * scale)), + } + + while (true) { + const resized = photon.resize(image, current.width, current.height, photon.SamplingFilter.Lanczos3) + const candidate = [ + { data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" }, + ...JPEG_QUALITIES.map((quality) => ({ + data: Buffer.from(resized.get_bytes_jpeg(quality)).toString("base64"), + mime: "image/jpeg", + })), + ] + .map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") })) + .filter((item) => item.bytes <= info.maxBase64Bytes) + .sort((a, b) => a.bytes - b.bytes)[0] + resized.free() + + if (candidate) + return { + ...input, + mime: candidate.mime, + url: `data:${candidate.mime};base64,${candidate.data}`, + } + + const next = { + width: current.width === 1 ? 1 : Math.max(1, Math.floor(current.width * 0.75)), + height: current.height === 1 ? 1 : Math.max(1, Math.floor(current.height * 0.75)), + } + if (next.width === current.width && next.height === current.height) return input + current = next + } + } finally { + image.free() + } }) return Service.of({ get, checkBase64Size, sanitize }) From 6785a45776c00837528b62255379bee5f7630835 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Fri, 8 May 2026 23:36:56 -0500 Subject: [PATCH 03/13] resize stuff --- packages/opencode/src/image/image.ts | 140 +++++++++++++-------- packages/opencode/src/session/processor.ts | 19 ++- packages/opencode/src/session/prompt.ts | 10 +- packages/opencode/test/image/image.test.ts | 78 ++++++++++++ 4 files changed, 186 insertions(+), 61 deletions(-) create mode 100644 packages/opencode/test/image/image.test.ts diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index b80e282e4717..011d0ca9f5ee 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -1,15 +1,17 @@ import { Config } from "@/config/config" import type { MessageV2 } from "@/session/message-v2" -import { Context, Effect, Layer } from "effect" +import * as Log from "@opencode-ai/core/util/log" +import { Context, Effect, Layer, Schema } from "effect" import fs from "fs" import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" } -export const MAX_BASE64_BYTES = 4.5 * 1024 * 1024 -export const MAX_WIDTH = 2000 -export const MAX_HEIGHT = 2000 -export const AUTO_RESIZE = true +const MAX_BASE64_BYTES = 4.5 * 1024 * 1024 +const MAX_WIDTH = 2000 +const MAX_HEIGHT = 2000 +const AUTO_RESIZE = true const JPEG_QUALITIES = [80, 85, 70, 55, 40] +const log = Log.create({ service: "image" }) type Photon = typeof import("@silvia-odwyer/photon-node") @@ -40,17 +42,46 @@ function loadPhoton() { return photonPromise } -export interface Info { - autoResize: boolean - maxWidth: number - maxHeight: number - maxBase64Bytes: number +export class PhotonUnavailableError extends Schema.TaggedErrorClass()( + "ImagePhotonUnavailableError", + {}, +) { + override get message() { + return "Photon image processor is unavailable" + } } +export class InvalidDataUrlError extends Schema.TaggedErrorClass()("ImageInvalidDataUrlError", { + url: Schema.String, +}) { + override get message() { + return "Image URL must be a base64 data URL" + } +} + +export class DecodeError extends Schema.TaggedErrorClass()("ImageDecodeError", {}) { + override get message() { + return "Image could not be decoded" + } +} + +export class SizeError extends Schema.TaggedErrorClass()("ImageSizeError", { + bytes: Schema.Number, + max: Schema.Number, + width: Schema.Number, + height: Schema.Number, + max_width: Schema.Number, + max_height: Schema.Number, +}) { + override get message() { + return `Image ${this.width}x${this.height} with base64 size ${this.bytes} exceeds configured limits and could not be resized below ${this.max_width}x${this.max_height}/${this.max} bytes` + } +} + +export type Error = PhotonUnavailableError | InvalidDataUrlError | DecodeError | SizeError + export interface Interface { - readonly get: () => Effect.Effect - readonly checkBase64Size: (input: string) => Effect.Effect<{ ok: boolean; bytes: number }> - readonly sanitize: (input: MessageV2.FilePart) => Effect.Effect + readonly normalize: (input: MessageV2.FilePart) => Effect.Effect } export class Service extends Context.Service()("@opencode/Image") {} @@ -60,60 +91,56 @@ export const layer = Layer.effect( Effect.gen(function* () { const config = yield* Config.Service - const get = Effect.fn("Image.get")(function* () { + const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) { const image = (yield* config.get()).attachment?.image - return { + const info = { autoResize: image?.auto_resize ?? AUTO_RESIZE, maxWidth: image?.max_width ?? MAX_WIDTH, maxHeight: image?.max_height ?? MAX_HEIGHT, maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES, } - }) - - const checkBase64Size = Effect.fn("Image.checkBase64Size")(function* (input: string) { - const bytes = Buffer.byteLength(input, "utf8") - return { - ok: bytes <= (yield* get()).maxBase64Bytes, - bytes, - } - }) - - const sanitize = Effect.fn("Image.sanitize")(function* (input: MessageV2.FilePart) { - const info = yield* get() if (!info.autoResize) return input - if (!input.url.startsWith("data:") || !input.url.includes(";base64,")) return input - const data = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length) + if (!input.url.startsWith("data:") || !input.url.includes(";base64,")) + return yield* new InvalidDataUrlError({ url: input.url }) + const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length) const photon = yield* Effect.promise(loadPhoton) - if (!photon) return input + if (!photon) return yield* new PhotonUnavailableError() - const image = yield* Effect.sync(() => { + const decoded = yield* Effect.sync(() => { try { - return photon.PhotonImage.new_from_byteslice(Buffer.from(data, "base64")) + return photon.PhotonImage.new_from_byteslice(Buffer.from(base64, "base64")) } catch { return undefined } }) - if (!image) return input + if (!decoded) return yield* new DecodeError() try { - const originalWidth = image.get_width() - const originalHeight = image.get_height() + const originalWidth = decoded.get_width() + const originalHeight = decoded.get_height() if ( originalWidth <= info.maxWidth && originalHeight <= info.maxHeight && - Buffer.byteLength(data, "utf8") <= info.maxBase64Bytes + Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes ) return input const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight) - let current = { - width: Math.max(1, Math.round(originalWidth * scale)), - height: Math.max(1, Math.round(originalHeight * scale)), - } - - while (true) { - const resized = photon.resize(image, current.width, current.height, photon.SamplingFilter.Lanczos3) + for (const size of Array.from({ length: 32 }).reduce>((acc) => { + const previous = acc.at(-1) ?? { + width: Math.max(1, Math.round(originalWidth * scale)), + height: Math.max(1, Math.round(originalHeight * scale)), + } + const next = acc.length === 0 + ? previous + : { + width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)), + height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)), + } + return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next] + }, [])) { + const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3) const candidate = [ { data: Buffer.from(resized.get_bytes()).toString("base64"), mime: "image/png" }, ...JPEG_QUALITIES.map((quality) => ({ @@ -126,26 +153,35 @@ export const layer = Layer.effect( .sort((a, b) => a.bytes - b.bytes)[0] resized.free() - if (candidate) + if (candidate) { + log.info("using resized image", { + from_mime: input.mime, + to_mime: candidate.mime, + from: `${originalWidth}x${originalHeight}`, + to: `${size.width}x${size.height}`, + }) return { ...input, mime: candidate.mime, url: `data:${candidate.mime};base64,${candidate.data}`, } - - const next = { - width: current.width === 1 ? 1 : Math.max(1, Math.floor(current.width * 0.75)), - height: current.height === 1 ? 1 : Math.max(1, Math.floor(current.height * 0.75)), } - if (next.width === current.width && next.height === current.height) return input - current = next } + + return yield* new SizeError({ + bytes: Buffer.byteLength(base64, "utf8"), + max: info.maxBase64Bytes, + width: originalWidth, + height: originalHeight, + max_width: info.maxWidth, + max_height: info.maxHeight, + }) } finally { - image.free() + decoded.free() } }) - return Service.of({ get, checkBase64Size, sanitize }) + return Service.of({ normalize }) }), ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 5f0883f010c0..c20144aec3db 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Layer, Context, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -185,21 +185,30 @@ export const layer: Layer.Layer< ) { const match = yield* readToolCall(toolCallID) if (!match || match.part.state.status !== "running") return - const attachments = output.attachments + const normalized = output.attachments ? yield* Effect.forEach(output.attachments, (attachment) => - attachment.mime.startsWith("image/") ? image.sanitize(attachment) : Effect.succeed(attachment), + attachment.mime.startsWith("image/") + ? image.normalize(attachment).pipe(Effect.exit) + : Effect.succeed(Exit.succeed(attachment)), ) : undefined + const omitted = normalized?.filter(Exit.isFailure).length ?? 0 + const attachments = normalized + ?.filter(Exit.isSuccess) + .map((item) => item.value) yield* session.updatePart({ ...match.part, state: { status: "completed", input: match.part.state.input, - output: output.output, + output: + omitted === 0 + ? output.output + : `${output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`, metadata: output.metadata, title: output.title, time: { start: match.part.state.time.start, end: Date.now() }, - attachments, + attachments: attachments?.length ? attachments : undefined, }, }) yield* settleToolCall(toolCallID) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 2a5ffa345e0f..7efee463bd58 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -81,10 +81,10 @@ const elog = EffectLogger.create({ service: "session.prompt" }) export interface Interface { readonly cancel: (sessionID: SessionID) => Effect.Effect - readonly prompt: (input: PromptInput) => Effect.Effect + readonly prompt: (input: PromptInput) => Effect.Effect readonly loop: (input: LoopInput) => Effect.Effect readonly shell: (input: ShellInput) => Effect.Effect - readonly command: (input: CommandInput) => Effect.Effect + readonly command: (input: CommandInput) => Effect.Effect readonly resolvePromptParts: (template: string) => Effect.Effect } @@ -1278,7 +1278,9 @@ NOTE: At any point in time through this workflow you should feel free to ask the ) const parts = yield* Effect.forEach(resolvedParts, (part) => - part.type === "file" && part.mime.startsWith("image/") ? image.sanitize(part) : Effect.succeed(part), + part.type === "file" && part.mime.startsWith("image/") + ? image.normalize(part) + : Effect.succeed(part), ) const parsed = MessageV2.Info.zod.safeParse(info) @@ -1374,7 +1376,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the return { info, parts } }, Effect.scoped) - const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( + const prompt: (input: PromptInput) => Effect.Effect = Effect.fn("SessionPrompt.prompt")( function* (input: PromptInput) { const session = yield* sessions.get(input.sessionID).pipe(Effect.orDie) yield* revert.cleanup(session) diff --git a/packages/opencode/test/image/image.test.ts b/packages/opencode/test/image/image.test.ts new file mode 100644 index 000000000000..0b46bf258950 --- /dev/null +++ b/packages/opencode/test/image/image.test.ts @@ -0,0 +1,78 @@ +import { describe, expect } from "bun:test" +import { Cause, Effect, Exit, Layer } from "effect" +import { Image } from "@/image/image" +import { MessageID, PartID, SessionID } from "@/session/schema" +import { TestConfig } from "../fixture/config" +import { testEffect } from "../lib/effect" + +const it = testEffect(Layer.mergeAll(Image.layer.pipe(Layer.provide(TestConfig.layer())))) +const tiny = testEffect( + Layer.mergeAll( + Image.layer.pipe( + Layer.provide(TestConfig.layer({ get: () => Effect.succeed({ attachment: { image: { max_base64_bytes: 1 } } }) })), + ), + ), +) + +function part(mime: string, data: string) { + return { + id: PartID.ascending(), + messageID: MessageID.ascending(), + sessionID: SessionID.make("test"), + type: "file" as const, + mime, + url: `data:${mime};base64,${data}`, + } +} + +describe("Image", () => { + it.effect("normalizes generated png and jpeg attachments", () => + Effect.gen(function* () { + const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) + const source = new photon.PhotonImage( + new Uint8Array(Array.from({ length: 64 * 64 * 4 }, (_, index) => (index % 4 === 3 ? 255 : index % 251))), + 64, + 64, + ) + const image = yield* Image.Service + const results = yield* Effect.all([ + image.normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))), + image.normalize(part("image/jpeg", Buffer.from(source.get_bytes_jpeg(90)).toString("base64"))), + ]) + + source.free() + expect(results.map((result) => result.url.startsWith(`data:${result.mime};base64,`))).toEqual([true, true]) + expect(results.every((result) => result.mime === "image/png" || result.mime === "image/jpeg")).toBe(true) + }), + ) + + it.effect("accepts webp attachments that are already within limits", () => + Effect.gen(function* () { + const image = yield* Image.Service + const input = part("image/webp", "UklGRiIAAABXRUJQVlA4IBYAAAAwAQCdASoBAAEADsD+JaQAA3AAAAAA") + + expect(yield* image.normalize(input)).toEqual(input) + }), + ) + + tiny.effect("fails with a typed size error when no resized candidate fits", () => + Effect.gen(function* () { + const photon = yield* Effect.promise(() => import("@silvia-odwyer/photon-node")) + const source = new photon.PhotonImage(new Uint8Array(Array.from({ length: 4 }, () => 255)), 1, 1) + const image = yield* Image.Service + const exit = yield* image.normalize(part("image/png", Buffer.from(source.get_bytes()).toString("base64"))).pipe(Effect.exit) + + source.free() + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isFailure(exit)) { + const error = Cause.squash(exit.cause) + expect(error).toBeInstanceOf(Image.SizeError) + if (error instanceof Image.SizeError) { + expect(error.width).toBe(1) + expect(error.height).toBe(1) + expect(error.max).toBe(1) + } + } + }), + ) +}) From a7060c6e6438333d5c59109c265b1696f794041b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 9 May 2026 00:44:15 -0500 Subject: [PATCH 04/13] type errs --- .../instance/httpapi/handlers/session.ts | 20 +++++++++++-------- packages/opencode/src/session/prompt.ts | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 56fa7adb15c5..0bdb4f1cbedf 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -203,13 +203,15 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof InitPayload.Type }) { - yield* promptSvc.command({ - sessionID: ctx.params.sessionID, - messageID: ctx.payload.messageID, - model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, - command: Command.Default.INIT, - arguments: "", - }) + yield* promptSvc + .command({ + sessionID: ctx.params.sessionID, + messageID: ctx.payload.messageID, + model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, + command: Command.Default.INIT, + arguments: "", + }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) return true }) @@ -290,7 +292,9 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } payload: typeof CommandPayload.Type }) { - return yield* promptSvc.command({ ...ctx.payload, sessionID: ctx.params.sessionID }) + return yield* promptSvc + .command({ ...ctx.payload, sessionID: ctx.params.sessionID }) + .pipe(Effect.mapError(() => new HttpApiError.BadRequest({}))) }) const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 7efee463bd58..5a531091fc0c 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -125,7 +125,7 @@ export const layer = Layer.effect( return { cancel: (sessionID: SessionID) => cancel(sessionID), resolvePromptParts: (template: string) => resolvePromptParts(template), - prompt: (input: PromptInput) => prompt(input), + prompt: (input: PromptInput) => prompt(input).pipe(Effect.catch(Effect.die)), } satisfies TaskPromptOps }) From 4850562ff61965570efd0fa812cc4fbe0f78647a Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 9 May 2026 01:14:45 -0500 Subject: [PATCH 05/13] load wasm --- packages/opencode/src/image/image.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index 011d0ca9f5ee..fb125199e0ea 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -4,8 +4,6 @@ import * as Log from "@opencode-ai/core/util/log" import { Context, Effect, Layer, Schema } from "effect" import fs from "fs" -import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type: "file" } - const MAX_BASE64_BYTES = 4.5 * 1024 * 1024 const MAX_WIDTH = 2000 const MAX_HEIGHT = 2000 @@ -23,6 +21,7 @@ function loadPhoton() { if (photonPromise) return photonPromise photonPromise = (async () => { + const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })).default const original = fs.readFileSync fs.readFileSync = ((file: fs.PathOrFileDescriptor, options?: Parameters[1]) => { if (typeof file === "string" && file.endsWith("photon_rs_bg.wasm")) return original(photonWasm, options) From 628044e1cdab1576ad261bd9d06a92baaffbb356 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 9 May 2026 12:57:35 -0500 Subject: [PATCH 06/13] address pr feedback --- packages/opencode/src/image/image.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index fb125199e0ea..903d82ae079d 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -98,7 +98,6 @@ export const layer = Layer.effect( maxHeight: image?.max_height ?? MAX_HEIGHT, maxBase64Bytes: image?.max_base64_bytes ?? MAX_BASE64_BYTES, } - if (!info.autoResize) return input if (!input.url.startsWith("data:") || !input.url.includes(";base64,")) return yield* new InvalidDataUrlError({ url: input.url }) @@ -124,6 +123,15 @@ export const layer = Layer.effect( Buffer.byteLength(base64, "utf8") <= info.maxBase64Bytes ) return input + if (!info.autoResize) + return yield* new SizeError({ + bytes: Buffer.byteLength(base64, "utf8"), + max: info.maxBase64Bytes, + width: originalWidth, + height: originalHeight, + max_width: info.maxWidth, + max_height: info.maxHeight, + }) const scale = Math.min(1, info.maxWidth / originalWidth, info.maxHeight / originalHeight) for (const size of Array.from({ length: 32 }).reduce>((acc) => { @@ -148,8 +156,7 @@ export const layer = Layer.effect( })), ] .map((item) => ({ ...item, bytes: Buffer.byteLength(item.data, "utf8") })) - .filter((item) => item.bytes <= info.maxBase64Bytes) - .sort((a, b) => a.bytes - b.bytes)[0] + .find((item) => item.bytes <= info.maxBase64Bytes) resized.free() if (candidate) { From 88af039f9aed13ff0e2b54edbacec1c001da8111 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 9 May 2026 15:37:14 -0500 Subject: [PATCH 07/13] feedback --- packages/opencode/src/image/image.ts | 47 +++++++---------- .../instance/httpapi/handlers/session.ts | 22 ++++---- packages/opencode/src/session/processor.ts | 52 ++++++++++++------- 3 files changed, 61 insertions(+), 60 deletions(-) diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index 903d82ae079d..a231cfb43fbc 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -13,34 +13,6 @@ const log = Log.create({ service: "image" }) type Photon = typeof import("@silvia-odwyer/photon-node") -let photonModule: Photon | null = null -let photonPromise: Promise | null = null - -function loadPhoton() { - if (photonModule) return Promise.resolve(photonModule) - if (photonPromise) return photonPromise - - photonPromise = (async () => { - const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })).default - const original = fs.readFileSync - fs.readFileSync = ((file: fs.PathOrFileDescriptor, options?: Parameters[1]) => { - if (typeof file === "string" && file.endsWith("photon_rs_bg.wasm")) return original(photonWasm, options) - return original(file, options) - }) as typeof fs.readFileSync - try { - photonModule = await import("@silvia-odwyer/photon-node") - return photonModule - } catch { - photonModule = null - return null - } finally { - fs.readFileSync = original - } - })() - - return photonPromise -} - export class PhotonUnavailableError extends Schema.TaggedErrorClass()( "ImagePhotonUnavailableError", {}, @@ -89,6 +61,23 @@ export const layer = Layer.effect( Service, Effect.gen(function* () { const config = yield* Config.Service + const loadPhoton = yield* Effect.cached( + Effect.promise(async () => { + const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })).default + const original = fs.readFileSync + fs.readFileSync = ((file: fs.PathOrFileDescriptor, options?: Parameters[1]) => { + if (typeof file === "string" && file.endsWith("photon_rs_bg.wasm")) return original(photonWasm, options) + return original(file, options) + }) as typeof fs.readFileSync + try { + return await import("@silvia-odwyer/photon-node") + } catch { + return null + } finally { + fs.readFileSync = original + } + }), + ) const normalize = Effect.fn("Image.normalize")(function* (input: MessageV2.FilePart) { const image = (yield* config.get()).attachment?.image @@ -102,7 +91,7 @@ export const layer = Layer.effect( return yield* new InvalidDataUrlError({ url: input.url }) const base64 = input.url.slice(input.url.indexOf(";base64,") + ";base64,".length) - const photon = yield* Effect.promise(loadPhoton) + const photon = yield* loadPhoton if (!photon) return yield* new PhotonUnavailableError() const decoded = yield* Effect.sync(() => { diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index 3baf409d3b82..e64ad6d504c6 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -260,18 +260,18 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", }) { const instance = yield* InstanceState.context const workspace = yield* InstanceState.workspaceID + const message = yield* promptSvc + .prompt({ + ...ctx.payload, + sessionID: ctx.params.sessionID, + }) + .pipe( + Effect.provideService(InstanceRef, instance), + Effect.provideService(WorkspaceRef, workspace), + Effect.mapError(() => new HttpApiError.BadRequest({})), + ) return HttpServerResponse.stream( - Stream.fromEffect( - promptSvc - .prompt({ - ...ctx.payload, - sessionID: ctx.params.sessionID, - }) - .pipe(Effect.provideService(InstanceRef, instance), Effect.provideService(WorkspaceRef, workspace)), - ).pipe( - Stream.map((message) => JSON.stringify(message)), - Stream.encodeText, - ), + Stream.make(JSON.stringify(message)).pipe(Stream.encodeText), { contentType: "application/json" }, ) }) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index c20144aec3db..c80823d23c27 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -185,30 +185,16 @@ export const layer: Layer.Layer< ) { const match = yield* readToolCall(toolCallID) if (!match || match.part.state.status !== "running") return - const normalized = output.attachments - ? yield* Effect.forEach(output.attachments, (attachment) => - attachment.mime.startsWith("image/") - ? image.normalize(attachment).pipe(Effect.exit) - : Effect.succeed(Exit.succeed(attachment)), - ) - : undefined - const omitted = normalized?.filter(Exit.isFailure).length ?? 0 - const attachments = normalized - ?.filter(Exit.isSuccess) - .map((item) => item.value) yield* session.updatePart({ ...match.part, state: { status: "completed", input: match.part.state.input, - output: - omitted === 0 - ? output.output - : `${output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`, + output: output.output, metadata: output.metadata, title: output.title, time: { start: match.part.state.time.start, end: Date.now() }, - attachments: attachments?.length ? attachments : undefined, + attachments: output.attachments, }, }) yield* settleToolCall(toolCallID) @@ -393,17 +379,43 @@ export const layer: Layer.Layer< case "tool-result": { const toolCall = yield* readToolCall(value.toolCallId) + const toolAttachments: MessageV2.FilePart[] = ( + Array.isArray(value.output.attachments) ? value.output.attachments : [] + ).filter( + (attachment: unknown): attachment is MessageV2.FilePart => + isRecord(attachment) && + attachment.type === "file" && + typeof attachment.mime === "string" && + typeof attachment.url === "string", + ) + const normalized = yield* Effect.forEach( + toolAttachments, + (attachment) => + attachment.mime.startsWith("image/") + ? image.normalize(attachment).pipe(Effect.exit) + : Effect.succeed(Exit.succeed(attachment)), + ) + const omitted = normalized.filter(Exit.isFailure).length + const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value) + const output = { + ...value.output, + output: + omitted === 0 + ? value.output.output + : `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the inline image size limit.]`, + attachments: attachments?.length ? attachments : undefined, + } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. EventV2.run(SessionEvent.Tool.Success.Sync, { sessionID: ctx.sessionID, callID: value.toolCallId, - structured: value.output.metadata, + structured: output.metadata, content: [ { type: "text", - text: value.output.output, + text: output.output, }, - ...(value.output.attachments?.map((item: MessageV2.FilePart) => ({ + ...(output.attachments?.map((item: MessageV2.FilePart) => ({ type: "file", uri: item.url, mime: item.mime, @@ -415,7 +427,7 @@ export const layer: Layer.Layer< }, timestamp: DateTime.makeUnsafe(Date.now()), }) - yield* completeToolCall(value.toolCallId, value.output) + yield* completeToolCall(value.toolCallId, output) return } From 9eb34421b4dbb7c05e749a704b24093ef961827c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 9 May 2026 15:42:25 -0500 Subject: [PATCH 08/13] fix attachment config imports --- packages/opencode/src/config/attachment.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/config/attachment.ts b/packages/opencode/src/config/attachment.ts index d2563f8b325e..7af429afdee7 100644 --- a/packages/opencode/src/config/attachment.ts +++ b/packages/opencode/src/config/attachment.ts @@ -1,8 +1,8 @@ export * as ConfigAttachment from "./attachment" import { Schema } from "effect" -import { zod } from "@/util/effect-zod" -import { PositiveInt, withStatics } from "@/util/schema" +import { zod } from "@opencode-ai/core/effect-zod" +import { PositiveInt, withStatics } from "@opencode-ai/core/schema" export const Image = Schema.Struct({ auto_resize: Schema.optional(Schema.Boolean).annotate({ From 3ed0ba923aca60a014df6570fd390bc2c2f9008e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 9 May 2026 15:44:56 -0500 Subject: [PATCH 09/13] regen sdk --- packages/sdk/js/src/v2/gen/types.gen.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 5a79ae266160..5460357c83e7 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1132,6 +1132,17 @@ export type McpRemoteConfig = { */ export type LayoutConfig = "auto" | "stretch" +export type ImageAttachmentConfig = { + auto_resize?: boolean + max_width?: number + max_height?: number + max_base64_bytes?: number +} + +export type AttachmentConfig = { + image?: ImageAttachmentConfig +} + export type Config = { $schema?: string shell?: string @@ -1246,6 +1257,7 @@ export type Config = { tools?: { [key: string]: boolean } + attachment?: AttachmentConfig enterprise?: { url?: string } From 31e3fe80ae721ee103d69704144c77de54f46ef7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sat, 9 May 2026 16:01:28 -0500 Subject: [PATCH 10/13] feedback --- bun.lock | 1 + package.json | 1 + packages/opencode/src/image/image.ts | 9 +-------- packages/opencode/src/session/processor.ts | 4 +++- packages/opencode/src/session/prompt.ts | 3 ++- packages/opencode/test/session/compaction.test.ts | 3 ++- .../opencode/test/session/processor-effect.test.ts | 3 ++- packages/opencode/test/session/prompt.test.ts | 4 +++- .../test/session/snapshot-tool-race.test.ts | 8 +++++++- patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch | 13 +++++++++++++ 10 files changed, 35 insertions(+), 14 deletions(-) create mode 100644 patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch diff --git a/bun.lock b/bun.lock index ad343cf3be3d..268ddea2a411 100644 --- a/bun.lock +++ b/bun.lock @@ -678,6 +678,7 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", }, "overrides": { "@types/bun": "catalog:", diff --git a/package.json b/package.json index 27a3597553e5..5faf8be92076 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ }, "patchedDependencies": { "@npmcli/agent@4.0.0": "patches/@npmcli%2Fagent@4.0.0.patch", + "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch" } diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index a231cfb43fbc..c8d465049381 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -2,7 +2,6 @@ import { Config } from "@/config/config" import type { MessageV2 } from "@/session/message-v2" import * as Log from "@opencode-ai/core/util/log" import { Context, Effect, Layer, Schema } from "effect" -import fs from "fs" const MAX_BASE64_BYTES = 4.5 * 1024 * 1024 const MAX_WIDTH = 2000 @@ -64,17 +63,11 @@ export const layer = Layer.effect( const loadPhoton = yield* Effect.cached( Effect.promise(async () => { const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })).default - const original = fs.readFileSync - fs.readFileSync = ((file: fs.PathOrFileDescriptor, options?: Parameters[1]) => { - if (typeof file === "string" && file.endsWith("photon_rs_bg.wasm")) return original(photonWasm, options) - return original(file, options) - }) as typeof fs.readFileSync + ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = photonWasm try { return await import("@silvia-odwyer/photon-node") } catch { return null - } finally { - fs.readFileSync = original } }), ) diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index c80823d23c27..d87f04f888a2 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -93,6 +93,7 @@ export const layer: Layer.Layer< | LLM.Service | Permission.Service | Plugin.Service + | Image.Service | SessionSummary.Service | SessionStatus.Service > = Layer.effect( @@ -774,7 +775,7 @@ export const layer: Layer.Layer< return Service.of({ create }) }), -).pipe(Layer.provide(Image.layer)) +) export const defaultLayer = Layer.suspend(() => layer.pipe( @@ -786,6 +787,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionStatus.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), ), diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index eafa1f3735cc..1fd61d23e094 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -1774,7 +1774,7 @@ NOTE: At any point in time through this workflow you should feel free to ask the resolvePromptParts, }) }), -).pipe(Layer.provide(Image.layer)) +) export const defaultLayer = Layer.suspend(() => layer.pipe( @@ -1796,6 +1796,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Session.defaultLayer), Layer.provide(SessionRevert.defaultLayer), Layer.provide(SessionSummary.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide( Layer.mergeAll( Agent.defaultLayer, diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index cde9c1397f0b..03b2576154f7 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -5,6 +5,7 @@ import * as Stream from "effect/Stream" import z from "zod" import { Bus } from "../../src/bus" import { Config } from "@/config/config" +import { Image } from "@/image/image" import { Agent } from "../../src/agent/agent" import { LLM } from "../../src/session/llm" import { SessionCompaction } from "../../src/session/compaction" @@ -278,7 +279,7 @@ function llm() { function liveRuntime(layer: Layer.Layer, provider = ProviderTest.fake(), config = Config.defaultLayer) { const bus = Bus.layer const status = SessionStatus.layer.pipe(Layer.provide(bus)) - const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary)) + const processor = SessionProcessorModule.SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer)) return ManagedRuntime.make( Layer.mergeAll(SessionCompaction.layer.pipe(Layer.provide(processor)), processor, bus, status).pipe( Layer.provide(provider.layer), diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 226bab9864ec..a0736b459bea 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -6,6 +6,7 @@ import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" import { Config } from "@/config/config" +import { Image } from "@/image/image" import { Permission } from "../../src/permission" import { Plugin } from "../../src/plugin" import { Provider } from "@/provider/provider" @@ -168,7 +169,7 @@ const deps = Layer.mergeAll( ).pipe(Layer.provideMerge(infra)) const env = Layer.mergeAll( TestLLMServer.layer, - SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)), + SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provideMerge(deps)), ) const it = testEffect(env) diff --git a/packages/opencode/test/session/prompt.test.ts b/packages/opencode/test/session/prompt.test.ts index 3b0009d2b356..bf3811113c7e 100644 --- a/packages/opencode/test/session/prompt.test.ts +++ b/packages/opencode/test/session/prompt.test.ts @@ -16,6 +16,7 @@ import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" import { Git } from "../../src/git" +import { Image } from "../../src/image/image" import { ModelID, ProviderID } from "../../src/provider/schema" import { Question } from "../../src/question" import { Todo } from "../../src/session/todo" @@ -187,12 +188,13 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe(Layer.provide(summary), Layer.provide(Image.defaultLayer), Layer.provideMerge(deps)) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(summary), Layer.provideMerge(run), Layer.provideMerge(compact), diff --git a/packages/opencode/test/session/snapshot-tool-race.test.ts b/packages/opencode/test/session/snapshot-tool-race.test.ts index 82b88a72fd6d..131229d933d3 100644 --- a/packages/opencode/test/session/snapshot-tool-race.test.ts +++ b/packages/opencode/test/session/snapshot-tool-race.test.ts @@ -41,6 +41,7 @@ import { Plugin } from "../../src/plugin" import { Provider as ProviderSvc } from "@/provider/provider" import { Env } from "../../src/env" import { Question } from "../../src/question" +import { Image } from "../../src/image/image" import { Skill } from "../../src/skill" import { SystemPrompt } from "../../src/session/system" import { Todo } from "../../src/session/todo" @@ -137,13 +138,18 @@ function makeHttp() { Layer.provideMerge(deps), ) const trunc = Truncate.layer.pipe(Layer.provideMerge(deps)) - const proc = SessionProcessor.layer.pipe(Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(deps)) + const proc = SessionProcessor.layer.pipe( + Layer.provide(SessionSummary.defaultLayer), + Layer.provide(Image.defaultLayer), + Layer.provideMerge(deps), + ) const compact = SessionCompaction.layer.pipe(Layer.provideMerge(proc), Layer.provideMerge(deps)) return Layer.mergeAll( TestLLMServer.layer, SessionSummary.defaultLayer, SessionPrompt.layer.pipe( Layer.provide(SessionRevert.defaultLayer), + Layer.provide(Image.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provideMerge(run), Layer.provideMerge(compact), diff --git a/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch new file mode 100644 index 000000000000..0d75d8da2055 --- /dev/null +++ b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch @@ -0,0 +1,13 @@ +diff --git a/photon_rs.js b/photon_rs.js +index 8f4144d..b83e9a9 100644 +--- a/photon_rs.js ++++ b/photon_rs.js +@@ -4509,7 +4509,7 @@ module.exports.__wbindgen_init_externref_table = function() { + ; + }; + +-const path = require('path').join(__dirname, 'photon_rs_bg.wasm'); ++const path = globalThis.__OPENCODE_PHOTON_WASM_PATH || require('path').join(__dirname, 'photon_rs_bg.wasm'); + const bytes = require('fs').readFileSync(path); + + const wasmModule = new WebAssembly.Module(bytes); From 0f1a4f6a707ba504dbf2d1c8b6cd3bc8d74a168f Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 10 May 2026 01:22:04 -0500 Subject: [PATCH 11/13] fix --- packages/opencode/src/image/image.ts | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index c8d465049381..8243620612ba 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -10,8 +10,6 @@ const AUTO_RESIZE = true const JPEG_QUALITIES = [80, 85, 70, 55, 40] const log = Log.create({ service: "image" }) -type Photon = typeof import("@silvia-odwyer/photon-node") - export class PhotonUnavailableError extends Schema.TaggedErrorClass()( "ImagePhotonUnavailableError", {}, @@ -62,8 +60,10 @@ export const layer = Layer.effect( const config = yield* Config.Service const loadPhoton = yield* Effect.cached( Effect.promise(async () => { - const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })).default - ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = photonWasm + const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })) + .default + ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = + photonWasm try { return await import("@silvia-odwyer/photon-node") } catch { @@ -121,12 +121,13 @@ export const layer = Layer.effect( width: Math.max(1, Math.round(originalWidth * scale)), height: Math.max(1, Math.round(originalHeight * scale)), } - const next = acc.length === 0 - ? previous - : { - width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)), - height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)), - } + const next = + acc.length === 0 + ? previous + : { + width: previous.width === 1 ? 1 : Math.max(1, Math.floor(previous.width * 0.75)), + height: previous.height === 1 ? 1 : Math.max(1, Math.floor(previous.height * 0.75)), + } return acc.some((item) => item.width === next.width && item.height === next.height) ? acc : [...acc, next] }, [])) { const resized = photon.resize(decoded, size.width, size.height, photon.SamplingFilter.Lanczos3) From 9b10fe2e75afbfd8016e8179261854861f2f1f54 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 10 May 2026 01:29:49 -0500 Subject: [PATCH 12/13] fixes --- packages/opencode/src/image/image.ts | 1 + packages/opencode/test/image/image.test.ts | 2 +- patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index 8243620612ba..a822d22e0ea6 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -62,6 +62,7 @@ export const layer = Layer.effect( Effect.promise(async () => { const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })) .default + // Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path. ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = photonWasm try { diff --git a/packages/opencode/test/image/image.test.ts b/packages/opencode/test/image/image.test.ts index 0b46bf258950..67e6977e385b 100644 --- a/packages/opencode/test/image/image.test.ts +++ b/packages/opencode/test/image/image.test.ts @@ -18,7 +18,7 @@ function part(mime: string, data: string) { return { id: PartID.ascending(), messageID: MessageID.ascending(), - sessionID: SessionID.make("test"), + sessionID: SessionID.make("ses_test"), type: "file" as const, mime, url: `data:${mime};base64,${data}`, diff --git a/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch index 0d75d8da2055..2e432255627e 100644 --- a/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch +++ b/patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch @@ -2,11 +2,12 @@ diff --git a/photon_rs.js b/photon_rs.js index 8f4144d..b83e9a9 100644 --- a/photon_rs.js +++ b/photon_rs.js -@@ -4509,7 +4509,7 @@ module.exports.__wbindgen_init_externref_table = function() { +@@ -4509,7 +4509,8 @@ module.exports.__wbindgen_init_externref_table = function() { ; }; -const path = require('path').join(__dirname, 'photon_rs_bg.wasm'); ++// Allow opencode's Bun compiled binary to point photon-node at its embedded wasm asset. +const path = globalThis.__OPENCODE_PHOTON_WASM_PATH || require('path').join(__dirname, 'photon_rs_bg.wasm'); const bytes = require('fs').readFileSync(path); From 4436a7b8d76d8b1ab788670f79752aa6f2ecd766 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 10 May 2026 01:33:22 -0500 Subject: [PATCH 13/13] fix --- packages/opencode/src/image/image.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/image/image.ts b/packages/opencode/src/image/image.ts index a822d22e0ea6..2115e19198b7 100644 --- a/packages/opencode/src/image/image.ts +++ b/packages/opencode/src/image/image.ts @@ -60,12 +60,12 @@ export const layer = Layer.effect( const config = yield* Config.Service const loadPhoton = yield* Effect.cached( Effect.promise(async () => { - const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })) - .default - // Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path. - ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = - photonWasm try { + const photonWasm = (await import("@silvia-odwyer/photon-node/photon_rs_bg.wasm", { with: { type: "file" } })) + .default + // Patched photon-node reads this during module init so Bun compiled binaries use the embedded wasm path. + ;(globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH?: string }).__OPENCODE_PHOTON_WASM_PATH = + photonWasm return await import("@silvia-odwyer/photon-node") } catch { return null