245 lines
9.2 KiB
TypeScript
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 },
|
|
};
|
|
},
|
|
});
|
|
}
|