vipcode 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +60 -0
- package/README.md +401 -2
- package/dist/agent.js +502 -0
- package/dist/agents.js +174 -0
- package/dist/app.js +1025 -0
- package/dist/auth.js +108 -0
- package/dist/catalog.js +487 -0
- package/dist/commands.js +121 -0
- package/dist/config.js +230 -0
- package/dist/debug.js +32 -0
- package/dist/discovery.js +277 -0
- package/dist/frontmatter.js +45 -0
- package/dist/images.js +81 -0
- package/dist/index.js +623 -0
- package/dist/login.js +157 -0
- package/dist/lsp.js +480 -0
- package/dist/markdown.js +262 -0
- package/dist/match.js +8 -0
- package/dist/mcp.js +225 -0
- package/dist/meta.js +7 -0
- package/dist/paths.js +53 -0
- package/dist/permissions.js +144 -0
- package/dist/prompt.js +123 -0
- package/dist/ranking.js +230 -0
- package/dist/router.js +726 -0
- package/dist/sanitize.js +38 -0
- package/dist/session.js +219 -0
- package/dist/skills.js +374 -0
- package/dist/snapshot.js +199 -0
- package/dist/tools.js +1311 -0
- package/dist/trust.js +171 -0
- package/dist/tui/app.js +1019 -0
- package/dist/tui/dialogs.js +197 -0
- package/dist/tui/editor.js +120 -0
- package/dist/ui.js +184 -0
- package/dist/update.js +237 -0
- package/package.json +64 -4
package/dist/app.js
ADDED
|
@@ -0,0 +1,1025 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
import { Agent } from "./agent.js";
|
|
5
|
+
import { loadAgents, primaryAgents, subagents } from "./agents.js";
|
|
6
|
+
import { envNames, maskKey, readAuth, removeAuth, resolveCredentials, saveAuth, urlVars } from "./auth.js";
|
|
7
|
+
import { CATALOG } from "./catalog.js";
|
|
8
|
+
import { expandCommand, loadCommands } from "./commands.js";
|
|
9
|
+
import { CONFIG_PATH, configNotices, isAnonymous, loadConfig, setGlobalConfigValue } from "./config.js";
|
|
10
|
+
import { buildProviders, detectLocal, refreshModels } from "./discovery.js";
|
|
11
|
+
import { isRemoteSession, loginOpenRouter, openBrowser } from "./login.js";
|
|
12
|
+
import { LspManager } from "./lsp.js";
|
|
13
|
+
import { McpManager } from "./mcp.js";
|
|
14
|
+
import { AUTH_PATH, HOME_DIR, NOTICES_PATH, STATE_PATH, ensureHomeDir } from "./paths.js";
|
|
15
|
+
import { SessionGrants, createAsker, permissionsFor } from "./permissions.js";
|
|
16
|
+
import { Ranking, loadRankingData, rankingKey, refreshRanking } from "./ranking.js";
|
|
17
|
+
import { Router } from "./router.js";
|
|
18
|
+
import { loadSkills, skillContent } from "./skills.js";
|
|
19
|
+
import { deleteSession, listSessions, newSession, saveExport, saveSession } from "./session.js";
|
|
20
|
+
import { Snapshots } from "./snapshot.js";
|
|
21
|
+
import { updateVipcode } from "./update.js";
|
|
22
|
+
import { formatTodos, truncate } from "./tools.js";
|
|
23
|
+
import { projectTrust } from "./trust.js";
|
|
24
|
+
export const BUILTIN_COMMANDS = [
|
|
25
|
+
{ name: "help", description: "esta ayuda" },
|
|
26
|
+
{ name: "models", args: "[refresh]", description: "estado de cada modelo; elige uno o busca modelos nuevos" },
|
|
27
|
+
{ name: "model", args: "<p/m|auto>", description: "fijar un modelo como preferido (auto = rotación)" },
|
|
28
|
+
{ name: "connect", args: "[proveedor]", description: "añadir la clave de un proveedor gratuito (en OpenRouter, iniciando sesión)" },
|
|
29
|
+
{ name: "disconnect", args: "<proveedor>", description: "borrar la clave guardada de un proveedor" },
|
|
30
|
+
{ name: "providers", description: "proveedores gratuitos, si tienes clave y dónde conseguirla" },
|
|
31
|
+
{ name: "agents", description: "agentes disponibles (Tab cambia entre los principales)" },
|
|
32
|
+
{ name: "agent", args: "<nombre>", description: "cambiar de agente" },
|
|
33
|
+
{ name: "plan", description: "agente de solo lectura" },
|
|
34
|
+
{ name: "build", description: "agente que puede modificar ficheros" },
|
|
35
|
+
{ name: "undo", description: "deshacer el último mensaje (ficheros y conversación)" },
|
|
36
|
+
{ name: "redo", description: "rehacer lo deshecho" },
|
|
37
|
+
{ name: "diff", args: "[all]", description: "cambios del último mensaje (o de toda la sesión)" },
|
|
38
|
+
{ name: "todos", description: "lista de tareas del agente" },
|
|
39
|
+
{ name: "skills", description: "skills instaladas; elige una para usarla (vipcode skills add instala más)" },
|
|
40
|
+
{ name: "compact", description: "resumir la conversación para ahorrar contexto" },
|
|
41
|
+
{ name: "new", description: "empezar una sesión nueva" },
|
|
42
|
+
{ name: "sessions", description: "sesiones de esta carpeta" },
|
|
43
|
+
{ name: "load", args: "<n>", description: "cargar una sesión de la lista" },
|
|
44
|
+
{ name: "delete", args: "<n>", description: "borrar una sesión de la lista" },
|
|
45
|
+
{ name: "export", description: "guardar la conversación en Markdown" },
|
|
46
|
+
{ name: "usage", description: "tokens gastados en esta sesión" },
|
|
47
|
+
{ name: "mcp", description: "estado de los servidores MCP" },
|
|
48
|
+
{ name: "lsp", description: "servidores de lenguaje (diagnósticos)" },
|
|
49
|
+
{ name: "yolo", args: "[on|off]", description: "aprobar todo sin preguntar en esta sesión" },
|
|
50
|
+
{ name: "reset-limits", description: "olvidar las pausas guardadas de los proveedores" },
|
|
51
|
+
{ name: "config", description: "rutas de configuración" },
|
|
52
|
+
{ name: "update", description: "actualiza vipcode a lo último del repositorio o a la última versión publicada" },
|
|
53
|
+
{ name: "exit", description: "salir" },
|
|
54
|
+
];
|
|
55
|
+
function shortContext(tokens) {
|
|
56
|
+
if (tokens >= 1000000) {
|
|
57
|
+
return `${Number((tokens / 1000000).toFixed(1))}M`;
|
|
58
|
+
}
|
|
59
|
+
return `${Math.round(tokens / 1000)}k`;
|
|
60
|
+
}
|
|
61
|
+
// Colorea la salida de git diff
|
|
62
|
+
export function colorGitDiff(text) {
|
|
63
|
+
return text
|
|
64
|
+
.split("\n")
|
|
65
|
+
.map((line) => {
|
|
66
|
+
if (line.startsWith("+++") || line.startsWith("---")) {
|
|
67
|
+
return pc.bold(line);
|
|
68
|
+
}
|
|
69
|
+
if (line.startsWith("+")) {
|
|
70
|
+
return pc.green(line);
|
|
71
|
+
}
|
|
72
|
+
if (line.startsWith("-")) {
|
|
73
|
+
return pc.red(line);
|
|
74
|
+
}
|
|
75
|
+
if (line.startsWith("@@")) {
|
|
76
|
+
return pc.cyan(line);
|
|
77
|
+
}
|
|
78
|
+
if (line.startsWith("diff --git")) {
|
|
79
|
+
return pc.bold(pc.yellow(line));
|
|
80
|
+
}
|
|
81
|
+
return line;
|
|
82
|
+
})
|
|
83
|
+
.join("\n");
|
|
84
|
+
}
|
|
85
|
+
// Comprueba una clave pidiendo la lista de modelos
|
|
86
|
+
async function checkProvider(provider) {
|
|
87
|
+
const credentials = resolveCredentials(provider);
|
|
88
|
+
if (!credentials) {
|
|
89
|
+
return { ok: false, message: "falta la clave o alguna variable" };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const headers = { ...(provider.headers || {}) };
|
|
93
|
+
if (credentials.apiKey !== "no-key") {
|
|
94
|
+
headers.authorization = `Bearer ${credentials.apiKey}`;
|
|
95
|
+
}
|
|
96
|
+
const response = await fetch(`${credentials.baseURL.replace(/\/+$/, "")}/models`, { headers, signal: AbortSignal.timeout(15000) });
|
|
97
|
+
if (response.status === 401 || response.status === 403) {
|
|
98
|
+
return { ok: false, message: `la clave no es válida (HTTP ${response.status})` };
|
|
99
|
+
}
|
|
100
|
+
if (!response.ok) {
|
|
101
|
+
return { ok: true, message: `no he podido comprobarla (HTTP ${response.status}), pero la guardo` };
|
|
102
|
+
}
|
|
103
|
+
return { ok: true, message: "clave comprobada" };
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
return { ok: true, message: `no he podido comprobarla (${err.message}), pero la guardo` };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export class App {
|
|
110
|
+
root;
|
|
111
|
+
config;
|
|
112
|
+
providers;
|
|
113
|
+
router;
|
|
114
|
+
// Puntuaciones de LLM Stats con las que se ordenan los modelos (null si está desactivado)
|
|
115
|
+
ranking;
|
|
116
|
+
agents;
|
|
117
|
+
commands;
|
|
118
|
+
skills;
|
|
119
|
+
// Configuración que trae el proyecto y si la estamos usando (solo si confías en la carpeta)
|
|
120
|
+
trust;
|
|
121
|
+
trusted;
|
|
122
|
+
lsp;
|
|
123
|
+
mcp;
|
|
124
|
+
snapshots;
|
|
125
|
+
agent;
|
|
126
|
+
grants = new SessionGrants();
|
|
127
|
+
yolo = false;
|
|
128
|
+
local = {};
|
|
129
|
+
lastSessions = [];
|
|
130
|
+
// La interfaz pone aquí cómo preguntar los permisos
|
|
131
|
+
promptPermission = async () => ({
|
|
132
|
+
allow: false,
|
|
133
|
+
feedback: "no hay nadie para aprobarlo (usa --yolo)",
|
|
134
|
+
});
|
|
135
|
+
constructor(root, options = {}) {
|
|
136
|
+
this.root = root;
|
|
137
|
+
this.trust = projectTrust(root);
|
|
138
|
+
this.trusted = options.trusted ?? this.trust.trusted;
|
|
139
|
+
if (!this.trusted) {
|
|
140
|
+
const notice = `Esta carpeta trae configuración de vipcode que no estoy usando (${this.trust.files.join(", ")}): puede arrancar programas o cambiar a dónde van tus claves. Si confías en ella, ejecuta «vipcode trust».`;
|
|
141
|
+
if (!configNotices.includes(notice)) {
|
|
142
|
+
configNotices.push(notice);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
this.config = loadConfig(root, { trusted: this.trusted });
|
|
146
|
+
this.providers = buildProviders(this.config);
|
|
147
|
+
this.ranking = this.buildRanking();
|
|
148
|
+
this.router = new Router(this.config, this.providers, this.ranking);
|
|
149
|
+
if (this.config.model) {
|
|
150
|
+
this.router.pinned = this.config.model;
|
|
151
|
+
}
|
|
152
|
+
this.agents = loadAgents(root, this.config, this.trusted);
|
|
153
|
+
this.commands = loadCommands(root, this.config, this.trusted);
|
|
154
|
+
this.skills = loadSkills(root, this.config);
|
|
155
|
+
this.lsp = new LspManager(root, this.config);
|
|
156
|
+
if (!this.lsp.enabled()) {
|
|
157
|
+
this.lsp = null;
|
|
158
|
+
}
|
|
159
|
+
this.mcp = null;
|
|
160
|
+
if (Object.keys(this.config.mcp || {}).length > 0) {
|
|
161
|
+
this.mcp = new McpManager(this.config.mcp, root);
|
|
162
|
+
}
|
|
163
|
+
this.snapshots = new Snapshots(root, this.config.snapshot !== false);
|
|
164
|
+
this.agent = new Agent({
|
|
165
|
+
router: this.router,
|
|
166
|
+
config: this.config,
|
|
167
|
+
root,
|
|
168
|
+
agents: this.agents,
|
|
169
|
+
lsp: this.lsp,
|
|
170
|
+
mcp: this.mcp,
|
|
171
|
+
snapshots: this.snapshots,
|
|
172
|
+
skills: this.skills,
|
|
173
|
+
ask: (request, agent) => this.ask(request, agent),
|
|
174
|
+
}, options.session || newSession(root), options.agentName || options.session?.agent || "build");
|
|
175
|
+
}
|
|
176
|
+
ask(request, agent) {
|
|
177
|
+
const permission = permissionsFor(this.config.permission, agent.permission);
|
|
178
|
+
return createAsker(() => permission, this.grants, (req) => this.promptPermission(req, agent))(request);
|
|
179
|
+
}
|
|
180
|
+
setYolo(value) {
|
|
181
|
+
this.yolo = value;
|
|
182
|
+
if (value) {
|
|
183
|
+
for (const kind of ["write", "bash", "web", "mcp"]) {
|
|
184
|
+
this.grants.allowKind(kind);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
this.grants = new SessionGrants();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
reloadProviders() {
|
|
192
|
+
this.providers = buildProviders(this.config, this.local);
|
|
193
|
+
this.router.reload(this.providers);
|
|
194
|
+
}
|
|
195
|
+
// Aviso de privacidad: una sola vez, si se van a usar proveedores que no piden clave
|
|
196
|
+
needsPrivacyNotice() {
|
|
197
|
+
if (!this.router.slots.some((slot) => slot.anonymous)) {
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
try {
|
|
201
|
+
return !JSON.parse(fs.readFileSync(NOTICES_PATH, "utf8")).privacy;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
privacyNotice() {
|
|
208
|
+
const names = [...new Set(this.router.slots.filter((slot) => slot.anonymous).map((slot) => (slot.provider.label || slot.provider.name).replace(/ \(sin clave\)$/, "")))];
|
|
209
|
+
return `Sin clave, vipcode usa proveedores gratuitos que no piden registro (${names.join(", ")}). Pueden guardar lo que les envías y usarlo para mejorar sus modelos: no les mandes contraseñas, claves ni código confidencial. Con /connect añades un proveedor con clave, y con "keyless": false en la configuración dejas de usar los anónimos.`;
|
|
210
|
+
}
|
|
211
|
+
acceptPrivacyNotice() {
|
|
212
|
+
let data = {};
|
|
213
|
+
try {
|
|
214
|
+
data = JSON.parse(fs.readFileSync(NOTICES_PATH, "utf8"));
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// primera vez
|
|
218
|
+
}
|
|
219
|
+
data.privacy = new Date().toISOString();
|
|
220
|
+
ensureHomeDir();
|
|
221
|
+
fs.writeFileSync(NOTICES_PATH, JSON.stringify(data, null, 2));
|
|
222
|
+
}
|
|
223
|
+
// «No usar nunca los que no piden clave»: se guarda en tu config global
|
|
224
|
+
disableKeyless() {
|
|
225
|
+
setGlobalConfigValue("keyless", false);
|
|
226
|
+
this.config.keyless = false;
|
|
227
|
+
this.reloadProviders();
|
|
228
|
+
}
|
|
229
|
+
// Vuelve a buscar las skills (por si has instalado alguna con la interfaz abierta)
|
|
230
|
+
reloadSkills() {
|
|
231
|
+
this.skills = loadSkills(this.root, this.config);
|
|
232
|
+
this.agent.updateDeps({ skills: this.skills });
|
|
233
|
+
}
|
|
234
|
+
// Ollama, LM Studio o llama.cpp encendidos en este ordenador
|
|
235
|
+
async detectLocalModels() {
|
|
236
|
+
const found = await detectLocal(this.providers);
|
|
237
|
+
this.local = found;
|
|
238
|
+
if (Object.keys(found).length > 0) {
|
|
239
|
+
this.reloadProviders();
|
|
240
|
+
}
|
|
241
|
+
return found;
|
|
242
|
+
}
|
|
243
|
+
buildRanking() {
|
|
244
|
+
if (this.config.ranking?.enabled === false) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
const data = loadRankingData();
|
|
248
|
+
if (!data) {
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
return new Ranking(data, this.config.ranking?.metric || "coding");
|
|
252
|
+
}
|
|
253
|
+
// Busca modelos nuevos y, si tienes clave de LLM Stats, actualiza también sus puntuaciones
|
|
254
|
+
async refreshModels(only) {
|
|
255
|
+
const summary = await refreshModels(this.providers, { only });
|
|
256
|
+
const key = rankingKey();
|
|
257
|
+
if (key && this.config.ranking?.enabled !== false && (!only || only.includes("llm-stats"))) {
|
|
258
|
+
try {
|
|
259
|
+
const result = await refreshRanking(key);
|
|
260
|
+
this.ranking = this.buildRanking();
|
|
261
|
+
this.router.setRanking(this.ranking, this.providers);
|
|
262
|
+
summary.updated.push({ name: "llm-stats", count: result.count, added: [] });
|
|
263
|
+
}
|
|
264
|
+
catch (err) {
|
|
265
|
+
summary.failed.push({ name: "llm-stats", error: err.message });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
this.reloadProviders();
|
|
269
|
+
return summary;
|
|
270
|
+
}
|
|
271
|
+
rankingInfo() {
|
|
272
|
+
if (this.config.ranking?.enabled === false) {
|
|
273
|
+
return "Orden del catálogo (ranking desactivado con \"ranking\": { \"enabled\": false }).";
|
|
274
|
+
}
|
|
275
|
+
if (!this.ranking) {
|
|
276
|
+
return "Orden del catálogo. Para ordenarlos de mejor a peor con los datos de LLM Stats, consigue una clave gratis en https://llm-stats.com/developer y guárdala con «vipcode auth login llm-stats».";
|
|
277
|
+
}
|
|
278
|
+
let update = "con tu clave de LLM Stats se actualizan solos cada día";
|
|
279
|
+
if (!rankingKey()) {
|
|
280
|
+
update = "sin clave ya no se actualizan: vipcode auth login llm-stats";
|
|
281
|
+
}
|
|
282
|
+
return `Ordenados de mejor a peor. Datos de LLM Stats (llm-stats.com) del ${this.ranking.data.updated}; ${update}.`;
|
|
283
|
+
}
|
|
284
|
+
async startMcp(onChange) {
|
|
285
|
+
if (this.mcp) {
|
|
286
|
+
await this.mcp.start(onChange);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
async shutdown() {
|
|
290
|
+
saveSession(this.agent.session);
|
|
291
|
+
await Promise.allSettled([this.lsp?.shutdown(), this.mcp?.close()]);
|
|
292
|
+
}
|
|
293
|
+
primaryAgents() {
|
|
294
|
+
return primaryAgents(this.agents);
|
|
295
|
+
}
|
|
296
|
+
// true si todos los modelos disponibles son de proveedores anónimos (no has puesto ninguna clave)
|
|
297
|
+
onlyAnonymous() {
|
|
298
|
+
if (this.router.slots.length === 0) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
const auth = readAuth();
|
|
302
|
+
return this.router.slots.every((slot) => isAnonymous(slot.provider) && resolveCredentials(slot.provider, auth)?.apiKey === "no-key");
|
|
303
|
+
}
|
|
304
|
+
anonymousNotice() {
|
|
305
|
+
const names = [...new Set(this.router.slots.map((slot) => (slot.provider.label || slot.provider.name).replace(/ \(sin clave\)/, "")))];
|
|
306
|
+
return `Sin claves: usando modelos gratis anónimos (${names.join(", ")}). Tienen pocas peticiones por hora y pueden guardar lo que envías; con /connect añades claves gratis con más límites.`;
|
|
307
|
+
}
|
|
308
|
+
// Siguiente agente principal (Tab)
|
|
309
|
+
cycleAgent(direction = 1) {
|
|
310
|
+
const list = this.primaryAgents();
|
|
311
|
+
const index = list.findIndex((agent) => agent.name === this.agent.agent.name);
|
|
312
|
+
const next = list[(index + direction + list.length) % list.length];
|
|
313
|
+
this.agent.setAgent(next.name);
|
|
314
|
+
return next;
|
|
315
|
+
}
|
|
316
|
+
newSession() {
|
|
317
|
+
saveSession(this.agent.session);
|
|
318
|
+
this.agent.setSession(newSession(this.root));
|
|
319
|
+
}
|
|
320
|
+
loadSession(session) {
|
|
321
|
+
saveSession(this.agent.session);
|
|
322
|
+
this.agent.setSession(session);
|
|
323
|
+
if (session.agent) {
|
|
324
|
+
this.agent.setAgent(session.agent);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// Ejecuta un mensaje con el agente (y opciones de un comando personalizado)
|
|
328
|
+
async runPrompt(input, options, signal) {
|
|
329
|
+
const previousAgent = this.agent.agent.name;
|
|
330
|
+
const previousPin = this.router.pinned;
|
|
331
|
+
try {
|
|
332
|
+
if (options.model) {
|
|
333
|
+
this.router.pinned = options.model;
|
|
334
|
+
}
|
|
335
|
+
if (options.subtask) {
|
|
336
|
+
const report = await this.agent.runSubagent(options.agent || "general", input.text, "comando", this.agent.agent, signal);
|
|
337
|
+
this.agent.events.onText?.(report, 0);
|
|
338
|
+
this.agent.events.onTextEnd?.(0);
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
if (options.agent && this.agents.some((agent) => agent.name === options.agent)) {
|
|
342
|
+
this.agent.setAgent(options.agent);
|
|
343
|
+
}
|
|
344
|
+
await this.agent.run(input, signal);
|
|
345
|
+
}
|
|
346
|
+
finally {
|
|
347
|
+
if (options.agent) {
|
|
348
|
+
this.agent.setAgent(previousAgent);
|
|
349
|
+
}
|
|
350
|
+
if (options.model) {
|
|
351
|
+
this.router.pinned = previousPin;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
helpText() {
|
|
356
|
+
const title = (text) => pc.bold(pc.magenta(text));
|
|
357
|
+
const row = (name, description) => ` ${pc.cyan(name.padEnd(24))} ${description}`;
|
|
358
|
+
const lines = [title("Comandos")];
|
|
359
|
+
for (const command of BUILTIN_COMMANDS) {
|
|
360
|
+
lines.push(row(`/${command.name}${command.args ? ` ${command.args}` : ""}`, command.description));
|
|
361
|
+
}
|
|
362
|
+
if (this.commands.length > 0) {
|
|
363
|
+
lines.push("", `${title("Comandos con plantilla")}${pc.dim(" (los tuyos en ~/.vipcode/commands o .vipcode/commands)")}`);
|
|
364
|
+
for (const command of this.commands) {
|
|
365
|
+
lines.push(row(`/${command.name}`, command.description));
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
const invocable = this.skills.filter((skill) => skill.userInvocable);
|
|
369
|
+
if (invocable.length > 0) {
|
|
370
|
+
lines.push("", `${title("Skills")}${pc.dim(" (el agente las carga solo cuando encajan; también puedes usarlas con /nombre)")}`);
|
|
371
|
+
for (const skill of invocable) {
|
|
372
|
+
lines.push(row(`/${skill.name}`, skill.description.length > 100 ? `${skill.description.slice(0, 99)}…` : skill.description));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
lines.push("", title("Atajos"), row("Enter", "envía · Alt+Enter, Ctrl+J o \\ al final: línea nueva"), row("Tab · Shift+Tab", "cambia de agente (build, plan y los tuyos)"), row("Ctrl+P", "paleta con todos los comandos y skills"), row("↑ ↓", "historial de lo que has escrito"), row("@ruta", "adjunta un fichero, una carpeta o una imagen · Ctrl+V pega una imagen"), row("!comando", "lo ejecuta en la terminal; la salida le llega al agente con tu siguiente mensaje"), row("Esc", "interrumpe al agente (mientras trabaja puedes seguir escribiendo)"), row("Ctrl+C", "interrumpe o borra lo escrito; dos veces seguidas sale"));
|
|
376
|
+
return lines.join("\n");
|
|
377
|
+
}
|
|
378
|
+
modelsTable() {
|
|
379
|
+
const rows = this.router.describe();
|
|
380
|
+
if (rows.length === 0) {
|
|
381
|
+
return "No hay modelos: añade una clave con /connect o «vipcode auth login».";
|
|
382
|
+
}
|
|
383
|
+
// En el orden real de la rotación, con la nota de LLM Stats delante
|
|
384
|
+
const lines = [pc.dim(this.rankingInfo()), pc.dim(this.ranking ? " nota modelo" : " modelo"), ""];
|
|
385
|
+
for (const row of rows) {
|
|
386
|
+
let color = pc.green;
|
|
387
|
+
if (!row.available) {
|
|
388
|
+
color = pc.yellow;
|
|
389
|
+
}
|
|
390
|
+
const vision = row.vision ? " imagen" : "";
|
|
391
|
+
let score = "";
|
|
392
|
+
if (this.ranking) {
|
|
393
|
+
score = `${row.score !== null ? pc.cyan(String(row.score).padStart(4)) : pc.dim(" —")} `;
|
|
394
|
+
}
|
|
395
|
+
lines.push(` ${score}${row.key.padEnd(52)} ${color(row.status)} ${pc.dim(`${shortContext(row.context)}${vision}`)}`);
|
|
396
|
+
}
|
|
397
|
+
lines.push("", pc.dim(`${this.router.availableCount()} de ${rows.length} disponibles ahora · /models refresh busca modelos gratis nuevos`));
|
|
398
|
+
return lines.join("\n");
|
|
399
|
+
}
|
|
400
|
+
providersTable() {
|
|
401
|
+
const auth = readAuth();
|
|
402
|
+
const lines = [];
|
|
403
|
+
for (const provider of this.providers) {
|
|
404
|
+
const credentials = resolveCredentials(provider, auth);
|
|
405
|
+
let state = pc.dim("sin configurar");
|
|
406
|
+
if (credentials && credentials.apiKey === "no-key") {
|
|
407
|
+
state = pc.green("✓ sin clave (anónimo)");
|
|
408
|
+
}
|
|
409
|
+
else if (credentials) {
|
|
410
|
+
state = pc.green(`✓ ${credentials.source} ${maskKey(credentials.apiKey)}`);
|
|
411
|
+
}
|
|
412
|
+
if (provider.local) {
|
|
413
|
+
state = this.local[provider.name] ? pc.green(`✓ encendido (${this.local[provider.name].length} modelos)`) : pc.dim("apagado");
|
|
414
|
+
}
|
|
415
|
+
const env = envNames(provider);
|
|
416
|
+
lines.push(`${pc.bold((provider.label || provider.name).padEnd(36))} ${state}`);
|
|
417
|
+
lines.push(pc.dim(` ${provider.name} · ${provider.models.length} modelos${env.length ? ` · ${env[0]}` : ""}${provider.signup ? ` · ${provider.signup}` : ""}`));
|
|
418
|
+
if (provider.freeInfo) {
|
|
419
|
+
lines.push(pc.dim(` ${provider.freeInfo}`));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
lines.push("", pc.dim(this.rankingInfo()));
|
|
423
|
+
return lines.join("\n");
|
|
424
|
+
}
|
|
425
|
+
// La clave de LLM Stats no es de un proveedor de modelos: solo sirve para actualizar el ranking
|
|
426
|
+
async connectRanking(ui, key) {
|
|
427
|
+
ui.info("LLM Stats ordena los modelos de mejor a peor. Consigue la clave gratis (250 consultas al día) en https://llm-stats.com/developer");
|
|
428
|
+
let apiKey = key;
|
|
429
|
+
if (apiKey === undefined && ui.prompt) {
|
|
430
|
+
const answer = await ui.prompt("Clave de LLM Stats", { mask: true });
|
|
431
|
+
if (answer === null) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
apiKey = answer.trim();
|
|
435
|
+
}
|
|
436
|
+
if (!apiKey) {
|
|
437
|
+
ui.error("No has escrito ninguna clave.");
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
saveAuth("llm-stats", { key: apiKey });
|
|
441
|
+
try {
|
|
442
|
+
const result = await refreshRanking(apiKey);
|
|
443
|
+
this.ranking = this.buildRanking();
|
|
444
|
+
this.router.setRanking(this.ranking, this.providers);
|
|
445
|
+
ui.info(`Ranking actualizado: ${result.count} modelos con puntuación. A partir de ahora se actualiza solo cada día.`);
|
|
446
|
+
}
|
|
447
|
+
catch (err) {
|
|
448
|
+
ui.error(`He guardado la clave, pero no he podido descargar el ranking (${err.message}). Sigo con los datos que trae vipcode.`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// Añade la clave de un proveedor (pidiéndola si la interfaz sabe preguntar)
|
|
452
|
+
async connect(ui, name, key) {
|
|
453
|
+
let providerName = name;
|
|
454
|
+
if (!providerName) {
|
|
455
|
+
if (!ui.select) {
|
|
456
|
+
ui.block(this.providersTable());
|
|
457
|
+
ui.info("Uso: /connect <proveedor> <clave>");
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const auth = readAuth();
|
|
461
|
+
const choice = await ui.select("¿Qué proveedor quieres conectar?", [
|
|
462
|
+
...this.providers
|
|
463
|
+
.filter((provider) => !provider.local)
|
|
464
|
+
.map((provider) => {
|
|
465
|
+
let mark = "";
|
|
466
|
+
if (resolveCredentials(provider, auth)) {
|
|
467
|
+
mark = "✓ ";
|
|
468
|
+
}
|
|
469
|
+
return { label: `${mark}${provider.label || provider.name}`, description: provider.freeInfo, value: provider.name };
|
|
470
|
+
}),
|
|
471
|
+
{
|
|
472
|
+
label: `${rankingKey() ? "✓ " : ""}LLM Stats (ranking de modelos)`,
|
|
473
|
+
description: "clave gratis para que el orden de los modelos se actualice solo cada día",
|
|
474
|
+
value: "llm-stats",
|
|
475
|
+
},
|
|
476
|
+
]);
|
|
477
|
+
if (!choice) {
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
providerName = choice;
|
|
481
|
+
}
|
|
482
|
+
if (providerName === "llm-stats" || providerName === "llmstats") {
|
|
483
|
+
await this.connectRanking(ui, key);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
const provider = this.providers.find((p) => p.name === providerName) || CATALOG.find((p) => p.name === providerName);
|
|
487
|
+
if (!provider) {
|
|
488
|
+
ui.error(`No conozco el proveedor «${providerName}». Mira /providers`);
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
if (provider.local) {
|
|
492
|
+
ui.info(`${provider.label} no necesita clave: arráncalo y vipcode lo detecta solo al empezar (${provider.baseURL}).`);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const anonymous = isAnonymous(provider);
|
|
496
|
+
let apiKey = key;
|
|
497
|
+
let loggedIn = false;
|
|
498
|
+
// Donde el proveedor lo permite oficialmente, iniciar sesión en el navegador crea la clave sola
|
|
499
|
+
if (apiKey === undefined && provider.login === "openrouter" && ui.select) {
|
|
500
|
+
const how = await ui.select(`¿Cómo quieres conectar ${provider.label}?`, [
|
|
501
|
+
{ label: "Iniciar sesión en el navegador (recomendado)", description: "la clave se crea y se guarda sola", value: "login" },
|
|
502
|
+
{ label: "Pegar una clave que ya tengo", description: provider.signup, value: "key" },
|
|
503
|
+
]);
|
|
504
|
+
if (!how) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
if (how === "login") {
|
|
508
|
+
try {
|
|
509
|
+
const baseURL = resolveCredentials(provider)?.baseURL || provider.baseURL;
|
|
510
|
+
const found = await loginOpenRouter(ui, baseURL, { headless: isRemoteSession() });
|
|
511
|
+
if (!found) {
|
|
512
|
+
ui.error("No se ha completado el inicio de sesión.");
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
apiKey = found;
|
|
516
|
+
loggedIn = true;
|
|
517
|
+
}
|
|
518
|
+
catch (err) {
|
|
519
|
+
ui.error(`No he podido iniciar sesión en ${provider.label}: ${err.message}`);
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (apiKey === undefined) {
|
|
525
|
+
if (anonymous) {
|
|
526
|
+
ui.info(`${provider.label} ya funciona sin clave. Con una clave gratis tienes más límites${provider.signup ? ` (${provider.signup})` : ""}.`);
|
|
527
|
+
}
|
|
528
|
+
else if (provider.signup) {
|
|
529
|
+
ui.info(`Consigue la clave gratis en ${provider.signup}`);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
if (apiKey === undefined && ui.prompt) {
|
|
533
|
+
// Con Enter sin escribir nada se abre la página de la clave: allí se inicia sesión y se copia
|
|
534
|
+
const canOpen = !anonymous && Boolean(provider.signup);
|
|
535
|
+
let title = `Clave de ${provider.label}`;
|
|
536
|
+
if (anonymous) {
|
|
537
|
+
title += " (opcional, Enter para seguir sin clave)";
|
|
538
|
+
}
|
|
539
|
+
else if (canOpen) {
|
|
540
|
+
title += " (Enter sin escribir nada abre la página para conseguirla)";
|
|
541
|
+
}
|
|
542
|
+
let answer = await ui.prompt(title, { mask: true });
|
|
543
|
+
if (answer === null) {
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
if (!answer.trim() && canOpen) {
|
|
547
|
+
openBrowser(provider.signup);
|
|
548
|
+
ui.info(`He abierto ${provider.signup} en el navegador: inicia sesión, crea la clave y pégala aquí.`);
|
|
549
|
+
answer = await ui.prompt(`Clave de ${provider.label}`, { mask: true });
|
|
550
|
+
if (answer === null) {
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
apiKey = answer.trim();
|
|
555
|
+
}
|
|
556
|
+
if (!apiKey && anonymous) {
|
|
557
|
+
ui.info(`Sigues usando ${provider.label} sin clave.`);
|
|
558
|
+
return;
|
|
559
|
+
}
|
|
560
|
+
if (!apiKey) {
|
|
561
|
+
ui.error("No has escrito ninguna clave.");
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
const vars = {};
|
|
565
|
+
for (const variable of urlVars(provider)) {
|
|
566
|
+
if (process.env[variable] || readAuth()[provider.name]?.vars?.[variable]) {
|
|
567
|
+
continue;
|
|
568
|
+
}
|
|
569
|
+
if (!ui.prompt) {
|
|
570
|
+
ui.error(`Este proveedor necesita también ${variable}: expórtala o usa «vipcode auth login ${provider.name}».`);
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
const value = await ui.prompt(`Valor de ${variable}`);
|
|
574
|
+
if (!value) {
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
vars[variable] = value.trim();
|
|
578
|
+
}
|
|
579
|
+
saveAuth(provider.name, { key: apiKey, vars });
|
|
580
|
+
this.reloadProviders();
|
|
581
|
+
const check = await checkProvider(this.providers.find((p) => p.name === provider.name) || provider);
|
|
582
|
+
if (!check.ok) {
|
|
583
|
+
ui.error(`${provider.label}: ${check.message}. La he guardado igualmente; cámbiala con /connect ${provider.name}.`);
|
|
584
|
+
}
|
|
585
|
+
else {
|
|
586
|
+
ui.info(`${provider.label} conectado (${check.message}). Guardado en ${AUTH_PATH}.`);
|
|
587
|
+
}
|
|
588
|
+
if (loggedIn && provider.signup) {
|
|
589
|
+
ui.info(`La clave se llama «vipcode» en ${provider.signup}: desde ahí puedes borrarla cuando quieras.`);
|
|
590
|
+
}
|
|
591
|
+
if (provider.discover) {
|
|
592
|
+
const summary = await this.refreshModels([provider.name]);
|
|
593
|
+
const updated = summary.updated.find((entry) => entry.name === provider.name);
|
|
594
|
+
if (updated) {
|
|
595
|
+
ui.info(`${updated.count} modelos gratis disponibles en ${provider.label}.`);
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
// Comandos que empiezan por /
|
|
600
|
+
async handleCommand(text, ui) {
|
|
601
|
+
const [command, ...rest] = text.trim().split(/\s+/);
|
|
602
|
+
const arg = rest.join(" ");
|
|
603
|
+
const name = command.slice(1);
|
|
604
|
+
switch (command) {
|
|
605
|
+
case "/exit":
|
|
606
|
+
case "/quit":
|
|
607
|
+
return { exit: true };
|
|
608
|
+
case "/help":
|
|
609
|
+
ui.block(this.helpText());
|
|
610
|
+
return {};
|
|
611
|
+
case "/models": {
|
|
612
|
+
if (arg === "refresh") {
|
|
613
|
+
ui.info("Buscando modelos gratis nuevos…");
|
|
614
|
+
await this.detectLocalModels();
|
|
615
|
+
const summary = await this.refreshModels();
|
|
616
|
+
for (const entry of summary.updated) {
|
|
617
|
+
let added = "";
|
|
618
|
+
if (entry.added.length > 0) {
|
|
619
|
+
added = ` (nuevos: ${entry.added.slice(0, 5).join(", ")}${entry.added.length > 5 ? "…" : ""})`;
|
|
620
|
+
}
|
|
621
|
+
ui.info(` ${entry.name}: ${entry.count} modelos${added}`);
|
|
622
|
+
}
|
|
623
|
+
for (const entry of summary.failed) {
|
|
624
|
+
ui.notice(` ${entry.name}: ${entry.error}`);
|
|
625
|
+
}
|
|
626
|
+
if (summary.updated.length === 0 && summary.failed.length === 0) {
|
|
627
|
+
ui.info("No hay proveedores con clave que listen modelos.");
|
|
628
|
+
}
|
|
629
|
+
return {};
|
|
630
|
+
}
|
|
631
|
+
if (ui.select && this.router.slots.length > 0) {
|
|
632
|
+
const rows = this.router.describe();
|
|
633
|
+
const choice = await ui.select("Elige el modelo preferido (Esc para salir)", [
|
|
634
|
+
{ label: "auto", description: "rotación automática: los mejores primero según LLM Stats", value: "auto" },
|
|
635
|
+
...rows.map((row) => ({
|
|
636
|
+
label: row.key,
|
|
637
|
+
description: `${row.score !== null ? `★${row.score} · ` : ""}${row.status} · ${shortContext(row.context)}${row.vision ? " · imagen" : ""}`,
|
|
638
|
+
value: row.key,
|
|
639
|
+
})),
|
|
640
|
+
]);
|
|
641
|
+
if (choice) {
|
|
642
|
+
return this.handleCommand(`/model ${choice}`, ui);
|
|
643
|
+
}
|
|
644
|
+
return {};
|
|
645
|
+
}
|
|
646
|
+
ui.block(this.modelsTable());
|
|
647
|
+
return {};
|
|
648
|
+
}
|
|
649
|
+
case "/model": {
|
|
650
|
+
if (arg === "" || arg === "auto") {
|
|
651
|
+
this.router.pinned = null;
|
|
652
|
+
ui.info("Rotación automática.");
|
|
653
|
+
}
|
|
654
|
+
else if (this.router.slots.some((slot) => slot.key === arg)) {
|
|
655
|
+
this.router.pinned = arg;
|
|
656
|
+
ui.info(`Preferido: ${arg}`);
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
ui.error(`No existe ${arg}. Mira /models`);
|
|
660
|
+
}
|
|
661
|
+
return {};
|
|
662
|
+
}
|
|
663
|
+
case "/connect": {
|
|
664
|
+
const [providerName, key] = rest;
|
|
665
|
+
await this.connect(ui, providerName || "", key);
|
|
666
|
+
return {};
|
|
667
|
+
}
|
|
668
|
+
case "/disconnect": {
|
|
669
|
+
if (!arg) {
|
|
670
|
+
ui.error("Uso: /disconnect <proveedor>");
|
|
671
|
+
}
|
|
672
|
+
else if (removeAuth(arg)) {
|
|
673
|
+
this.reloadProviders();
|
|
674
|
+
ui.info(`Clave de ${arg} borrada.`);
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
ui.error(`No había ninguna clave guardada para ${arg} (las variables de entorno no se pueden borrar desde aquí).`);
|
|
678
|
+
}
|
|
679
|
+
return {};
|
|
680
|
+
}
|
|
681
|
+
case "/providers":
|
|
682
|
+
ui.block(this.providersTable());
|
|
683
|
+
return {};
|
|
684
|
+
case "/agents": {
|
|
685
|
+
const list = this.agents.map((agent) => ({
|
|
686
|
+
label: `${agent.name}${agent.name === this.agent.agent.name ? " (actual)" : ""}`,
|
|
687
|
+
description: `${agent.mode === "subagent" ? "subagente · " : ""}${agent.description}`,
|
|
688
|
+
value: agent.name,
|
|
689
|
+
}));
|
|
690
|
+
if (ui.select) {
|
|
691
|
+
const choice = await ui.select("Agentes (Enter cambia al elegido)", list);
|
|
692
|
+
if (choice) {
|
|
693
|
+
return this.handleCommand(`/agent ${choice}`, ui);
|
|
694
|
+
}
|
|
695
|
+
return {};
|
|
696
|
+
}
|
|
697
|
+
ui.block(list.map((item) => ` ${item.label.padEnd(20)} ${item.description}`).join("\n"));
|
|
698
|
+
return {};
|
|
699
|
+
}
|
|
700
|
+
case "/agent": {
|
|
701
|
+
const found = this.agents.find((agent) => agent.name === arg);
|
|
702
|
+
if (!found) {
|
|
703
|
+
ui.error(`No existe el agente «${arg}». Mira /agents`);
|
|
704
|
+
}
|
|
705
|
+
else if (found.mode === "subagent") {
|
|
706
|
+
ui.error(`«${arg}» es un subagente: lo usa el agente principal con la herramienta task.`);
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
this.agent.setAgent(found.name);
|
|
710
|
+
ui.info(`Agente ${found.name}: ${found.description}`);
|
|
711
|
+
}
|
|
712
|
+
return {};
|
|
713
|
+
}
|
|
714
|
+
case "/undo": {
|
|
715
|
+
if (this.agent.running) {
|
|
716
|
+
ui.error("Espera a que termine (o interrúmpelo con Esc) antes de deshacer.");
|
|
717
|
+
return {};
|
|
718
|
+
}
|
|
719
|
+
try {
|
|
720
|
+
const result = await this.agent.undo();
|
|
721
|
+
if (!result) {
|
|
722
|
+
ui.info("No hay nada que deshacer.");
|
|
723
|
+
return {};
|
|
724
|
+
}
|
|
725
|
+
if (result.files.length > 0) {
|
|
726
|
+
ui.info(`Ficheros restaurados: ${result.files.join(", ")}`);
|
|
727
|
+
}
|
|
728
|
+
else {
|
|
729
|
+
ui.info("No había cambios en ficheros.");
|
|
730
|
+
}
|
|
731
|
+
if (result.restoredMessages) {
|
|
732
|
+
ui.info("He quitado ese mensaje de la conversación (/redo lo recupera).");
|
|
733
|
+
ui.setInput?.(result.text);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
catch (err) {
|
|
737
|
+
ui.error(err.message);
|
|
738
|
+
}
|
|
739
|
+
return {};
|
|
740
|
+
}
|
|
741
|
+
case "/redo": {
|
|
742
|
+
try {
|
|
743
|
+
const result = await this.agent.redo();
|
|
744
|
+
if (!result) {
|
|
745
|
+
ui.info("No hay nada que rehacer.");
|
|
746
|
+
}
|
|
747
|
+
else {
|
|
748
|
+
ui.info(`Rehecho${result.files.length > 0 ? `: ${result.files.join(", ")}` : ""}.`);
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
catch (err) {
|
|
752
|
+
ui.error(err.message);
|
|
753
|
+
}
|
|
754
|
+
return {};
|
|
755
|
+
}
|
|
756
|
+
case "/diff": {
|
|
757
|
+
const diff = await this.agent.diff(arg === "all");
|
|
758
|
+
if (!diff.trim()) {
|
|
759
|
+
if (this.snapshots.disabledReason) {
|
|
760
|
+
ui.info(`Sin instantáneas: ${this.snapshots.disabledReason}.`);
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
ui.info("No hay cambios.");
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
else {
|
|
767
|
+
ui.block(colorGitDiff(diff.trimEnd()));
|
|
768
|
+
}
|
|
769
|
+
return {};
|
|
770
|
+
}
|
|
771
|
+
case "/todos":
|
|
772
|
+
ui.block(formatTodos(this.agent.session.todos));
|
|
773
|
+
return {};
|
|
774
|
+
case "/skills": {
|
|
775
|
+
this.reloadSkills();
|
|
776
|
+
if (this.skills.length === 0) {
|
|
777
|
+
ui.info("No tienes skills. Instala alguna con «vipcode skills add <carpeta o repositorio git>» o crea una con «vipcode skills new <nombre>».");
|
|
778
|
+
return {};
|
|
779
|
+
}
|
|
780
|
+
if (ui.select && ui.setInput) {
|
|
781
|
+
const choice = await ui.select("Skills (Enter la prepara para usarla; el agente también las carga solo cuando encajan)", this.skills.map((skill) => ({ label: skill.name, description: `${skill.description} · ${skill.where}`, value: skill.name })));
|
|
782
|
+
const skill = this.skills.find((item) => item.name === choice);
|
|
783
|
+
if (skill && skill.userInvocable) {
|
|
784
|
+
ui.setInput(`/${skill.name} `);
|
|
785
|
+
}
|
|
786
|
+
else if (skill) {
|
|
787
|
+
ui.info(`«${skill.name}» solo la usa el agente cuando la tarea encaja.`);
|
|
788
|
+
}
|
|
789
|
+
return {};
|
|
790
|
+
}
|
|
791
|
+
ui.block(this.skills.map((skill) => ` ${`/${skill.name}`.padEnd(26)} ${skill.description} ${pc.dim(skill.where)}`).join("\n"));
|
|
792
|
+
return {};
|
|
793
|
+
}
|
|
794
|
+
case "/compact": {
|
|
795
|
+
try {
|
|
796
|
+
await this.agent.compact();
|
|
797
|
+
}
|
|
798
|
+
catch (err) {
|
|
799
|
+
ui.error(err.message);
|
|
800
|
+
}
|
|
801
|
+
return {};
|
|
802
|
+
}
|
|
803
|
+
case "/new":
|
|
804
|
+
case "/clear":
|
|
805
|
+
this.newSession();
|
|
806
|
+
ui.info("Sesión nueva.");
|
|
807
|
+
return {};
|
|
808
|
+
case "/sessions": {
|
|
809
|
+
this.lastSessions = listSessions(this.root).slice(0, 30);
|
|
810
|
+
if (this.lastSessions.length === 0) {
|
|
811
|
+
ui.info("No hay sesiones guardadas en esta carpeta.");
|
|
812
|
+
return {};
|
|
813
|
+
}
|
|
814
|
+
if (ui.select) {
|
|
815
|
+
const choice = await ui.select("Sesiones de esta carpeta (Enter carga)", this.lastSessions.map((session) => ({
|
|
816
|
+
label: session.title,
|
|
817
|
+
description: `${new Date(session.updated).toLocaleString()} · ${session.messages.length} mensajes`,
|
|
818
|
+
value: session,
|
|
819
|
+
})));
|
|
820
|
+
if (choice) {
|
|
821
|
+
this.loadSession(choice);
|
|
822
|
+
ui.info(`Cargada: ${choice.title} (${choice.messages.length} mensajes)`);
|
|
823
|
+
}
|
|
824
|
+
return {};
|
|
825
|
+
}
|
|
826
|
+
ui.block(this.lastSessions
|
|
827
|
+
.map((session, i) => ` ${pc.bold(String(i + 1).padStart(2))} ${session.title} ${pc.dim(new Date(session.updated).toLocaleString())}`)
|
|
828
|
+
.join("\n"));
|
|
829
|
+
return {};
|
|
830
|
+
}
|
|
831
|
+
case "/load": {
|
|
832
|
+
const chosen = this.lastSessions[Number(arg) - 1];
|
|
833
|
+
if (chosen) {
|
|
834
|
+
this.loadSession(chosen);
|
|
835
|
+
ui.info(`Cargada: ${chosen.title} (${chosen.messages.length} mensajes)`);
|
|
836
|
+
}
|
|
837
|
+
else {
|
|
838
|
+
ui.error("Número no válido. Usa /sessions primero.");
|
|
839
|
+
}
|
|
840
|
+
return {};
|
|
841
|
+
}
|
|
842
|
+
case "/delete": {
|
|
843
|
+
const chosen = this.lastSessions[Number(arg) - 1];
|
|
844
|
+
if (chosen && deleteSession(chosen.id)) {
|
|
845
|
+
ui.info(`Borrada: ${chosen.title}`);
|
|
846
|
+
this.lastSessions.splice(Number(arg) - 1, 1);
|
|
847
|
+
}
|
|
848
|
+
else {
|
|
849
|
+
ui.error("Número no válido. Usa /sessions primero.");
|
|
850
|
+
}
|
|
851
|
+
return {};
|
|
852
|
+
}
|
|
853
|
+
case "/export": {
|
|
854
|
+
saveSession(this.agent.session);
|
|
855
|
+
if (this.agent.session.messages.length === 0) {
|
|
856
|
+
ui.info("La conversación está vacía.");
|
|
857
|
+
}
|
|
858
|
+
else {
|
|
859
|
+
ui.info(`Conversación guardada en ${saveExport(this.agent.session)}`);
|
|
860
|
+
}
|
|
861
|
+
return {};
|
|
862
|
+
}
|
|
863
|
+
case "/usage": {
|
|
864
|
+
const usage = this.agent.session.usage;
|
|
865
|
+
ui.info(`Tokens en esta sesión: ${usage.prompt} de entrada y ${usage.completion} de salida (todo gratis). Última petición: ~${this.agent.lastTokens} tokens con ${this.agent.lastSlot || "—"}.`);
|
|
866
|
+
return {};
|
|
867
|
+
}
|
|
868
|
+
case "/mcp": {
|
|
869
|
+
if (!this.mcp) {
|
|
870
|
+
ui.info('No hay servidores MCP. Añádelos en "mcp" de tu config (mira el README).');
|
|
871
|
+
return {};
|
|
872
|
+
}
|
|
873
|
+
ui.block(this.mcp
|
|
874
|
+
.status()
|
|
875
|
+
.map((server) => ` ${server.name.padEnd(20)} ${server.state}${server.state === "conectado" ? ` (${server.tools} herramientas)` : ""}${server.error ? ` ${pc.red(server.error)}` : ""}`)
|
|
876
|
+
.join("\n"));
|
|
877
|
+
return {};
|
|
878
|
+
}
|
|
879
|
+
case "/lsp": {
|
|
880
|
+
if (!this.lsp) {
|
|
881
|
+
ui.info("Los diagnósticos LSP están desactivados en la configuración.");
|
|
882
|
+
return {};
|
|
883
|
+
}
|
|
884
|
+
const status = await this.lsp.status();
|
|
885
|
+
const available = await this.lsp.availableServers();
|
|
886
|
+
const lines = [];
|
|
887
|
+
if (status.length > 0) {
|
|
888
|
+
lines.push("En marcha:", ...status.map((row) => ` ${row.id.padEnd(28)} ${row.state} (${row.files} ficheros)`));
|
|
889
|
+
}
|
|
890
|
+
else {
|
|
891
|
+
lines.push("Ninguno en marcha todavía (arrancan solos al editar un fichero).");
|
|
892
|
+
}
|
|
893
|
+
lines.push("", "Disponibles en este ordenador:", ...available.map((line) => ` ${line}`));
|
|
894
|
+
ui.block(lines.join("\n"));
|
|
895
|
+
return {};
|
|
896
|
+
}
|
|
897
|
+
case "/yolo": {
|
|
898
|
+
const value = arg === "" ? !this.yolo : arg === "on";
|
|
899
|
+
this.setYolo(value);
|
|
900
|
+
if (value) {
|
|
901
|
+
ui.notice("Modo yolo: apruebo todo sin preguntar en esta sesión (las reglas «deny» se respetan).");
|
|
902
|
+
}
|
|
903
|
+
else {
|
|
904
|
+
ui.info("Modo yolo desactivado: vuelvo a preguntar.");
|
|
905
|
+
}
|
|
906
|
+
return {};
|
|
907
|
+
}
|
|
908
|
+
case "/update": {
|
|
909
|
+
const result = await updateVipcode((text) => ui.info(text));
|
|
910
|
+
if (result.status === "error") {
|
|
911
|
+
ui.error(result.message);
|
|
912
|
+
}
|
|
913
|
+
else if (result.status === "manual") {
|
|
914
|
+
ui.notice(result.message);
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
ui.info(result.message);
|
|
918
|
+
}
|
|
919
|
+
return {};
|
|
920
|
+
}
|
|
921
|
+
case "/reset-limits":
|
|
922
|
+
this.router.resetCooldowns();
|
|
923
|
+
ui.info("Pausas borradas.");
|
|
924
|
+
return {};
|
|
925
|
+
case "/config":
|
|
926
|
+
ui.block([
|
|
927
|
+
`Config global: ${CONFIG_PATH}`,
|
|
928
|
+
"Config proyecto: vipcode.json o .vipcode/config.json",
|
|
929
|
+
`Claves: ${AUTH_PATH}`,
|
|
930
|
+
`Estado: ${STATE_PATH}`,
|
|
931
|
+
`Carpeta: ${HOME_DIR}`,
|
|
932
|
+
].join("\n"));
|
|
933
|
+
return {};
|
|
934
|
+
}
|
|
935
|
+
const custom = this.commands.find((item) => item.name === name);
|
|
936
|
+
if (custom) {
|
|
937
|
+
const expanded = await expandCommand(custom, arg, this.root);
|
|
938
|
+
return { run: { input: { text: expanded }, options: { agent: custom.agent, model: custom.model, subtask: custom.subtask } } };
|
|
939
|
+
}
|
|
940
|
+
// /nombre-de-skill tarea: se le da la skill ya cargada junto con la tarea
|
|
941
|
+
const skill = this.skills.find((item) => item.name === name && item.userInvocable);
|
|
942
|
+
if (skill) {
|
|
943
|
+
const task = arg ? `\n\nTarea: ${arg}` : "";
|
|
944
|
+
return { run: { input: { text: `Usa la skill «${skill.name}» para esto.${task}\n\n${skillContent(skill)}` }, options: {} } };
|
|
945
|
+
}
|
|
946
|
+
const agent = this.primaryAgents().find((item) => item.name === name);
|
|
947
|
+
if (agent) {
|
|
948
|
+
this.agent.setAgent(agent.name);
|
|
949
|
+
ui.info(`Agente ${agent.name}: ${agent.description}`);
|
|
950
|
+
if (arg) {
|
|
951
|
+
return { run: { input: { text: arg }, options: {} } };
|
|
952
|
+
}
|
|
953
|
+
return {};
|
|
954
|
+
}
|
|
955
|
+
ui.error(`Comando desconocido: ${command}. Escribe /help`);
|
|
956
|
+
return {};
|
|
957
|
+
}
|
|
958
|
+
// Sugerencias para el autocompletado de /comandos
|
|
959
|
+
commandSuggestions() {
|
|
960
|
+
const list = BUILTIN_COMMANDS.map((command) => ({
|
|
961
|
+
name: command.name,
|
|
962
|
+
description: command.description,
|
|
963
|
+
needsArgs: Boolean(command.args && command.args.startsWith("<")),
|
|
964
|
+
}));
|
|
965
|
+
for (const command of this.commands) {
|
|
966
|
+
list.push({ name: command.name, description: command.description, needsArgs: false });
|
|
967
|
+
}
|
|
968
|
+
for (const skill of this.skills) {
|
|
969
|
+
if (skill.userInvocable && !list.some((item) => item.name === skill.name)) {
|
|
970
|
+
list.push({ name: skill.name, description: `skill: ${skill.description}`, needsArgs: false });
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
for (const agent of this.primaryAgents()) {
|
|
974
|
+
if (!list.some((item) => item.name === agent.name)) {
|
|
975
|
+
list.push({ name: agent.name, description: `agente: ${agent.description}`, needsArgs: false });
|
|
976
|
+
}
|
|
977
|
+
}
|
|
978
|
+
return list;
|
|
979
|
+
}
|
|
980
|
+
// !comando: lo ejecuta en la carpeta del proyecto (lo escribes tú, así que no pide permiso)
|
|
981
|
+
// y guarda la salida para que el agente la vea con tu siguiente mensaje
|
|
982
|
+
runShell(command, onOutput, signal) {
|
|
983
|
+
return new Promise((resolve) => {
|
|
984
|
+
let shell = process.env.SHELL || "bash";
|
|
985
|
+
let args = ["-lc", command];
|
|
986
|
+
if (process.platform === "win32") {
|
|
987
|
+
shell = "cmd.exe";
|
|
988
|
+
args = ["/c", command];
|
|
989
|
+
}
|
|
990
|
+
const child = spawn(shell, args, { cwd: this.root, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat" } });
|
|
991
|
+
let output = "";
|
|
992
|
+
const add = (data) => {
|
|
993
|
+
const text = data.toString();
|
|
994
|
+
output += text;
|
|
995
|
+
onOutput?.(text);
|
|
996
|
+
};
|
|
997
|
+
child.stdout.on("data", add);
|
|
998
|
+
child.stderr.on("data", add);
|
|
999
|
+
const timer = setTimeout(() => child.kill("SIGTERM"), this.config.bashTimeoutMs);
|
|
1000
|
+
const stop = () => child.kill("SIGTERM");
|
|
1001
|
+
signal?.addEventListener("abort", stop, { once: true });
|
|
1002
|
+
let done = false;
|
|
1003
|
+
const finish = (code) => {
|
|
1004
|
+
// "error" y "close" pueden llegar los dos
|
|
1005
|
+
if (done) {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
done = true;
|
|
1009
|
+
clearTimeout(timer);
|
|
1010
|
+
signal?.removeEventListener("abort", stop);
|
|
1011
|
+
const clean = output.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "").trimEnd();
|
|
1012
|
+
this.agent.addNote(`(El usuario ha ejecutado en la terminal \`${command}\`, código de salida ${code ?? "desconocido"}:\n\`\`\`\n${truncate(clean, 8000)}\n\`\`\`)\n\n`);
|
|
1013
|
+
resolve({ output: clean, code });
|
|
1014
|
+
};
|
|
1015
|
+
child.on("error", (err) => {
|
|
1016
|
+
output += `No se pudo ejecutar: ${err.message}`;
|
|
1017
|
+
finish(null);
|
|
1018
|
+
});
|
|
1019
|
+
child.on("close", (code) => finish(code));
|
|
1020
|
+
});
|
|
1021
|
+
}
|
|
1022
|
+
subagentNames() {
|
|
1023
|
+
return subagents(this.agents).map((agent) => agent.name);
|
|
1024
|
+
}
|
|
1025
|
+
}
|