feat: auto-delete old conversations, and stop a tool call that repeats (#15)

This commit was merged in pull request #15.
This commit is contained in:
2026-10-05 09:05:15 +00:00
parent 576c5946f7
commit 05fd9ff13b
25 changed files with 2372 additions and 59 deletions
+109 -7
View File
@@ -1,4 +1,4 @@
import { MAX_TOOL_ITERATIONS } from "./constants";
import { DEFAULT_MAX_TOOL_ROUNDS, MAX_REPEATED_TOOL_CALLS } from "./constants";
import { streamChat } from "./ollama";
import { executeTool, toolLabel, type ToolContext } from "./tools";
import type { ApiMessage, ToolCall, ToolCallRecord, ToolDefinition } from "./types";
@@ -14,6 +14,11 @@ export interface AgentEvents {
onContentDelta?: (delta: string) => void;
onThinkingDelta?: (delta: string) => void;
onToolCall?: (record: ToolCallRecord) => void;
/**
* A tool is about to run, with how many times this turn has already made the
* identical call, so the UI can tell progress from a rut.
*/
onToolStart?: (name: string, label: string, count: number) => void;
/** A short line for the UI's activity indicator, e.g. "Searching the web". */
onStatus?: (status: string) => void;
}
@@ -26,6 +31,16 @@ export interface AgentOptions {
think?: string;
messages: ApiMessage[];
tools: ToolDefinition[];
/**
* Rounds of tool calls this turn may spend before it has to answer.
*
* Omitted means {@link DEFAULT_MAX_TOOL_ROUNDS}, and `null` means no cap at
* all. Unlimited is spelled as an explicit `null` rather than as an omitted
* option because the opposite arrangement makes "no cap" the default for a
* function whose whole job is to stop looping, and a caller that forgets to
* pass a preference should not be the one that removes the guard.
*/
maxToolRounds?: number | null;
toolContext: Omit<ToolContext, "signal" | "now"> & { now?: Date };
signal?: AbortSignal;
events?: AgentEvents;
@@ -59,6 +74,31 @@ function normalizeToolCall(call: ToolCall): { name: string; arguments: Record<st
return { name, arguments: args as Record<string, unknown> };
}
/**
* A stable key for "the same call".
*
* Keys are sorted so that a model which writes the same arguments in a different
* order still counts as repeating itself, which is the shape the rut usually
* takes. Values are compared as JSON, so two searches for the same query count as
* one call, which is the point: the second one will not return anything the first
* did not.
*/
function callSignature(name: string, args: Record<string, unknown>): string {
const sorted = Object.keys(args)
.sort()
.map((key) => [key, args[key]] as const);
return `${name}(${JSON.stringify(sorted)})`;
}
/** Why the tool loop stopped short: the budget ran out, or the model got stuck. */
type StopReason = { kind: "budget"; rounds: number } | { kind: "repeat"; name: string; count: number };
/** Sent to the model in place of a tool result, so the protocol stays valid and it learns why. */
function repeatedNotice(name: string, count: number): string {
return `Refused: this turn has now asked for \`${name}\` with identical arguments ${count} times, so running it again will not return anything new. No more tool calls are available. Answer with what you have already gathered.`;
}
/**
* The last word, when the tool budget runs out.
*
@@ -72,14 +112,36 @@ function normalizeToolCall(call: ToolCall): { name: string; arguments: Record<st
const CLOSING_INSTRUCTION =
"You have reached the limit on tool calls for this turn, so no more tools are available. Answer now using what you have already gathered. If something is missing, say plainly which parts you could not confirm.";
/**
* The closing instruction when the loop stopped because the model was repeating itself.
*
* Worth saying outright rather than reusing the budget wording: the model has no
* way to know it was caught going in circles, and naming the call is what turns
* "I ran out of tools" into something it can act on.
*/
function closingInstruction(reason: StopReason): string {
if (reason.kind === "budget") return CLOSING_INSTRUCTION;
return `This turn asked for \`${reason.name}\` with identical arguments ${reason.count} times, so no more tools are available. Answer now using what you have already gathered. If something is missing, say plainly which parts you could not confirm.`;
}
/** Only reachable if the closing round comes back empty too. */
function exhaustedNotice(rounds: number): string {
return `_Ran out of tool calls after ${rounds} rounds and could not write an answer. Try asking something narrower._`;
function exhaustedNotice(reason: StopReason): string {
if (reason.kind === "repeat") {
return `_Stopped because \`${reason.name}\` kept repeating the same call, and could not write an answer from what it had. Try asking something narrower._`;
}
return `_Ran out of tool calls after ${reason.rounds} rounds and could not write an answer. Try asking something narrower._`;
}
export async function runAgent(options: AgentOptions): Promise<AgentResult> {
const conversation: ApiMessage[] = [...options.messages];
const toolCalls: ToolCallRecord[] = [];
/** `Infinity` needs no special case: the loop simply never reaches its own end. */
const rounds =
options.maxToolRounds === null ? Infinity : (options.maxToolRounds ?? DEFAULT_MAX_TOOL_ROUNDS);
/** How many times this turn has asked for each identical call. */
const repeats = new Map<string, number>();
let thinking = "";
/**
* The most recent preamble the model wrote.
@@ -89,8 +151,9 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
* something to fall back on if the closing round produces nothing.
*/
let lastPreamble = "";
let stopped: StopReason | undefined;
for (let iteration = 0; iteration < MAX_TOOL_ITERATIONS; iteration++) {
for (let iteration = 0; iteration < rounds; iteration++) {
let content = "";
let streamedThinking = "";
let pending: ToolCall[] | undefined;
@@ -127,6 +190,8 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
conversation.push({ role: "assistant", content, tool_calls: pending });
let repeated: { name: string; count: number } | undefined;
for (const rawCall of pending) {
const { name, arguments: args } = normalizeToolCall(rawCall);
@@ -140,6 +205,32 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
continue;
}
const signature = callSignature(name, args);
const count = (repeats.get(signature) ?? 0) + 1;
repeats.set(signature, count);
// Every tool call in a round needs a result, so a refused one is answered
// with the refusal rather than skipped: dropping it would leave the next
// request carrying a tool call the protocol says must be answered.
if (count >= MAX_REPEATED_TOOL_CALLS) {
repeated = { name, count };
const refusal = repeatedNotice(name, count);
const record: ToolCallRecord = {
name,
arguments: args,
result: refusal,
isError: true,
repeated: true,
};
conversation.push({ role: "tool", content: refusal, tool_name: name });
toolCalls.push(record);
options.events?.onToolCall?.(record);
continue;
}
options.events?.onToolStart?.(name, toolLabel(name), count);
options.events?.onStatus?.(toolLabel(name));
const result = await executeTool(name, args, {
@@ -161,15 +252,26 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
conversation.push({ role: "tool", content: result.content, tool_name: name });
}
// The rest of the round still ran, because every call in it needs an answer,
// but the turn is over: the model is going in circles and one more round
// would only add one more circle.
if (repeated) {
stopped = { kind: "repeat", name: repeated.name, count: repeated.count };
break;
}
}
// Budget spent, so the turn gets one closing round with the tools taken away.
// The loop is done, either because the budget is spent or because the model got
// stuck. Either way the turn gets one closing round with the tools taken away.
// Ending on an answer drawn from what was gathered beats ending on a notice
// about the loop itself, and the model is the only one that can say what it
// did and did not manage to confirm.
const reason: StopReason = stopped ?? { kind: "budget", rounds };
options.events?.onStatus?.("Writing the answer");
conversation.push({ role: "user", content: CLOSING_INSTRUCTION });
conversation.push({ role: "user", content: closingInstruction(reason) });
let closing = "";
@@ -194,7 +296,7 @@ export async function runAgent(options: AgentOptions): Promise<AgentResult> {
}
return {
content: closing.trim() || lastPreamble.trim() || exhaustedNotice(MAX_TOOL_ITERATIONS),
content: closing.trim() || lastPreamble.trim() || exhaustedNotice(reason),
thinking,
toolCalls,
};
+72
View File
@@ -0,0 +1,72 @@
import { Toast, showToast } from "@vicinae/api";
import { describeRemoval } from "./retention";
import { clearSessions, cullOrphanedSnips, sweepExpiredSessions } from "./sessions";
/**
* 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
* side effect injected, and out of the commands so that four call sites do not
* each carry their own copy of the same toast. This is the one place that knows
* both what was deleted and how to say it.
*
* Two audiences live here, and they want opposite things. The background sweep
* stays silent unless it did something, because a notification on every launch is
* 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> {
const outcome = await sweepExpiredSessions(options);
const summary = describeRemoval(outcome);
if (!summary) return;
await showToast({
style: Toast.Style.Success,
title: "Cleared out old conversations",
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",
});
}
+46 -6
View File
@@ -1,7 +1,8 @@
import { Action, ActionPanel, Icon, LaunchType, List, launchCommand } from "@vicinae/api";
import { useCallback, useEffect, useMemo, useState } from "react";
import { readAttachment } from "../attachments";
import { useChat } from "../hooks/useChat";
import { runAutoDelete } from "../cleanup";
import { useChat, type ToolActivity } from "../hooks/useChat";
import { useVisionSupport } from "../hooks/useVision";
import { buildThreadMarkdown, renderAttachments, type TranscriptOptions } from "../markdown";
import { getLamPreferences } from "../preferences";
@@ -61,6 +62,18 @@ const DEFAULT_IMAGE_PROMPT = "What is in this image?";
/** The reminder shown while a snip is attached but not yet sent. */
const PENDING_NOTE = "_Ask a question about it, or press Return to send it as it is._";
/**
* The line that shows what Lam is doing right now.
*
* A count on its own reads as progress, so the second time round says what it is:
* the same call, asked again. That is the visible half of the loop guard in
* `agent.ts`, and it is the only warning the user gets before a turn is cut short.
*/
function describeActivity(activity: ToolActivity): string {
if (activity.count <= 1) return `_${activity.label}…_`;
return `_${activity.label} · the same call again (${activity.count})_`;
}
export interface ChatViewProps {
/** Opens a specific conversation instead of resuming the latest one. */
initialSession?: Session;
@@ -92,6 +105,7 @@ export function ChatView({
const chat = useChat({ preferences, initialSession });
const { replaceSession } = chat;
const { canSeeImages } = useVisionSupport(preferences);
const { activity } = chat;
const [input, setInput] = useState(initialPrompt ?? "");
const [summaries, setSummaries] = useState<SessionSummary[] | undefined>(undefined);
@@ -110,10 +124,30 @@ export function ChatView({
.catch(() => setSummaries([]));
}, []);
// Refreshed after each turn so the sidebar picks up a newly created chat.
// Refreshed after each turn so the sidebar picks up a newly created chat, and
// swept at the same time: a turn is exactly when a conversation's age changes,
// and waiting for the next launch to collect an expired one would mean showing
// the user a conversation that has already been deleted.
useEffect(() => {
reload();
}, [reload, chat.session.updatedAt]);
// Not until startup has finished, because until then the open conversation is
// still the placeholder one and the real conversation being restored has no
// id to be exempted by. Sweeping first could delete the chat on its way to
// the screen.
if (startup !== "ready") return;
let cancelled = false;
void (async () => {
// The open conversation is exempt: it is about to be rendered, and being
// the one it opens by default is the clearest sign it is still in use.
await runAutoDelete({ maxAgeMs: preferences.autoDeleteMs, keepId: chat.session.id });
if (!cancelled) reload();
})();
return () => {
cancelled = true;
};
}, [startup, chat.session.id, chat.session.updatedAt, preferences.autoDeleteMs, reload]);
/**
* Everything that has to happen before the first render, in one place.
@@ -188,6 +222,12 @@ export function ChatView({
// The preview comes first so the captured image is visible before the user
// decides what to ask about it, rather than only after sending.
pending ? `${renderAttachments([pending])}\n\n${PENDING_NOTE}` : undefined,
// Deliberately not gated on the Show Tool Calls preference. That setting is
// about the finished transcript, where a list of names adds nothing. This
// is about the turn in flight, where the same tool coming round a second
// time is the difference between a long answer and a stuck one, and hiding
// it would leave the user watching a spinner they cannot interpret.
activity ? describeActivity(activity) : undefined,
].filter((note): note is string => Boolean(note));
// The placeholder is what tells a user that the search bar is the composer.
@@ -198,7 +238,7 @@ export function ChatView({
thread.length === 0 ? empty : buildThreadMarkdown(thread, transcriptOptions);
return [...notes, transcript].filter((part) => part.length > 0).join("\n\n");
}, [launchNote, snipNote, pending, thread, transcriptOptions]);
}, [launchNote, snipNote, pending, activity, thread, transcriptOptions]);
const rows: SessionRow[] = useMemo(() => {
const base = summaries ?? [];
@@ -277,7 +317,7 @@ export function ChatView({
const hasPrompt = input.trim().length > 0 || pending !== undefined;
const stopAction = <Action title="Stop Generating" icon={Icon.Stop} onAction={chat.cancel} />;
const stopAction = <Action title="Force Stop" icon={Icon.Stop} onAction={chat.cancel} />;
const sendAction = <Action title="Send" icon={Icon.SpeechBubble} onAction={() => send(input)} />;
const newChatAction = (
+25 -1
View File
@@ -16,8 +16,32 @@ export const STORAGE_SESSION_PREFIX = "lam.session.";
* preamble the model had written was discarded, and the turn ended with a notice
* about the limit rather than an answer. The closing round in `agent.ts` now
* turns that into an answer, and this is the room it gets to gather first.
*
* A default rather than a ceiling: the Tool Call Limit preference overrides it,
* and "Unlimited" removes it entirely.
*/
export const MAX_TOOL_ITERATIONS = 8;
export const DEFAULT_MAX_TOOL_ROUNDS = 8;
/**
* How many times one turn may ask for the exact same call before the loop gives
* up on it.
*
* A model stuck in a rut repeats itself with identical arguments, so the repeat
* is the signal: the third time it asks the same question it is not going to
* learn anything new, and the only thing a fourth round buys is another round.
* This is the backstop the rounds cap cannot be, because the cap can be set to
* Unlimited.
*/
export const MAX_REPEATED_TOOL_CALLS = 3;
/**
* How long a snip must sit unreferenced before the orphan sweep collects it.
*
* Snips are written to disk before the chat view opens, and a capture the user
* never sends is stored nowhere. The wait is what keeps the sweep from deleting
* the file out from under a snip the user is still deciding what to ask about.
*/
export const SNIP_ORPHAN_GRACE_MS = 24 * 60 * 60 * 1000;
/** Oldest sessions past this count are evicted, along with their storage keys. */
export const MAX_SESSIONS = 100;
+32
View File
@@ -35,6 +35,19 @@ export interface UseChatOptions {
initialSession?: Session;
}
/** Which tool is running right now, and how stale it is getting. */
export interface ToolActivity {
/** Human-readable label, e.g. "Searching the web". */
label: string;
/**
* How many times this turn has now started this *identical* call.
*
* Counted by the agent over name and arguments together, so two different
* searches are two tools doing their job rather than a repeat.
*/
count: number;
}
export interface UseChat {
session: Session;
isStreaming: boolean;
@@ -42,6 +55,15 @@ export interface UseChat {
streamingThinking: string;
/** Short activity line such as "Searching the web", shown while a turn is in flight. */
status?: string;
/**
* The tool running right now.
*
* Separate from {@link UseChat.status} because `status` goes quiet as soon as
* the answer starts arriving, and the whole point of this one is to stay on
* screen: a model that has started circling looks identical to a model that is
* working, unless the tool and its repeat count stay visible.
*/
activity?: ToolActivity;
error?: string;
ask: (prompt: string, attachment?: PendingAttachment) => void;
cancel: () => void;
@@ -58,6 +80,7 @@ export function useChat(options: UseChatOptions): UseChat {
const [streamingResponse, setStreamingResponse] = useState("");
const [streamingThinking, setStreamingThinking] = useState("");
const [status, setStatus] = useState<string | undefined>(undefined);
const [activity, setActivity] = useState<ToolActivity | undefined>(undefined);
const [error, setError] = useState<string | undefined>(undefined);
const sessionRef = useRef(session);
@@ -149,6 +172,7 @@ export function useChat(options: UseChatOptions): UseChat {
setStreamingResponse("");
setStreamingThinking("");
setError(undefined);
setActivity(undefined);
setStatusValue("Thinking");
liveRef.current = true;
setIsStreaming(true);
@@ -173,6 +197,10 @@ export function useChat(options: UseChatOptions): UseChat {
apiKey: preferences.apiKey,
model: preferences.model,
think: preferences.think,
// `undefined` is "unlimited" in the preferences, and `null` is what the
// agent reads as "no cap": an omitted option there means the default
// cap, so the two conventions meet here.
maxToolRounds: preferences.maxToolRounds ?? null,
messages,
tools: toolsFor({ webEnabled: preferences.webEnabled }),
toolContext: {
@@ -195,6 +223,7 @@ export function useChat(options: UseChatOptions): UseChat {
scheduleFlush();
},
onStatus: (value) => setStatusValue(value),
onToolStart: (_name, label, count) => setActivity({ label, count }),
onToolCall: (record) => {
toolCalls = [...toolCalls, record];
},
@@ -232,6 +261,7 @@ export function useChat(options: UseChatOptions): UseChat {
commit(updated);
setIsStreaming(false);
setStatusValue(undefined);
setActivity(undefined);
setError(failure);
flushNow();
@@ -249,6 +279,7 @@ export function useChat(options: UseChatOptions): UseChat {
liveRef.current = false;
setIsStreaming(false);
setStatusValue(undefined);
setActivity(undefined);
setError(undefined);
setStreamingResponse("");
setStreamingThinking("");
@@ -274,6 +305,7 @@ export function useChat(options: UseChatOptions): UseChat {
streamingResponse,
streamingThinking,
status,
activity,
error,
ask,
cancel,
+18 -2
View File
@@ -60,7 +60,13 @@ export function quote(text: string): string {
/** One italic line, well below the weight of the answer it belongs to. */
function renderTools(message: ThreadMessage): string {
const names = message.toolCalls
.map((call) => `\`${call.name}\`${call.isError ? " (failed)" : ""}`)
.map((call) => {
const name = `\`${call.name}\``;
// A refused repeat did not fail, so it is not called a failure: it is the
// loop noticing it was going in circles, which is worth saying plainly.
if (call.repeated) return `${name} (repeated, stopped)`;
return call.isError ? `${name} (failed)` : name;
})
.join(", ");
return `_Tools: ${names}_`;
@@ -92,7 +98,17 @@ function renderMessage(message: ThreadMessage, options: TranscriptOptions): stri
// something went wrong and should not be skimmed past.
if (message.error) sections.push("", `**Error:** ${message.error}`);
if (options.showToolCalls && message.toolCalls.length > 0) {
/**
* Tool calls are a display preference, with one exception: a call that failed,
* or that was refused because it kept repeating, is always listed. Those are
* the two cases where hiding the line would leave the user with an answer they
* cannot account for, and a model stuck in a loop is exactly the thing they
* need to see.
*/
const showTools =
options.showToolCalls || message.toolCalls.some((call) => call.isError || call.repeated);
if (showTools && message.toolCalls.length > 0) {
sections.push("", renderTools(message));
}
+89 -4
View File
@@ -1,5 +1,5 @@
import { getPreferenceValues } from "@vicinae/api";
import { DEFAULT_HOST, DEFAULT_MODEL } from "./constants";
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL } from "./constants";
/**
* Preferences, read and normalised.
@@ -22,6 +22,8 @@ export interface LamPreferences {
showToolCalls?: boolean;
showReasoning?: boolean;
resumeTimeout?: string;
autoDeleteAfter?: string;
maxToolRounds?: string;
}
/** Preferences with every optional value filled in, so callers never branch on undefined. */
@@ -49,6 +51,22 @@ export interface ResolvedPreferences {
showReasoning: boolean;
/** Idle time after which opening the command starts a fresh chat. Undefined means never. */
resumeTimeoutMs?: number;
/**
* Age past which a conversation is deleted the next time a command runs.
*
* Undefined means the sweep is off entirely, which is the default. Every path
* that cannot answer the question confidently resolves here, so a preference
* that is missing, empty, or unrecognised deletes nothing.
*/
autoDeleteMs?: number;
/**
* Rounds of tool calls one turn may spend. Undefined means unlimited.
*
* Note the opposite convention to {@link ResolvedPreferences.autoDeleteMs}:
* there, "unrecognised" falls back to doing nothing, and here it falls back to
* the cap. See {@link resolveMaxToolRounds}.
*/
maxToolRounds?: number;
}
const MINUTE = 60_000;
@@ -68,18 +86,82 @@ export function resolveResumeTimeout(value: string | undefined): number | undefi
return RESUME_TIMEOUTS[(value ?? "").trim()];
}
/** Keep in sync with the `autoDeleteAfter` dropdown in `package.json`. */
const AUTO_DELETE_WINDOWS: Record<string, number> = {
"1d": DAY,
"7d": 7 * DAY,
"30d": 30 * DAY,
"90d": 90 * DAY,
};
/**
* `undefined` for "disabled" and for anything unrecognised.
*
* Nothing here can turn an unrecognised value into a deletion: the fallback is
* the off switch, which is the only safe reading of a preference this codebase
* does not understand.
*/
export function resolveAutoDelete(value: string | undefined): number | undefined {
return AUTO_DELETE_WINDOWS[(value ?? "").trim().toLowerCase()];
}
/**
* Round limits, keyed by the preference values in `package.json`.
*
* `undefined` here means unlimited, so the table cannot be indexed the way the
* other two are: an absent key and an "unlimited" key would be indistinguishable.
*/
const TOOL_ROUNDS: Record<string, number | undefined> = {
"4": 4,
"8": 8,
"16": 16,
unlimited: undefined,
};
/**
* The tool-call round cap, where `undefined` means unlimited.
*
* Falls back to the default cap rather than to unlimited, unlike
* {@link resolveResumeTimeout} and {@link resolveAutoDelete}, which both treat an
* unrecognised value as "off". Those two are safe when they do nothing; this one
* guards a loop, so the safe reading of a value this codebase does not recognise
* is the cap and not the absence of one.
*/
export function resolveMaxToolRounds(value: string | undefined): number | undefined {
const key = (value ?? "").trim().toLowerCase();
return key in TOOL_ROUNDS ? TOOL_ROUNDS[key] : DEFAULT_MAX_TOOL_ROUNDS;
}
/** Human-readable form of {@link ResolvedPreferences.autoDeleteMs}, for the setup screen. */
export function describeAutoDelete(milliseconds: number | undefined): string {
if (milliseconds === undefined) return "Disabled (conversations are kept)";
return `After ${describeSpan(milliseconds)}`;
}
/** Human-readable form of {@link ResolvedPreferences.maxToolRounds}, for the setup screen. */
export function describeMaxToolRounds(rounds: number | undefined): string {
if (rounds === undefined) return "Unlimited (no cap on tool calls)";
return `${rounds} round${rounds === 1 ? "" : "s"} per answer`;
}
/** Human-readable form of {@link ResolvedPreferences.resumeTimeoutMs}, for the setup screen. */
export function describeResumeTimeout(milliseconds: number | undefined): string {
if (milliseconds === undefined) return "Never (always reopen the last chat)";
return `After ${describeSpan(milliseconds)}`;
}
/** "15 minutes", "6 hours", "7 days". Shared so the two windows phrase themselves the same way. */
function describeSpan(milliseconds: number): string {
const minutes = Math.round(milliseconds / MINUTE);
if (minutes < 60) return `After ${minutes} minutes`;
if (minutes < 60) return `${minutes} minutes`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `After ${hours} hour${hours === 1 ? "" : "s"}`;
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"}`;
const days = Math.round(hours / 24);
return `After ${days} day${days === 1 ? "" : "s"}`;
return `${days} day${days === 1 ? "" : "s"}`;
}
export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
@@ -102,6 +184,9 @@ export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
// Falls back to the manifest's declared default, so a missing preference
// behaves the same whether Vicinae substitutes it or not.
resumeTimeoutMs: resolveResumeTimeout(raw.resumeTimeout ?? "1h"),
// Defaults to off, the one setting here that destroys data.
autoDeleteMs: resolveAutoDelete(raw.autoDeleteAfter ?? "disabled"),
maxToolRounds: resolveMaxToolRounds(raw.maxToolRounds ?? String(DEFAULT_MAX_TOOL_ROUNDS)),
};
}
+180
View File
@@ -0,0 +1,180 @@
import { SNIP_ORPHAN_GRACE_MS } from "./constants";
import type { SessionSummary } from "./types";
/**
* What gets deleted, and when.
*
* Two separate jobs live here, because they are the same job from the user's
* point of view ("Lam keeps my history tidy") but nothing like each other in code:
*
* - Conversations unused for longer than the retention window.
* - Screenshot files nothing refers to any more. A capture is written to disk
* before the chat view opens, so a snip the user never sent belongs to no
* conversation at all and no amount of session deletion would ever collect it.
*
* Both are pure decisions here, with every side effect injected, following
* `startup.ts`: the alternative is discovering from a screenshot that a sweep
* deleted a conversation it should not have. Imports nothing from Vicinae, so it
* unit tests with no runtime.
*
* ## The bias is always towards deleting nothing
*
* This is the only code in the extension that destroys data, so every ambiguity
* resolves towards leaving it alone. An unrecognised retention value disables the
* sweep rather than guessing at a window; a snip is only collected once it is
* both unreferenced *and* older than a grace period; and when the referenced set
* cannot be established at all, the orphan pass is skipped rather than run
* against a set it knows to be incomplete.
*/
/** One file in the snip directory, with the age the sweep judges it by. */
export interface SnipFile {
path: string;
/** Last modification time, which for a capture is when it was taken. */
mtimeMs: number;
}
/**
* Conversations unused for at least `maxAgeMs`, oldest first.
*
* Age is measured from `updatedAt`, not `createdAt`, so a conversation started
* last month but used this morning is not old. The open conversation is excluded
* outright: it is about to be rendered, and deleting the thing the user is
* looking at is the one outcome that would be indistinguishable from a bug.
*
* `undefined` means the sweep is off, which is the default. Every value that
* cannot be resolved to a window arrives here as `undefined`.
*/
export function expiredSessions(
summaries: SessionSummary[],
maxAgeMs: number | undefined,
now: number,
keepId?: string,
): SessionSummary[] {
if (maxAgeMs === undefined) return [];
return summaries.filter((summary) => summary.id !== keepId && now - summary.updatedAt >= maxAgeMs);
}
/**
* Snip files that nothing refers to and that have waited out the grace period.
*
* The wait is the whole safety story. A snip is on disk seconds after it is
* taken and stays unreferenced until the user sends it, so without a grace
* period the sweep would delete the file out from under a question the user is
* still typing. A day is far longer than any real pending snip and far shorter
* than the point at which the file matters.
*/
export function orphanedSnips(
files: SnipFile[],
referenced: Iterable<string>,
now: number,
graceMs: number = SNIP_ORPHAN_GRACE_MS,
): SnipFile[] {
const kept = new Set(referenced);
return files.filter((file) => !kept.has(file.path) && now - file.mtimeMs >= graceMs);
}
export interface SweepInput {
/** Age past which an unused conversation is deleted. `undefined` disables everything. */
maxAgeMs?: number;
now: number;
/** The conversation currently open, which is never deleted. */
keepId?: string;
/** How long an unreferenced snip must sit before it is collected. */
snipGraceMs?: number;
}
/** The side effects, injected so nothing here touches storage or the file system directly. */
export interface SweepPorts {
listSessions: () => Promise<SessionSummary[]>;
/** Deletes conversations by id and returns every image path they referred to. */
deleteSessions: (ids: string[]) => Promise<string[]>;
/**
* Image paths still referred to by conversations that remain, or `undefined`
* when that cannot be established.
*
* `undefined` is not the same as an empty array and must not be treated as
* one: it means the referenced set is unknown, and a set that is merely
* incomplete looks exactly like a pile of orphans.
*/
referencedAttachments: () => Promise<string[] | undefined>;
listSnipFiles: () => Promise<SnipFile[]>;
/** 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 {
/** Conversations deleted. */
sessions: number;
/** Image files deleted, from deleted conversations and unreferenced orphans alike. */
files: number;
}
const NOTHING_DONE: SweepOutcome = { sessions: 0, files: 0 };
/**
* What to tell the user was removed, or `undefined` when nothing was.
*
* The count names the thing rather than saying "items", because "2 conversations
* and 4 screenshots" is the sentence a person wants to read before deciding
* 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 describeRemoval(counts: SweepOutcome): string | undefined {
const parts: string[] = [];
if (counts.sessions > 0) {
parts.push(`${counts.sessions} conversation${counts.sessions === 1 ? "" : "s"}`);
}
if (counts.files > 0) {
parts.push(`${counts.files} screenshot${counts.files === 1 ? "" : "s"}`);
}
return parts.length > 0 ? parts.join(" and ") : undefined;
}
/**
* Runs both passes.
*
* Disabled means disabled. When `maxAgeMs` is undefined nothing is read and
* nothing is written, which also keeps the common case off the hot path: this
* runs on every command open and after every answer.
*/
export async function sweepExpired(input: SweepInput, ports: SweepPorts): Promise<SweepOutcome> {
if (input.maxAgeMs === undefined) return NOTHING_DONE;
const expired = expiredSessions(await ports.listSessions(), input.maxAgeMs, input.now, input.keepId);
const fromSessions =
expired.length > 0 ? await ports.deleteSessions(expired.map((summary) => summary.id)) : [];
const referenced = await ports.referencedAttachments();
// An incomplete referenced set would make every screenshot it does not happen
// to mention look like an orphan, so an unknown set means neither pass runs.
const kept = referenced === undefined ? undefined : new Set(referenced);
// A deleted conversation's files are removed explicitly rather than by the
// grace-period rule, because a screenshot taken five minutes before its
// conversation is deleted should go with it rather than sit on disk for a day.
// A shared path is the exception: two conversations can refer to the same
// capture, and the survivor still needs it.
const fromDeleted = kept === undefined ? [] : fromSessions.filter((path) => !kept.has(path));
const orphans =
kept === undefined
? []
: orphanedSnips(
await ports.listSnipFiles(),
kept,
input.now,
input.snipGraceMs ?? SNIP_ORPHAN_GRACE_MS,
);
// Deduplicated because a file belonging to a deleted conversation can also
// satisfy the orphan rule once that conversation has left the index.
const paths = [...new Set([...fromDeleted, ...orphans.map((file) => file.path)])];
if (paths.length > 0) await ports.removeSnipFiles(paths);
return { sessions: expired.length, files: paths.length };
}
+5
View File
@@ -12,6 +12,7 @@ export const ToolCallRecordSchema = z.object({
arguments: z.record(z.string(), z.unknown()),
result: z.string(),
isError: z.boolean(),
repeated: z.boolean().optional(),
});
export const AttachmentSchema = z.object({
@@ -54,6 +55,10 @@ export const SessionSummarySchema = z.object({
updatedAt: z.number(),
turnCount: z.number(),
preview: z.string(),
// Optional rather than defaulted, so an index written before the orphan sweep
// existed is still readable. A default of `[]` would look identical to "this
// conversation has no screenshots", and the sweep would delete the files.
attachmentPaths: z.array(z.string()).optional(),
});
export const SessionIndexSchema = z.array(SessionSummarySchema);
+200 -2
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 { orphanedSnips, 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));
}
export async function clearSessions(): Promise<void> {
/**
* 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<SweepOutcome> {
const summaries = await listSessions();
const owned = await attachmentPathsFor(
summaries.map((summary) => summary.id),
summaries,
);
for (const summary of summaries) {
try {
@@ -182,6 +354,32 @@ 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.
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. */
+84 -2
View File
@@ -1,10 +1,13 @@
import { environment } from "@vicinae/api";
import { join } from "node:path";
import { readdir, rm, stat } from "node:fs/promises";
import { isAbsolute, join, relative, resolve, sep } from "node:path";
import { CapturePermissionError, NoCaptureToolError } from "./capture";
import type { SnipFile } from "./retention";
import { errorMessage } from "./utils";
/**
* The seam between the snip command and the chat command.
* The seam between the snip command and the chat command, and the directory they
* both live out of.
*
* The capture cannot happen inside the chat view. Vicinae shows the launcher
* window when it activates a command, so a region selector started from a view
@@ -12,6 +15,10 @@ import { errorMessage } from "./utils";
* a `no-view` command and handing the file over is what keeps the window out of
* the way, which means the two commands need a small agreed contract.
*
* The directory also belongs here because it is the only place that knows where
* captures go, and the retention sweep has to list and delete them without going
* through a capture. `listSnipFiles` and `removeSnipFiles` are that half.
*
* Imports Vicinae for `environment.supportPath` only, and nothing else in the
* logic layer depends on it.
*/
@@ -36,6 +43,81 @@ export function snipDirectory(): string {
return join(environment.supportPath, "snips");
}
/**
* Every file in the snip directory, with the age the orphan sweep judges it by.
*
* Read from the directory rather than from a stored list of captures, because
* the directory is the only thing that cannot drift: a file whose record was
* lost to a crash, or written before the sweep existed, is still found here.
*/
export async function listSnipFiles(): Promise<SnipFile[]> {
const directory = snipDirectory();
let entries;
try {
entries = await readdir(directory, { withFileTypes: true });
} catch {
// No directory yet simply means nothing has been snipped.
return [];
}
const found = await Promise.all(
entries
.filter((entry) => entry.isFile())
.map(async (entry): Promise<SnipFile | undefined> => {
const path = join(directory, entry.name);
try {
const stats = await stat(path);
return { path, mtimeMs: stats.mtimeMs };
} catch {
// Deleted between the listing and the stat.
return undefined;
}
}),
);
return found.filter((file): file is SnipFile => file !== undefined);
}
/**
* Deletes snip files, refusing anything outside the snip directory.
*
* The paths arrive from stored JSON, which is data rather than code and can be
* hand-edited or corrupted, so this cannot be a general-purpose delete-any-file
* helper. Resolving both sides and checking containment is what keeps an edited
* path from reaching `rm`, and a path that is refused is simply left alone.
*
* 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
* 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<number> {
const directory = resolve(snipDirectory());
let removed = 0;
for (const path of paths) {
const target = resolve(path);
const inside = relative(directory, target);
const contained =
inside !== "" && inside !== ".." && !inside.startsWith(`..${sep}`) && !isAbsolute(inside);
if (!contained) continue;
try {
await rm(target, { force: true });
removed += 1;
} catch {
// Nothing worth reporting: the file is either gone already or stuck.
}
}
return removed;
}
/**
* Reads the handoff payload.
*
+21
View File
@@ -90,6 +90,14 @@ export interface ToolCallRecord {
arguments: Record<string, unknown>;
result: string;
isError: boolean;
/**
* Set when the call was refused because the turn had already made it twice.
*
* Stored rather than inferred from the message text, so the transcript can say
* what happened in its own words, and so a repeat that ended a turn stays
* visible even when the user has hidden tool calls from the transcript.
*/
repeated?: boolean;
}
/** One question and its answer. The unit the transcript is rendered from. */
@@ -125,6 +133,19 @@ export interface SessionSummary {
updatedAt: number;
turnCount: number;
preview: string;
/**
* Every image path any turn in this conversation refers to.
*
* Kept here rather than derived from the turns for one reason: the orphan
* sweep needs to know which snips are still spoken for, and loading every
* transcript to find out would mean a hundred JSON parses on every turn.
*
* Absent on entries written before this field existed, which is deliberately
* not the same as an empty array: an absent value means "unknown", and the
* sweep resolves it by reading that one transcript rather than by assuming the
* conversation has no screenshots and deleting them.
*/
attachmentPaths?: string[];
}
/** A model as reported by `GET /api/tags`. */