Files
pi-new-project-dist/extensions/modelo-proyecto.ts
T

245 lines
9.2 KiB
TypeScript

/**
* modelo-proyecto — modelo por defecto de un proyecto.
*
* Guarda el modelo elegido en `<proyecto>/.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<Api>): 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<Api>, 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<Api>[] {
return [...ctx.modelRegistry.getAvailable()].sort((a, b) => clave(a).localeCompare(clave(b)));
}
function leerSettings(proyecto: string): Record<string, unknown> {
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<string, unknown>;
}
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<Api>): 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<Api> | 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<Model<Api> | 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<string | null>((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<Api> | 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 <ruta>/.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<Api> | 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 },
};
},
});
}