feat: modelo por defecto por proyecto con selector y orden /modelo-proyecto
This commit is contained in:
1 parent
b35bc7a607
commit
f6d2870635
12 files changed
+372
-22
No files matched your search
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* 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 { Type } from "typebox";
|
||||
|
||||
const SETTINGS_REL = join(".pi", "settings.json");
|
||||
|
||||
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 opciones = lista.map((m) => etiqueta(m, actual));
|
||||
const elegido = await ctx.ui.select(`Modelo por defecto del proyecto (${proyecto})`, opciones);
|
||||
if (!elegido) return undefined;
|
||||
return lista[opciones.indexOf(elegido)];
|
||||
}
|
||||
|
||||
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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user