import { join } from "node:path"; import { homedir } from "node:os"; import { readFile } from "node:fs/promises"; import type { ExtensionAPI, ExtensionContext, ModelLike, ProviderConfigLike, ThinkingLevel } from "../_shared/pi-api.js"; import { getCommandText, getProjectRoot, setTextWidget } from "../_shared/pi-api.js"; const EXTENSION_ID = "model-roles"; const USER_HOME_ENV = "PI_MODEL_ROLES_HOME"; const DEFAULT_ROLES = ["default", "smol", "slow", "plan", "vision", "designer", "commit", "task"]; const DEFAULT_CYCLE_ORDER = ["smol", "default", "slow"]; const THINKING_LEVELS = new Set(["off", "low", "medium", "high", "xhigh"]); type RoleSource = "settings" | "user" | "project" | "unset"; export interface ModelRoleAssignment { model: string; thinking?: ThinkingLevel; } export type ModelRoleValue = string | ModelRoleAssignment | null; export interface ModelRolesConfig { version?: 1; roles?: Record; cycleOrder?: string[]; providers?: Record; } export interface ModelRolesConfigPaths { user: string; project: string; } export interface EffectiveRole { role: string; source: RoleSource; inherited: boolean; assignment?: ModelRoleAssignment; } interface ModelRolesState { paths: ModelRolesConfigPaths; settings: ModelRolesConfig; user: ModelRolesConfig; project: ModelRolesConfig; effective: Map; cycleOrder: string[]; } interface ParsedSelector { model: string; thinking?: ThinkingLevel; } export default function modelRoles(pi: ExtensionAPI): void { pi.registerCommand("models", { description: "OMP-style interactive model selection and role assignment.", handler: async (args, ctx) => { await handleModelsCommand(getCommandText(args), ctx); }, }); pi.registerCommand("model-roles", { description: "Legacy read-only status alias for models role assignment.", handler: async (args, ctx) => { await handleModelRolesCommand(getCommandText(args), ctx); }, }); } export function getModelRolesConfigPaths(projectRoot: string, env: NodeJS.ProcessEnv = process.env): ModelRolesConfigPaths { const userRoot = env[USER_HOME_ENV] ?? join(homedir(), ".pi", "agent"); return { user: join(userRoot, "model-roles", "config.json"), project: join(projectRoot, ".pi", "model-roles", "config.json"), }; } export async function loadModelRolesState(ctx: ExtensionContext): Promise { const paths = getModelRolesConfigPaths(getProjectRoot(ctx)); const settings = readSettingsConfig(ctx); const user = await readConfig(paths.user); const project = await readConfig(paths.project); return buildState(paths, settings, user, project); } export function formatModelRolesStatus(state: ModelRolesState): string { const roles = [...state.effective.values()].sort((left, right) => DEFAULT_ROLES.indexOf(left.role) - DEFAULT_ROLES.indexOf(right.role) || left.role.localeCompare(right.role)); const roleLines = roles.map((role) => { if (!role.assignment) return `- ${role.role}: `; const inherited = role.inherited ? " inherited" : ""; const thinking = role.assignment.thinking ? `:${role.assignment.thinking}` : ""; return `- ${role.role}: ${role.assignment.model}${thinking} (${role.source}${inherited})`; }); return [ "model-roles", `user: ${state.paths.user}`, `project: ${state.paths.project}`, `cycleOrder: ${state.cycleOrder.join(" > ")}`, "roles:", ...roleLines, "", "commands:", "- /model-roles status", "- mutating legacy commands are disabled; use /models, /models assign, and /models use", ].join("\n"); } export async function formatModelsStatus(ctx: ExtensionContext): Promise { return formatModelsStatusForQuery(ctx); } async function formatModelsStatusForQuery(ctx: ExtensionContext, searchPattern = ""): Promise { const state = await loadModelRolesState(ctx); const assignedByModel = new Map(); for (const role of state.effective.values()) { if (!role.assignment) continue; const roles = assignedByModel.get(role.assignment.model) ?? []; roles.push(formatRoleTag(role)); assignedByModel.set(role.assignment.model, roles); } const models = await getAvailableModels(ctx); const providers = [...new Set(models.map((model) => model.provider))].sort(); const rows = filterModels(models, searchPattern).map((model) => { const selector = toSelector(model); const roles = assignedByModel.get(selector)?.join(", "); return `${selector}${roles ? ` [${roles}]` : ""}${model.name ? ` - ${model.name}` : ""}`; }).sort(); return [ "models", "Only showing models with configured API keys.", "", `Models: ALL CANONICAL${providers.length ? ` ${providers.map(formatProviderTabLabel).join(" ")}` : ""}`, ...(searchPattern ? [`Search: ${searchPattern}`] : []), ...(rows.length ? rows.map((row) => `- ${row}`) : [searchPattern ? `- ` : "- "]), "", "commands:", "- /models", "- /models status", "- /models ", "- /models select [query]", "- /models assign [query]", "- /models use ", "- role assignments are stored in OMP-native settings key `modelRoles`", "- /models cycle is disabled until the full OMP selector/carousel is ported", "- /model-roles status (legacy)", ].join("\n"); } async function handleModelsCommand(text: string, ctx: ExtensionContext): Promise { const input = text.trim(); if (input === "") { await selectTemporaryModel(ctx, ""); return; } if (input === "status" || input === "list") { setTextWidget(ctx, "models", await formatModelsStatusForQuery(ctx)); return; } const [action = "", ...rest] = input.split(/\s+/); if (action === "status" || action === "list") { setTextWidget(ctx, "models", await formatModelsStatusForQuery(ctx, rest.join(" "))); return; } if (action === "select") { await selectTemporaryModel(ctx, rest.join(" ")); return; } if (action === "assign" || action === "set") { const [role, ...query] = rest; await assignModelRole(ctx, role, query.join(" ")); return; } if (action === "use") { await useModelRole(ctx, rest[0] ?? "default"); return; } if (action === "cycle") { await showDisabledModelMutation(ctx, "models", "cycle"); return; } setTextWidget(ctx, "models", await formatModelsStatusForQuery(ctx, input)); } async function handleModelRolesCommand(text: string, ctx: ExtensionContext): Promise { const input = text.trim(); if (input === "" || input === "status") { setTextWidget(ctx, EXTENSION_ID, formatModelRolesStatus(await loadModelRolesState(ctx))); return; } const [action = "", ...rest] = input.split(/\s+/); if (action === "select") { await showDisabledModelMutation(ctx, EXTENSION_ID, "select"); return; } if (action === "set") { await showDisabledModelMutation(ctx, EXTENSION_ID, `set ${rest.join(" ")}`.trim()); return; } if (action === "inherit") { await showDisabledModelMutation(ctx, EXTENSION_ID, `inherit ${rest[0] ?? ""}`.trim()); return; } if (action === "use") { await showDisabledModelMutation(ctx, EXTENSION_ID, `use ${rest[0] ?? "default"}`); return; } if (action === "cycle") { await showDisabledModelMutation(ctx, EXTENSION_ID, "cycle"); return; } ctx.ui.notify(`Unknown model-roles command: ${action}`, "warn"); setTextWidget(ctx, EXTENSION_ID, formatModelRolesStatus(await loadModelRolesState(ctx))); } async function showDisabledModelMutation(ctx: ExtensionContext, widget: string, action: string): Promise { ctx.ui.notify(`Model selection action '${action}' is disabled until the full OMP selector/carousel is ported.`, "warn"); const status = widget === "models" ? await formatModelsStatusForQuery(ctx) : formatModelRolesStatus(await loadModelRolesState(ctx)); setTextWidget(ctx, widget, [ "model selection action disabled", `requestedAction: ${action}`, "owner: OMP model selector/settings", "ported: false", "", status, ].join("\n")); } async function assignModelRole(ctx: ExtensionContext, role: string | undefined, searchPattern: string): Promise { const normalizedRole = role?.trim(); if (!normalizedRole) { ctx.ui.notify("Usage: /models assign [query]", "warn"); setTextWidget(ctx, "models", await formatModelsStatusForQuery(ctx, searchPattern)); return; } const models = sortModels(filterModels(await getAvailableModels(ctx), searchPattern)); if (models.length === 0) { setTextWidget(ctx, "models", [ "model role assignment unavailable", searchPattern ? `No models matching "${searchPattern}".` : "No configured models are available.", "", await formatModelsStatusForQuery(ctx, searchPattern), ].join("\n")); return; } const selected = await ctx.ui.select(`Assign ${normalizedRole}`, models.map((model) => ({ value: toSelector(model), label: formatModelOption(model), }))); if (selected.cancelled || !selected.value) { setTextWidget(ctx, "models", [ "model role assignment cancelled", "", await formatModelsStatusForQuery(ctx, searchPattern), ].join("\n")); return; } const model = models.find((candidate) => toSelector(candidate) === selected.value); if (!model) { ctx.ui.notify(`Selected model is no longer available: ${selected.value}`, "warn"); setTextWidget(ctx, "models", await formatModelsStatusForQuery(ctx, searchPattern)); return; } await setModelRoleSetting(ctx, normalizedRole, { model: toSelector(model) }); ctx.ui.notify(`Assigned ${normalizedRole}: ${selected.value}`, "info"); setTextWidget(ctx, "models", [ "model role assigned", `role: ${normalizedRole}`, `model: ${selected.value}`, "persisted: true", "settingsKey: modelRoles", "", await formatModelsStatusForQuery(ctx, searchPattern), ].join("\n")); } async function useModelRole(ctx: ExtensionContext, role: string): Promise { if (!ctx.setModel) { await showDisabledModelMutation(ctx, "models", `use ${role}`); return; } const state = await loadModelRolesState(ctx); const assignment = state.effective.get(role)?.assignment; if (!assignment) { setTextWidget(ctx, "models", [ "model role is not assigned", `role: ${role}`, "", await formatModelsStatusForQuery(ctx), ].join("\n")); return; } const model = await findModelBySelector(ctx, assignment.model); if (!model) { setTextWidget(ctx, "models", [ "assigned model is unavailable", `role: ${role}`, `model: ${assignment.model}`, "", await formatModelsStatusForQuery(ctx), ].join("\n")); return; } const ok = await Promise.resolve(ctx.setModel(model)); if (!ok) { ctx.ui.notify(`Model selection failed: ${assignment.model}`, "warn"); setTextWidget(ctx, "models", await formatModelsStatusForQuery(ctx)); return; } if (assignment.thinking && ctx.setThinkingLevel) ctx.setThinkingLevel(assignment.thinking); ctx.ui.notify(`Using ${role}: ${assignment.model}`, "info"); setTextWidget(ctx, "models", [ "model role selected", `role: ${role}`, `model: ${assignment.model}`, ...(assignment.thinking ? [`thinking: ${assignment.thinking}`] : []), "source: OMP-native settings/modelRoles", "", await formatModelsStatusForQuery(ctx), ].join("\n")); } async function selectTemporaryModel(ctx: ExtensionContext, searchPattern: string): Promise { if (!ctx.setModel) { await showDisabledModelMutation(ctx, "models", "select"); return; } const models = sortModels(filterModels(await getAvailableModels(ctx), searchPattern)); if (models.length === 0) { setTextWidget(ctx, "models", [ "model selection unavailable", searchPattern ? `No models matching "${searchPattern}".` : "No configured models are available.", "", await formatModelsStatusForQuery(ctx, searchPattern), ].join("\n")); return; } const selected = await ctx.ui.select("Select model", models.map((model) => ({ value: toSelector(model), label: formatModelOption(model), }))); if (selected.cancelled || !selected.value) { setTextWidget(ctx, "models", [ "model selection cancelled", "", await formatModelsStatusForQuery(ctx, searchPattern), ].join("\n")); return; } const model = models.find((candidate) => toSelector(candidate) === selected.value); if (!model) { ctx.ui.notify(`Selected model is no longer available: ${selected.value}`, "warn"); setTextWidget(ctx, "models", await formatModelsStatusForQuery(ctx, searchPattern)); return; } const ok = await Promise.resolve(ctx.setModel(model)); if (!ok) { ctx.ui.notify(`Model selection failed: ${selected.value}`, "warn"); setTextWidget(ctx, "models", await formatModelsStatusForQuery(ctx, searchPattern)); return; } ctx.ui.notify(`Temporary model: ${selected.value}`, "info"); setTextWidget(ctx, "models", [ "temporary model selected", `model: ${selected.value}`, "persisted: false", "owner: OMP temporary model selector semantics", "", await formatModelsStatusForQuery(ctx, searchPattern), ].join("\n")); } function buildState(paths: ModelRolesConfigPaths, settings: ModelRolesConfig, user: ModelRolesConfig, project: ModelRolesConfig): ModelRolesState { const cycleOrder = settings.cycleOrder ?? project.cycleOrder ?? user.cycleOrder ?? DEFAULT_CYCLE_ORDER; const roles = new Set([...DEFAULT_ROLES, ...Object.keys(user.roles ?? {}), ...Object.keys(project.roles ?? {}), ...Object.keys(settings.roles ?? {}), ...cycleOrder]); const effective = new Map(); for (const role of roles) { const settingsValue = settings.roles?.[role]; const projectValue = project.roles?.[role]; const userValue = user.roles?.[role]; if (settingsValue !== undefined && settingsValue !== null) { const assignment = normalizeAssignment(settingsValue); effective.set(role, assignment ? { role, source: "settings", inherited: false, assignment } : { role, source: "unset", inherited: false }); } else if (projectValue !== undefined && projectValue !== null) { const assignment = normalizeAssignment(projectValue); effective.set(role, assignment ? { role, source: "project", inherited: false, assignment } : { role, source: "unset", inherited: false }); } else if (userValue !== undefined && userValue !== null) { const assignment = normalizeAssignment(userValue); effective.set(role, assignment ? { role, source: "user", inherited: projectValue === null, assignment } : { role, source: "unset", inherited: projectValue === null }); } else { effective.set(role, { role, source: "unset", inherited: projectValue === null }); } } return { paths, settings, user, project, effective, cycleOrder }; } function readSettingsConfig(ctx: ExtensionContext): ModelRolesConfig { const roles = stringRecord(ctx.settings.get("modelRoles")); const cycleOrder = stringArray(ctx.settings.get("cycleOrder")); const config: ModelRolesConfig = {}; if (Object.keys(roles).length) config.roles = roles; if (cycleOrder.length) config.cycleOrder = cycleOrder; return config; } async function readConfig(path: string): Promise { try { const raw = await readFile(path, "utf8"); const parsed = JSON.parse(raw) as unknown; return isConfig(parsed) ? parsed : {}; } catch (error) { if (isNotFound(error)) return {}; throw error; } } function isConfig(value: unknown): value is ModelRolesConfig { return typeof value === "object" && value !== null && !Array.isArray(value); } async function setModelRoleSetting(ctx: ExtensionContext, role: string, assignment: ModelRoleAssignment): Promise { const current = stringRecord(ctx.settings.get("modelRoles")); await ctx.settings.set("modelRoles", { ...current, [role]: formatAssignment(assignment) }); } function normalizeAssignment(value: ModelRoleValue): ModelRoleAssignment | undefined { if (value === null) return undefined; if (typeof value === "string") return parseSelector(value); return parseSelector(formatAssignment(value)); } function parseSelector(selector: string): ParsedSelector | undefined { const trimmed = selector.trim(); if (!trimmed.includes("/")) return undefined; const colon = trimmed.lastIndexOf(":"); const suffix = colon > -1 ? trimmed.slice(colon + 1) : ""; if (suffix && isThinkingLevel(suffix)) return { model: trimmed.slice(0, colon), thinking: suffix }; return { model: trimmed }; } function formatRoleTag(role: EffectiveRole): string { const label = role.role.toUpperCase(); return role.assignment?.thinking ? `${label} (${role.assignment.thinking})` : label; } function filterModels(models: ModelLike[], searchPattern: string): ModelLike[] { const query = normalizeSearchText(searchPattern); if (!query) return models; const tokens = query.split(" ").filter(Boolean); return models.filter((model) => { const haystack = normalizeSearchText(`${model.provider} ${model.id} ${model.name ?? ""}`); return tokens.every((token) => haystack.includes(token)); }); } function sortModels(models: ModelLike[]): ModelLike[] { return [...models].sort((left, right) => toSelector(left).localeCompare(toSelector(right))); } function normalizeSearchText(value: string): string { return value.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim(); } async function getAvailableModels(ctx: ExtensionContext): Promise { const available = await Promise.resolve(ctx.modelRegistry?.getAvailable?.() ?? []); if (available.length > 0) return available; return Promise.resolve(ctx.modelRegistry?.getAll?.() ?? []); } async function findModelBySelector(ctx: ExtensionContext, selector: string): Promise { const parsed = parseSelector(selector); if (!parsed) return undefined; const slash = parsed.model.indexOf("/"); const provider = parsed.model.slice(0, slash); const id = parsed.model.slice(slash + 1); const found = await Promise.resolve(ctx.modelRegistry?.find?.(provider, id)); if (found) return found; return (await getAvailableModels(ctx)).find((model) => toSelector(model) === parsed.model); } function toSelector(model: ModelLike): string { return `${model.provider}/${model.id}`; } function formatProviderTabLabel(providerId: string): string { return providerId.replace(/[-_]+/g, " ").toUpperCase(); } function formatModelOption(model: ModelLike): string { return `${toSelector(model)}${model.name ? ` - ${model.name}` : ""}`; } function formatAssignment(assignment: ModelRoleAssignment): string { return assignment.thinking ? `${assignment.model}:${assignment.thinking}` : assignment.model; } function isThinkingLevel(value: string): value is ThinkingLevel { return THINKING_LEVELS.has(value); } function stringRecord(value: unknown): Record { if (typeof value !== "object" || value === null || Array.isArray(value)) return {}; const record: Record = {}; for (const [key, item] of Object.entries(value)) { if (typeof item === "string") record[key] = item; } return record; } function stringArray(value: unknown): string[] { return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []; } function isNotFound(error: unknown): boolean { return typeof error === "object" && error !== null && "code" in error && (error as { code?: string }).code === "ENOENT"; }