import { LocalStorage } from "@vicinae/api"; import { MAX_SESSIONS, MAX_STORED_CHARS_PER_TURN, MAX_TURNS_PER_SESSION, STORAGE_INDEX_KEY, STORAGE_SESSION_PREFIX, TITLE_MAX_LENGTH } from "./constants"; import { sweepExpired, type SweepOutcome } from "./retention"; import { SessionIndexSchema, SessionSchema } from "./schemas"; import { listSnipFiles, removeSnipFiles } from "./snip"; import type { Attachment, ChatTurn, Session, SessionSummary } from "./types"; import { titleFromPrompt, truncate } from "./utils"; /** * Session persistence on top of Vicinae's LocalStorage. * * LocalStorage only accepts strings, numbers, and booleans, so everything is * JSON-encoded. An index holding small summaries is stored separately from the * session bodies: listing sessions is a hot path, and it should not require * decoding every transcript. * * A session body is one key, so saving a turn rewrites only that session. * Transcripts are capped twice over, per turn and per session, because * LocalStorage has no documented ceiling and a runaway loop should not be able * to wedge the extension. */ function newId(): string { return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; } /** Builds the lightweight index record kept in the session list. */ export function summarizeSession(session: Session): SessionSummary { return summarize(session); } function sessionKey(id: string): string { return `${STORAGE_SESSION_PREFIX}${id}`; } function summarize(session: Session): SessionSummary { const lastTurn = session.turns[session.turns.length - 1]; return { id: session.id, title: session.title, model: session.model, createdAt: session.createdAt, updatedAt: session.updatedAt, turnCount: session.turns.length, preview: lastTurn ? truncate(lastTurn.response || lastTurn.prompt, TITLE_MAX_LENGTH * 2) : "", attachmentPaths: attachmentPathsOf(session), }; } /** Every image path a conversation refers to, deduplicated and in turn order. */ export function attachmentPathsOf(session: Session): string[] { return [ ...new Set(session.turns.flatMap((turn) => turn.attachments.map((attachment) => attachment.path))), ]; } export function createSession(model: string, now: number = Date.now()): Session { return { id: newId(), title: "New chat", model, createdAt: now, updatedAt: now, turns: [], }; } /** Creates the turn record for a prompt that is about to be sent. */ export function createTurn( prompt: string, model: string, now: number = Date.now(), attachments: Attachment[] = [], ): ChatTurn { return { id: newId(), prompt, response: "", thinking: "", model, createdAt: now, toolCalls: [], attachments, }; } /** Drops the oldest turns once a session exceeds its cap, keeping the most recent context. */ export function capTurns(turns: ChatTurn[]): ChatTurn[] { return turns.length <= MAX_TURNS_PER_SESSION ? turns : turns.slice(turns.length - MAX_TURNS_PER_SESSION); } /** Trims a single turn's stored text so one enormous answer cannot dominate storage. */ export function capTurn(turn: ChatTurn): ChatTurn { const cap = (text: string): string => text.length <= MAX_STORED_CHARS_PER_TURN ? text : `${text.slice(0, MAX_STORED_CHARS_PER_TURN)}\n\n[truncated]`; return { ...turn, prompt: cap(turn.prompt), response: cap(turn.response), thinking: cap(turn.thinking), toolCalls: turn.toolCalls.map((call) => ({ ...call, result: cap(call.result) })), }; } export async function listSessions(): Promise { try { const raw = await LocalStorage.getItem(STORAGE_INDEX_KEY); if (!raw) return []; const parsed = SessionIndexSchema.safeParse(JSON.parse(raw)); if (!parsed.success) return []; return [...parsed.data].sort((a, b) => b.updatedAt - a.updatedAt); } catch { // A corrupt index should degrade to "no sessions", never to a crashed command. return []; } } async function writeIndex(summaries: SessionSummary[]): Promise { await LocalStorage.setItem(STORAGE_INDEX_KEY, JSON.stringify(summaries)); } export async function loadSession(id: string): Promise { try { const raw = await LocalStorage.getItem(sessionKey(id)); if (!raw) return undefined; const parsed = SessionSchema.safeParse(JSON.parse(raw)); if (!parsed.success) return undefined; return { ...parsed.data, turns: parsed.data.turns.map((turn) => ({ ...turn, toolCalls: turn.toolCalls ?? [] })), }; } catch { return undefined; } } /** * Persists a session and refreshes its index entry, evicting the oldest * sessions past {@link MAX_SESSIONS} along with their storage keys. */ export async function saveSession(session: Session): Promise { const capped: Session = { ...session, turns: capTurns(session.turns).map(capTurn), }; await LocalStorage.setItem(sessionKey(capped.id), JSON.stringify(capped)); const summaries = await listSessions(); const others = summaries.filter((summary) => summary.id !== capped.id); const merged = [summarize(capped), ...others].sort((a, b) => b.updatedAt - a.updatedAt); const kept = merged.slice(0, MAX_SESSIONS); const evicted = merged.slice(MAX_SESSIONS); await writeIndex(kept); for (const summary of evicted) { try { await LocalStorage.removeItem(sessionKey(summary.id)); } catch { // Losing an eviction just leaves an orphaned key; not worth failing the save. } } } export async function deleteSession(id: string): Promise { const summaries = await listSessions(); const survivors = summaries.filter((summary) => summary.id !== id); const owned = await attachmentPathsFor([id], summaries); await writeIndex(survivors); try { await LocalStorage.removeItem(sessionKey(id)); } catch { // Already gone. } // Deleting a conversation takes its screenshots with it, the same as an // expired one does. Without this a manual delete leaks image files that // nothing will ever collect, and the orphan pass only runs while the retention // setting is on. await removeSnipFiles(await pathsSafeToDelete(owned, survivors)); } /** * The paths a deleted conversation held that nothing else still needs. * * Ownership alone is not enough: two conversations can refer to the same * capture, and removing the file would leave the survivor showing a broken * thumbnail. When the surviving references cannot be established, nothing is * removed. */ async function pathsSafeToDelete(owned: string[], survivors: SessionSummary[]): Promise { if (owned.length === 0) return []; const kept = await pathsKeptBy(survivors); return kept === undefined ? [] : owned.filter((path) => !kept.has(path)); } /** * Every image path the given conversations refer to, or `undefined` when that * cannot be established. * * Index-backed, for the reason set out on `SessionSummary.attachmentPaths`: * deriving this from the transcripts would mean a hundred JSON parses on every * sweep, and the sweep runs after every answer. * * Entries written before that field existed are filled in from their transcripts * and the index is written back once, so an upgrade pays for the repair on its * first sweep rather than on every one. A transcript that will not load is the * one case that returns `undefined` instead of an answer: its screenshots would * look exactly like orphans, and deleting someone's images because a key failed * to parse once is not a trade worth making. */ async function pathsKeptBy(summaries: SessionSummary[]): Promise | undefined> { if (summaries.every((summary) => summary.attachmentPaths !== undefined)) { return new Set(summaries.flatMap((summary) => summary.attachmentPaths ?? [])); } let unknown = false; const repaired = await Promise.all( summaries.map(async (summary): Promise => { if (summary.attachmentPaths !== undefined) return summary; const session = await loadSession(summary.id); if (!session) { unknown = true; return summary; } return { ...summary, attachmentPaths: attachmentPathsOf(session) }; }), ); if (unknown) return undefined; try { await writeIndex(repaired); } catch { // A failed repair only means the next sweep reads these transcripts again. } return new Set(repaired.flatMap((summary) => summary.attachmentPaths ?? [])); } /** * The image paths held by the named conversations. * * Bodies are read before their keys are removed, because the turns are the only * record of which screenshots belonged to them. A body that will not load * contributes the paths its summary already knew about, which is one of the * things `attachmentPaths` is stored for. */ async function attachmentPathsFor(ids: string[], summaries: SessionSummary[]): Promise { if (ids.length === 0) return []; const wanted = new Set(ids); const doomed = summaries.filter((summary) => wanted.has(summary.id)); const collected = await Promise.all( doomed.map(async (summary) => { const session = await loadSession(summary.id); return session ? attachmentPathsOf(session) : (summary.attachmentPaths ?? []); }), ); return [...new Set(collected.flat())]; } /** * Deletes several conversations in one pass, returning the image paths they held. * * Separate from {@link deleteSession} rather than a loop over it because the * sweep can expire dozens of conversations at once, and one index rewrite per * conversation is dozens of writes to re-save a shrinking list. * * Bodies are read before their keys are removed, because the turns are the only * record of which screenshots belonged to these conversations. */ export async function deleteSessions(ids: string[]): Promise { if (ids.length === 0) return []; const removing = new Set(ids); const summaries = await listSessions(); const paths = await attachmentPathsFor(ids, summaries); await writeIndex(summaries.filter((summary) => !removing.has(summary.id))); for (const id of ids) { try { await LocalStorage.removeItem(sessionKey(id)); } catch { // Already gone. } } // Returned rather than removed: the sweep that calls this has already listed // the snip directory, and removing the files here would mean two passes over // it for one tidy-up. return paths; } /** * Image paths still referred to by a conversation that exists, or `undefined` * when that cannot be established. * * See {@link pathsKeptBy}, which is where the decision actually lives. */ export async function referencedAttachments(): Promise { const kept = await pathsKeptBy(await listSessions()); return kept === undefined ? undefined : [...kept]; } /** * Runs the retention sweep against real storage and the real file system. * * The one place the pure decision in `retention.ts` meets its side effects, so a * command entrypoint has a single thing to call. Resolves to a zero outcome * rather than throwing: it runs on the way into a command the user is waiting * for, and a tidy-up that fails is not worth an error message. */ export async function sweepExpiredSessions(input: { maxAgeMs?: number; now?: number; keepId?: string; }): Promise { return sweepExpired( { maxAgeMs: input.maxAgeMs, now: input.now ?? Date.now(), keepId: input.keepId }, { listSessions, deleteSessions, referencedAttachments, listSnipFiles, removeSnipFiles }, ).catch(() => ({ sessions: 0, files: 0 })); } export async function clearSessions(): Promise { const summaries = await listSessions(); const owned = await attachmentPathsFor( summaries.map((summary) => summary.id), summaries, ); for (const summary of summaries) { try { await LocalStorage.removeItem(sessionKey(summary.id)); } catch { // Keep going: clearing as much as possible beats failing outright. } } await LocalStorage.removeItem(STORAGE_INDEX_KEY); // Every screenshot too, for the same reason a single delete takes its own. // Nothing survives to share them with, so they are all safe to remove. await removeSnipFiles(await pathsSafeToDelete(owned, [])); } /** Recomputes a session's title from its first prompt, once there is a prompt worth naming it after. */ export function withTitleFromFirstTurn(session: Session): Session { if (session.turns.length === 0) return session; if (session.title !== "New chat") return session; return { ...session, title: titleFromPrompt(session.turns[0].prompt, TITLE_MAX_LENGTH) }; }