/** * Model Profiles Extension * * Named profiles of model + thinking effort, with favorites you can cycle. * * Config files (merged, project wins on name conflicts): * - ~/.pi/agent/model-profiles.json (global) * - /.pi/model-profiles.json (project-local) * * Example model-profiles.json: * ```json * { * "deep": { * "provider": "anthropic", * "model": "claude-opus-4-1", * "thinkingLevel": "high", * "favorite": true * }, * "fast": { * "provider": "openai", * "model": "gpt-5-mini", * "thinkingLevel": "low", * "favorite": true * } * } * ``` * * Usage: * - `/profile` - pick a profile from a list (favorites marked with ★) * - `/profile deep` - apply a profile by name * - `alt+p` - cycle through favorite profiles (configurable, see below) * * The cycle shortcut can be changed via a reserved `$settings` key: * ```json * { * "$settings": { "cycleShortcut": "ctrl+shift+u" }, * "deep": { ... } * } * ``` * Key format: modifier+key, e.g. "alt+p", "ctrl+shift+u". Run `/reload` after editing. */ import { existsSync, readFileSync } from "node:fs"; import { join } from "node:path"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent"; import type { KeyId } from "@earendil-works/pi-tui"; type ThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max"; interface Profile { /** Provider name (e.g., "anthropic", "openai") */ provider: string; /** Model ID (e.g., "claude-sonnet-4-5") */ model: string; /** Thinking level (default: leave unchanged) */ thinkingLevel?: ThinkingLevel; /** Include in alt+p cycling */ favorite?: boolean; } type ProfilesConfig = Record; const DEFAULT_SHORTCUT = "alt+p"; interface LoadedConfig { profiles: ProfilesConfig; shortcut: string; } function loadProfiles(cwd: string): LoadedConfig { const paths = [join(getAgentDir(), "model-profiles.json"), join(cwd, CONFIG_DIR_NAME, "model-profiles.json")]; let profiles: ProfilesConfig = {}; let shortcut = DEFAULT_SHORTCUT; for (const path of paths) { if (!existsSync(path)) continue; try { const { $settings, ...rest } = JSON.parse(readFileSync(path, "utf-8")); profiles = { ...profiles, ...rest }; if (typeof $settings?.cycleShortcut === "string") shortcut = $settings.cycleShortcut; } catch (err) { console.error(`Failed to load profiles from ${path}: ${err}`); } } return { profiles, shortcut }; } function describe(name: string, p: Profile): string { const fav = p.favorite ? "★ " : ""; return `${fav}${name} — ${p.provider}/${p.model}${p.thinkingLevel ? ` (${p.thinkingLevel})` : ""}`; } export default function modelProfiles(pi: ExtensionAPI) { let profiles: ProfilesConfig = {}; let activeName: string | undefined; // Shortcuts are bound at registration time, so resolve the key now. // pi's process cwd is the session cwd at load; project configs apply on /reload. const { shortcut } = loadProfiles(process.cwd()); async function applyProfile(name: string, ctx: ExtensionContext): Promise { const profile = profiles[name]; if (!profile) { ctx.ui.notify(`Unknown profile "${name}". Defined: ${Object.keys(profiles).join(", ") || "(none)"}`, "warning"); return; } const model = ctx.modelRegistry.find(profile.provider, profile.model); if (!model) { ctx.ui.notify(`Profile "${name}": model ${profile.provider}/${profile.model} not found`, "warning"); return; } if (!(await pi.setModel(model))) { ctx.ui.notify(`Profile "${name}": no API key for ${profile.provider}/${profile.model}`, "warning"); return; } if (profile.thinkingLevel) { pi.setThinkingLevel(profile.thinkingLevel); } activeName = name; ctx.ui.notify(`Profile: ${describe(name, profile)}`, "info"); } async function selectProfile(ctx: ExtensionContext): Promise { const names = Object.keys(profiles); if (names.length === 0) { ctx.ui.notify( `No profiles defined. Add them to ${join(getAgentDir(), "model-profiles.json")} or ${join(ctx.cwd, CONFIG_DIR_NAME, "model-profiles.json")}`, "warning", ); return; } // ponytail: label-based select; name is recovered by index, labels are display-only const choice = await ctx.ui.select("Select profile:", names.map((n) => describe(n, profiles[n]))); if (choice === undefined) return; const index = names.map((n) => describe(n, profiles[n])).indexOf(choice); await applyProfile(names[index], ctx); } function cycleFavorite(ctx: ExtensionContext): void { const favorites = Object.keys(profiles).filter((n) => profiles[n].favorite); if (favorites.length === 0) { ctx.ui.notify("No favorite profiles. Mark profiles with \"favorite\": true", "warning"); return; } // Check if current model/thinking matches any profile, or use activeName let currentIndex = favorites.indexOf(activeName ?? ""); if (currentIndex === -1 && ctx.model) { // Try to find a favorite profile matching current model and thinking level const currentThinking = pi.getThinkingLevel(); currentIndex = favorites.findIndex((n) => { const p = profiles[n]; return p.provider === ctx.model?.provider && p.model === ctx.model?.id && (p.thinkingLevel ?? "off") === (currentThinking ?? "off"); }); } const next = favorites[(currentIndex + 1) % favorites.length]; void applyProfile(next, ctx); } pi.on("session_start", async (_event, ctx) => { profiles = loadProfiles(ctx.cwd).profiles; }); pi.registerCommand("profile", { description: "Apply a model profile (/profile [name])", handler: async (args, ctx) => { const name = args.trim(); if (name) await applyProfile(name, ctx); else await selectProfile(ctx); }, }); pi.registerShortcut(shortcut as KeyId, { description: "Cycle favorite model profiles", handler: async (ctx) => cycleFavorite(ctx), }); }