Files
lam-agent-vicinae/src/lib/preferences.ts
T

272 lines
10 KiB
TypeScript

import { getPreferenceValues, LocalStorage } from "@vicinae/api";
import { DEFAULT_HOST, DEFAULT_MAX_TOOL_ROUNDS, DEFAULT_MODEL, STORAGE_MODEL_KEY } from "./constants";
/**
* Preferences, read and normalised.
*
* Declared locally rather than relying on the `vicinae-env.d.ts` that the
* `vici` toolchain generates, so this file typechecks and unit-tests without a
* codegen step. The manifest in `package.json` remains the source of truth; if
* a preference is added there, mirror it here.
*/
export interface LamPreferences {
apiKey?: string;
model?: string;
ollamaHost?: string;
systemPrompt?: string;
location?: string;
ipGeolocation?: boolean;
webSearchEnabled?: boolean;
thinking?: string;
showToolCalls?: boolean;
showReasoning?: boolean;
resumeTimeout?: string;
autoDeleteAfter?: string;
maxToolRounds?: string;
}
/** Where the model in use came from, so the UI can explain a divergence from the preference. */
export type ModelSource = "preference" | "selection";
/** Preferences with every optional value filled in, so callers never branch on undefined. */
export interface ResolvedPreferences {
apiKey: string;
model: string;
/**
* Whether {@link ResolvedPreferences.model} came from the preference or from a
* pick in the Models command.
*
* Carried rather than inferred, because the two disagree silently otherwise:
* the setup screen would show one model while the chat used another, and the
* user would have no way to tell which one to change.
*/
modelSource: ModelSource;
/**
* What the `Model` preference says, whatever is in use.
*
* Kept alongside `model` so a screen can answer the question a selection
* raises: what would happen if I reset it?
*/
preferenceModel: string;
host: string;
systemPrompt: string;
/**
* The place the user says they are, verbatim, or an empty string.
*
* The only exact location this extension has. Everything else is inferred from
* the device timezone and locale, which is accurate to a region rather than a
* city, and a timezone name is not a city at all.
*/
place: string;
/** Whether to resolve a rough place from the IP address. Off unless asked for. */
ipGeolocation: boolean;
webEnabled: boolean;
/** Undefined when reasoning is off, which is what the API client expects. */
think?: string;
/** Whether the transcript lists the tools Lam called. */
showToolCalls: boolean;
/** Whether the transcript includes the model's thinking trace. */
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;
const HOUR = 60 * MINUTE;
const DAY = 24 * HOUR;
/** Keep in sync with the `resumeTimeout` dropdown in `package.json`. */
const RESUME_TIMEOUTS: Record<string, number> = {
"15m": 15 * MINUTE,
"1h": HOUR,
"6h": 6 * HOUR,
"1d": DAY,
};
/** `undefined` for "never" and for anything unrecognised, which both mean "always reopen the last chat". */
export function resolveResumeTimeout(value: string | undefined): number | undefined {
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 `${minutes} minutes`;
const hours = Math.round(minutes / 60);
if (hours < 24) return `${hours} hour${hours === 1 ? "" : "s"}`;
const days = Math.round(hours / 24);
return `${days} day${days === 1 ? "" : "s"}`;
}
export function resolvePreferences(raw: LamPreferences): ResolvedPreferences {
const thinking = (raw.thinking ?? "").trim();
const model = (raw.model ?? "").trim() || DEFAULT_MODEL;
return {
apiKey: (raw.apiKey ?? "").trim(),
model,
// Anything read straight from Vicinae is the preference by definition. The
// selection is layered on top in `loadPreferences`.
modelSource: "preference",
preferenceModel: model,
host: (raw.ollamaHost ?? "").trim() || DEFAULT_HOST,
systemPrompt: (raw.systemPrompt ?? "").trim(),
place: (raw.location ?? "").trim(),
// Defaults to off: this is the one setting that sends something about the
// user to a third party, so it is a deliberate choice rather than a default.
ipGeolocation: raw.ipGeolocation ?? false,
// Defaults to on: the preference is a checkbox the user has to actively untick.
webEnabled: raw.webSearchEnabled ?? true,
think: thinking && thinking !== "off" ? thinking : undefined,
showToolCalls: raw.showToolCalls ?? true,
showReasoning: raw.showReasoning ?? true,
// 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)),
};
}
/**
* Vicinae's own preferences, resolved and normalised. Not exported: everything
* outside this module should go through {@link loadPreferences}, which also
* applies the model chosen in the Models command.
*/
function getLamPreferences(): ResolvedPreferences {
return resolvePreferences(getPreferenceValues<LamPreferences>());
}
/**
* The model chosen in the Models command, if there is one.
*
* Never throws. Storage that cannot be read means no selection rather than a
* broken command, because the fallback (the preference) is always a valid
* answer.
*/
export async function readModelSelection(): Promise<string | undefined> {
const stored = await LocalStorage.getItem<string>(STORAGE_MODEL_KEY).catch(() => undefined);
const trimmed = (stored ?? "").trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/** Remembers a model as the one to use, taking precedence over the preference. */
export async function selectModel(model: string): Promise<void> {
await LocalStorage.setItem(STORAGE_MODEL_KEY, model.trim());
}
/** Drops the selection, handing the model back to the `Model` preference. */
export async function clearModelSelection(): Promise<void> {
await LocalStorage.removeItem(STORAGE_MODEL_KEY).catch(() => undefined);
}
/**
* The preferences the rest of the extension should actually use.
*
* The only reader anyone else needs: it applies the model chosen in the Models
* command on top of the preferences Vicinae holds. A second, synchronous reader
* would be a trap, because it looks right and silently ignores the selection,
* which is why `getLamPreferences` above is not exported.
*
* The model is the only thing a selection can override. Everything else here has
* a single home in Vicinae's own settings, and adding a shadow copy of those
* would make "where do I change this" a question with two answers.
*/
export async function loadPreferences(): Promise<ResolvedPreferences> {
const base = getLamPreferences();
const selected = await readModelSelection();
return selected ? { ...base, model: selected, modelSource: "selection" } : base;
}
/** True when the extension cannot work until the user supplies a key. */
export function needsSetup(preferences: ResolvedPreferences): boolean {
return preferences.apiKey.length === 0;
}