198 lines · 6.6 KB · TypeScript
RawDownload
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);}