Files
oh-my-pi/packages/coding-agent/src/session/session-persistence.ts
T
can1357 e4a87fa3ce feat(coding-agent): added matplotlib rendering and image persistence across session reload
- Added Matplotlib figure PNG rendering and display tracking in Python runner to emit PNG output immediately when figures are displayed via display(fig).
- Extended session persistence to externalize oversized image payloads in both content and details.images, enabling tool result images to survive session reload.
- Enhanced session loader to resolve image data payloads and blob references across content and details.images during session reconstruction.
- Added image cache invalidation in TUI image component when image protocol, cell dimensions, or Kitty Unicode placeholder mode changes.
- Added comprehensive test coverage for Matplotlib display, image persistence across reload, and TUI image rendering with protocol and dimension changes.
2026-06-17 13:19:28 +02:00

148 lines
5.2 KiB
TypeScript

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<readonly [string, unknown]> = [];
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;
}