/** * modelo-proyecto — modelo por defecto de un proyecto. * * Guarda el modelo elegido en `/.pi/settings.json` (`defaultProvider` * y `defaultModel`), que pi aplica al iniciar cualquier sesión nueva en ese * proyecto (una vez concedida la confianza del proyecto). * * - Orden `/modelo-proyecto [proveedor/modelo]`: muestra los modelos * disponibles para elegir (o usa el indicado), lo guarda en el proyecto del * directorio actual y lo activa en la sesión en curso. * - Herramienta `elegir_modelo_proyecto`: la usa la skill `new-project` para * que el usuario elija el modelo al crear un proyecto en otra ruta. */ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; import type { Api, Model } from "@earendil-works/pi-ai"; import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; import { DynamicBorder } from "@earendil-works/pi-coding-agent"; import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui"; import { Type } from "typebox"; const SETTINGS_REL = join(".pi", "settings.json"); /** Máximo de modelos visibles a la vez en el selector (la lista se desplaza). */ const MAX_VISIBLES = 15; function clave(m: Model): string { return `${m.provider}/${m.id}`; } function kilos(n: number | undefined): string { if (!n) return "?"; return n >= 1_000_000 ? `${(n / 1_000_000).toFixed(1)}M` : `${Math.round(n / 1000)}K`; } function etiqueta(m: Model, actual?: string): string { const marca = actual === clave(m) ? " ← actual del proyecto" : ""; return `${clave(m)} (${m.name ?? m.id}, ctx ${kilos(m.contextWindow)})${marca}`; } function disponibles(ctx: ExtensionContext): Model[] { return [...ctx.modelRegistry.getAvailable()].sort((a, b) => clave(a).localeCompare(clave(b))); } function leerSettings(proyecto: string): Record { const ruta = join(proyecto, SETTINGS_REL); if (!existsSync(ruta)) return {}; const texto = readFileSync(ruta, "utf-8").trim(); if (!texto) return {}; const datos = JSON.parse(texto); if (typeof datos !== "object" || datos === null || Array.isArray(datos)) { throw new Error(`${ruta} no contiene un objeto JSON`); } return datos as Record; } export function modeloDelProyecto(proyecto: string): string | undefined { try { const s = leerSettings(proyecto); if (typeof s.defaultProvider === "string" && typeof s.defaultModel === "string") { return `${s.defaultProvider}/${s.defaultModel}`; } } catch { // settings ilegible: se trata como sin modelo } return undefined; } /** Fusiona defaultProvider/defaultModel en .pi/settings.json sin tocar el resto. */ function guardarModelo(proyecto: string, m: Model): string { const ruta = join(proyecto, SETTINGS_REL); const datos = leerSettings(proyecto); datos.defaultProvider = m.provider; datos.defaultModel = m.id; mkdirSync(join(proyecto, ".pi"), { recursive: true }); const tmp = `${ruta}.tmp`; writeFileSync(tmp, `${JSON.stringify(datos, null, 2)}\n`, "utf-8"); renameSync(tmp, ruta); return ruta; } function buscar(ctx: ExtensionContext, texto: string): Model | undefined { const t = texto.trim(); const lista = disponibles(ctx); return ( lista.find((m) => clave(m) === t) ?? (t.includes("/") ? undefined : lista.filter((m) => m.id === t).at(0)) ); } async function elegir(ctx: ExtensionContext, proyecto: string): Promise | undefined> { const lista = disponibles(ctx); if (lista.length === 0) throw new Error("no hay modelos disponibles (¿falta /login o credenciales?)"); const actual = modeloDelProyecto(proyecto); const titulo = `Modelo por defecto del proyecto (${proyecto})`; // Fuera de la TUI (p. ej. RPC) no hay componentes propios: diálogo estándar. if (ctx.mode !== "tui") { const opciones = lista.map((m) => etiqueta(m, actual)); const elegido = await ctx.ui.select(titulo, opciones); if (!elegido) return undefined; return lista[opciones.indexOf(elegido)]; } const items: SelectItem[] = lista.map((m) => ({ value: clave(m), label: etiqueta(m, actual) })); const valor = await ctx.ui.custom((tui, theme, _kb, done) => { const contenedor = new Container(); contenedor.addChild(new DynamicBorder((s) => theme.fg("accent", s))); contenedor.addChild(new Text(theme.fg("accent", theme.bold(titulo)))); // Ventana de MAX_VISIBLES elementos que se desplaza con el cursor; // ↑ en el primero salta al último y ↓ en el último vuelve al primero. const selector = new SelectList(items, Math.min(items.length, MAX_VISIBLES), { selectedPrefix: (t) => theme.fg("accent", t), selectedText: (t) => theme.fg("accent", t), description: (t) => theme.fg("muted", t), scrollInfo: (t) => theme.fg("dim", t), noMatch: (t) => theme.fg("warning", t), }); const inicial = actual ? items.findIndex((i) => i.value === actual) : -1; if (inicial >= 0) selector.setSelectedIndex(inicial); selector.onSelect = (item) => done(item.value); selector.onCancel = () => done(null); contenedor.addChild(selector); contenedor.addChild(new Text(theme.fg("dim", "↑↓ moverse • enter elegir • esc cancelar"))); contenedor.addChild(new DynamicBorder((s) => theme.fg("accent", s))); return { render: (width: number) => contenedor.render(width), invalidate: () => contenedor.invalidate(), handleInput: (data: string) => { selector.handleInput(data); tui.requestRender(); }, }; }); if (!valor) return undefined; return lista.find((m) => clave(m) === valor); } export default function (pi: ExtensionAPI) { // Lista para autocompletar (getArgumentCompletions no recibe ctx). let cache: string[] = []; pi.on("session_start", (_event, ctx) => { try { cache = disponibles(ctx).map(clave); } catch { cache = []; } }); pi.registerCommand("modelo-proyecto", { description: "Elige el modelo por defecto de este proyecto (se guarda en .pi/settings.json)", getArgumentCompletions: (prefijo) => { const p = prefijo.trim().toLowerCase(); const items = cache.filter((k) => k.toLowerCase().includes(p)).map((k) => ({ value: k, label: k })); return items.length ? items : null; }, handler: async (args, ctx) => { const proyecto = ctx.cwd; let modelo: Model | undefined; try { if (args.trim()) { modelo = buscar(ctx, args); if (!modelo) { ctx.ui.notify(`Modelo "${args.trim()}" no disponible. Usa /modelo-proyecto sin argumentos para elegir.`, "error"); return; } } else { if (!ctx.hasUI) { ctx.ui.notify("Sin interfaz: indica el modelo, /modelo-proyecto proveedor/modelo", "error"); return; } modelo = await elegir(ctx, proyecto); if (!modelo) { ctx.ui.notify("Sin cambios: no se eligió modelo.", "info"); return; } } const ruta = guardarModelo(proyecto, modelo); const activo = await pi.setModel(modelo); ctx.ui.notify( `Modelo del proyecto: ${clave(modelo)} (guardado en ${ruta})` + (activo ? "; activado en esta sesión." : "; no se pudo activar ahora (¿credenciales?)."), activo ? "info" : "warning", ); } catch (err) { ctx.ui.notify(`modelo-proyecto: ${(err as Error).message}`, "error"); } }, }); pi.registerTool({ name: "elegir_modelo_proyecto", label: "Elegir modelo del proyecto", description: "Muestra al usuario la lista de modelos disponibles para que elija el modelo por defecto de un proyecto " + "y lo guarda en /.pi/settings.json (defaultProvider/defaultModel), que pi usará en las nuevas sesiones " + "de ese proyecto. Úsala al crear un proyecto. No elijas tú el modelo: deja que el usuario lo seleccione. " + "Pasa 'modelo' (proveedor/id) solo si el usuario ya lo ha dicho explícitamente.", parameters: Type.Object({ ruta: Type.String({ description: "Directorio raíz del proyecto" }), modelo: Type.Optional( Type.String({ description: "proveedor/id ya indicado por el usuario; si falta se muestra el selector" }), ), }), async execute(_id, params, _signal, _onUpdate, ctx) { const proyecto = resolve(ctx.cwd, params.ruta.replace(/^~(?=$|\/)/, process.env.HOME ?? "~")); let modelo: Model | undefined; if (params.modelo) { modelo = buscar(ctx, params.modelo); if (!modelo) { const lista = disponibles(ctx).map(clave).join(", "); throw new Error(`modelo "${params.modelo}" no disponible. Disponibles: ${lista}`); } } else { if (!ctx.hasUI) { const lista = disponibles(ctx).map(clave).join("\n"); throw new Error( `sin interfaz para mostrar el selector. Pregunta al usuario y vuelve a llamar con 'modelo'. Disponibles:\n${lista}`, ); } modelo = await elegir(ctx, proyecto); if (!modelo) { return { content: [{ type: "text", text: "El usuario canceló la elección; no se guardó modelo. Pregúntale si quiere elegir uno." }], details: { proyecto, modelo: null }, }; } } const ruta = guardarModelo(proyecto, modelo); return { content: [ { type: "text", text: `Modelo por defecto del proyecto: ${clave(modelo)}. Guardado en ${ruta}. Se cambia con /modelo-proyecto.`, }, ], details: { proyecto, modelo: clave(modelo), ruta }, }; }, }); }