11export * as ReadTool from "./read"
22
33import { ToolFailure } from "@opencode-ai/llm"
4- // @ts -ignore Bun's static file import is embedded by `bun build --compile`; some consumers also declare *.wasm.
5- import photonWasm from "@silvia-odwyer/photon-node/photon_rs_bg.wasm" with { type : "file" }
64import { Effect , Layer , Schema } from "effect"
7- import path from "node:path"
8- import { fileURLToPath } from "node:url"
9- import { Config } from "../config"
105import { FileSystem } from "../filesystem"
6+ import { Image } from "../image"
117import { PermissionV2 } from "../permission"
128import { Tool } from "./tool"
139import { Tools } from "./tools"
1410
1511export const name = "read"
1612const SUPPORTED_IMAGE_MIMES = new Set ( [ "image/jpeg" , "image/png" , "image/gif" , "image/webp" ] )
17- const MAX_IMAGE_BASE64_BYTES = 5 * 1024 * 1024
18- const MAX_IMAGE_WIDTH = 2_000
19- const MAX_IMAGE_HEIGHT = 2_000
20- const JPEG_QUALITIES = [ 80 , 85 , 70 , 55 , 40 ]
21-
22- class ImageDecodeError extends Error {
23- constructor ( readonly resource : string ) {
24- super ( `Image could not be decoded: ${ resource } ` )
25- this . name = "ImageDecodeError"
26- }
27- }
28-
29- class ImageSizeError extends Error {
30- constructor (
31- readonly resource : string ,
32- readonly width : number ,
33- readonly height : number ,
34- readonly bytes : number ,
35- readonly maxWidth : number ,
36- readonly maxHeight : number ,
37- readonly maxBytes : number ,
38- ) {
39- super (
40- `Image ${ resource } is ${ width } x${ height } with base64 size ${ bytes } , exceeding configured limits ${ maxWidth } x${ maxHeight } /${ maxBytes } bytes` ,
41- )
42- this . name = "ImageSizeError"
43- }
44- }
4513const LocationInput = Schema . Struct ( {
4614 ...FileSystem . ReadInput . fields ,
4715 offset : FileSystem . ListPageInput . fields . offset . annotate ( {
@@ -58,14 +26,8 @@ export const layer = Layer.effectDiscard(
5826 Effect . gen ( function * ( ) {
5927 const tools = yield * Tools . Service
6028 const filesystem = yield * FileSystem . Service
61- const config = yield * Config . Service
29+ const image = yield * Image . Service
6230 const permission = yield * PermissionV2 . Service
63- const loadPhoton = yield * Effect . cached (
64- Effect . sync ( ( ) => {
65- ; ( globalThis as typeof globalThis & { __OPENCODE_PHOTON_WASM_PATH ?: string } ) . __OPENCODE_PHOTON_WASM_PATH =
66- path . isAbsolute ( photonWasm ) ? photonWasm : fileURLToPath ( new URL ( photonWasm , import . meta. url ) )
67- } ) . pipe ( Effect . andThen ( ( ) => Effect . promise ( ( ) => import ( "@silvia-odwyer/photon-node" ) ) ) ) ,
68- )
6931
7032 yield * tools
7133 . register ( {
@@ -98,95 +60,9 @@ export const layer = Layer.effectDiscard(
9860 limit : input . limit ,
9961 } )
10062 if ( content . type === "binary" && SUPPORTED_IMAGE_MIMES . has ( content . mime ) ) {
101- const mime = content . mime
102- const base64 = content . content
103- const image = Object . assign (
104- { } ,
105- ...( yield * config . entries ( ) ) . flatMap ( ( entry ) =>
106- entry . type === "document" && entry . info . attachments ?. image ? [ entry . info . attachments . image ] : [ ] ,
107- ) ,
108- )
109- const limits = {
110- autoResize : image . auto_resize ?? true ,
111- maxWidth : image . max_width ?? MAX_IMAGE_WIDTH ,
112- maxHeight : image . max_height ?? MAX_IMAGE_HEIGHT ,
113- maxBase64Bytes : image . max_base64_bytes ?? MAX_IMAGE_BASE64_BYTES ,
114- }
115- const photon = yield * loadPhoton
116- const decoded = yield * Effect . try ( {
117- try : ( ) => photon . PhotonImage . new_from_byteslice ( Buffer . from ( base64 , "base64" ) ) ,
118- catch : ( ) => new ImageDecodeError ( resolved . resource ) ,
119- } )
120- try {
121- const width = decoded . get_width ( )
122- const height = decoded . get_height ( )
123- const bytes = Buffer . byteLength ( base64 , "utf-8" )
124- if ( width <= limits . maxWidth && height <= limits . maxHeight && bytes <= limits . maxBase64Bytes )
125- return new FileSystem . BinaryContent ( { type : "binary" , content : base64 , encoding : "base64" , mime } )
126- if ( ! limits . autoResize )
127- return yield * Effect . fail (
128- new ImageSizeError (
129- resolved . resource ,
130- width ,
131- height ,
132- bytes ,
133- limits . maxWidth ,
134- limits . maxHeight ,
135- limits . maxBase64Bytes ,
136- ) ,
137- )
138- const scale = Math . min ( 1 , limits . maxWidth / width , limits . maxHeight / height )
139- const sizes = Array . from ( { length : 32 } ) . reduce < Array < { width : number ; height : number } > > ( ( acc ) => {
140- const previous = acc . at ( - 1 ) ?? {
141- width : Math . max ( 1 , Math . round ( width * scale ) ) ,
142- height : Math . max ( 1 , Math . round ( height * scale ) ) ,
143- }
144- const next =
145- acc . length === 0
146- ? previous
147- : {
148- width : previous . width === 1 ? 1 : Math . max ( 1 , Math . floor ( previous . width * 0.75 ) ) ,
149- height : previous . height === 1 ? 1 : Math . max ( 1 , Math . floor ( previous . height * 0.75 ) ) ,
150- }
151- return acc . some ( ( item ) => item . width === next . width && item . height === next . height )
152- ? acc
153- : [ ...acc , next ]
154- } , [ ] )
155- for ( const size of sizes ) {
156- const resized = photon . resize ( decoded , size . width , size . height , photon . SamplingFilter . Lanczos3 )
157- try {
158- const candidate = [
159- { content : Buffer . from ( resized . get_bytes ( ) ) . toString ( "base64" ) , mime : "image/png" } ,
160- ...JPEG_QUALITIES . map ( ( quality ) => ( {
161- content : Buffer . from ( resized . get_bytes_jpeg ( quality ) ) . toString ( "base64" ) ,
162- mime : "image/jpeg" ,
163- } ) ) ,
164- ] . find ( ( item ) => Buffer . byteLength ( item . content , "utf-8" ) <= limits . maxBase64Bytes )
165- if ( candidate )
166- return new FileSystem . BinaryContent ( {
167- type : "binary" ,
168- content : candidate . content ,
169- encoding : "base64" ,
170- mime : candidate . mime ,
171- } )
172- } finally {
173- resized . free ( )
174- }
175- }
176- return yield * Effect . fail (
177- new ImageSizeError (
178- resolved . resource ,
179- width ,
180- height ,
181- bytes ,
182- limits . maxWidth ,
183- limits . maxHeight ,
184- limits . maxBase64Bytes ,
185- ) ,
186- )
187- } finally {
188- decoded . free ( )
189- }
63+ return yield * image
64+ . normalize ( resolved . resource , content )
65+ . pipe ( Effect . catchTag ( "Image.ResizerUnavailableError" , ( ) => Effect . succeed ( content ) ) )
19066 }
19167 if ( content . type === "binary" )
19268 return yield * Effect . fail ( new FileSystem . BinaryFileError ( resolved . resource ) )
@@ -196,8 +72,8 @@ export const layer = Layer.effectDiscard(
19672 const message =
19773 error instanceof FileSystem . BinaryFileError ||
19874 error instanceof FileSystem . MediaIngestLimitError ||
199- error instanceof ImageDecodeError ||
200- error instanceof ImageSizeError
75+ error instanceof Image . DecodeError ||
76+ error instanceof Image . SizeError
20177 ? error . message
20278 : `Unable to read ${ input . path } `
20379 return new ToolFailure ( { message } )
0 commit comments