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 = { "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 = { "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 = { "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()); } /** * 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 { const stored = await LocalStorage.getItem(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 { await LocalStorage.setItem(STORAGE_MODEL_KEY, model.trim()); } /** Drops the selection, handing the model back to the `Model` preference. */ export async function clearModelSelection(): Promise { 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 { 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; }