import { type BlobStore, externalizeImageDataSync, externalizeImageDataUrlSync, isBlobRef, isImageDataUrl, } from "./blob-store"; import type { FileEntry } from "./session-entries"; const MAX_PERSIST_CHARS = 500_000; const TRUNCATION_NOTICE = "\n\n[Session persistence truncated large content]"; /** Minimum base64 length to externalize to blob store (skip tiny inline images) */ const BLOB_EXTERNALIZE_THRESHOLD = 1024; const TEXT_CONTENT_KEY = "content"; function truncateString(value: string, maxLength: number): string { if (value.length <= maxLength) return value; let truncated = value.slice(0, maxLength); if (truncated.length > 0) { const last = truncated.charCodeAt(truncated.length - 1); if (last >= 0xd800 && last <= 0xdbff) { truncated = truncated.slice(0, -1); } } return truncated; } export function isImageBlock(value: unknown): value is { type: "image"; data: string; mimeType?: string } { return ( typeof value === "object" && value !== null && "type" in value && (value as { type?: string }).type === "image" && "data" in value && typeof (value as { data?: string }).data === "string" ); } function isImageMimeType(value: unknown): value is string { return typeof value === "string" && value.toLowerCase().startsWith("image/"); } export function isImageDataPayload(value: unknown): value is { data: string; mimeType?: string } { return ( typeof value === "object" && value !== null && "data" in value && typeof (value as { data?: string }).data === "string" && (isImageBlock(value) || ("mimeType" in value && isImageMimeType((value as { mimeType?: unknown }).mimeType))) ); } function shouldExternalizeImagePayload( value: unknown, key: string | undefined, ): value is { data: string; mimeType?: string } { if (!isImageDataPayload(value)) return false; if (isBlobRef(value.data) || value.data.length < BLOB_EXTERNALIZE_THRESHOLD) return false; return (key === TEXT_CONTENT_KEY && isImageBlock(value)) || key === "images"; } /** * Recursively truncate large strings in an object for session persistence. * - Truncates any oversized string fields (key-agnostic) * - Externalizes oversized image payloads to blob refs * - Updates lineCount when content is truncated * - Returns original object if no changes needed (structural sharing) * * Runs in one synchronous tick so an OOM/SIGKILL landing right after a persist * call returns cannot lose the entry. Image externalization happens via the * synchronous blob-store path (`fs.writeFileSync`), so blob bytes are in the * kernel page cache before the JSONL line referencing them is written. */ function truncateForPersistence(obj: unknown, blobStore: BlobStore, key?: string): unknown { if (obj === null || obj === undefined) return obj; if (shouldExternalizeImagePayload(obj, key)) { return { ...obj, data: externalizeImageDataSync(blobStore, obj.data, obj.mimeType) }; } if (typeof obj === "string") { if (key === "image_url" && isImageDataUrl(obj)) { return externalizeImageDataUrlSync(blobStore, obj); } if (obj.length > MAX_PERSIST_CHARS) { // Cryptographic signatures must be preserved exactly or cleared entirely — never truncated. // Truncation would produce an invalid signature that the API rejects. if (key === "thinkingSignature" || key === "thoughtSignature" || key === "textSignature") { return ""; } const limit = Math.max(0, MAX_PERSIST_CHARS - TRUNCATION_NOTICE.length); return `${truncateString(obj, limit)}${TRUNCATION_NOTICE}`; } return obj; } if (Array.isArray(obj)) { let changed = false; const result: unknown[] = new Array(obj.length); for (let i = 0; i < obj.length; i++) { const item = obj[i]; const newItem = truncateForPersistence(item, blobStore, key); if (newItem !== item) changed = true; result[i] = newItem; } return changed ? result : obj; } if (typeof obj === "object") { let changed = false; const entries: Array = []; for (const [childKey, value] of Object.entries(obj)) { // Strip transient/redundant properties that shouldn't be persisted. // - partialJson: streaming accumulator for tool call JSON parsing // - jsonlEvents: raw subprocess streaming events (already saved to artifact files) if (childKey === "partialJson" || childKey === "jsonlEvents") { changed = true; continue; } const newValue = truncateForPersistence(value, blobStore, childKey); if (newValue !== value) changed = true; entries.push([childKey, newValue]); } if (!changed) return obj; const contentEntry = entries.find(([childKey]) => childKey === "content"); const lineCountEntry = entries.find(([childKey]) => childKey === "lineCount"); if ( contentEntry && typeof contentEntry[1] === "string" && lineCountEntry && typeof lineCountEntry[1] === "number" ) { const content = contentEntry[1]; const updatedEntries = entries.map(([childKey, value]) => childKey === "lineCount" ? ([childKey, content.split("\n").length] as const) : ([childKey, value] as const), ); return Object.fromEntries(updatedEntries); } return Object.fromEntries(entries); } return obj; } export function prepareEntryForPersistence(entry: FileEntry, blobStore: BlobStore): FileEntry { return truncateForPersistence(entry, blobStore) as FileEntry; }