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, type RawFileEntry, SESSION_TITLE_SLOT_BYTES, type SessionEntry, type SessionHeader, type SessionTitleSlotEntry, } from "./session-entries"; import { migrateToCurrentVersion } from "./session-migrations"; import { isImageBlock, isImageDataPayload } from "./session-persistence"; import { FileSessionStorage, type SessionStorage } from "./session-storage"; import { parseTitleSlotFromContent, parseTitleSlotLine, type SessionTitleUpdate, titleUpdateFromSlot, } from "./session-title-slot"; 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 foldTitleSlot(entries: FileEntry[], slot: SessionTitleUpdate | undefined): FileEntry[] { if (!slot || entries.length === 0) return entries; const header = entries[0] as SessionHeader; if (header.type !== "session" || typeof header.id !== "string") return entries; if (slot.title && slot.title.length > 0) { header.title = slot.title; } else { delete header.title; } if (slot.source) { header.titleSource = slot.source; } else { delete header.titleSource; } return entries; } /** Parse session JSONL while stripping and folding the optional fixed title slot. */ export function parseSessionContent(content: string): { entries: FileEntry[]; titleSlot: SessionTitleUpdate | undefined; } { const { body, slot } = splitTitleSlot(content); const entries = parseJsonlLenient(body) as FileEntry[]; return { entries: foldTitleSlot(entries, slot), titleSlot: slot }; } /** Read only the fixed-size head window to detect a physical title slot. */ export async function readTitleSlotFromFile( filePath: string, storage: SessionStorage = new FileSessionStorage(), ): Promise { let head: string; try { [head] = await storage.readTextSlices(filePath, SESSION_TITLE_SLOT_BYTES, 0); } catch (err) { if (isEnoent(err)) return undefined; throw err; } const newlineIndex = head.indexOf("\n"); if (newlineIndex < 0) return undefined; return parseTitleSlotLine(head.slice(0, newlineIndex)); } /** Exported for compaction.test.ts */ export function parseSessionEntries(content: string): FileEntry[] { return parseSessionContent(content).entries; } /** 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 } = parseSessionContent(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; }