feat: auto-delete old conversations, and stop a tool call that repeats
This commit is contained in:
+177
-1
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user