import type { AgentMessage } from "@oh-my-pi/pi-agent-core"; import { getBlobsDir, isEnoent, parseJsonlLenient } from "@oh-my-pi/pi-utils"; import { BlobStore, isBlobRef, resolveImageData, resolveImageDataUrl } from "./blob-store"; import { buildSessionContext } from "./session-context"; import type { FileEntry, SessionEntry, SessionHeader } from "./session-entries"; import { migrateToCurrentVersion } from "./session-migrations"; import { isImageBlock, isImageDataPayload } from "./session-persistence"; import { FileSessionStorage, type SessionStorage } from "./session-storage"; /** Exported for compaction.test.ts */ export function parseSessionEntries(content: string): FileEntry[] { return parseJsonlLenient(content); } /** Exported for testing */ export async function loadEntriesFromFile( filePath: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { let content: string; try { content = await storage.readText(filePath); } catch (err) { if (isEnoent(err)) return []; throw err; } const entries = parseJsonlLenient(content); // Validate session header if (entries.length === 0) return entries; const header = entries[0] as SessionHeader; if (header.type !== "session" || typeof header.id !== "string") { return []; } return entries; } /** * Resolve blob references in loaded entries, restoring both session image blocks and persisted * provider image URLs back to the inline data expected by downstream transports. Mutates entries in place. */ function hasImageUrl(value: unknown): value is { image_url: string } { return typeof value === "object" && value !== null && "image_url" in value && typeof value.image_url === "string"; } function shouldResolveImagePayload(value: unknown, key: string | undefined): value is { data: string } { if (!isImageDataPayload(value) || !isBlobRef(value.data)) return false; return (key === "content" && isImageBlock(value)) || key === "images"; } async function resolvePersistedBlobRefs(value: unknown, blobStore: BlobStore, key?: string): Promise { if (shouldResolveImagePayload(value, key)) { value.data = await resolveImageData(blobStore, value.data); return; } if (Array.isArray(value)) { await Promise.all(value.map(item => resolvePersistedBlobRefs(item, blobStore, key))); return; } if (typeof value !== "object" || value === null) return; if (hasImageUrl(value) && isBlobRef(value.image_url)) { value.image_url = await resolveImageDataUrl(blobStore, value.image_url); } await Promise.all( Object.entries(value).map(([childKey, item]) => resolvePersistedBlobRefs(item, blobStore, childKey)), ); } export async function resolveBlobRefsInEntries(entries: FileEntry[], blobStore: BlobStore): Promise { await Promise.all( entries.filter(entry => entry.type !== "session").map(entry => resolvePersistedBlobRefs(entry, blobStore)), ); } /** * Read-only message view of a session file: load entries, migrate to the * current version, resolve blob refs, and build the context along the * persisted leaf path (last entry). 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); await resolveBlobRefsInEntries(entries, new BlobStore(getBlobsDir())); const sessionEntries = entries.filter((e): e is SessionEntry => e.type !== "session"); return buildSessionContext(sessionEntries).messages; }