24c8bb24c6
- 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.
132 lines
4.5 KiB
TypeScript
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;
|
|
}
|