import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import * as fs from "node:fs/promises"; import * as path from "node:path"; import { ConcatSink, getBlobsDir, isEisdir, isEnoent, isEnotdir, jsonContainerEnd, parseJsonlLenient, peekFile, tryParseJson, } from "@oh-my-pi/pi-utils"; import * as snapcompact from "@oh-my-pi/snapcompact"; import { Semaphore } from "../task/parallel"; import { BlobStore, isBlobRef, lazyImageDataSync, resolveImageData, resolveImageDataSync, resolveImageDataUrl, resolveImageDataUrlSync, } from "./blob-store"; import { buildSessionContext } from "./session-context"; import type { FileEntry, RawFileEntry, SessionEntry, SessionHeader } from "./session-entries"; import { migrateToCurrentVersion } from "./session-migrations"; import { isExternalizableImagePosition, isPersistenceTruncatedString } from "./session-persistence"; import { FileSessionStorage, type SessionStorage } from "./session-storage"; import { enotdir } from "./session-storage-errors"; import { parseTitleSlotFromContent, parseTitleSlotLine, type SessionTitleUpdate, titleUpdateFromSlot, } from "./session-title-slot"; const LF = new Uint8Array([0x0a]); const STREAM_LOAD_THRESHOLD_BYTES = 8 * 1024 * 1024; const STREAM_YIELD_BYTES = 1 * 1024 * 1024; const STREAM_YIELD_ENTRIES = 8_192; /** Initial parse window after a malformed record; see the recovery note in visitEntriesFromFileStream. */ const RECOVERY_PARSE_WINDOW = 64 * 1024; const BLOB_READ_CONCURRENCY = 8; /** * Complete optional accounting fields from persisted/imported assistant turns. * Complete provider usage is left untouched; missing prices never discard reported tokens. */ export function normalizeAssistantUsage(message: Extract): boolean { const usage = message.usage; if ( usage && usage.input !== undefined && usage.output !== undefined && usage.cacheRead !== undefined && usage.cacheWrite !== undefined && usage.totalTokens !== undefined && usage.cost?.input !== undefined && usage.cost.output !== undefined && usage.cost.cacheRead !== undefined && usage.cost.cacheWrite !== undefined && usage.cost.total !== undefined ) { return false; } const input = usage?.input ?? 0; const output = usage?.output ?? 0; const cacheRead = usage?.cacheRead ?? 0; const cacheWrite = usage?.cacheWrite ?? 0; const cost = usage?.cost; message.usage = { ...usage, input, output, cacheRead, cacheWrite, totalTokens: usage?.totalTokens ?? input + output + cacheRead + cacheWrite + (usage?.orchestration?.input ?? 0) + (usage?.orchestration?.cacheRead ?? 0) + (usage?.orchestration?.output ?? 0), cost: { ...cost, input: cost?.input ?? 0, output: cost?.output ?? 0, cacheRead: cost?.cacheRead ?? 0, cacheWrite: cost?.cacheWrite ?? 0, total: cost?.total ?? (cost?.input ?? 0) + (cost?.output ?? 0) + (cost?.cacheRead ?? 0) + (cost?.cacheWrite ?? 0), }, }; return true; } export interface VisitEntriesFromFileStreamOptions { /** Stop after the visitor returns `false`. */ shouldContinue?: () => boolean; /** Stop after this many valid or malformed JSONL records have been consumed. */ maxRecords?: number; /** Read at most this many bytes from the file's current prefix. */ maxBytes?: number; /** Yield to the macrotask queue after this many bytes have been consumed. */ yieldEveryBytes?: number; /** Yield to the macrotask queue after this many entries have been visited. */ yieldEveryEntries?: number; /** Called once for every malformed JSONL record skipped by the stream. */ onMalformedRecord?: () => void; /** Rethrow missing source instead of visiting nothing (fork backstop). */ throwIfMissing?: boolean; /** * Called with each stream chunk's byte length as it is consumed. Lets * callers derive the exact snapshot size without re-stating the file. */ onBytesConsumed?: (bytes: number) => void; } /** Controls how a missing session file is handled. */ export interface LoadSessionOptions { /** Propagate ENOENT instead of treating the path as a new empty session. */ throwIfMissing?: boolean; } /** Parsed session entries plus corruption metadata needed by writable loaders. */ export interface SessionLoadResult { entries: FileEntry[]; titleSlot: SessionTitleUpdate | undefined; malformedRecords: number; /** Byte length of the snapshot actually parsed, or `null` when the path did not exist. */ sourceSize?: number | null; /** Whether non-empty session data was found without a valid leading session header. */ invalidHeader: boolean; } function splitTitleSlot(content: string): { body: string; slot: SessionTitleUpdate | undefined } { const slot = titleUpdateFromSlot(parseTitleSlotFromContent(content)); if (!slot) return { body: content, slot: undefined }; const newlineIndex = content.indexOf("\n"); return { body: content.slice(newlineIndex + 1), slot }; } function isValidSessionHeader(entry: FileEntry | undefined): entry is SessionHeader { return entry?.type === "session" && typeof entry.id === "string"; } function applyTitleSlot(entry: FileEntry | undefined, slot: SessionTitleUpdate | undefined): void { if (!slot || !isValidSessionHeader(entry)) return; if (slot.title && slot.title.length > 0) { entry.title = slot.title; } else { delete entry.title; } if (slot.source) { entry.titleSource = slot.source; } else { delete entry.titleSource; } } /** Parse session JSONL while stripping and folding the optional fixed title slot. */ export function parseSessionContent(content: string): SessionLoadResult { const { body, slot } = splitTitleSlot(content); let malformedRecords = 0; const entries = parseJsonlLenient(body, { onMalformedRecord: () => { malformedRecords++; }, }) as FileEntry[]; applyTitleSlot(entries[0], slot); return { entries, titleSlot: slot, malformedRecords, sourceSize: Buffer.byteLength(content, "utf8"), invalidHeader: entries.length > 0 ? !isValidSessionHeader(entries[0]) : malformedRecords > 0, }; } /** Upper bound on the bytes read to find a header: real headers (and the title slot before them) are far smaller. */ const SESSION_HEADER_READ_LIMIT = 1024 * 1024; /** First read; holds the title slot and any real header, so the full limit is read only for outliers. */ const SESSION_HEADER_FIRST_READ = 16 * 1024; /** {@link sessionHeaderIdFromPrefix} result when the window ends inside the first record. */ const HEADER_INCOMPLETE = Symbol("header-incomplete"); /** * The session id in `filePath`'s header, or `undefined` when there is no file * there or it does not start with a valid session header. Reads * {@link SESSION_HEADER_FIRST_READ} bytes, and up to * {@link SESSION_HEADER_READ_LIMIT} only when the first record runs past them. */ export async function readSessionHeaderId(filePath: string): Promise { try { const id = await peekFile(filePath, SESSION_HEADER_FIRST_READ, prefix => sessionHeaderIdFromPrefix(prefix, prefix.length < SESSION_HEADER_FIRST_READ), ); if (id !== HEADER_INCOMPLETE) return id; const full = await peekFile(filePath, SESSION_HEADER_READ_LIMIT, prefix => sessionHeaderIdFromPrefix(prefix, true), ); return full === HEADER_INCOMPLETE ? undefined : full; } catch (err) { if (isEnoent(err) || isEnotdir(err) || isEisdir(err)) return undefined; throw err; } } /** * The header id from a file's leading bytes, past the optional title slot * line. Parses only the first record, which may span lines (a pretty-printed * header). {@link HEADER_INCOMPLETE} when that record runs past `prefix` and * `final` is false (more of the file can be read). */ function sessionHeaderIdFromPrefix(prefix: Uint8Array, final: boolean): string | undefined | typeof HEADER_INCOMPLETE { let records = prefix; const newline = prefix.indexOf(0x0a); if (newline !== -1) { const firstLine = new TextDecoder().decode(prefix.subarray(0, newline)).trim(); if (firstLine && parseTitleSlotLine(firstLine)) records = prefix.subarray(newline + 1); } // Like the full parser, skip blank lines (and other JSON whitespace) before the first record. let start = 0; while (start < records.length) { const byte = records[start]; if (byte !== 0x20 && byte !== 0x0a && byte !== 0x0d && byte !== 0x09) break; start++; } records = records.subarray(start); if (records.length === 0) return final ? undefined : HEADER_INCOMPLETE; // A header is an object; anything else is not a session file. if (records[0] !== 0x7b) return undefined; const text = new TextDecoder().decode(records); const end = jsonContainerEnd(text, 0); if (end === -1) return final ? undefined : HEADER_INCOMPLETE; const entry = tryParseJson(text.slice(0, end)) ?? undefined; return isValidSessionHeader(entry) ? entry.id : undefined; } /** Parse session JSONL and visit each entry without retaining prior entries. */ export async function visitEntriesFromFileStream( filePath: string, visit: (entry: FileEntry) => void | boolean, options: VisitEntriesFromFileStreamOptions = {}, ): Promise { let titleSlot: SessionTitleUpdate | undefined; let sawFirstLine = false; let sawFirstEntry = false; let bytesSinceYield = 0; let entriesSinceYield = 0; let recordsSeen = 0; const maxRecords = Math.max(0, options.maxRecords ?? Number.POSITIVE_INFINITY); let stopped = false; let visitorThrew = false; const yieldEveryBytes = Math.max(0, options.yieldEveryBytes ?? STREAM_YIELD_BYTES); const yieldEveryEntries = Math.max(0, options.yieldEveryEntries ?? STREAM_YIELD_ENTRIES); const maxBytes = Math.max(0, options.maxBytes ?? Number.POSITIVE_INFINITY); // Bytes, not text: a multibyte UTF-8 sequence straddling a chunk boundary // stays intact, and Bun.JSONL.parseChunk takes typed arrays directly. Only // the unconsumed remainder is held (≤ one record + a chunk), so the ≥8MiB // memory guard holds — the file is never fully loaded. const sink = new ConcatSink(); const decoder = new TextDecoder(); const yieldToMacrotask = async (): Promise => { if (yieldEveryBytes === 0 && yieldEveryEntries === 0) return; const bytesReady = yieldEveryBytes === 0 || bytesSinceYield < yieldEveryBytes; const entriesReady = yieldEveryEntries === 0 || entriesSinceYield < yieldEveryEntries; if (bytesReady && entriesReady) { return; } bytesSinceYield = 0; entriesSinceYield = 0; await Bun.sleep(0); }; const hasPayload = (bytes: Uint8Array, start: number, end: number): boolean => { for (let index = start; index < end; index++) { const byte = bytes[index]; if (byte !== 0x09 && byte !== 0x0a && byte !== 0x0d && byte !== 0x20) return true; } return false; }; const drain = async (endOfInput = false): Promise => { const view = sink.flush(); if (!view) return; // Only newline-terminated bytes may reach the parser: a trailing fragment // in the same call turns an end-of-input `done` into a syntax error at the // preceding newline, which would then be miscounted as a malformed record. const lastNewline = view.lastIndexOf(0x0a); if (lastNewline === -1) return; let buffer: Uint8Array = view.subarray(0, lastNewline + 1); let consumed = 0; const advance = (count: number): void => { consumed += count; buffer = buffer.subarray(count); }; // parseChunk costs time proportional to its whole input even when it stops at // the first bad line, so recovering line by line over the full buffer is // quadratic. After a malformed record, parse a newline-cut window that grows // while the input keeps parsing and resets on the next error. let window = Number.POSITIVE_INFINITY; while (buffer.length > 0 && !stopped) { if (recordsSeen >= maxRecords) { stopped = true; break; } let input = buffer; if (window < buffer.length) { const cut = buffer.indexOf(0x0a, window - 1); if (cut !== -1) input = buffer.subarray(0, cut + 1); } const partial = input.length < buffer.length; const { values, error, read, done } = Bun.JSONL.parseChunk(input); for (const value of values) { if (recordsSeen >= maxRecords) { stopped = true; break; } if (options.shouldContinue && !options.shouldContinue()) { stopped = true; break; } const entry = value as FileEntry; if (!sawFirstEntry) { sawFirstEntry = true; applyTitleSlot(entry, titleSlot); } try { if (visit(entry) === false) { stopped = true; break; } recordsSeen++; entriesSinceYield++; if (recordsSeen >= maxRecords) { stopped = true; break; } } catch (err) { visitorThrew = true; throw err; } await yieldToMacrotask(); } if (stopped) break; if (error) { // Malformed record: skip past the next newline and continue. const nextNewline = buffer.indexOf(0x0a, read); if (nextNewline === -1) break; // rest of the bad line not yet received if (hasPayload(buffer, read, nextNewline)) options.onMalformedRecord?.(); recordsSeen++; advance(nextNewline + 1); window = RECOVERY_PARSE_WINDOW; if (recordsSeen >= maxRecords) { stopped = true; break; } continue; } if (read === 0) { if (partial) { // The record runs past the window, not past the buffered input. window *= 2; continue; } // Incomplete value awaiting more bytes. Once the input has ended nothing more // is coming, so it is a truncated record (parseJsonlLenient counts it too). if (endOfInput) { if (hasPayload(buffer, 0, buffer.length)) options.onMalformedRecord?.(); recordsSeen++; advance(buffer.length); } break; } advance(read); if (done) { // The parser reached the end of its input; whatever trails the last value is whitespace. advance(input.length - read); if (!partial) break; } if (partial) window *= 2; } sink.consume(consumed); }; try { // Capped by hand, not with `file.slice(0, maxBytes)`: on Bun 1.3.14 a slice's // stream over a file that continues past the slice either stays pending after // delivering its bytes (macOS) or ignores the bound and streams the whole // file (Windows). let unread = maxBytes; for await (const streamed of Bun.file(filePath).stream()) { if (stopped) break; const chunk = streamed.byteLength > unread ? streamed.subarray(0, unread) : streamed; unread -= chunk.byteLength; bytesSinceYield += chunk.byteLength; options.onBytesConsumed?.(chunk.byteLength); // Parsing before the chunk closes a line re-scans the unfinished record // on every chunk, which is quadratic for large records. if (chunk.lastIndexOf(0x0a) === -1) { // Skipping drain() also skips the only enforcement of the record cap, // so re-check it here: a delimiter-free file would otherwise be read // and buffered in full despite an exhausted budget. if (recordsSeen >= maxRecords) { stopped = true; break; } sink.append(chunk); await yieldToMacrotask(); if (unread === 0) break; continue; } sink.append(chunk); // The optional fixed-width title slot is a physical first line that is // NOT JSON; peel it before the parser would (correctly) reject it. The // first line ends at a '\n' byte, so it is a complete UTF-8 sequence and // safe to decode. A non-slot first line is a real entry and is left for // the parser; a blank first line is left for the parser to skip. if (!sawFirstLine) { const buffered = sink.flush()!; const newline = buffered.indexOf(0x0a); if (newline !== -1) { sawFirstLine = true; const firstLine = decoder.decode(buffered.subarray(0, newline)).trim(); if (firstLine) { const slot = parseTitleSlotLine(firstLine); if (slot) { titleSlot = titleUpdateFromSlot(slot); sink.consume(newline + 1); } } } } // parseChunk can leave a value unfinished even after a newline; the // sink keeps that remainder for the next chunk. await drain(); await yieldToMacrotask(); if (unread === 0) break; } // A trailing record without a final newline: terminate it so the parser // can complete it (readline yielded it; parseChunk needs the delimiter). if (!stopped && !sink.isEmpty) { sink.append(LF); await drain(true); } } catch (err) { if (visitorThrew) throw err; if (options.throwIfMissing && (isEnoent(err) || isEnotdir(err))) { throw err; } if (isEnoent(err)) { return undefined; } throw err; } return titleSlot; } /** Exported for testing — the ≥8MiB streaming path (works on any file size). */ export async function loadEntriesFromFileStream( filePath: string, options?: Pick, ): Promise { const entries: FileEntry[] = []; let malformedRecords = 0; let bytesConsumed = 0; const titleSlot = await visitEntriesFromFileStream( filePath, entry => { entries.push(entry); }, { onMalformedRecord: () => { malformedRecords++; }, throwIfMissing: options?.throwIfMissing, onBytesConsumed: bytes => { bytesConsumed += bytes; }, }, ); return { entries, titleSlot, malformedRecords, sourceSize: bytesConsumed, invalidHeader: entries.length > 0 ? !isValidSessionHeader(entries[0]) : malformedRecords > 0, }; } /** Exported for compaction.test.ts */ export function parseSessionEntries(content: string): FileEntry[] { return parseSessionContent(content).entries; } function shouldStreamEntries(storage: SessionStorage, size: number): boolean { return storage instanceof FileSessionStorage && size >= STREAM_LOAD_THRESHOLD_BYTES; } async function loadWithKnownSize( filePath: string, storage: SessionStorage, size: number, options?: { throwIfMissing?: boolean }, ): Promise<{ loaded: SessionLoadResult; sourceSize: number }> { if (shouldStreamEntries(storage, size)) { const loaded = await loadEntriesFromFileStream(filePath, { throwIfMissing: options?.throwIfMissing }); const sourceSize = loaded.sourceSize ?? 0; return { loaded: loaded.invalidHeader ? { ...loaded, entries: [] } : loaded, sourceSize }; } const content = await storage.readText(filePath); const loaded = parseSessionContent(content); return { loaded: loaded.invalidHeader ? { ...loaded, entries: [] } : loaded, sourceSize: Buffer.byteLength(content, "utf8"), }; } /** * Windows reports ENOENT when a path component is a regular file, where POSIX * yields ENOTDIR. Inspecting the leaf itself and then climbing to the first * existing ancestor distinguishes that path error from a genuinely missing * file. */ export async function hasFileAsPathComponent(filePath: string): Promise { let current = path.resolve(filePath); for (;;) { try { return !(await fs.stat(current)).isDirectory(); } catch { // Missing component: climb to the first existing ancestor. } const parent = path.dirname(current); if (parent === current) return false; current = parent; } } /** Load and validate a session while retaining malformed-record diagnostics. */ export async function loadSessionFile( filePath: string, storage: SessionStorage = new FileSessionStorage(), options: LoadSessionOptions = {}, ): Promise { try { const statSize = storage.statSync(filePath).size; const { loaded, sourceSize } = await loadWithKnownSize(filePath, storage, statSize, options); return { ...loaded, sourceSize }; } catch (caught) { // Windows reports ENOENT where POSIX reports ENOTDIR for a path running // through a regular file; restore ENOTDIR so that path error is never // masked as a missing session. const err = isEnoent(caught) && process.platform === "win32" && storage instanceof FileSessionStorage && (await hasFileAsPathComponent(filePath)) ? enotdir(filePath) : caught; if (options?.throwIfMissing && (isEnoent(err) || isEnotdir(err))) { throw err; } if (isEnoent(err)) { return { entries: [], titleSlot: undefined, malformedRecords: 0, sourceSize: null, invalidHeader: false, }; } throw err; } } /** * Load the valid entries from a session file, skipping malformed records. * Each call returns freshly parsed entries owned by the caller. */ export async function loadEntriesFromFile( filePath: string, storage: SessionStorage = new FileSessionStorage(), options?: { throwIfMissing?: boolean }, ): Promise { return (await loadSessionFile(filePath, storage, options)).entries; } /** * Visit session entries, using bounded streaming for large file-backed journals. * Small files and non-file backends keep the existing full-load path. */ export async function visitEntriesFromFile( filePath: string, visit: (entry: FileEntry) => void | boolean, storage: SessionStorage = new FileSessionStorage(), ): Promise { const size = storage.statSync(filePath).size; if (shouldStreamEntries(storage, size)) { let sawFirstEntry = false; await visitEntriesFromFileStream(filePath, entry => { if (!sawFirstEntry) { sawFirstEntry = true; if (!isValidSessionHeader(entry)) return false; } return visit(entry); }); return; } for (const entry of (await loadWithKnownSize(filePath, storage, size)).loaded.entries) { if (visit(entry) === false) return; } } function hasImageUrl(value: unknown): value is { image_url: string } { return typeof value === "object" && value !== null && "image_url" in value && typeof value.image_url === "string"; } /** A persisted blob reference and the field that holds it. */ interface BlobRefSite { holder: Record; key: string; ref: string; /** Provider image URLs persist the whole data URL; image payloads persist bare base64. */ asDataUrl: boolean; } /** * Collect the blob references in session image blocks and provider image URLs. * Snapcompact frames stay as references until context rebuilding selects them. */ function collectBlobRefSites(value: unknown, sites: BlobRefSite[], key?: string): void { if (key !== "frames" && isExternalizableImagePosition(value, key) && isBlobRef(value.data)) { sites.push({ holder: value, key: "data", ref: value.data, asDataUrl: false }); return; } if (Array.isArray(value)) { for (const item of value) collectBlobRefSites(item, sites, key); return; } if (typeof value !== "object" || value === null) return; const holder = value as Record; if (holder.type === "image_generation_call" && typeof holder.result === "string" && isBlobRef(holder.result)) { sites.push({ holder, key: "result", ref: holder.result, asDataUrl: false }); } if (hasImageUrl(value) && isBlobRef(value.image_url)) { sites.push({ holder, key: "image_url", ref: value.image_url, asDataUrl: true }); } for (const childKey in holder) collectBlobRefSites(holder[childKey], sites, childKey); } /** * Cheap synchronous precheck: does this value's tree contain any `blob:sha256:` string? * Early-exits on the first hit, so blob-free entries skip the * {@link collectBlobRefSites} descent entirely. Conservative — a blob ref in a * non-resolved position still returns true, which only costs an extra (no-op) walk. */ function containsBlobRef(value: unknown): boolean { if (typeof value === "string") return isBlobRef(value); if (Array.isArray(value)) { for (const item of value) { if (containsBlobRef(item)) return true; } return false; } if (typeof value !== "object" || value === null) return false; for (const key in value) { if (containsBlobRef((value as Record)[key])) return true; } return false; } /** * Older persistence versions truncated oversized frame base64 in place. Recover * those archives from their retained source text so an already-wedged session * resumes as text instead of sending malformed image data to the provider. */ function repairTruncatedSnapcompactFrames(entry: FileEntry): void { if (entry.type !== "compaction") return; const archive = snapcompact.getPreservedArchive(entry.preserveData); if (!archive?.frames.some(frame => isPersistenceTruncatedString(frame.data))) return; const slot = entry.preserveData?.[snapcompact.PRESERVE_KEY]; if (typeof slot !== "object" || slot === null) return; if (archive.text) { Object.assign(slot, { frames: [], textHead: archive.text, textTail: "" }); return; } Object.assign(slot, { frames: archive.frames.filter(frame => !isPersistenceTruncatedString(frame.data)), }); } function blobRefSites(values: readonly unknown[]): BlobRefSite[] { const sites: BlobRefSite[] = []; for (const value of values) { if (containsBlobRef(value)) collectBlobRefSites(value, sites); } return sites; } function blobSiteKey(site: BlobRefSite): string { return `${site.asDataUrl ? "url" : "base64"}:${site.ref}`; } async function resolveBlobRefs(values: readonly unknown[], blobStore: BlobStore): Promise { const semaphore = new Semaphore(BLOB_READ_CONCURRENCY); const resolved = new Map>(); await Promise.all( blobRefSites(values).map(async site => { const key = blobSiteKey(site); let data = resolved.get(key); if (!data) { data = (async () => { await semaphore.acquire(); try { return await (site.asDataUrl ? resolveImageDataUrl(blobStore, site.ref) : resolveImageData(blobStore, site.ref)); } finally { semaphore.release(); } })(); resolved.set(key, data); } site.holder[site.key] = await data; }), ); } /** The non-header entries, with legacy truncated snapcompact frames repaired before their blobs resolve. */ function entriesForBlobResolution(entries: FileEntry[]): FileEntry[] { const sessionEntries = entries.filter(entry => entry.type !== "session"); for (const entry of sessionEntries) repairTruncatedSnapcompactFrames(entry); return sessionEntries; } /** Restore the image data that persistence externalized to the blob store, in place. */ export async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise { await resolveBlobRefs(entriesForBlobResolution(entries), blobStore); } /** Synchronous {@link resolveBlobRefsInEntries}. */ export function resolveBlobRefsInEntriesSync(entries: FileEntry[], blobStore: BlobStore): void { const resolved = new Map(); for (const site of blobRefSites(entriesForBlobResolution(entries))) { const key = blobSiteKey(site); let data = resolved.get(key); if (data === undefined) { data = site.asDataUrl ? resolveImageDataUrlSync(blobStore, site.ref) : resolveImageDataSync(blobStore, site.ref); resolved.set(key, data); } site.holder[site.key] = data; } } /** * Read-only transcript view of a session file: load entries, migrate to the * current version, resolve blob refs, and build the display transcript along * the persisted leaf path (last entry). Uses transcript mode (collapsed to the * latest compaction) so failed/aborted tail turns stay visible, unlike the * provider-context builder which drops them. Does NOT create a writer or take * the session lock — safe to call against a file another session is writing. */ export async function loadSessionMessagesReadOnly(filePath: string): Promise { const entries = await loadEntriesFromFile(filePath); if (entries.length === 0) return []; migrateToCurrentVersion(entries); for (const entry of entries) repairTruncatedSnapcompactFrames(entry); const blobs = new BlobStore(getBlobsDir()); const sessionEntries = entries.filter((e): e is SessionEntry => e.type !== "session"); const { messages } = buildSessionContext(sessionEntries, undefined, undefined, { transcript: true, collapseCompactedHistory: true, resolveFrameData: data => lazyImageDataSync(blobs, data), }); // A collapsed summary carries the remote-compaction replacement history for // provider replay only; this transcript is never replayed, and the renderer // reads just the summary. Dropping it keeps hydration off every image blob // buried in that hidden history. const displayMessages = messages.map(message => message.role === "compactionSummary" && message.providerPayload !== undefined ? { ...message, providerPayload: undefined } : message, ); await resolveBlobRefs(displayMessages, blobs); return displayMessages; }