1import type { OrchestrationV2TurnItem } from "@t3tools/contracts";
2import {
3 MAX_TOOL_OUTPUT_IMAGE_BASE64_LENGTH,
4 readToolOutputImage,
5 toolOutputImageBlocks,
6} from "@t3tools/shared/toolOutput";
7
8const IMAGE_MIME_KEYS = ["mimeType", "mime_type", "media_type", "type"] as const;
9const IMAGE_BODY_KEYS = new Set(["data", "blob", "base64"]);
10const MAX_DEPTH = 32;
11// Shorter strings are identifiers or tiny fixtures, not pixels worth removing.
12const MIN_BODY_LENGTH = 64;
13const BASE64_BODY = /^[A-Za-z0-9+/_-]+={0,2}$/;
14
15type UnknownRecord = Record<string, unknown>;
16
17function 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 */
30function 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
40function isBase64Body(value: unknown): value is string {
41 return typeof value === "string" && value.length >= MIN_BODY_LENGTH && BASE64_BODY.test(value);
42}
43
44function 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
52interface 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. */
58function 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
65function 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 */
127export 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}