/** * @pi-unipi/fusion — fusion preset store * * The preset is the user-curated, finite model list the `/unipi:model` picker * shows (pi's full catalogue can be 400+ entries). It also remembers the * per-model effort (thinking level), the default lead/sidekick pair, the * currently active selection, and the MRU list. * * Layers (deep-merged, project on top; arrays replace, objects merge): * global ~/.unipi/config/fusion/preset.json * project /.unipi/fusion-preset.json * * Writes go to the layer the user chose (default global). The active * selection + recent list are runtime state and always persist globally. */ import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs"; import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { getSettings, getSettingsScoped, registerSettings, setSettings } from "@pi-unipi/core"; import { persistDefaultModel, readDefaultModel } from "./pi-settings.js"; export const PRESET_SCHEMA_VERSION = 1; /** pi thinking levels in ascending effort order (used by ←/→ in the picker). */ export const EFFORT_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const; export type EffortLevel = (typeof EFFORT_LEVELS)[number]; export type FusionBadge = "new" | "promotion" | "beta"; export const RECENT_LIMIT = 5; /** `provider/modelId` */ export type ModelKey = string; export interface FusionPair { lead: ModelKey; sidekick: ModelKey; } export type ActiveSelection = | { kind: "single"; model: ModelKey } | { kind: "fusion"; lead: ModelKey; sidekick: ModelKey; /** Fusion-row efforts are independent of per-model memory. */ leadEffort?: EffortLevel | undefined; sidekickEffort?: EffortLevel | undefined; }; export interface FusionPreset { schema_version: number; /** Models offered as lead (also shown as plain single-model rows). */ lead: ModelKey[]; /** Models offered as sidekick. */ sidekick: ModelKey[]; /** Default pair used when the Fusion row is confirmed without editing. */ default: Partial; /** Remembered per-model effort. */ effort: Record; /** MRU single models / leads, newest first, max RECENT_LIMIT. */ recent: ModelKey[]; /** Optional hand-curated model badge metadata. */ badges: Record; /** Manual pricing overrides for providers that report no pricing. */ prices: Record; /** What the user last confirmed in the picker. */ active?: ActiveSelection | undefined; } export function emptyPreset(): FusionPreset { return { schema_version: PRESET_SCHEMA_VERSION, lead: [], sidekick: [], default: {}, effort: {}, recent: [], badges: {}, prices: {}, }; } export function globalPresetPath(home = homedir()): string { return join(home, ".unipi", "config", "fusion", "preset.json"); } export function projectPresetPath(cwd: string): string { return join(cwd, ".unipi", "fusion-preset.json"); } export function modelKey(model: { provider: string; id: string }): ModelKey { return `${model.provider}/${model.id}`; } export function splitModelKey(key: ModelKey): { provider: string; id: string } | undefined { const slash = key.indexOf("/"); if (slash <= 0 || slash === key.length - 1) return undefined; return { provider: key.slice(0, slash), id: key.slice(slash + 1) }; } export function isEffortLevel(value: unknown): value is EffortLevel { return typeof value === "string" && (EFFORT_LEVELS as readonly string[]).includes(value); } function stringArray(value: unknown): string[] { if (!Array.isArray(value)) return []; const out: string[] = []; for (const entry of value) { if (typeof entry === "string" && entry.includes("/") && !out.includes(entry)) out.push(entry); } return out; } /** Validate + normalise an arbitrary JSON value into a partial preset. */ export function parsePreset(raw: unknown): Partial { if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {}; const r = raw as Record; const out: Partial = {}; if ("lead" in r) out.lead = stringArray(r["lead"]); if ("sidekick" in r) out.sidekick = stringArray(r["sidekick"]); if (typeof r["default"] === "object" && r["default"] !== null) { const d = r["default"] as Record; out.default = {}; if (typeof d["lead"] === "string") out.default.lead = d["lead"]; if (typeof d["sidekick"] === "string") out.default.sidekick = d["sidekick"]; } if (typeof r["effort"] === "object" && r["effort"] !== null) { const effort: Record = {}; for (const [k, v] of Object.entries(r["effort"] as Record)) { if (isEffortLevel(v)) effort[k] = v; } out.effort = effort; } if ("recent" in r) out.recent = stringArray(r["recent"]).slice(0, RECENT_LIMIT); if (typeof r["badges"] === "object" && r["badges"] !== null) { const badges: Record = {}; for (const [k, v] of Object.entries(r["badges"] as Record)) { if (v === "new" || v === "promotion" || v === "beta") badges[k] = v; } out.badges = badges; } if (typeof r["prices"] === "object" && r["prices"] !== null) { const prices: FusionPreset["prices"] = {}; for (const [k, v] of Object.entries(r["prices"] as Record)) { if (typeof v !== "object" || v === null) continue; const price = v as Record; const input = price["input"]; const cachedInput = price["cachedInput"]; const output = price["output"]; if ([input, cachedInput, output].every((n) => typeof n === "number" && Number.isFinite(n) && n >= 0)) { prices[k] = { input: input as number, cachedInput: cachedInput as number, output: output as number }; } } out.prices = prices; } const active = r["active"]; if (typeof active === "object" && active !== null) { const a = active as Record; if (a["kind"] === "single" && typeof a["model"] === "string") { out.active = { kind: "single", model: a["model"] }; } else if ( a["kind"] === "fusion" && typeof a["lead"] === "string" && typeof a["sidekick"] === "string" ) { out.active = { kind: "fusion", lead: a["lead"], sidekick: a["sidekick"], ...(isEffortLevel(a["leadEffort"]) ? { leadEffort: a["leadEffort"] } : {}), ...(isEffortLevel(a["sidekickEffort"]) ? { sidekickEffort: a["sidekickEffort"] } : {}), }; } } return out; } export function mergePresets(base: FusionPreset, over: Partial): FusionPreset { return { schema_version: PRESET_SCHEMA_VERSION, lead: over.lead ?? base.lead, sidekick: over.sidekick ?? base.sidekick, default: { ...base.default, ...(over.default ?? {}) }, effort: { ...base.effort, ...(over.effort ?? {}) }, recent: over.recent ?? base.recent, badges: { ...base.badges, ...(over.badges ?? {}) }, prices: { ...base.prices, ...(over.prices ?? {}) }, active: over.active ?? base.active, }; } function readJson(path: string): unknown { try { if (!existsSync(path)) return undefined; return JSON.parse(readFileSync(path, "utf8")) as unknown; } catch { return undefined; } } export interface LoadedPreset { preset: FusionPreset; globalPath: string; projectPath: string; hasProjectLayer: boolean; } const STARTUP_THINKING = ["off", "minimal", "low", "medium", "high", "xhigh"] as const; /** * Hub → pi: a global write of `startup.*` becomes pi's own startup default * (settings.json defaultProvider/defaultModel/defaultThinkingLevel — what * pi's /model ctrl+s and the picker's alt+enter write). A single startup * model also stops a remembered Fusion pair from overriding it at startup. * Exported for tests. */ export function applyStartupSettings(layer: Record, home = homedir()): boolean { const startup = layer.startup as { model?: unknown; thinking?: unknown } | undefined; const model = typeof startup?.model === "string" ? startup.model.trim() : ""; const thinking = typeof startup?.thinking === "string" && (STARTUP_THINKING as readonly string[]).includes(startup.thinking) ? startup.thinking : undefined; const parts = model ? splitModelKey(model) : undefined; const current = readDefaultModel(); if (parts === undefined && thinking === undefined) return false; const nextKey = parts ? model : current.key; if (nextKey === current.key && (thinking === undefined || thinking === current.thinking)) return false; // mirror echo const target = nextKey ? splitModelKey(nextKey) : undefined; if (target === undefined) return false; persistDefaultModel({ provider: target.provider, model: target.id, thinkingLevel: thinking }); if (parts !== undefined) { const path = globalPresetPath(home); const existing = mergePresets(emptyPreset(), parsePreset(readJson(path))); if (existing.active?.kind === "fusion" && existing.active.lead !== model) { saveRuntimeState(path, { effort: existing.effort, recent: existing.recent, active: { kind: "single", model } }); } } return true; } /** pi → hub: show pi's real startup default in /unipi:settings (it also * changes through pi's /model ctrl+s and the picker's alt+enter). */ export function mirrorStartupFromPi(cwd: string): void { const { key, thinking } = readDefaultModel(); const shown = (getSettingsScoped("fusion", "global", cwd)?.startup ?? {}) as { model?: string; thinking?: string }; const patch: Record = {}; if (key !== undefined && shown.model !== key) patch.model = key; if (thinking !== undefined && shown.thinking !== thinking && (STARTUP_THINKING as readonly string[]).includes(thinking)) patch.thinking = thinking; if (Object.keys(patch).length > 0) setSettings("fusion", { startup: patch }, "global", cwd); } // Registered with the unified settings hub: the DEFAULT PAIR is editable // there (model pickers, engine-layered). The curated arrays, effort, recent, // badges and prices remain in the preset files — the hub action "Edit fusion presets…" owns // those. The engine overlay wins for the default pair on load. registerSettings({ namespace: "fusion", label: "Fusion", defaults: { default: { lead: "", sidekick: "" }, startup: { model: "" } }, onSet: (layer, scope) => { if (scope === "global") applyStartupSettings(layer); }, schema: [ { title: "Startup", description: "The model pi opens with. /unipi:model → alt+enter sets the same thing (on the Fusion row it starts in Fusion).", fields: [ { key: "startup.model", type: "model", label: "Startup model", capability: "text", emptyLabel: "pi default", scopes: ["global"], description: "The model pi opens with. /unipi:model alt+enter sets the same thing." }, { key: "startup.thinking", type: "enum", label: "Startup thinking level", plainOptions: true, options: [...STARTUP_THINKING], scopes: ["global"], description: "Thinking level the session starts at." }, ], }, { title: "Default pair", description: "Used when the Fusion row is confirmed without editing", fields: [ { key: "default.lead", type: "model", label: "Lead", emptyLabel: "picker default", capability: "text", emptyOption: "picker default", description: "Lead model of the pair used when the Fusion row is confirmed unedited." }, { key: "default.sidekick", type: "model", label: "Sidekick", emptyLabel: "picker default", capability: "text", emptyOption: "picker default", description: "Sidekick model of the pair used when the Fusion row is confirmed unedited." }, { key: "presets.editor", type: "action", label: "Edit fusion presets…", description: "Curate the lead and sidekick lists and the defaults.", command: "unipi:fusion-preset" }, ], }, ], }); export function loadPreset(cwd: string, home = homedir()): LoadedPreset { const globalPath = globalPresetPath(home); const projectPath = projectPresetPath(cwd); const globalRaw = readJson(globalPath); const projectRaw = readJson(projectPath); let preset = mergePresets(emptyPreset(), parsePreset(globalRaw)); const hasProjectLayer = projectRaw !== undefined; if (hasProjectLayer) preset = mergePresets(preset, parsePreset(projectRaw)); // Engine overlay: hub-edited default pair wins over the preset files. try { const engine = getSettings("fusion", cwd) as { default?: Partial }; if (engine?.default?.lead) preset = { ...preset, default: { ...preset.default, lead: engine.default.lead } }; if (engine?.default?.sidekick) { preset = { ...preset, default: { ...preset.default, sidekick: engine.default.sidekick } }; } } catch { // Engine unavailable — preset files stand alone. } return { preset, globalPath, projectPath, hasProjectLayer }; } /** Persist a hub-side default-pair change to the engine layer. */ export function saveDefaultPair(cwd: string, pair: Partial): void { setSettings("fusion", { default: pair } as Record, "global", cwd); } function writeJsonAtomic(path: string, value: unknown): void { mkdirSync(dirname(path), { recursive: true }); const tmp = `${path}.${String(process.pid)}.tmp`; writeFileSync(tmp, `${JSON.stringify(value, null, 2)}\n`, "utf8"); renameSync(tmp, path); } /** Persist the curated lists (lead/sidekick/default) to one layer. */ export function saveCuration( path: string, curation: Pick, ): void { const existing = parsePreset(readJson(path)); writeJsonAtomic(path, { schema_version: PRESET_SCHEMA_VERSION, ...existing, lead: curation.lead, sidekick: curation.sidekick, default: curation.default, }); } /** Persist runtime state (effort / recent / active) — always to the global layer. */ export function saveRuntimeState( globalPath: string, state: Pick & { active?: ActiveSelection | undefined }, ): void { const existing = parsePreset(readJson(globalPath)); writeJsonAtomic(globalPath, { schema_version: PRESET_SCHEMA_VERSION, lead: existing.lead ?? [], sidekick: existing.sidekick ?? [], default: existing.default ?? {}, ...existing, effort: state.effort, recent: state.recent.slice(0, RECENT_LIMIT), active: state.active, }); } // ── pure helpers used by the picker ──────────────────────────────────────── export function pushRecent(recent: readonly ModelKey[], key: ModelKey): ModelKey[] { return [key, ...recent.filter((k) => k !== key)].slice(0, RECENT_LIMIT); } export function stepEffort(current: EffortLevel, delta: -1 | 1): EffortLevel { const index = EFFORT_LEVELS.indexOf(current); const next = Math.min(EFFORT_LEVELS.length - 1, Math.max(0, index + delta)); return EFFORT_LEVELS[next] ?? current; } /** Devin-style label: off→None, xhigh→XHigh, max→Max, others capitalised. */ export function effortLabel(level: EffortLevel): string { if (level === "off") return "None"; if (level === "xhigh") return "XHigh"; if (level === "max") return "Max"; return level.charAt(0).toUpperCase() + level.slice(1); }