| 1 | import type { OrchestrationV2TurnItem } from "@t3tools/contracts"; |
| 2 | import { |
| 3 | MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH, |
| 4 | readToolOutputImage, |
| 5 | toolOutputImageBlocks, |
| 6 | } from "@t3tools/shared/toolOutput"; |
| 7 | |
| 8 | const IMAGE_MIME_KEYS = ["mimeType", "mime_type", "media_type", "type"] as const; |
| 9 | const IMAGE_BODY_KEYS = new Set(["data", "blob", "base64"]); |
| 10 | const MAX_DEPTH = 32; |
| 11 | // Shorter strings are identifiers or tiny fixtures, not pixels worth removing. |
| 12 | const MIN_BODY_LENGTH = 64; |
| 13 | const BASE64_BODY = /^[A-Za-z0-9+/_-]+={0,2}$/; |
| 14 | |
| 15 | type UnknownRecord = Record<string, unknown>; |
| 16 | |
| 17 | function hasImageMime(record: UnknownRecord): boolean { |
| 18 | return IMAGE_MIME_KEYS.some((key) => { |
| 19 | const mime = record[key]; |
| 20 | return typeof mime === "string" && mime.toLowerCase().startsWith("image/"); |
| 21 | }); |
| 22 | } |
| 23 | |
| 24 | /** |
| 25 | * The record that carries an image's bytes: an MCP or ACP `type: "image"` |
| 26 | * block, Cursor's `image` part (its mime type is optional), or a record with |
| 27 | * an `image/*` mime field such as Claude's `source`, the `file` of Claude's |
| 28 | * structured Read result, and Grok's `ImageContent`. |
| 29 | */ |
| 30 | function isImageBody(record: UnknownRecord, key: string | undefined): boolean { |
| 31 | if (record.type === "image" || key === "image") return true; |
| 32 | if (!hasImageMime(record)) return false; |
| 33 | return ( |
| 34 | record.type === undefined || |
| 35 | record.type === "base64" || |
| 36 | (typeof record.type === "string" && record.type.toLowerCase().startsWith("image/")) |
| 37 | ); |
| 38 | } |
| 39 | |
| 40 | function isBase64Body(value: unknown): value is string { |
| 41 | return typeof value === "string" && value.length >= MIN_BODY_LENGTH && BASE64_BODY.test(value); |
| 42 | } |
| 43 | |
| 44 | function base64DecodedLength(encoded: string): number { |
| 45 | let length = encoded.length; |
| 46 | while (length > 0 && encoded.charCodeAt(length - 1) === 0x3d /* = */) { |
| 47 | length -= 1; |
| 48 | } |
| 49 | return Math.floor((length * 3) / 4); |
| 50 | } |
| 51 | |
| 52 | interface StripContext { |
| 53 | readonly output: unknown; |
| 54 | served: ReadonlyArray<unknown> | undefined; |
| 55 | } |
| 56 | |
| 57 | /** A block the asset route serves: in a served position and within its size limit. */ |
| 58 | function isServed(context: StripContext, record: UnknownRecord): boolean { |
| 59 | context.served ??= toolOutputImageBlocks(context.output); |
| 60 | if (!context.served.includes(record)) return false; |
| 61 | const data = readToolOutputImage(record)?.data; |
| 62 | return data === undefined || data.length <= MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH; |
| 63 | } |
| 64 | |
| 65 | function strip( |
| 66 | value: unknown, |
| 67 | key: string | undefined, |
| 68 | context: StripContext, |
| 69 | depth: number, |
| 70 | ): unknown { |
| 71 | if (depth > MAX_DEPTH || typeof value !== "object" || value === null) { |
| 72 | return value; |
| 73 | } |
| 74 | if (Array.isArray(value)) { |
| 75 | let next: Array<unknown> | undefined; |
| 76 | for (let index = 0; index < value.length; index += 1) { |
| 77 | const entry = value[index]; |
| 78 | const stripped = strip(entry, undefined, context, depth + 1); |
| 79 | if (stripped !== entry) { |
| 80 | next ??= value.slice(); |
| 81 | next[index] = stripped; |
| 82 | } |
| 83 | } |
| 84 | return next ?? value; |
| 85 | } |
| 86 | const record = value as UnknownRecord; |
| 87 | // Every served block is a `type: "image"` block; keep it whole. |
| 88 | if (record.type === "image" && isServed(context, record)) return record; |
| 89 | const imageBody = isImageBody(record, key); |
| 90 | let next: UnknownRecord | undefined; |
| 91 | let removedBody: string | undefined; |
| 92 | for (const entryKey in record) { |
| 93 | if (!Object.hasOwn(record, entryKey)) continue; |
| 94 | const entry = record[entryKey]; |
| 95 | if (imageBody && IMAGE_BODY_KEYS.has(entryKey) && isBase64Body(entry)) { |
| 96 | next ??= { ...record }; |
| 97 | delete next[entryKey]; |
| 98 | removedBody = entry; |
| 99 | continue; |
| 100 | } |
| 101 | const stripped = strip(entry, entryKey, context, depth + 1); |
| 102 | if (stripped !== entry) { |
| 103 | next ??= { ...record }; |
| 104 | next[entryKey] = stripped; |
| 105 | } |
| 106 | } |
| 107 | if (next !== undefined && removedBody !== undefined) { |
| 108 | // Measured from the removed body, never taken from a provider's size field. |
| 109 | next.sizeBytes = base64DecodedLength(removedBody); |
| 110 | } |
| 111 | return next ?? record; |
| 112 | } |
| 113 | |
| 114 | /** |
| 115 | * Replaces the base64 image bodies in a dynamic tool's output with their |
| 116 | * decoded byte size, except the blocks a `tool-output-image` asset serves. |
| 117 | * |
| 118 | * Tools that read or capture an image return the whole file as base64, and a |
| 119 | * turn item is stored twice: in the event log and in the projection. Only the |
| 120 | * blocks `toolOutputImageBlocks` finds, within the asset size limit, are ever |
| 121 | * read back; previews of a read file load from `viewedImagePath`, and context |
| 122 | * handoff skips tool items. |
| 123 | * Strings that are not a base64 body are kept, so identifiers and inline SVG |
| 124 | * under the same keys survive. Containers are copied only when a descendant |
| 125 | * changes; an item without such bytes is returned by reference. |
| 126 | */ |
| 127 | export function stripUnservedToolOutputImageBytes( |
| 128 | item: OrchestrationV2TurnItem, |
| 129 | ): OrchestrationV2TurnItem { |
| 130 | if (item.type !== "dynamic_tool" || typeof item.output !== "object" || item.output === null) { |
| 131 | return item; |
| 132 | } |
| 133 | const output = strip(item.output, undefined, { output: item.output, served: undefined }, 0); |
| 134 | return output === item.output ? item : { ...item, output }; |
| 135 | } |