Files
oh-my-pi/packages/coding-agent/src/session/session-persistence.ts
T
can1357 24c8bb24c6 feat(session): added modular session APIs and rebuilt listing/persistence behavior
- Added session-domain modules and exports for session-entries, context, listing, loader, and migrations.
- Changed persistence to async append writes plus writeTextAtomic, removing sync line APIs.
- Added compaction-aware session context rebuild with dangling tool-call cleanup.
- Added resumable session resolution with status inference, id/stem/suffix matching, and backup recovery.
2026-06-14 02:02:53 +02:00

132 lines
4.5 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"
);
}
/**
* Recursively truncate large strings in an object for session persistence.
* - Truncates any oversized string fields (key-agnostic)
* - Replaces oversized image blocks with text notices
* - 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 (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];
if (
key === TEXT_CONTENT_KEY &&
isImageBlock(item) &&
!isBlobRef(item.data) &&
item.data.length >= BLOB_EXTERNALIZE_THRESHOLD
) {
changed = true;
result[i] = { ...item, data: externalizeImageDataSync(blobStore, item.data, item.mimeType) };
continue;
}
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;
}