import type { AssistantBlock, ChatMessage, ChatMessageAttachment, } from "@/features/agent/messages/types"; import { isRecord } from "@/lib/guards"; type TranscriptStorage = Pick; export const TRANSCRIPT_CACHE_PREFIX = "local-studio.agent.transcript.v2."; const MAX_MESSAGES_PER_SESSION = 200; const MAX_CHARS_PER_SESSION = 512 * 1024; const MAX_SESSIONS = 24; const MAX_BLOCK_TEXT = 16 * 1024; export type CachedTranscript = { version: 2; updatedAt: number; title?: string; messages: ChatMessage[]; }; function defaultStorage(): TranscriptStorage | null { return typeof window === "undefined" ? null : window.localStorage; } function sessionKey(piSessionId: string): string { return `${TRANSCRIPT_CACHE_PREFIX}${piSessionId}`; } function truncateText(text: string | undefined): string | undefined { if (typeof text !== "string" || text.length <= MAX_BLOCK_TEXT) return text; return `${text.slice(0, MAX_BLOCK_TEXT)}\n…[truncated]`; } function sanitizeBlock(block: AssistantBlock): AssistantBlock { if (block.kind === "tool") { return { ...block, text: truncateText(block.text) ?? "", ...(block.argsText !== undefined ? { argsText: truncateText(block.argsText) } : {}), ...(block.resultText !== undefined ? { resultText: truncateText(block.resultText) } : {}), }; } return { ...block, text: truncateText(block.text) ?? "" }; } function stripAttachmentBody(attachment: ChatMessageAttachment): ChatMessageAttachment { return { id: attachment.id, name: attachment.name, type: attachment.type, size: attachment.size, mode: attachment.mode, content: "", ...(attachment.path ? { path: attachment.path } : {}), ...(attachment.previewKind ? { previewKind: attachment.previewKind } : {}), }; } function sanitizeMessage(message: ChatMessage): ChatMessage { const clean: ChatMessage = { id: message.id, role: message.role, text: truncateText(message.text) ?? "", }; if (message.timestamp) clean.timestamp = message.timestamp; if (message.skills?.length) clean.skills = message.skills; if (message.blocks?.length) clean.blocks = message.blocks.map(sanitizeBlock); if (message.attachments?.length) clean.attachments = message.attachments.map(stripAttachmentBody); return clean; } export function boundMessagesForCache(messages: ChatMessage[]): ChatMessage[] { const kept = messages.slice(-MAX_MESSAGES_PER_SESSION).map(sanitizeMessage); // Size each message once instead of re-stringifying the whole array per trim // iteration — near the cap that loop stringified ~0.5 MB repeatedly on every // settled turn, on the main thread. const sizes = kept.map((message) => JSON.stringify(message).length + 1); let total = sizes.reduce((sum, size) => sum + size, 2); let start = 0; while (kept.length - start > 1 && total > MAX_CHARS_PER_SESSION) { total -= sizes[start]; start += 1; } return start > 0 ? kept.slice(start) : kept; } function parseCachedTranscript(raw: string | null): CachedTranscript | null { if (!raw) return null; try { const parsed: unknown = JSON.parse(raw); if (!isRecord(parsed) || parsed.version !== 2 || !Array.isArray(parsed.messages)) return null; return parsed as unknown as CachedTranscript; } catch { return null; } } function cacheKeys(storage: TranscriptStorage): string[] { const local = storage as Partial; if (typeof local.length !== "number" || typeof local.key !== "function") return []; const keys: string[] = []; for (let index = 0; index < local.length; index += 1) { const key = local.key(index); if (key?.startsWith(TRANSCRIPT_CACHE_PREFIX)) keys.push(key); } return keys; } function evictStaleSessions(storage: TranscriptStorage, keepKey: string): void { const keys = cacheKeys(storage); if (keys.length <= MAX_SESSIONS) return; const dated = keys .filter((key) => key !== keepKey) .map((key) => { const entry = parseCachedTranscript(storage.getItem(key)); return { key, updatedAt: entry?.updatedAt ?? 0 }; }) .sort((a, b) => a.updatedAt - b.updatedAt); for (const { key } of dated.slice(0, keys.length - MAX_SESSIONS)) { storage.removeItem(key); } } export function readTranscriptSnapshot( piSessionId: string | null | undefined, storage: TranscriptStorage | null = defaultStorage(), ): ChatMessage[] | null { const entry = readTranscriptSnapshotEntry(piSessionId, storage); return entry && entry.messages.length > 0 ? entry.messages : null; } export function readTranscriptSnapshotEntry( piSessionId: string | null | undefined, storage: TranscriptStorage | null = defaultStorage(), ): CachedTranscript | null { if (!storage || !piSessionId) return null; try { return parseCachedTranscript(storage.getItem(sessionKey(piSessionId))); } catch { return null; } } export function writeTranscriptSnapshot( piSessionId: string | null | undefined, messages: ChatMessage[], title: string | undefined, storage: TranscriptStorage | null = defaultStorage(), now: number = Date.now(), ): void { if (!storage || !piSessionId || messages.length === 0) return; const key = sessionKey(piSessionId); const entry: CachedTranscript = { version: 2, updatedAt: now, ...(title ? { title } : {}), messages: boundMessagesForCache(messages), }; const payload = JSON.stringify(entry); try { storage.setItem(key, payload); evictStaleSessions(storage, key); return; } catch { // Out of quota. Fall through and make room. } // Give up the oldest snapshots one at a time until this one fits. // // Dropping them all at once also works and is simpler, but it throws away // every other session's transcript to store one: with tool-heavy sessions // (~500KB each against a ~5MB origin quota) that fires about every tenth // write, so the cache that exists to make reopening instant is empty // precisely when the most sessions are open. Measured over 24 writes, the // wholesale version collapsed to a single cached session twice; this keeps // roughly nine. for (const stale of oldestFirst(storage, key)) { storage.removeItem(stale); try { storage.setItem(key, payload); return; } catch { // Still too big — drop the next-oldest. } } } /** Cached sessions other than `keepKey`, least recently updated first. */ function oldestFirst(storage: TranscriptStorage, keepKey: string): string[] { return cacheKeys(storage) .filter((key) => key !== keepKey) .map((key) => ({ key, updatedAt: parseCachedTranscript(storage.getItem(key))?.updatedAt ?? 0 })) .sort((a, b) => a.updatedAt - b.updatedAt) .map((entry) => entry.key); }