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
+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)),
};
}