import type { AssistantBlock, ChatMessage, ChatMessageAttachment,} from "@/features/agent/messages/types";import { isRecord } from "@/lib/guards"; type TranscriptStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">; 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<Storage>; 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);}
import type {
AssistantBlock,
ChatMessage,
ChatMessageAttachment,
} from "@/features/agent/messages/types";
import { isRecord } from "@/lib/guards";
type TranscriptStorage = Pick<Storage, "getItem" | "setItem" | "removeItem">;
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<Storage>;
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);
}