clanka 0.5.2 → 0.6.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.
Files changed (76) hide show
  1. package/dist/Acp.d.ts.map +1 -1
  2. package/dist/Acp.js +195 -16
  3. package/dist/Acp.js.map +1 -1
  4. package/dist/AcpImage.test.d.ts +2 -0
  5. package/dist/AcpImage.test.d.ts.map +1 -0
  6. package/dist/AcpImage.test.js +470 -0
  7. package/dist/AcpImage.test.js.map +1 -0
  8. package/dist/Agent.d.ts +9 -2
  9. package/dist/Agent.d.ts.map +1 -1
  10. package/dist/Agent.js +43 -6
  11. package/dist/Agent.js.map +1 -1
  12. package/dist/AgentExecutor.d.ts +27 -5
  13. package/dist/AgentExecutor.d.ts.map +1 -1
  14. package/dist/AgentExecutor.js +29 -4
  15. package/dist/AgentExecutor.js.map +1 -1
  16. package/dist/AgentExecutorImage.test.d.ts +2 -0
  17. package/dist/AgentExecutorImage.test.d.ts.map +1 -0
  18. package/dist/AgentExecutorImage.test.js +63 -0
  19. package/dist/AgentExecutorImage.test.js.map +1 -0
  20. package/dist/AgentImage.test.d.ts +2 -0
  21. package/dist/AgentImage.test.d.ts.map +1 -0
  22. package/dist/AgentImage.test.js +176 -0
  23. package/dist/AgentImage.test.js.map +1 -0
  24. package/dist/AgentTools.d.ts +25 -5
  25. package/dist/AgentTools.d.ts.map +1 -1
  26. package/dist/AgentTools.js +40 -4
  27. package/dist/AgentTools.js.map +1 -1
  28. package/dist/AgentToolsImage.test.d.ts +2 -0
  29. package/dist/AgentToolsImage.test.d.ts.map +1 -0
  30. package/dist/AgentToolsImage.test.js +158 -0
  31. package/dist/AgentToolsImage.test.js.map +1 -0
  32. package/dist/Codex.js +6 -1
  33. package/dist/Codex.js.map +1 -1
  34. package/dist/Compaction.d.ts.map +1 -1
  35. package/dist/Compaction.js +16 -1
  36. package/dist/Compaction.js.map +1 -1
  37. package/dist/CompactionImage.test.d.ts +2 -0
  38. package/dist/CompactionImage.test.d.ts.map +1 -0
  39. package/dist/CompactionImage.test.js +105 -0
  40. package/dist/CompactionImage.test.js.map +1 -0
  41. package/dist/Copilot.d.ts.map +1 -1
  42. package/dist/Copilot.js +5 -1
  43. package/dist/Copilot.js.map +1 -1
  44. package/dist/Image.d.ts +192 -0
  45. package/dist/Image.d.ts.map +1 -0
  46. package/dist/Image.js +586 -0
  47. package/dist/Image.js.map +1 -0
  48. package/dist/Image.test.d.ts +2 -0
  49. package/dist/Image.test.d.ts.map +1 -0
  50. package/dist/Image.test.js +142 -0
  51. package/dist/Image.test.js.map +1 -0
  52. package/dist/fixtures/TestImages.d.ts +73 -0
  53. package/dist/fixtures/TestImages.d.ts.map +1 -0
  54. package/dist/fixtures/TestImages.js +263 -0
  55. package/dist/fixtures/TestImages.js.map +1 -0
  56. package/dist/index.d.ts +4 -0
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +4 -0
  59. package/dist/index.js.map +1 -1
  60. package/package.json +2 -1
  61. package/src/Acp.ts +274 -21
  62. package/src/AcpImage.test.ts +676 -0
  63. package/src/Agent.ts +69 -7
  64. package/src/AgentExecutor.ts +51 -3
  65. package/src/AgentExecutorImage.test.ts +103 -0
  66. package/src/AgentImage.test.ts +295 -0
  67. package/src/AgentTools.ts +78 -7
  68. package/src/AgentToolsImage.test.ts +302 -0
  69. package/src/Codex.ts +6 -1
  70. package/src/Compaction.ts +16 -1
  71. package/src/CompactionImage.test.ts +142 -0
  72. package/src/Copilot.ts +5 -1
  73. package/src/Image.test.ts +220 -0
  74. package/src/Image.ts +704 -0
  75. package/src/fixtures/TestImages.ts +287 -0
  76. package/src/index.ts +5 -0
package/src/Image.ts ADDED
@@ -0,0 +1,704 @@
1
+ /**
2
+ * Image handling shared by ACP prompt ingest and the `readFile` tool.
3
+ *
4
+ * - Media type detection by extension and by magic bytes.
5
+ * - The size limits every image goes through before it reaches a model
6
+ * (OpenCode's numbers): 2000x2000 pixels and 5MB of base64. Images inside
7
+ * the limits pass through untouched; larger ones are resized with Photon
8
+ * (Lanczos3), re-encoded as PNG then JPEG at decreasing quality, shrunk and
9
+ * retried, and rejected when they still do not fit.
10
+ * - Prompt helpers for models that cannot take images: strip the parts and
11
+ * leave an explicit `[image: <name> omitted]` note.
12
+ *
13
+ * @since 1.0.0
14
+ */
15
+ import * as Photon from "@silvia-odwyer/photon-node"
16
+ import * as Effect from "effect/Effect"
17
+ import * as Encoding from "effect/Encoding"
18
+ import type * as FileSystem from "effect/FileSystem"
19
+ import * as Option from "effect/Option"
20
+ import * as Schema from "effect/Schema"
21
+ import * as Stream from "effect/Stream"
22
+ import type * as AiError from "effect/unstable/ai/AiError"
23
+ import * as Prompt from "effect/unstable/ai/Prompt"
24
+
25
+ /**
26
+ * @since 1.0.0
27
+ * @category Models
28
+ */
29
+ export const ImageMediaType = Schema.Literals([
30
+ "image/png",
31
+ "image/jpeg",
32
+ "image/gif",
33
+ "image/webp",
34
+ ])
35
+
36
+ /**
37
+ * @since 1.0.0
38
+ * @category Models
39
+ */
40
+ export type ImageMediaType = typeof ImageMediaType.Type
41
+
42
+ /**
43
+ * @since 1.0.0
44
+ * @category Models
45
+ */
46
+ export interface ImageData {
47
+ readonly data: Uint8Array
48
+ readonly mediaType: ImageMediaType
49
+ }
50
+
51
+ /**
52
+ * @since 1.0.0
53
+ * @category Models
54
+ */
55
+ export interface Limits {
56
+ readonly maxDimension: number
57
+ readonly maxBytes: number
58
+ }
59
+
60
+ /**
61
+ * @since 1.0.0
62
+ * @category Errors
63
+ */
64
+ export class ImageError extends Schema.TaggedError<ImageError>()("ImageError", {
65
+ reason: Schema.Literals(["TooLarge", "Decode"]),
66
+ message: Schema.String,
67
+ }) {}
68
+
69
+ // =============================================================================
70
+ // Limits
71
+ // =============================================================================
72
+
73
+ /**
74
+ * Maximum width and height, in pixels.
75
+ *
76
+ * @since 1.0.0
77
+ * @category Limits
78
+ */
79
+ export const maxDimension = 2000
80
+
81
+ /**
82
+ * Maximum size of the base64-encoded image, in bytes.
83
+ *
84
+ * @since 1.0.0
85
+ * @category Limits
86
+ */
87
+ export const maxBytes = 5 * 1024 * 1024
88
+
89
+ /**
90
+ * Maximum encoded input size before decoding.
91
+ * @since 1.0.0
92
+ * @category Limits
93
+ */
94
+ export const maxInputBytes = 20 * 1024 * 1024
95
+
96
+ /**
97
+ * Maximum declared canvas or frame area before allocating decoder buffers.
98
+ * @since 1.0.0
99
+ * @category Limits
100
+ */
101
+ export const maxInputPixels = 50_000_000
102
+
103
+ /**
104
+ * Stat first, then bound the actual read independently of file growth.
105
+ * @since 1.0.0
106
+ * @category Input
107
+ */
108
+ export const readBoundedFile = Effect.fnUntraced(function* (
109
+ fs: FileSystem.FileSystem,
110
+ path: string,
111
+ ) {
112
+ const tooLarge = () =>
113
+ new ImageError({
114
+ reason: "TooLarge",
115
+ message: `Image ${path} exceeds the ${maxInputBytes} byte input limit`,
116
+ })
117
+ const stat = yield* fs.stat(path)
118
+ if (stat.size > maxInputBytes) return yield* tooLarge()
119
+ // The extra byte detects growth past the ceiling without reading the rest.
120
+ return yield* collectInput(
121
+ fs.stream(path, {
122
+ chunkSize: 64 * 1024,
123
+ bytesToRead: maxInputBytes + 1,
124
+ }),
125
+ tooLarge,
126
+ )
127
+ })
128
+
129
+ /**
130
+ * Collect image input, rejecting overflow before retaining the next chunk.
131
+ * @since 1.0.0
132
+ * @category Input
133
+ */
134
+ export const collectInput = Effect.fnUntraced(function* <E, R, E2>(
135
+ stream: Stream.Stream<Uint8Array, E, R>,
136
+ tooLarge: () => E2,
137
+ ) {
138
+ const chunks: Array<Uint8Array> = []
139
+ let size = 0
140
+ yield* Stream.runForEach(stream, (chunk) => {
141
+ if (chunk.length > maxInputBytes - size) return Effect.fail(tooLarge())
142
+ size += chunk.length
143
+ chunks.push(chunk)
144
+ return Effect.void
145
+ })
146
+ const bytes = new Uint8Array(size)
147
+ let offset = 0
148
+ for (const chunk of chunks) {
149
+ bytes.set(chunk, offset)
150
+ offset += chunk.length
151
+ }
152
+ return bytes
153
+ })
154
+
155
+ const defaultLimits: Limits = { maxDimension, maxBytes }
156
+
157
+ /** JPEG qualities tried, in order, when the PNG re-encode is too large. */
158
+ const jpegQualities = [85, 70, 55, 40]
159
+
160
+ /** Scale applied on every shrink-and-retry round. */
161
+ const shrinkFactor = 0.75
162
+
163
+ /** Shrink rounds attempted before giving up. */
164
+ const maxShrinkRounds = 6
165
+
166
+ // =============================================================================
167
+ // Media type detection
168
+ // =============================================================================
169
+
170
+ const extensions: Record<string, ImageMediaType> = {
171
+ png: "image/png",
172
+ jpg: "image/jpeg",
173
+ jpeg: "image/jpeg",
174
+ gif: "image/gif",
175
+ webp: "image/webp",
176
+ }
177
+
178
+ /**
179
+ * Media type from a file extension. SVG and every non-raster extension is
180
+ * `None`, so those files keep being read as text.
181
+ *
182
+ * @since 1.0.0
183
+ * @category Detection
184
+ */
185
+ export const mediaTypeFromPath = (
186
+ path: string,
187
+ ): Option.Option<ImageMediaType> => {
188
+ const base = path.slice(
189
+ Math.max(path.lastIndexOf("/"), path.lastIndexOf("\\")) + 1,
190
+ )
191
+ const dot = base.lastIndexOf(".")
192
+ if (dot <= 0) return Option.none()
193
+ return Option.fromNullishOr(extensions[base.slice(dot + 1).toLowerCase()])
194
+ }
195
+
196
+ const startsWith = (
197
+ bytes: Uint8Array,
198
+ prefix: ReadonlyArray<number>,
199
+ offset = 0,
200
+ ) =>
201
+ bytes.length >= offset + prefix.length &&
202
+ prefix.every((byte, i) => bytes[offset + i] === byte)
203
+
204
+ const ascii = (text: string): ReadonlyArray<number> =>
205
+ Array.from(text, (char) => char.charCodeAt(0))
206
+
207
+ /**
208
+ * Media type from the magic bytes at the start of the data.
209
+ *
210
+ * @since 1.0.0
211
+ * @category Detection
212
+ */
213
+ export const mediaTypeFromBytes = (
214
+ bytes: Uint8Array,
215
+ ): Option.Option<ImageMediaType> => {
216
+ if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
217
+ return Option.some("image/png")
218
+ }
219
+ if (startsWith(bytes, [0xff, 0xd8, 0xff])) {
220
+ return Option.some("image/jpeg")
221
+ }
222
+ if (
223
+ startsWith(bytes, ascii("GIF87a")) ||
224
+ startsWith(bytes, ascii("GIF89a"))
225
+ ) {
226
+ return Option.some("image/gif")
227
+ }
228
+ if (startsWith(bytes, ascii("RIFF")) && startsWith(bytes, ascii("WEBP"), 8)) {
229
+ return Option.some("image/webp")
230
+ }
231
+ return Option.none()
232
+ }
233
+
234
+ const validDimensions = (width: number, height: number) =>
235
+ width > 0 && height > 0 ? Option.some({ width, height }) : Option.none()
236
+
237
+ /**
238
+ * Read conservative dimension bounds without decoding pixels, including inner
239
+ * frames in container formats. Truncated or malformed headers are rejected
240
+ * before they can reach Photon; over-budget dimensions stop parsing early.
241
+ * @since 1.0.0
242
+ * @category Detection
243
+ */
244
+ export const dimensions = (
245
+ bytes: Uint8Array,
246
+ ): Option.Option<{ width: number; height: number }> => {
247
+ const type = mediaTypeFromBytes(bytes)
248
+ if (Option.isNone(type)) return Option.none()
249
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength)
250
+ switch (type.value) {
251
+ case "image/png":
252
+ if (
253
+ bytes.length < 33 ||
254
+ view.getUint32(8) !== 13 ||
255
+ !startsWith(bytes, ascii("IHDR"), 12)
256
+ )
257
+ return Option.none()
258
+ return validDimensions(view.getUint32(16), view.getUint32(20))
259
+ case "image/gif": {
260
+ if (bytes.length < 13) return Option.none()
261
+ let width = view.getUint16(6, true)
262
+ let height = view.getUint16(8, true)
263
+ if (width === 0 || height === 0) return Option.none()
264
+ if (width * height > maxInputPixels) return validDimensions(width, height)
265
+ let offset = 13
266
+ const skipColorTable = (packed: number) => {
267
+ if (packed & 0x80) offset += 3 * (1 << ((packed & 7) + 1))
268
+ return offset <= bytes.length
269
+ }
270
+ const skipSubBlocks = () => {
271
+ while (offset < bytes.length) {
272
+ const length = view.getUint8(offset++)
273
+ if (length === 0) return true
274
+ if (length > bytes.length - offset) return false
275
+ offset += length
276
+ }
277
+ return false
278
+ }
279
+ if (!skipColorTable(view.getUint8(10))) return Option.none()
280
+ let hasFrame = false
281
+ while (offset < bytes.length) {
282
+ const tag = view.getUint8(offset++)
283
+ if (tag === 0x3b) {
284
+ return hasFrame ? validDimensions(width, height) : Option.none()
285
+ }
286
+ if (tag === 0x21) {
287
+ // Extension label followed by length-prefixed sub-blocks.
288
+ if (offset >= bytes.length) return Option.none()
289
+ offset++
290
+ if (!skipSubBlocks()) return Option.none()
291
+ continue
292
+ }
293
+ if (tag !== 0x2c || bytes.length - offset < 9) return Option.none()
294
+ const frameWidth = view.getUint16(offset + 4, true)
295
+ const frameHeight = view.getUint16(offset + 6, true)
296
+ if (frameWidth === 0 || frameHeight === 0) return Option.none()
297
+ // The decoder allocates the frame independently of the logical screen.
298
+ width = Math.max(width, frameWidth)
299
+ height = Math.max(height, frameHeight)
300
+ if (width * height > maxInputPixels)
301
+ return validDimensions(width, height)
302
+ const packed = view.getUint8(offset + 8)
303
+ offset += 9
304
+ if (!skipColorTable(packed) || offset >= bytes.length)
305
+ return Option.none()
306
+ offset++ // LZW minimum code size; pixel decoding validates its value.
307
+ if (!skipSubBlocks()) return Option.none()
308
+ hasFrame = true
309
+ }
310
+ return Option.none()
311
+ }
312
+ case "image/jpeg": {
313
+ let offset = 2
314
+ while (offset < bytes.length) {
315
+ if (view.getUint8(offset++) !== 0xff) return Option.none()
316
+ // JPEG permits padding FF bytes before a marker.
317
+ while (offset < bytes.length && view.getUint8(offset) === 0xff) offset++
318
+ if (offset >= bytes.length) return Option.none()
319
+ const marker = view.getUint8(offset++)
320
+ if (
321
+ marker === 0xda ||
322
+ marker === 0xd9 ||
323
+ marker === 0 ||
324
+ marker === 0xd8
325
+ ) {
326
+ return Option.none()
327
+ }
328
+ if (marker === 1 || (marker >= 0xd0 && marker <= 0xd7)) continue
329
+ if (offset + 2 > bytes.length) return Option.none()
330
+ const length = view.getUint16(offset)
331
+ if (length < 2 || length > bytes.length - offset) return Option.none()
332
+ if (
333
+ marker >= 0xc0 &&
334
+ marker <= 0xcf &&
335
+ marker !== 0xc4 &&
336
+ marker !== 0xc8 &&
337
+ marker !== 0xcc
338
+ ) {
339
+ if (
340
+ length < 8 ||
341
+ view.getUint8(offset + 7) === 0 ||
342
+ length !== 8 + 3 * view.getUint8(offset + 7)
343
+ )
344
+ return Option.none()
345
+ return validDimensions(
346
+ view.getUint16(offset + 5),
347
+ view.getUint16(offset + 3),
348
+ )
349
+ }
350
+ offset += length
351
+ }
352
+ return Option.none()
353
+ }
354
+ case "image/webp": {
355
+ if (bytes.length < 20) return Option.none()
356
+ const end = view.getUint32(4, true) + 8
357
+ if (end < 20 || end > bytes.length) return Option.none()
358
+ const u24 = (offset: number) =>
359
+ view.getUint8(offset) |
360
+ (view.getUint8(offset + 1) << 8) |
361
+ (view.getUint8(offset + 2) << 16)
362
+ let width = 0
363
+ let height = 0
364
+ let hasFrame = false
365
+ const include = (w: number, h: number) => {
366
+ if (w === 0 || h === 0) return false
367
+ width = Math.max(width, w)
368
+ height = Math.max(height, h)
369
+ return true
370
+ }
371
+ // Only one level of nesting is legal: ANMF contains frame chunks, not ANMF.
372
+ const scan = (
373
+ start: number,
374
+ limit: number,
375
+ inFrame: boolean,
376
+ ): boolean => {
377
+ let offset = start
378
+ while (offset < limit) {
379
+ if (limit - offset < 8) return false
380
+ const length = view.getUint32(offset + 4, true)
381
+ const data = offset + 8
382
+ const paddedLength = length + (length & 1)
383
+ if (paddedLength > limit - data) return false
384
+ if (startsWith(bytes, ascii("VP8X"), offset)) {
385
+ if (inFrame || offset !== 12 || length !== 10) return false
386
+ include(u24(data + 4) + 1, u24(data + 7) + 1)
387
+ } else if (startsWith(bytes, ascii("VP8L"), offset)) {
388
+ if (
389
+ length < 5 ||
390
+ view.getUint8(data) !== 0x2f ||
391
+ view.getUint8(data + 4) >> 5 !== 0
392
+ )
393
+ return false
394
+ include(
395
+ (view.getUint8(data + 1) |
396
+ ((view.getUint8(data + 2) & 0x3f) << 8)) +
397
+ 1,
398
+ ((view.getUint8(data + 2) >> 6) |
399
+ (view.getUint8(data + 3) << 2) |
400
+ ((view.getUint8(data + 4) & 0x0f) << 10)) +
401
+ 1,
402
+ )
403
+ hasFrame = true
404
+ } else if (startsWith(bytes, ascii("VP8 "), offset)) {
405
+ if (
406
+ length < 10 ||
407
+ (view.getUint8(data) & 1) !== 0 ||
408
+ !startsWith(bytes, [0x9d, 0x01, 0x2a], data + 3) ||
409
+ !include(
410
+ view.getUint16(data + 6, true) & 0x3fff,
411
+ view.getUint16(data + 8, true) & 0x3fff,
412
+ )
413
+ )
414
+ return false
415
+ hasFrame = true
416
+ } else if (startsWith(bytes, ascii("ANMF"), offset)) {
417
+ if (inFrame || length < 16) return false
418
+ include(u24(data + 6) + 1, u24(data + 9) + 1)
419
+ if (width * height > maxInputPixels) return true
420
+ if (!scan(data + 16, data + length, true)) return false
421
+ }
422
+ // Reject over-budget headers even if the pixel payload is incomplete.
423
+ if (width * height > maxInputPixels) return true
424
+ offset = data + paddedLength
425
+ }
426
+ return true
427
+ }
428
+ if (!scan(12, end, false)) return Option.none()
429
+ return hasFrame || width * height > maxInputPixels
430
+ ? validDimensions(width, height)
431
+ : Option.none()
432
+ }
433
+ }
434
+ }
435
+
436
+ /**
437
+ * Whether a declared mime type is one of the supported image types.
438
+ *
439
+ * @since 1.0.0
440
+ * @category Detection
441
+ */
442
+ export const isImageMediaType = Schema.is(ImageMediaType)
443
+
444
+ // =============================================================================
445
+ // Resize
446
+ // =============================================================================
447
+
448
+ const base64Length = (byteLength: number) => Math.ceil(byteLength / 3) * 4
449
+
450
+ const fits = (bytes: Uint8Array, limits: Limits) =>
451
+ base64Length(bytes.length) <= limits.maxBytes
452
+
453
+ const decode = (
454
+ data: Uint8Array,
455
+ ): Effect.Effect<Photon.PhotonImage, ImageError> =>
456
+ Effect.try({
457
+ try: () => {
458
+ const image = Photon.PhotonImage.new_from_byteslice(data)
459
+ // Photon returns an empty image rather than throwing for some inputs.
460
+ if (image.get_width() === 0 || image.get_height() === 0) {
461
+ image.free()
462
+ throw new Error("Image has no pixels")
463
+ }
464
+ return image
465
+ },
466
+ catch: (cause) =>
467
+ new ImageError({
468
+ reason: "Decode",
469
+ message: `Could not decode image: ${cause instanceof Error ? cause.message : String(cause)}`,
470
+ }),
471
+ })
472
+
473
+ /** Encode as PNG, then JPEG at decreasing quality; `None` if nothing fits. */
474
+ const encodeWithin = (
475
+ image: Photon.PhotonImage,
476
+ limits: Limits,
477
+ ): Option.Option<ImageData> => {
478
+ const png = image.get_bytes()
479
+ if (fits(png, limits))
480
+ return Option.some({ data: png, mediaType: "image/png" })
481
+ for (const quality of jpegQualities) {
482
+ const jpeg = image.get_bytes_jpeg(quality)
483
+ if (fits(jpeg, limits)) {
484
+ return Option.some({ data: jpeg, mediaType: "image/jpeg" })
485
+ }
486
+ }
487
+ return Option.none()
488
+ }
489
+
490
+ /**
491
+ * Bring an image inside the limits.
492
+ *
493
+ * Images already within `limits` are returned unchanged, byte for byte.
494
+ * Otherwise the image is decoded, scaled down to fit the dimension cap with
495
+ * Lanczos3, and re-encoded as PNG, then as JPEG at decreasing quality. If it
496
+ * is still over the byte cap it is shrunk by 25% and the encoding is retried,
497
+ * up to a fixed number of rounds, after which it is rejected.
498
+ *
499
+ * `limits` exists so tests can exercise the reject path with a small fixture.
500
+ * It is not a user-facing setting.
501
+ *
502
+ * @since 1.0.0
503
+ * @category Resize
504
+ */
505
+ export const prepare = Effect.fnUntraced(function* (options: {
506
+ readonly data: Uint8Array
507
+ readonly mediaType: ImageMediaType
508
+ readonly limits?: Limits | undefined
509
+ }): Effect.fn.Return<ImageData, ImageError> {
510
+ const limits = options.limits ?? defaultLimits
511
+ if (options.data.length > maxInputBytes) {
512
+ return yield* new ImageError({
513
+ reason: "TooLarge",
514
+ message: `Image exceeds the ${maxInputBytes} byte input limit`,
515
+ })
516
+ }
517
+ const size = dimensions(options.data)
518
+ if (Option.isNone(size)) {
519
+ return yield* new ImageError({
520
+ reason: "Decode",
521
+ message: "Invalid or truncated image header",
522
+ })
523
+ }
524
+ if (size.value.width * size.value.height > maxInputPixels) {
525
+ return yield* new ImageError({
526
+ reason: "TooLarge",
527
+ message: `Image exceeds the ${maxInputPixels} pixel input limit`,
528
+ })
529
+ }
530
+ const image = yield* decode(options.data)
531
+ let current = image
532
+ try {
533
+ const width = image.get_width()
534
+ const height = image.get_height()
535
+ const withinDimensions =
536
+ width <= limits.maxDimension && height <= limits.maxDimension
537
+ if (withinDimensions && fits(options.data, limits)) {
538
+ return { data: options.data, mediaType: options.mediaType }
539
+ }
540
+
541
+ let scale = Math.min(
542
+ 1,
543
+ limits.maxDimension / width,
544
+ limits.maxDimension / height,
545
+ )
546
+ for (let round = 0; round <= maxShrinkRounds; round++) {
547
+ const targetWidth = Math.max(1, Math.round(width * scale))
548
+ const targetHeight = Math.max(1, Math.round(height * scale))
549
+ if (
550
+ targetWidth !== current.get_width() ||
551
+ targetHeight !== current.get_height()
552
+ ) {
553
+ const resized = Photon.resize(
554
+ image,
555
+ targetWidth,
556
+ targetHeight,
557
+ Photon.SamplingFilter.Lanczos3,
558
+ )
559
+ if (current !== image) current.free()
560
+ current = resized
561
+ }
562
+ const encoded = encodeWithin(current, limits)
563
+ if (Option.isSome(encoded)) return encoded.value
564
+ scale *= shrinkFactor
565
+ }
566
+ return yield* new ImageError({
567
+ reason: "TooLarge",
568
+ message: `Image is still over ${limits.maxBytes} bytes of base64 after resizing`,
569
+ })
570
+ } finally {
571
+ if (current !== image) current.free()
572
+ image.free()
573
+ }
574
+ })
575
+
576
+ // =============================================================================
577
+ // Prompt helpers
578
+ // =============================================================================
579
+
580
+ /**
581
+ * The text left in place of a stripped image part.
582
+ *
583
+ * @since 1.0.0
584
+ * @category Prompt
585
+ */
586
+ export const omittedText = (fileName: string | undefined): string =>
587
+ `[image: ${fileName ?? "unnamed"} omitted]`
588
+
589
+ /**
590
+ * Whether a prompt part is an image `file` part.
591
+ *
592
+ * @since 1.0.0
593
+ * @category Prompt
594
+ */
595
+ export const isImagePart = (part: Prompt.Part): part is Prompt.FilePart =>
596
+ part.type === "file" && part.mediaType.startsWith("image/")
597
+
598
+ /**
599
+ * Whether any user message in the prompt carries an image part.
600
+ *
601
+ * @since 1.0.0
602
+ * @category Prompt
603
+ */
604
+ export const hasImages = (prompt: Prompt.Prompt): boolean =>
605
+ prompt.content.some(
606
+ (message) => message.role === "user" && message.content.some(isImagePart),
607
+ )
608
+
609
+ /**
610
+ * Replace every image part with an `omittedText` text part, so a model that
611
+ * cannot take images still sees that one was there.
612
+ *
613
+ * @since 1.0.0
614
+ * @category Prompt
615
+ */
616
+ export const stripImages = (prompt: Prompt.Prompt): Prompt.Prompt => {
617
+ if (!hasImages(prompt)) return prompt
618
+ return Prompt.fromMessages(
619
+ prompt.content.map((message) => {
620
+ if (message.role !== "user" || !message.content.some(isImagePart)) {
621
+ return message
622
+ }
623
+ return Prompt.makeMessage("user", {
624
+ content: message.content.map((part) =>
625
+ isImagePart(part)
626
+ ? Prompt.makePart("text", { text: omittedText(part.fileName) })
627
+ : part,
628
+ ),
629
+ options: message.options,
630
+ })
631
+ }),
632
+ )
633
+ }
634
+
635
+ /**
636
+ * Build a user message carrying image parts.
637
+ *
638
+ * @since 1.0.0
639
+ * @category Prompt
640
+ */
641
+ export const userMessage = (
642
+ images: ReadonlyArray<{
643
+ readonly fileName: string
644
+ readonly mediaType: ImageMediaType
645
+ readonly data: Uint8Array
646
+ }>,
647
+ ): Prompt.UserMessage =>
648
+ Prompt.makeMessage("user", {
649
+ content: images.map((image) =>
650
+ Prompt.makePart("file", {
651
+ mediaType: image.mediaType,
652
+ fileName: image.fileName,
653
+ data: image.data,
654
+ }),
655
+ ),
656
+ })
657
+
658
+ /**
659
+ * Bytes of a `file` part, whichever representation it carries. Byte arrays
660
+ * come back from persistence as base64 strings.
661
+ *
662
+ * @since 1.0.0
663
+ * @category Prompt
664
+ */
665
+ export const partBytes = (part: Prompt.FilePart): Option.Option<Uint8Array> => {
666
+ if (part.data instanceof Uint8Array) return Option.some(part.data)
667
+ if (part.data instanceof URL) return Option.none()
668
+ const base64 = part.data.startsWith("data:")
669
+ ? part.data.slice(part.data.indexOf(",") + 1)
670
+ : part.data
671
+ const decoded = Encoding.decodeBase64(base64)
672
+ return decoded._tag === "Success"
673
+ ? Option.some(decoded.success)
674
+ : Option.none()
675
+ }
676
+
677
+ // =============================================================================
678
+ // Provider errors
679
+ // =============================================================================
680
+
681
+ const unsupportedImagePattern =
682
+ /image[^.]{0,80}(only supported|not supported|unsupported|does not support|cannot|can't|invalid)|(only supported|not supported|unsupported|does not support|cannot|can't|invalid)[^.]{0,80}image/i
683
+
684
+ /**
685
+ * Whether a provider error looks like a rejection of image input, so the
686
+ * turn can be retried once without the images.
687
+ *
688
+ * @since 1.0.0
689
+ * @category Provider errors
690
+ */
691
+ export const isUnsupportedImageError = (error: AiError.AiError): boolean => {
692
+ const reason = error.reason
693
+ switch (reason._tag) {
694
+ case "InvalidRequestError":
695
+ case "UnknownError":
696
+ case "InternalProviderError":
697
+ return (
698
+ reason.description !== undefined &&
699
+ unsupportedImagePattern.test(reason.description)
700
+ )
701
+ default:
702
+ return false
703
+ }
704
+ }