feat: auto-delete old conversations, and stop a tool call that repeats

This commit is contained in:
2026-10-05 16:59:38 +08:00
parent 576c5946f7
commit d27c4374fe
25 changed files with 2004 additions and 55 deletions
+177 -1
View File
@@ -1,6 +1,8 @@
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";
@@ -41,9 +43,17 @@ function summarize(session: Session): SessionSummary {
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(),
@@ -161,17 +171,179 @@ export async function saveSession(session: Session): Promise<void> {
export async function deleteSession(id: string): Promise<void> {
const summaries = await listSessions();
await writeIndex(summaries.filter((summary) => summary.id !== id));
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<string[]> {
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<Set<string> | 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<SessionSummary> => {
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<string[]> {
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<string[]> {
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<string[] | undefined> {
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<SweepOutcome> {
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<void> {
const summaries = await listSessions();
const owned = await attachmentPathsFor(
summaries.map((summary) => summary.id),
summaries,
);
for (const summary of summaries) {
try {
@@ -182,6 +354,10 @@ export async function clearSessions(): Promise<void> {
}
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. */