feat: two maintenance actions in the setup screen

This commit is contained in:
2026-10-05 17:03:13 +08:00
parent d27c4374fe
commit e7c61c4268
10 changed files with 418 additions and 54 deletions
+17 -2
View File
@@ -18,7 +18,7 @@ Hit a global shortcut, type a question, get a streamed answer without leaving wh
- **Real markdown.** Tables, bulleted and numbered lists, headings, blockquotes, bold, italics and links all render natively, and the default prompt asks for the shape that fits the answer rather than flattening everything into prose. - **Real markdown.** Tables, bulleted and numbered lists, headings, blockquotes, bold, italics and links all render natively, and the default prompt asks for the shape that fits the answer rather than flattening everything into prose.
- **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching. A turn that uses up its tool budget still ends in an answer, written from what it gathered, rather than a notice that it stopped. - **Tools.** Lam decides when to call them: current time, location context, web search, and page fetching. A turn that uses up its tool budget still ends in an answer, written from what it gathered, rather than a notice that it stopped.
- **Tool calls you can watch and stop.** What Lam is doing right now stays on screen, including when it asks the same question twice, the round limit is a preference, Force Stop is always one keystroke away, and a call that repeats itself with identical arguments ends the turn instead of running forever. - **Tool calls you can watch and stop.** What Lam is doing right now stays on screen, including when it asks the same question twice, the round limit is a preference, Force Stop is always one keystroke away, and a call that repeats itself with identical arguments ends the turn instead of running forever.
- **Optional auto-deletion.** Set a retention window and Lam clears out the conversations you have stopped using the next time you open a command, taking their screenshots with them and collecting any capture you never sent. Off by default, and nothing at all is read or written while it is off. - **Optional auto-deletion.** Set a retention window and Lam clears out the conversations you have stopped using the next time you open a command, taking their screenshots with them and collecting any capture you never sent. Off by default, and nothing at all is read or written while it is off. The setup screen also has both tidy-ups as explicit actions, confirmed first, for when you would rather press the button yourself.
- **Screenshots, two ways.** Snip a region into the conversation you are already in from the action panel, or run **Snip to Lam** to start a new chat about it. The launcher window gets out of the way first, so it never ends up in the shot, and the capture appears in the transcript as a thumbnail with Open Screenshot for the full image. Neither is offered unless the current model can actually see images. - **Screenshots, two ways.** Snip a region into the conversation you are already in from the action panel, or run **Snip to Lam** to start a new chat about it. The launcher window gets out of the way first, so it never ends up in the shot, and the capture appears in the transcript as a thumbnail with Open Screenshot for the full image. Neither is offered unless the current model can actually see images.
- **Chat only.** Lam answers questions. It does not touch your files or run commands. The one exception is capture, and it only ever happens because you pressed the key for it. - **Chat only.** Lam answers questions. It does not touch your files or run commands. The one exception is capture, and it only ever happens because you pressed the key for it.
@@ -115,7 +115,7 @@ vicinae 'vicinae://launch/lam-agent/chat?fallbackText=summarise%20this'
| **Chat with Lam** | The chat surface. Bind this to your global shortcut. | | **Chat with Lam** | The chat surface. Bind this to your global shortcut. |
| **Snip to Lam** | Capture a region and start a new chat about it. A `no-view` command, so the launcher window closes before the selector appears. Bind this to a second shortcut for one-key "snip and ask". | | **Snip to Lam** | Capture a region and start a new chat about it. A `no-view` command, so the launcher window closes before the selector appears. Bind this to a second shortcut for one-key "snip and ask". |
| **Chat Sessions** | Browse, reopen, and delete past conversations. | | **Chat Sessions** | Browse, reopen, and delete past conversations. |
| **Set Up Lam Agent** | Verify the connection, list available models, and check whether the selected model can see images. | | **Set Up Lam Agent** | Verify the connection, list available models, check whether the selected model can see images, and clear out chats or unattached photos. |
## Configuration ## Configuration
@@ -190,6 +190,21 @@ The index entry for a conversation carries its image paths (`SessionSummary.atta
**The whole design leans towards deleting nothing.** Retention off means nothing is read and nothing is written. An unrecognised value for the preference resolves to off. The orphan pass is skipped entirely when the referenced set cannot be established, because an incomplete set of references looks exactly like a pile of orphans and guessing wrong destroys real images. A path is checked to be inside the snip directory before anything is removed, so a hand-edited entry in storage cannot reach anything else on the disk. And the sweep never throws, so a failed tidy-up cannot break the command you opened. **The whole design leans towards deleting nothing.** Retention off means nothing is read and nothing is written. An unrecognised value for the preference resolves to off. The orphan pass is skipped entirely when the referenced set cannot be established, because an incomplete set of references looks exactly like a pile of orphans and guessing wrong destroys real images. A path is checked to be inside the snip directory before anything is removed, so a hand-edited entry in storage cannot reach anything else on the disk. And the sweep never throws, so a failed tidy-up cannot break the command you opened.
### Doing it by hand
**Set Up Lam Agent** has a Maintenance section with the same two jobs as explicit actions:
| Action | What it deletes |
| --- | --- |
| **Clear Out All Chats** | Every conversation, and every screenshot they captured. |
| **Clear Out Unattached Photos** | Captures that no conversation refers to any more. |
Both ask for confirmation first, and both say what they did even when the answer is nothing, because a button that appears to do nothing reads as a bug. That is deliberately the opposite of the background sweep, which stays silent unless it deleted something.
The photo clean-up applies exactly the same rule as the sweep, including the one-day wait, and it works whether or not **Delete Old Chats After** is switched on. So it covers the case that setting exists for: you would rather decide when your history is cleared than have Lam decide it for you.
When a conversation cannot be read there is no way to know which captures are still in use, so that action says it could not check and deletes nothing, rather than risking a screenshot a surviving chat still shows.
## Architecture ## Architecture
``` ```
+104 -3
View File
@@ -1,8 +1,8 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { rm } from "node:fs/promises"; import { access, mkdir, rm, utimes, writeFile } from "node:fs/promises";
import { __resetLocalStorage, __resetToasts, __toasts } from "../__mocks__/@vicinae/api"; import { __resetLocalStorage, __resetToasts, __toasts } from "../__mocks__/@vicinae/api";
import { runAutoDelete } from "../lib/cleanup"; import { clearAllChats, clearOrphanedSnips, runAutoDelete } from "../lib/cleanup";
import { createSession, listSessions, saveSession } from "../lib/sessions"; import { createSession, createTurn, listSessions, saveSession } from "../lib/sessions";
/** /**
* The one thing the sweep does that the pure decision in `retention.ts` cannot: * The one thing the sweep does that the pure decision in `retention.ts` cannot:
@@ -23,6 +23,28 @@ afterEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true }); await rm(DIRECTORY, { recursive: true, force: true });
}); });
/** A turn carrying one attached capture. */
function snipTurn(path: string) {
return { ...createTurn("what is this", "m", Date.now()), attachments: [{ path, mime: "image/png" }] };
}
/** Saves a conversation that refers to a fresh capture of the given name. */
async function referenceSnip(name: string, ageMs: number): Promise<string> {
await mkdir(DIRECTORY, { recursive: true });
const path = `${DIRECTORY}/${name}`;
await writeFile(path, "png");
const when = new Date(Date.now() - ageMs);
await utimes(path, when, when);
await saveSession({
...createSession("m", Date.now()),
turns: [snipTurn(path)],
});
return path;
}
describe("runAutoDelete", () => { describe("runAutoDelete", () => {
it("deletes nothing and says nothing when auto-deletion is off", async () => { it("deletes nothing and says nothing when auto-deletion is off", async () => {
await saveSession({ ...createSession("m", 1), updatedAt: 1 }); await saveSession({ ...createSession("m", 1), updatedAt: 1 });
@@ -74,3 +96,82 @@ describe("runAutoDelete", () => {
expect(__toasts).toEqual([]); expect(__toasts).toEqual([]);
}); });
}); });
describe("the maintenance actions", () => {
const DAY = 24 * 60 * 60 * 1000;
async function makeSnip(name: string, ageMs: number): Promise<string> {
await mkdir(`${DIRECTORY}`, { recursive: true });
const path = `${DIRECTORY}/${name}`;
await writeFile(path, "png");
const when = new Date(Date.now() - ageMs);
await utimes(path, when, when);
return path;
}
it("deletes every conversation and its screenshots, and reports both counts", async () => {
const path = await makeSnip("one.png", 0);
await saveSession({
...createSession("m", Date.now()),
turns: [snipTurn(path)],
});
await saveSession({ ...createSession("m", Date.now()), turns: [createTurn("hi", "m", Date.now())] });
await clearAllChats();
expect(await listSessions()).toHaveLength(0);
await expect(access(path)).rejects.toThrow();
expect(__toasts).toEqual([
{
title: "Cleared out all conversations",
message: "2 conversations and 1 screenshot",
},
]);
});
it("answers an explicit clear even when there was nothing to delete", async () => {
// The user pressed a button, so silence would read as a failure. The
// background sweep stays quiet in the same situation, on purpose.
await clearAllChats();
expect(__toasts).toEqual([
{ title: "Cleared out all conversations", message: "there was nothing to delete" },
]);
});
it("collects unattached photos and reports the count", async () => {
await referenceSnip("kept.png", 30 * DAY);
await makeSnip("orphan.png", 30 * DAY);
await clearOrphanedSnips();
expect(__toasts).toEqual([{ title: "Cleared out unattached photos", message: "1 screenshot" }]);
});
it("says there was nothing to delete when every photo is spoken for", async () => {
await referenceSnip("kept.png", 30 * DAY);
await clearOrphanedSnips();
expect(__toasts).toEqual([
{ title: "Cleared out unattached photos", message: "there was nothing to delete" },
]);
});
it("refuses rather than guessing when a conversation cannot be read", async () => {
await makeSnip("orphan.png", 30 * DAY);
await saveSession({ ...createSession("m", Date.now()) });
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
const [summary] = await listSessions();
const legacy = { ...summary };
delete legacy.attachmentPaths;
await LocalStorage.setItem("lam.sessions.index", JSON.stringify([legacy]));
await LocalStorage.removeItem(`lam.session.${summary.id}`);
await clearOrphanedSnips();
expect(__toasts).toHaveLength(1);
expect(__toasts[0].title).toContain("Could not check");
});
});
+8 -8
View File
@@ -1,7 +1,7 @@
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { SNIP_ORPHAN_GRACE_MS } from "../lib/constants"; import { SNIP_ORPHAN_GRACE_MS } from "../lib/constants";
import { import {
describeSweep, describeRemoval,
expiredSessions, expiredSessions,
orphanedSnips, orphanedSnips,
sweepExpired, sweepExpired,
@@ -110,7 +110,7 @@ describe("sweepExpired", () => {
deleteSessions: vi.fn(async () => []), deleteSessions: vi.fn(async () => []),
referencedAttachments: vi.fn(async () => []), referencedAttachments: vi.fn(async () => []),
listSnipFiles: vi.fn(async () => []), listSnipFiles: vi.fn(async () => []),
removeSnipFiles: vi.fn(async () => undefined), removeSnipFiles: vi.fn(async () => 0),
...overrides, ...overrides,
}; };
} }
@@ -250,18 +250,18 @@ describe("sweepExpired", () => {
}); });
}); });
describe("describeSweep", () => { describe("describeRemoval", () => {
it("says nothing when nothing was deleted", () => { it("says nothing when nothing was deleted", () => {
expect(describeSweep({ sessions: 0, files: 0 })).toBeUndefined(); expect(describeRemoval({ sessions: 0, files: 0 })).toBeUndefined();
}); });
it("counts conversations, keeping the noun singular at one", () => { it("counts conversations, keeping the noun singular at one", () => {
expect(describeSweep({ sessions: 1, files: 0 })).toBe("1 conversation"); expect(describeRemoval({ sessions: 1, files: 0 })).toBe("1 conversation");
expect(describeSweep({ sessions: 3, files: 0 })).toBe("3 conversations"); expect(describeRemoval({ sessions: 3, files: 0 })).toBe("3 conversations");
}); });
it("mentions screenshots when it removed any", () => { it("mentions screenshots when it removed any", () => {
expect(describeSweep({ sessions: 0, files: 1 })).toBe("1 screenshot"); expect(describeRemoval({ sessions: 0, files: 1 })).toBe("1 screenshot");
expect(describeSweep({ sessions: 2, files: 4 })).toBe("2 conversations and 4 screenshots"); expect(describeRemoval({ sessions: 2, files: 4 })).toBe("2 conversations and 4 screenshots");
}); });
}); });
+77
View File
@@ -8,6 +8,7 @@ import {
createSession, createSession,
createTurn, createTurn,
clearSessions, clearSessions,
cullOrphanedSnips,
deleteSession, deleteSession,
deleteSessions, deleteSessions,
listSessions, listSessions,
@@ -437,3 +438,79 @@ describe("manual deletion", () => {
await expect(access(shared)).resolves.toBeUndefined(); await expect(access(shared)).resolves.toBeUndefined();
}); });
}); });
describe("cullOrphanedSnips", () => {
const DIRECTORY = "/tmp/lam-agent-support/snips";
const DAY = 24 * 60 * 60 * 1000;
async function makeSnip(name: string, ageMs: number): Promise<string> {
const path = `${DIRECTORY}/${name}`;
await writeFile(path, "png");
const when = new Date(Date.now() - ageMs);
await utimes(path, when, when);
return path;
}
async function reference(path: string): Promise<void> {
await saveSession({
...createSession("m", Date.now()),
turns: [{ ...turn(1), attachments: [{ path, mime: "image/png" }] }],
});
}
beforeEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
await mkdir(DIRECTORY, { recursive: true });
});
afterEach(async () => {
await rm(DIRECTORY, { recursive: true, force: true });
});
it("collects a capture that no conversation refers to", async () => {
const orphan = await makeSnip("orphan.png", 2 * DAY);
await expect(cullOrphanedSnips()).resolves.toBe(1);
await expect(access(orphan)).rejects.toThrow();
});
it("keeps a capture a conversation still shows", async () => {
const referenced = await makeSnip("referenced.png", 30 * DAY);
await reference(referenced);
await expect(cullOrphanedSnips()).resolves.toBe(0);
await expect(access(referenced)).resolves.toBeUndefined();
});
it("keeps a fresh capture, which could still be one the user has not sent", async () => {
// This is the same grace period the background sweep uses. The file is on
// disk before the chat view opens and stays unreferenced until the question
// is sent, so deleting it early would break a snip in flight.
const pending = await makeSnip("pending.png", 60 * 1000);
await expect(cullOrphanedSnips()).resolves.toBe(0);
await expect(access(pending)).resolves.toBeUndefined();
});
it("answers nothing rather than zero when a transcript will not load", async () => {
// Zero would be a claim that everything referenced is accounted for. With an
// unreadable conversation that claim cannot be made, so the caller is told
// the answer is unknown and deletes nothing.
const { LocalStorage } = await import("../__mocks__/@vicinae/api");
const orphan = await makeSnip("orphan.png", 30 * DAY);
await saveSession({ ...createSession("m", Date.now()), turns: [turn(1)] });
const [summary] = await listSessions();
const legacy = { ...summary };
delete legacy.attachmentPaths;
await LocalStorage.setItem("lam.sessions.index", JSON.stringify([legacy]));
await LocalStorage.removeItem(`lam.session.${summary.id}`);
await expect(cullOrphanedSnips()).resolves.toBeUndefined();
await expect(access(orphan)).resolves.toBeUndefined();
});
it("reports zero when there is nothing to collect", async () => {
await expect(cullOrphanedSnips()).resolves.toBe(0);
});
});
+10 -11
View File
@@ -107,44 +107,43 @@ describe("removeSnipFiles", () => {
await rm(OUTSIDE, { force: true }); await rm(OUTSIDE, { force: true });
}); });
it("deletes a capture inside the snip directory", async () => { it("deletes a capture inside the snip directory and counts it", async () => {
const path = `${DIRECTORY}/one.png`; const path = `${DIRECTORY}/one.png`;
await writeFile(path, "png"); await writeFile(path, "png");
await removeSnipFiles([path]); await expect(removeSnipFiles([path])).resolves.toBe(1);
await expect(access(path)).rejects.toThrow(); await expect(access(path)).rejects.toThrow();
}); });
it("shrugs at a file that is already gone", async () => { it("counts a file that was already gone, because the question is whether it is still there", async () => {
await expect(removeSnipFiles([`${DIRECTORY}/never-existed.png`])).resolves.toBeUndefined(); await expect(removeSnipFiles([`${DIRECTORY}/never-existed.png`])).resolves.toBe(1);
}); });
it("refuses a path outside the snip directory", async () => { it("refuses a path outside the snip directory and does not count it", async () => {
// These paths come out of stored JSON, which is data rather than code, so // These paths come out of stored JSON, which is data rather than code, so
// this must not be a general-purpose delete-any-file helper. // this must not be a general-purpose delete-any-file helper.
await removeSnipFiles([OUTSIDE]); await expect(removeSnipFiles([OUTSIDE])).resolves.toBe(0);
await expect(access(OUTSIDE)).resolves.toBeUndefined(); await expect(access(OUTSIDE)).resolves.toBeUndefined();
}); });
it("refuses a path that climbs out of the directory", async () => { it("refuses a path that climbs out of the directory", async () => {
await removeSnipFiles([`${DIRECTORY}/../../lam-agent-not-a-snip.png`]); await expect(removeSnipFiles([`${DIRECTORY}/../../lam-agent-not-a-snip.png`])).resolves.toBe(0);
await expect(access(OUTSIDE)).resolves.toBeUndefined(); await expect(access(OUTSIDE)).resolves.toBeUndefined();
}); });
it("refuses the directory itself", async () => { it("refuses the directory itself", async () => {
await removeSnipFiles([DIRECTORY]); await expect(removeSnipFiles([DIRECTORY])).resolves.toBe(0);
await expect(access(DIRECTORY)).resolves.toBeUndefined(); await expect(access(DIRECTORY)).resolves.toBeUndefined();
}); });
it("keeps going when one path in the batch is refused", async () => { it("keeps going when one path in the batch is refused, and counts only the one it acted on", async () => {
const inside = `${DIRECTORY}/one.png`; const inside = `${DIRECTORY}/one.png`;
await writeFile(inside, "png"); await writeFile(inside, "png");
await removeSnipFiles([OUTSIDE, inside]); await expect(removeSnipFiles([OUTSIDE, inside])).resolves.toBe(1);
await expect(access(inside)).rejects.toThrow(); await expect(access(inside)).rejects.toThrow();
await expect(access(OUTSIDE)).resolves.toBeUndefined(); await expect(access(OUTSIDE)).resolves.toBeUndefined();
+55 -10
View File
@@ -1,22 +1,30 @@
import { Toast, showToast } from "@vicinae/api"; import { Toast, showToast } from "@vicinae/api";
import { describeSweep } from "./retention"; import { describeRemoval } from "./retention";
import { sweepExpiredSessions } from "./sessions"; import { clearSessions, cullOrphanedSnips, sweepExpiredSessions } from "./sessions";
/** /**
* The retention sweep, and telling the user about it. * The tidy-ups, and telling the user what they did.
* *
* Split out from `retention.ts` so that file can stay a pure decision with every * Split out from `retention.ts` so that file can stay a pure decision with every
* side effect injected, and out of the commands so that three of them do not each * side effect injected, and out of the commands so that four call sites do not
* carry their own copy of the same toast. This is the one place that knows both * each carry their own copy of the same toast. This is the one place that knows
* what the sweep did and how to say it. * both what was deleted and how to say it.
* *
* Silent when nothing was deleted. A notification on every launch is one people * Two audiences live here, and they want opposite things. The background sweep
* learn to dismiss without reading, and this one only matters on the rare * stays silent unless it did something, because a notification on every launch is
* occasion it appears. * one people learn to dismiss without reading. The two actions in the setup screen
* always answer, because the user pressed a button and silence would read as a
* failure.
*/
/**
* The retention sweep, run on the way into a command.
*
* Silent when nothing was deleted.
*/ */
export async function runAutoDelete(options: { maxAgeMs?: number; keepId?: string }): Promise<void> { export async function runAutoDelete(options: { maxAgeMs?: number; keepId?: string }): Promise<void> {
const outcome = await sweepExpiredSessions(options); const outcome = await sweepExpiredSessions(options);
const summary = describeSweep(outcome); const summary = describeRemoval(outcome);
if (!summary) return; if (!summary) return;
await showToast({ await showToast({
@@ -25,3 +33,40 @@ export async function runAutoDelete(options: { maxAgeMs?: number; keepId?: strin
message: summary, message: summary,
}); });
} }
/** Deletes every conversation, and every screenshot they captured. */
export async function clearAllChats(): Promise<void> {
const counts = await clearSessions();
await showToast({
style: Toast.Style.Success,
title: "Cleared out all conversations",
message: describeRemoval(counts) ?? "there was nothing to delete",
});
}
/**
* Deletes the captures no conversation refers to any more.
*
* The failure case is a refusal, not a failure to delete: when a conversation
* cannot be read, there is no way to know which files are still in use, and
* guessing there destroys real images. So it says so instead.
*/
export async function clearOrphanedSnips(): Promise<void> {
const removed = await cullOrphanedSnips();
if (removed === undefined) {
await showToast({
style: Toast.Style.Failure,
title: "Could not check which photos are in use",
message: "A conversation could not be read, so nothing was deleted.",
});
return;
}
await showToast({
style: Toast.Style.Success,
title: "Cleared out unattached photos",
message: describeRemoval({ sessions: 0, files: removed }) ?? "there was nothing to delete",
});
}
+14 -13
View File
@@ -100,7 +100,8 @@ export interface SweepPorts {
*/ */
referencedAttachments: () => Promise<string[] | undefined>; referencedAttachments: () => Promise<string[] | undefined>;
listSnipFiles: () => Promise<SnipFile[]>; listSnipFiles: () => Promise<SnipFile[]>;
removeSnipFiles: (paths: string[]) => Promise<void>; /** Returns how many files it acted on. The sweep ignores the count; the setup screen reports it. */
removeSnipFiles: (paths: string[]) => Promise<number>;
} }
export interface SweepOutcome { export interface SweepOutcome {
@@ -113,24 +114,24 @@ export interface SweepOutcome {
const NOTHING_DONE: SweepOutcome = { sessions: 0, files: 0 }; const NOTHING_DONE: SweepOutcome = { sessions: 0, files: 0 };
/** /**
* What to tell the user after a sweep, or `undefined` when there is nothing to say. * What to tell the user was removed, or `undefined` when nothing was.
* *
* Returns no message for "nothing was deleted", because a tidy-up that announces * The count names the thing rather than saying "items", because "2 conversations
* itself on every launch is the kind of notification people learn to ignore, and * and 4 screenshots" is the sentence a person wants to read before deciding
* the point of this one is that it should be rare enough to read. * whether the tidy-up did what they expected. The background sweep treats
* `undefined` as "stay silent"; an action the user pressed has to answer even
* when the answer is nothing, so those callers spell that case out themselves.
*/ */
export function describeSweep(outcome: SweepOutcome): string | undefined { export function describeRemoval(counts: SweepOutcome): string | undefined {
if (outcome.sessions === 0 && outcome.files === 0) return undefined;
const parts: string[] = []; const parts: string[] = [];
if (outcome.sessions > 0) { if (counts.sessions > 0) {
parts.push(`${outcome.sessions} conversation${outcome.sessions === 1 ? "" : "s"}`); parts.push(`${counts.sessions} conversation${counts.sessions === 1 ? "" : "s"}`);
} }
if (outcome.files > 0) { if (counts.files > 0) {
parts.push(`${outcome.files} screenshot${outcome.files === 1 ? "" : "s"}`); parts.push(`${counts.files} screenshot${counts.files === 1 ? "" : "s"}`);
} }
return parts.join(" and "); return parts.length > 0 ? parts.join(" and ") : undefined;
} }
/** /**
+25 -3
View File
@@ -1,6 +1,6 @@
import { LocalStorage } from "@vicinae/api"; 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 { 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 { orphanedSnips, sweepExpired, type SweepOutcome } from "./retention";
import { SessionIndexSchema, SessionSchema } from "./schemas"; import { SessionIndexSchema, SessionSchema } from "./schemas";
import { listSnipFiles, removeSnipFiles } from "./snip"; import { listSnipFiles, removeSnipFiles } from "./snip";
import type { Attachment, ChatTurn, Session, SessionSummary } from "./types"; import type { Attachment, ChatTurn, Session, SessionSummary } from "./types";
@@ -338,7 +338,7 @@ export async function sweepExpiredSessions(input: {
).catch(() => ({ sessions: 0, files: 0 })); ).catch(() => ({ sessions: 0, files: 0 }));
} }
export async function clearSessions(): Promise<void> { export async function clearSessions(): Promise<SweepOutcome> {
const summaries = await listSessions(); const summaries = await listSessions();
const owned = await attachmentPathsFor( const owned = await attachmentPathsFor(
summaries.map((summary) => summary.id), summaries.map((summary) => summary.id),
@@ -357,7 +357,29 @@ export async function clearSessions(): Promise<void> {
// Every screenshot too, for the same reason a single delete takes its own. // 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. // Nothing survives to share them with, so they are all safe to remove.
await removeSnipFiles(await pathsSafeToDelete(owned, [])); const files = await removeSnipFiles(await pathsSafeToDelete(owned, []));
return { sessions: summaries.length, files };
}
/**
* Deletes the captures no conversation refers to any more.
*
* The same rule the background sweep uses, so the two can never disagree about
* what counts as unattached: unreferenced, and older than the grace period. That
* wait matters more here than it does in the sweep, because this runs on demand
* while a chat could still be holding a snip the user has not sent yet.
*
* Returns how many files went, or `undefined` when the references could not be
* established. `undefined` is not zero: it means the answer is unknown, and the
* caller says so rather than reporting a clean bill of health it cannot support.
*/
export async function cullOrphanedSnips(now: number = Date.now()): Promise<number | undefined> {
const referenced = await referencedAttachments();
if (referenced === undefined) return undefined;
const orphans = orphanedSnips(await listSnipFiles(), new Set(referenced), now);
return removeSnipFiles(orphans.map((file) => file.path));
} }
/** Recomputes a session's title from its first prompt, once there is a prompt worth naming it after. */ /** Recomputes a session's title from its first prompt, once there is a prompt worth naming it after. */
+9 -1
View File
@@ -90,9 +90,14 @@ export async function listSnipFiles(): Promise<SnipFile[]> {
* Best effort throughout: the sweep that calls this is a background tidy-up, so * Best effort throughout: the sweep that calls this is a background tidy-up, so
* a file that is already gone or that the system will not release must not fail * a file that is already gone or that the system will not release must not fail
* the rest of the pass. * the rest of the pass.
*
* Returns how many paths it acted on, so a caller that reports back to the user
* can count rather than guess. A file that was already missing counts as removed,
* which is the honest answer to "is it still there".
*/ */
export async function removeSnipFiles(paths: string[]): Promise<void> { export async function removeSnipFiles(paths: string[]): Promise<number> {
const directory = resolve(snipDirectory()); const directory = resolve(snipDirectory());
let removed = 0;
for (const path of paths) { for (const path of paths) {
const target = resolve(path); const target = resolve(path);
@@ -104,10 +109,13 @@ export async function removeSnipFiles(paths: string[]): Promise<void> {
try { try {
await rm(target, { force: true }); await rm(target, { force: true });
removed += 1;
} catch { } catch {
// Nothing worth reporting: the file is either gone already or stuck. // Nothing worth reporting: the file is either gone already or stuck.
} }
} }
return removed;
} }
/** /**
+99 -3
View File
@@ -1,6 +1,6 @@
import { Action, ActionPanel, Icon, List, openExtensionPreferences } from "@vicinae/api"; import { Action, ActionPanel, Alert, Icon, List, confirmAlert, openExtensionPreferences } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
import { runAutoDelete } from "./lib/cleanup"; import { clearAllChats, clearOrphanedSnips, runAutoDelete } from "./lib/cleanup";
import { import {
describeAutoDelete, describeAutoDelete,
describeMaxToolRounds, describeMaxToolRounds,
@@ -98,6 +98,48 @@ export default function SetupCommand() {
void openExtensionPreferences(); void openExtensionPreferences();
}, []); }, []);
/**
* Confirms, then runs a destructive tidy-up.
*
* Both actions on this screen delete things that cannot be recovered: there is
* no trash to undo them from, and a conversation's screenshots go with it. That
* is worth one extra keystroke, and the alert says what will go rather than
* asking "are you sure" about nothing in particular.
*/
const confirmThen = useCallback(
async (options: { title: string; message: string }, run: () => Promise<void>) => {
const confirmed = await confirmAlert({
title: options.title,
message: options.message,
primaryAction: { title: "Delete", style: Alert.ActionStyle.Destructive },
});
if (confirmed) await run();
},
[],
);
/**
* The action panel for a maintenance row.
*
* The row's own action comes first, because it is why the user is looking at
* the row, and the two read-only actions sit in their own section below so they
* can never be the default that Return fires.
*/
const maintenanceActions = (primary: ReactNode) => (
<ActionPanel>
{primary}
<ActionPanel.Section>
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
<Action
title="Test Connection"
icon={Icon.ArrowClockwise}
onAction={() => void testConnection()}
/>
</ActionPanel.Section>
</ActionPanel>
);
const settingsActions = ( const settingsActions = (
<ActionPanel> <ActionPanel>
<Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} /> <Action title="Open Extension Preferences" icon={Icon.BulletPoints} onAction={openPreferences} />
@@ -220,6 +262,60 @@ export default function SetupCommand() {
/> />
</List.Section> </List.Section>
{/*
* Maintenance sits below the settings it acts on. Both rows are destructive
* rather than settings, so each builds its own action panel instead of
* sharing the one above: these are the only two things on this screen that
* delete anything, and neither should be one keystroke away from a row the
* user was only reading.
*/}
<List.Section title="Maintenance">
<List.Item
icon={Icon.Image}
title="Clear Out Unattached Photos"
subtitle="Delete captures no conversation refers to any more. Screenshots still attached to a chat are kept, and so is one snipped in the last day."
actions={maintenanceActions(
<Action
title="Clear Unattached Photos"
icon={Icon.Image}
style="destructive"
onAction={() =>
void confirmThen(
{
title: "Delete unattached photos?",
message:
"Screenshots that belong to no conversation will be deleted. Photos still attached to a chat are kept.",
},
clearOrphanedSnips,
)
}
/>,
)}
/>
<List.Item
icon={Icon.Trash}
title="Clear Out All Chats"
subtitle="Delete every conversation, and every screenshot it captured. This cannot be undone."
actions={maintenanceActions(
<Action
title="Clear All Chats"
icon={Icon.Trash}
style="destructive"
onAction={() =>
void confirmThen(
{
title: "Delete every conversation?",
message:
"Every conversation and every screenshot it captured will be deleted. This cannot be undone.",
},
clearAllChats,
)
}
/>,
)}
/>
</List.Section>
{error ? ( {error ? (
<List.Section title="Last Error"> <List.Section title="Last Error">
<List.Item <List.Item