vipcode 0.2.2 → 0.2.4

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/dist/router.js CHANGED
@@ -222,6 +222,76 @@ function prepareMessages(slot, messages) {
222
222
  return { ...message, tool_calls: toolCalls };
223
223
  });
224
224
  }
225
+ function utcDay(time = Date.now()) {
226
+ return new Date(time).toISOString().slice(0, 10);
227
+ }
228
+ const HOUR_MS = 60 * 60 * 1000;
229
+ // Cuándo se recupera un límite: segundos que faltan ("33011.38"), duración ("2m59.56s") o fecha en segundos o milisegundos
230
+ function parseReset(value, now) {
231
+ if (!value) {
232
+ return null;
233
+ }
234
+ const text = value.trim();
235
+ if (/^\d+(\.\d+)?$/.test(text)) {
236
+ const n = Number(text);
237
+ if (n > 1e12) {
238
+ return n;
239
+ }
240
+ if (n > 1e9) {
241
+ return n * 1000;
242
+ }
243
+ return now + n * 1000;
244
+ }
245
+ const ms = parseDuration(text);
246
+ if (ms > 0) {
247
+ return now + ms;
248
+ }
249
+ const date = Date.parse(text);
250
+ return Number.isNaN(date) ? null : date;
251
+ }
252
+ // Las cabeceras x-ratelimit-* de una respuesta (o de un error): peticiones y tokens que quedan
253
+ export function readLimitHeaders(headers, provider, now = Date.now()) {
254
+ if (!headers || typeof headers !== "object") {
255
+ return null;
256
+ }
257
+ const get = (name) => {
258
+ const h = headers;
259
+ if (typeof h.get === "function") {
260
+ return h.get(name);
261
+ }
262
+ const value = h[name] ?? h[name.toLowerCase()];
263
+ return typeof value === "string" ? value : null;
264
+ };
265
+ const read = (sets) => {
266
+ for (const [base, period] of sets) {
267
+ const remaining = get(`x-ratelimit-remaining${base}`);
268
+ if (remaining === null || remaining.trim() === "" || Number.isNaN(Number(remaining))) {
269
+ continue;
270
+ }
271
+ const limit = get(`x-ratelimit-limit${base}`);
272
+ return {
273
+ limit: limit !== null && !Number.isNaN(Number(limit)) ? Number(limit) : null,
274
+ remaining: Number(remaining),
275
+ resetAt: parseReset(get(`x-ratelimit-reset${base}`), now),
276
+ period,
277
+ };
278
+ }
279
+ return undefined;
280
+ };
281
+ const requests = read([
282
+ ["-requests-day", "day"],
283
+ ["-requests", provider.rateHeaders?.requests ?? null],
284
+ ["", null],
285
+ ]);
286
+ const tokens = read([
287
+ ["-tokens-minute", "minute"],
288
+ ["-tokens", provider.rateHeaders?.tokens ?? null],
289
+ ]);
290
+ if (!requests && !tokens) {
291
+ return null;
292
+ }
293
+ return { at: now, requests, tokens };
294
+ }
225
295
  export class Router {
226
296
  config;
227
297
  ranking;
@@ -230,6 +300,10 @@ export class Router {
230
300
  cooldowns = {};
231
301
  unsupported = {};
232
302
  noStreamUsage = {};
303
+ // Para /limites (se guardan en state.json con las pausas)
304
+ usage = { day: utcDay(), providers: {} };
305
+ limits = {};
306
+ recent = {};
233
307
  // Slots descartados durante esta ejecución (clave mala…)
234
308
  skipped = new Set();
235
309
  // Si no es null, se usa ese slot primero
@@ -330,6 +404,11 @@ export class Router {
330
404
  this.cooldowns = data.cooldowns || {};
331
405
  this.unsupported = data.unsupported || {};
332
406
  this.noStreamUsage = data.noStreamUsage || {};
407
+ if (data.usage?.day === utcDay() && data.usage.providers) {
408
+ this.usage = data.usage;
409
+ }
410
+ this.limits = data.limits || {};
411
+ this.recent = data.recent || {};
333
412
  }
334
413
  catch {
335
414
  this.cooldowns = {};
@@ -347,8 +426,27 @@ export class Router {
347
426
  }
348
427
  }
349
428
  }
429
+ // Lo que dijeron los proveedores hace más de dos días ya no sirve
430
+ for (const [key, snapshot] of Object.entries(this.limits)) {
431
+ if (now - snapshot.at > 2 * DAY_MS) {
432
+ delete this.limits[key];
433
+ }
434
+ }
435
+ for (const [name, times] of Object.entries(this.recent)) {
436
+ this.recent[name] = times.filter((time) => now - time < HOUR_MS);
437
+ if (this.recent[name].length === 0) {
438
+ delete this.recent[name];
439
+ }
440
+ }
350
441
  try {
351
- const state = { cooldowns: this.cooldowns, unsupported: this.unsupported, noStreamUsage: this.noStreamUsage };
442
+ const state = {
443
+ cooldowns: this.cooldowns,
444
+ unsupported: this.unsupported,
445
+ noStreamUsage: this.noStreamUsage,
446
+ usage: this.usage,
447
+ limits: this.limits,
448
+ recent: this.recent,
449
+ };
352
450
  fs.writeFileSync(STATE_PATH, JSON.stringify(state, null, 2));
353
451
  }
354
452
  catch {
@@ -361,6 +459,36 @@ export class Router {
361
459
  }
362
460
  this.saveState();
363
461
  }
462
+ // Uso de hoy (si ha cambiado el día, empieza de cero)
463
+ today() {
464
+ if (this.usage.day !== utcDay()) {
465
+ this.usage = { day: utcDay(), providers: {} };
466
+ }
467
+ return this.usage;
468
+ }
469
+ countRequest(slot) {
470
+ const day = this.today();
471
+ const entry = (day.providers[slot.provider.name] ??= { requests: 0, tokens: 0, models: {} });
472
+ entry.requests++;
473
+ entry.models[slot.model.id] = (entry.models[slot.model.id] || 0) + 1;
474
+ const now = Date.now();
475
+ const times = (this.recent[slot.provider.name] || []).filter((time) => now - time < HOUR_MS);
476
+ times.push(now);
477
+ // Basta con saber cuántas van en la última hora; más de 1.000 no hace falta guardarlas
478
+ this.recent[slot.provider.name] = times.slice(-1000);
479
+ }
480
+ countTokens(slot, usage) {
481
+ const entry = this.today().providers[slot.provider.name];
482
+ if (entry && usage) {
483
+ entry.tokens += usage.prompt + usage.completion;
484
+ }
485
+ }
486
+ noteLimits(slot, headers) {
487
+ const snapshot = readLimitHeaders(headers, slot.provider);
488
+ if (snapshot) {
489
+ this.limits[slot.key] = snapshot;
490
+ }
491
+ }
364
492
  slotFor(key) {
365
493
  return this.slots.find((slot) => slot.key === key);
366
494
  }
@@ -512,6 +640,7 @@ export class Router {
512
640
  const slot = list[0];
513
641
  let streamedText = false;
514
642
  handlers.onAttempt?.(slot.key);
643
+ this.countRequest(slot);
515
644
  try {
516
645
  const result = await this.streamOnce(slot, { ...request, messages }, signal, {
517
646
  onText: (text) => {
@@ -524,10 +653,14 @@ export class Router {
524
653
  handlers.onNotice?.(`usando ${slot.key}`);
525
654
  }
526
655
  this.lastSlot = slot;
656
+ this.countTokens(slot, result.usage);
657
+ this.saveState();
527
658
  return { ...result, slot };
528
659
  }
529
660
  catch (err) {
661
+ this.noteLimits(slot, err?.headers);
530
662
  if (isAbort(err, signal)) {
663
+ this.saveState();
531
664
  throw err;
532
665
  }
533
666
  const info = classify(err);
@@ -583,6 +716,7 @@ export class Router {
583
716
  excluded.add(slot.key);
584
717
  action = "pruebo otro";
585
718
  }
719
+ this.saveState();
586
720
  let prefix = "";
587
721
  if (streamedText) {
588
722
  prefix = "\n";
@@ -625,7 +759,10 @@ export class Router {
625
759
  const byIndex = new Map();
626
760
  const byId = new Map();
627
761
  try {
628
- const stream = (await slot.client.chat.completions.create(params, { signal: combined }));
762
+ // withResponse para leer las cabeceras: algunos proveedores dicen ahí cuántas peticiones te quedan
763
+ const { data, response } = await slot.client.chat.completions.create(params, { signal: combined }).withResponse();
764
+ this.noteLimits(slot, response.headers);
765
+ const stream = data;
629
766
  for await (const chunk of stream) {
630
767
  bump();
631
768
  // Algunos proveedores mandan el error dentro del stream
package/dist/shell.js ADDED
@@ -0,0 +1,107 @@
1
+ import { spawn } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import crossSpawn from "cross-spawn";
5
+ // Lanza un programa. En Windows npm instala los programas como .cmd, que spawn no puede lanzar directamente:
6
+ // cross-spawn los lanza a través de cmd.exe con las comillas bien puestas
7
+ export function spawnProgram(command, args, options) {
8
+ if (process.platform === "win32") {
9
+ return crossSpawn(command, args, options);
10
+ }
11
+ return spawn(command, args, options);
12
+ }
13
+ // Para un programa y todo lo que haya lanzado. En Windows no hay grupos de procesos: taskkill /T recorre los hijos
14
+ export function killTree(child) {
15
+ if (process.platform === "win32" && child.pid) {
16
+ const taskkill = spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true });
17
+ taskkill.on("error", () => child.kill());
18
+ return;
19
+ }
20
+ child.kill("SIGTERM");
21
+ }
22
+ // Separa los argumentos respetando las comillas: a "b c" d → [a, b c, d]
23
+ export function splitArgs(text) {
24
+ const result = [];
25
+ const pattern = /"([^"]*)"|'([^']*)'|(\S+)/g;
26
+ let match;
27
+ while ((match = pattern.exec(text)) !== null) {
28
+ result.push(match[1] ?? match[2] ?? match[3]);
29
+ }
30
+ return result;
31
+ }
32
+ function onPath(name, env) {
33
+ for (const dir of (env.PATH || env.Path || "").split(path.delimiter)) {
34
+ if (dir && fs.existsSync(path.join(dir, name))) {
35
+ return path.join(dir, name);
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ // El bash de Git para Windows. No vale el de C:\Windows\System32, que es el de WSL y trabaja en otro sistema de ficheros
41
+ function gitBash(env) {
42
+ const candidates = [];
43
+ const git = onPath("git.exe", env);
44
+ if (git) {
45
+ // git.exe está en …\Git\cmd, …\Git\bin o …\Git\mingw64\bin
46
+ candidates.push(path.resolve(path.dirname(git), "..", "bin", "bash.exe"), path.resolve(path.dirname(git), "..", "..", "bin", "bash.exe"));
47
+ }
48
+ const local = env.LOCALAPPDATA ? path.join(env.LOCALAPPDATA, "Programs") : undefined;
49
+ for (const base of [env.ProgramFiles, env.ProgramW6432, env["ProgramFiles(x86)"], local]) {
50
+ if (base) {
51
+ candidates.push(path.join(base, "Git", "bin", "bash.exe"));
52
+ }
53
+ }
54
+ return candidates.find((file) => fs.existsSync(file)) || null;
55
+ }
56
+ function bash(file, name, login) {
57
+ // CHERE_INVOKING: el bash de Git para Windows no se va a tu carpeta personal al arrancar
58
+ return { name, file, args: (command) => [login ? "-lc" : "-c", command], env: { CHERE_INVOKING: "1" } };
59
+ }
60
+ function powershell(file) {
61
+ return {
62
+ name: "PowerShell",
63
+ file,
64
+ // En UTF-8 (si no, las tildes salen rotas), sin barras de progreso y con el código de salida del último programa.
65
+ // Va codificado para que las comillas del comando lleguen tal cual
66
+ args: (command) => {
67
+ const script = [
68
+ "$ProgressPreference = 'SilentlyContinue'",
69
+ "[Console]::OutputEncoding = New-Object System.Text.UTF8Encoding $false",
70
+ command,
71
+ "$vipcodeOk = $?",
72
+ "if ($LASTEXITCODE) { exit $LASTEXITCODE }",
73
+ "if (-not $vipcodeOk) { exit 1 }",
74
+ ].join("\n");
75
+ return ["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "RemoteSigned", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")];
76
+ },
77
+ env: {},
78
+ };
79
+ }
80
+ function cmd(file) {
81
+ return { name: "cmd", file, args: (command) => ["/d", "/s", "/c", command], env: {} };
82
+ }
83
+ // La shell de los comandos: bash en macOS y Linux; en Windows, el bash de Git para Windows si lo tienes (los modelos
84
+ // escriben comandos de bash) y si no, PowerShell. VIPCODE_SHELL permite elegir otra (bash, pwsh, powershell o cmd)
85
+ export function detectShell(env = process.env, platform = process.platform) {
86
+ const custom = env.VIPCODE_SHELL;
87
+ if (custom) {
88
+ const base = path.basename(custom).toLowerCase().replace(/\.exe$/, "");
89
+ if (base === "pwsh" || base === "powershell") {
90
+ return powershell(custom);
91
+ }
92
+ if (base === "cmd") {
93
+ return cmd(custom);
94
+ }
95
+ return bash(custom, base, platform !== "win32");
96
+ }
97
+ if (platform !== "win32") {
98
+ return bash("bash", "bash", true);
99
+ }
100
+ const found = gitBash(env);
101
+ return found ? bash(found, "Git Bash", false) : powershell(onPath("pwsh.exe", env) || "powershell.exe");
102
+ }
103
+ let cached = null;
104
+ export function commandShell() {
105
+ cached ??= detectShell();
106
+ return cached;
107
+ }
package/dist/tools.js CHANGED
@@ -4,7 +4,8 @@ import { spawn, spawnSync } from "node:child_process";
4
4
  import fg from "fast-glob";
5
5
  import { isImagePath, toDataUrl } from "./images.js";
6
6
  import { stripControl } from "./sanitize.js";
7
- import { protectedDirs } from "./paths.js";
7
+ import { isInside, protectedDirs } from "./paths.js";
8
+ import { commandShell, killTree } from "./shell.js";
8
9
  const IGNORE = ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/build/**", "**/.next/**", "**/vendor/**"];
9
10
  const MAX_OUTPUT = 20000;
10
11
  export function truncate(text, max = MAX_OUTPUT) {
@@ -14,11 +15,11 @@ export function truncate(text, max = MAX_OUTPUT) {
14
15
  const half = Math.floor(max / 2);
15
16
  return `${text.slice(0, half)}\n\n[… ${text.length - max} caracteres omitidos …]\n\n${text.slice(-half)}`;
16
17
  }
17
- // Convierte una ruta relativa en absoluta y comprueba que no sale del proyecto
18
- // Ruta real siguiendo los enlaces simbólicos, aunque el destino (o parte de él) todavía no exista
18
+ // Ruta real siguiendo los enlaces simbólicos, aunque el destino (o parte de él) todavía no exista.
19
+ // La versión nativa devuelve además las mayúsculas reales de cada carpeta
19
20
  function realPathOf(file, depth = 0) {
20
21
  try {
21
- return fs.realpathSync(file);
22
+ return fs.realpathSync.native(file);
22
23
  }
23
24
  catch {
24
25
  // no existe (todavía) o es un enlace roto
@@ -39,25 +40,26 @@ function realPathOf(file, depth = 0) {
39
40
  }
40
41
  return path.join(realPathOf(parent, depth + 1), path.basename(file));
41
42
  }
43
+ // Convierte una ruta relativa en absoluta y comprueba que no sale del proyecto
42
44
  export function resolvePath(root, p, options = {}) {
43
45
  let input = p;
44
46
  if (!input) {
45
47
  input = ".";
46
48
  }
47
49
  const abs = path.resolve(root, input);
48
- if (abs !== root && !abs.startsWith(root + path.sep)) {
50
+ if (!isInside(abs, root)) {
49
51
  throw new Error(`La ruta ${p} está fuera del proyecto (${root})`);
50
52
  }
51
53
  // Un enlace simbólico dentro del proyecto tampoco puede llevar fuera (por ejemplo a ~/.ssh o a tus claves)
52
54
  const real = realPathOf(abs);
53
55
  const realRoot = realPathOf(root);
54
- if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
56
+ if (!isInside(real, realRoot)) {
55
57
  throw new Error(`La ruta ${p} es un enlace que lleva fuera del proyecto (${real})`);
56
58
  }
57
59
  // vipcode no puede tocarse a sí mismo: ni sus datos (claves, configuración) ni, para escribir, su instalación
58
60
  for (const { dir, read } of protectedDirs()) {
59
61
  const realDir = realPathOf(dir);
60
- if ((read || options.write) && (real === realDir || real.startsWith(realDir + path.sep))) {
62
+ if ((read || options.write) && isInside(real, realDir)) {
61
63
  if (read) {
62
64
  throw new Error(`La ruta ${p} está en la carpeta de datos de vipcode (${dir}), que guarda tus claves y tu configuración: no se puede leer ni cambiar desde aquí.`);
63
65
  }
@@ -78,12 +80,17 @@ function protectedIgnore(root) {
78
80
  }
79
81
  return patterns;
80
82
  }
83
+ // Ruta relativa al proyecto, siempre con / (también en Windows): es lo que ve el modelo y lo que comparan los permisos
81
84
  export function rel(root, abs) {
82
85
  const r = path.relative(root, abs);
83
86
  if (r === "") {
84
87
  return ".";
85
88
  }
86
- return r;
89
+ return r.split(path.sep).join("/");
90
+ }
91
+ // Los patrones glob van siempre con /; en Windows el modelo a veces los escribe con \
92
+ function globPattern(pattern) {
93
+ return process.platform === "win32" ? pattern.replace(/\\/g, "/") : pattern;
87
94
  }
88
95
  function rejected(reply, what) {
89
96
  if (reply.feedback) {
@@ -246,7 +253,7 @@ function readNumbered(file, label, offset, maxLines) {
246
253
  if (buffer.includes(0)) {
247
254
  return `${label} parece un fichero binario; no se muestra.`;
248
255
  }
249
- const lines = buffer.toString("utf8").split("\n");
256
+ const lines = buffer.toString("utf8").split(/\r?\n/);
250
257
  let start = 1;
251
258
  if (offset && offset > 0) {
252
259
  start = Math.floor(offset);
@@ -545,7 +552,7 @@ const globTool = {
545
552
  readOnly: true,
546
553
  async run(args, ctx) {
547
554
  const cwd = resolvePath(ctx.root, args.path);
548
- const files = await fg(String(args.pattern), { cwd, ignore: [...IGNORE, ...protectedIgnore(cwd)], dot: true, onlyFiles: true, followSymbolicLinks: false });
555
+ const files = await fg(globPattern(String(args.pattern)), { cwd, ignore: [...IGNORE, ...protectedIgnore(cwd)], dot: true, onlyFiles: true, followSymbolicLinks: false });
549
556
  if (files.length === 0) {
550
557
  return "Sin resultados.";
551
558
  }
@@ -610,12 +617,16 @@ const grepTool = {
610
617
  async run(args, ctx) {
611
618
  const pattern = String(args.pattern);
612
619
  if (hasRipgrep()) {
613
- const rgArgs = ["--line-number", "--no-heading", "--color", "never", "--with-filename", "--max-columns", "300", "--max-columns-preview", "-e", pattern];
620
+ // --crlf: en los ficheros con saltos de línea de Windows, $ también encaja antes del \r\n
621
+ const rgArgs = ["--line-number", "--no-heading", "--color", "never", "--with-filename", "--crlf", "--max-columns", "300", "--max-columns-preview", "-e", pattern];
622
+ if (process.platform === "win32") {
623
+ rgArgs.push("--path-separator", "/");
624
+ }
614
625
  if (args.ignore_case) {
615
626
  rgArgs.push("-i");
616
627
  }
617
628
  if (args.include) {
618
- rgArgs.push("-g", String(args.include));
629
+ rgArgs.push("-g", globPattern(String(args.include)));
619
630
  }
620
631
  for (const pattern of protectedIgnore(ctx.root)) {
621
632
  rgArgs.push("-g", `!${pattern}`);
@@ -629,7 +640,7 @@ const grepTool = {
629
640
  const lines = found.stdout
630
641
  .split("\n")
631
642
  .filter((line) => line.length > 0)
632
- .map((line) => line.replace(/^\.\//, "").replace(/^([^:]+):(\d+):/, "$1:$2: "));
643
+ .map((line) => line.replace(/\r$/, "").replace(/^\.\//, "").replace(/^([^:]+):(\d+):/, "$1:$2: "));
633
644
  if (lines.length > 200) {
634
645
  return `${lines.slice(0, 200).join("\n")}\n[… limitado a 200 resultados]`;
635
646
  }
@@ -649,7 +660,7 @@ const grepTool = {
649
660
  }
650
661
  let include = "**/*";
651
662
  if (args.include) {
652
- include = String(args.include);
663
+ include = globPattern(String(args.include));
653
664
  }
654
665
  const files = await fg(include, { cwd: ctx.root, ignore: [...IGNORE, ...protectedIgnore(ctx.root)], dot: false, onlyFiles: true, followSymbolicLinks: false });
655
666
  const results = [];
@@ -669,7 +680,7 @@ const grepTool = {
669
680
  if (buffer.includes(0)) {
670
681
  continue;
671
682
  }
672
- const lines = buffer.toString("utf8").split("\n");
683
+ const lines = buffer.toString("utf8").split(/\r?\n/);
673
684
  for (let i = 0; i < lines.length; i++) {
674
685
  if (regex.test(lines[i])) {
675
686
  results.push(`${file}:${i + 1}: ${lines[i].slice(0, 300)}`);
@@ -719,24 +730,24 @@ const bashTool = {
719
730
  timeout = Math.min(args.timeout_ms, 600000);
720
731
  }
721
732
  return await new Promise((resolve) => {
722
- let shell = "bash";
723
- let shellArgs = ["-lc", command];
724
- if (process.platform === "win32") {
725
- shell = "cmd.exe";
726
- shellArgs = ["/c", command];
727
- }
728
- const child = spawn(shell, shellArgs, {
733
+ const shell = commandShell();
734
+ const child = spawn(shell.file, shell.args(command), {
729
735
  cwd: ctx.root,
730
- env: { ...process.env, ...gitSafetyEnv(), VIPCODE: "1", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", PAGER: "cat" },
736
+ env: { ...process.env, ...shell.env, ...gitSafetyEnv(), VIPCODE: "1", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "cat", PAGER: "cat" },
731
737
  stdio: ["ignore", "pipe", "pipe"],
732
738
  // Grupo de procesos propio para poder matar también a los hijos (npm test, servidores…)
733
739
  detached: process.platform !== "win32",
740
+ windowsHide: true,
734
741
  });
735
742
  let output = "";
736
743
  let timedOut = false;
737
744
  const kill = () => {
745
+ if (process.platform === "win32") {
746
+ killTree(child);
747
+ return;
748
+ }
738
749
  try {
739
- if (process.platform !== "win32" && child.pid) {
750
+ if (child.pid) {
740
751
  process.kill(-child.pid, "SIGTERM");
741
752
  }
742
753
  else {
@@ -748,7 +759,7 @@ const bashTool = {
748
759
  }
749
760
  setTimeout(() => {
750
761
  try {
751
- if (process.platform !== "win32" && child.pid) {
762
+ if (child.pid) {
752
763
  process.kill(-child.pid, "SIGKILL");
753
764
  }
754
765
  }
@@ -1082,7 +1093,7 @@ export function taskTool(subagents) {
1082
1093
  if (!ctx.subagent) {
1083
1094
  return "Error: los subagentes no están disponibles aquí.";
1084
1095
  }
1085
- return await ctx.subagent(String(args.subagent_type || "general"), String(args.prompt || ""), String(args.description || ""));
1096
+ return await ctx.subagent(String(args.subagent_type || "ayudante"), String(args.prompt || ""), String(args.description || ""));
1086
1097
  },
1087
1098
  };
1088
1099
  }
@@ -1151,7 +1162,7 @@ export function describeCall(name, rawArgs) {
1151
1162
  try {
1152
1163
  const args = JSON.parse(rawArgs);
1153
1164
  if (name === "task") {
1154
- return `${args.subagent_type || "general"}: ${args.description || ""}`.trim();
1165
+ return `${args.subagent_type || "ayudante"}: ${args.description || ""}`.trim();
1155
1166
  }
1156
1167
  if (name === "todowrite" && Array.isArray(args.todos)) {
1157
1168
  return `${args.todos.length} tareas`;
package/dist/trust.js CHANGED
@@ -89,7 +89,8 @@ function readStore() {
89
89
  }
90
90
  function key(root) {
91
91
  try {
92
- return fs.realpathSync(root);
92
+ // La ruta real con sus mayúsculas: en macOS y Windows ~/Proyecto y ~/proyecto son la misma carpeta
93
+ return fs.realpathSync.native(root);
93
94
  }
94
95
  catch {
95
96
  return path.resolve(root);
@@ -99,7 +100,9 @@ export function projectTrust(root) {
99
100
  const files = [];
100
101
  const changes = [];
101
102
  const parts = [];
102
- const add = (relative, text, change) => {
103
+ const add = (file, text, change) => {
104
+ // Con / también en Windows: así se enseñan igual en todos los sistemas
105
+ const relative = file.split(path.sep).join("/");
103
106
  files.push(relative);
104
107
  parts.push(`${relative}\u0000${text}`);
105
108
  changes.push(...change.map((item) => `${relative}: ${item}`));
package/dist/tui/app.js CHANGED
@@ -9,6 +9,7 @@ import { configNotices } from "../config.js";
9
9
  import { discoveryIsStale } from "../discovery.js";
10
10
  import { isImagePath, readClipboardImage, toDataUrl } from "../images.js";
11
11
  import { toolLabel } from "../labels.js";
12
+ import { quotaFor } from "../limits.js";
12
13
  import { renderMarkdown } from "../markdown.js";
13
14
  import { VERSION } from "../meta.js";
14
15
  import { NoSlotError } from "../router.js";
@@ -200,6 +201,8 @@ function ItemView(props) {
200
201
  const shown = lines.slice(-30);
201
202
  return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsxs(Text, { children: [_jsxs(Text, { color: "yellow", bold: true, children: ["$", " "] }), _jsx(Text, { bold: true, children: showControl(item.command) })] }), lines.length > shown.length ? _jsxs(Text, { dimColor: true, children: [" \u2026 ", lines.length - shown.length, " l\u00EDneas antes"] }) : null, shown.length > 0 ? _jsx(Text, { children: shown.map((line) => ` ${line}`).join("\n") }) : null, _jsxs(Text, { dimColor: true, children: [" ", item.code === 0 ? "✓" : "✗", " c\u00F3digo ", item.code ?? "?", " \u00B7 el agente ver\u00E1 la salida con tu siguiente mensaje"] })] }));
202
203
  }
204
+ case "update":
205
+ return (_jsxs(Box, { marginY: 1, alignSelf: "flex-start", flexDirection: "column", borderStyle: "round", borderColor: "green", paddingX: 2, children: [_jsx(Text, { bold: true, color: "green", children: "\u2191 \u00A1Hay una versi\u00F3n nueva de vipcode!" }), _jsxs(Text, { children: [VERSION, " \u2192 ", _jsx(Text, { bold: true, color: "green", children: stripControl(item.version) }), " \u00B7 escribe", " ", _jsx(Text, { bold: true, color: "cyan", children: "/update" }), " ", "para instalarla"] })] }));
203
206
  // Avisos y bloques pueden llevar mensajes de los proveedores o datos de fuera: solo se dejan los colores
204
207
  case "info":
205
208
  return _jsx(Text, { dimColor: true, children: stripUnsafe(item.text) });
@@ -275,11 +278,26 @@ function StatusBar(props) {
275
278
  const usage = app.agent.session.usage;
276
279
  const spent = usage.prompt + usage.completion;
277
280
  const queued = app.agent.queue.length;
278
- let hint = "ctrl+p comandos · /help";
281
+ // Cuánto queda del plan gratis del modelo en uso (/limites lo enseña todo)
282
+ const quota = slot ? quotaFor(app.router, slot) : null;
283
+ let quotaColor;
284
+ if (quota && quota.remaining <= 0) {
285
+ quotaColor = "red";
286
+ }
287
+ else if (quota && quota.limit && quota.remaining / quota.limit <= 0.1) {
288
+ quotaColor = "yellow";
289
+ }
290
+ let hint = _jsx(Text, { dimColor: true, children: "ctrl+p comandos \u00B7 /help" });
279
291
  if (props.busy) {
280
- hint = "esc interrumpe · escribe para añadir instrucciones";
292
+ hint = _jsx(Text, { dimColor: true, children: "esc interrumpe \u00B7 escribe para a\u00F1adir instrucciones" });
293
+ }
294
+ else if (app.updateInstalled) {
295
+ hint = _jsx(Text, { color: "green", children: "\u2713 vipcode actualizado \u00B7 ci\u00E9rralo y vuelve a abrirlo para usarlo" });
296
+ }
297
+ else if (app.newVersion) {
298
+ hint = (_jsxs(Text, { bold: true, color: "green", children: ["\u2191 vipcode ", stripControl(app.newVersion), " disponible \u00B7 escribe /update"] }));
281
299
  }
282
- return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: agent?.color || "magenta", children: props.agentName }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", current || "rotación automática", slot && slot.score !== null ? ` ★${slot.score}` : "", app.router.pinned ? " (fijado)" : ""] }), _jsx(ContextGauge, { used: app.agent.lastTokens, total: context }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", available, "/", app.router.slots.length, " modelos", spent > 0 ? ` · ${shortTokens(spent)} tokens` : "", queued > 0 ? ` · ${queued} en cola` : ""] }), app.yolo ? _jsx(Text, { color: "red", children: " \u00B7 yolo" }) : null] }), _jsx(Text, { dimColor: true, wrap: "truncate-start", children: hint })] }));
300
+ return (_jsxs(Box, { justifyContent: "space-between", paddingX: 1, children: [_jsxs(Text, { wrap: "truncate-end", children: [_jsx(Text, { bold: true, color: agent?.color || "magenta", children: props.agentName }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", current || "rotación automática", slot && slot.score !== null ? ` ★${slot.score}` : "", app.router.pinned ? " (fijado)" : ""] }), quota ? (_jsxs(Text, { color: quotaColor, dimColor: !quotaColor, children: [" ", "\u00B7 ", quota.text] })) : null, _jsx(ContextGauge, { used: app.agent.lastTokens, total: context }), _jsxs(Text, { dimColor: true, children: [" ", "\u00B7 ", available, "/", app.router.slots.length, " modelos", spent > 0 ? ` · ${shortTokens(spent)} tokens` : "", queued > 0 ? ` · ${queued} en cola` : ""] }), app.yolo ? _jsx(Text, { color: "red", children: " \u00B7 yolo" }) : null] }), _jsx(Text, { wrap: "truncate-start", children: hint })] }));
283
301
  }
284
302
  function Main(props) {
285
303
  const { app } = props;
@@ -513,10 +531,10 @@ function Main(props) {
513
531
  };
514
532
  // La primera vez que se van a usar proveedores sin clave, explicamos qué pasa con lo que envías
515
533
  const askPrivacy = async () => {
516
- const choice = await adapter.select(`Antes de empezar: ${app.privacyNotice()}`, [
517
- { label: "Entendido, empezar", value: "ok" },
518
- { label: "Añadir antes un proveedor con clave", description: "como /connect", value: "connect" },
519
- { label: "No usar nunca los que no piden clave", description: 'guarda "keyless": false en tu configuración', value: "off" },
534
+ const choice = await adapter.select(`¡Te damos la bienvenida a vipcode!\n${app.privacyNotice()}`, [
535
+ { label: "Empezar", description: "con las IAs gratuitas sin registro", value: "ok" },
536
+ { label: "Conectar antes una cuenta gratuita", description: "para tener más mensajes y más modelos", value: "connect" },
537
+ { label: "Prefiero usar solo mis cuentas", description: "y no las IAs sin registro", value: "off" },
520
538
  ]);
521
539
  if (!choice) {
522
540
  // Con Esc se vuelve a preguntar la próxima vez
@@ -528,7 +546,7 @@ function Main(props) {
528
546
  }
529
547
  else if (choice === "off") {
530
548
  app.disableKeyless();
531
- push({ kind: "info", text: "No usaré los proveedores sin clave (lo puedes cambiar con \"keyless\" en la configuración)." });
549
+ push({ kind: "info", text: 'Hecho: solo usaré las cuentas que conectes. Si cambias de idea, pon "keyless": true en tu configuración (/config te dice dónde está).' });
532
550
  if (app.router.slots.length === 0) {
533
551
  await app.connect(adapter, "");
534
552
  }
@@ -683,7 +701,7 @@ function Main(props) {
683
701
  if (app.router.slots.length === 0) {
684
702
  push({
685
703
  kind: "notice",
686
- text: "Todavía no tienes ningún modelo. Elige un proveedor gratuito y pega su clave (puedes repetirlo con /connect).",
704
+ text: "Para empezar, conecta una IA gratuita: elige una de la lista y sigue los pasos (puedes repetirlo cuando quieras con /connect).",
687
705
  });
688
706
  void app.connect(adapter, "").then(refresh);
689
707
  }
@@ -705,7 +723,8 @@ function Main(props) {
705
723
  if (app.config.checkUpdates !== false) {
706
724
  void checkForUpdate().then((version) => {
707
725
  if (version) {
708
- push({ kind: "notice", text: `Hay una versión nueva de vipcode (${version}, tienes la ${VERSION}). Escribe /update para instalarla.` });
726
+ app.newVersion = version;
727
+ push({ kind: "update", version });
709
728
  }
710
729
  });
711
730
  }
@@ -97,7 +97,7 @@ export function PermissionDialog(props) {
97
97
  const lines = request.preview.split("\n");
98
98
  const shown = lines.slice(0, 30).join("\n");
99
99
  const hidden = lines.length - 30;
100
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["? ", showControl(request.title), props.agent !== "build" ? _jsxs(Text, { dimColor: true, children: [" (", props.agent, ")"] }) : null] }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { children: colorPreview(shown) }), hidden > 0 ? _jsxs(Text, { dimColor: true, children: ["\u2026 ", hidden, " l\u00EDneas m\u00E1s"] }) : null] }), explaining ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "\u00BFQu\u00E9 deber\u00EDa hacer en su lugar? (Enter env\u00EDa, Esc vuelve)" }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u276F " }), feedback.value.slice(0, feedback.cursor), _jsx(Text, { inverse: true, children: feedback.value[feedback.cursor] || " " }), feedback.value.slice(feedback.cursor + 1)] })] })) : (_jsxs(_Fragment, { children: [options.map((option, index) => (_jsxs(Text, { color: index === selected ? "cyan" : undefined, children: [index === selected ? "❯ " : " ", _jsxs(Text, { dimColor: true, children: ["[", option.key, "]"] }), " ", option.label] }, option.key))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 elige \u00B7 Enter acepta \u00B7 la letra es un atajo \u00B7 Esc = no" })] }))] }));
100
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "yellow", paddingX: 1, children: [_jsxs(Text, { bold: true, color: "yellow", children: ["? ", showControl(request.title), props.agent !== "programar" ? _jsxs(Text, { dimColor: true, children: [" (", props.agent, ")"] }) : null] }), _jsxs(Box, { marginY: 1, flexDirection: "column", children: [_jsx(Text, { children: colorPreview(shown) }), hidden > 0 ? _jsxs(Text, { dimColor: true, children: ["\u2026 ", hidden, " l\u00EDneas m\u00E1s"] }) : null] }), explaining ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "\u00BFQu\u00E9 deber\u00EDa hacer en su lugar? (Enter env\u00EDa, Esc vuelve)" }), _jsxs(Text, { children: [_jsx(Text, { color: "yellow", children: "\u276F " }), feedback.value.slice(0, feedback.cursor), _jsx(Text, { inverse: true, children: feedback.value[feedback.cursor] || " " }), feedback.value.slice(feedback.cursor + 1)] })] })) : (_jsxs(_Fragment, { children: [options.map((option, index) => (_jsxs(Text, { color: index === selected ? "cyan" : undefined, children: [index === selected ? "❯ " : " ", _jsxs(Text, { dimColor: true, children: ["[", option.key, "]"] }), " ", option.label] }, option.key))), _jsx(Text, { dimColor: true, children: "\u2191\u2193 elige \u00B7 Enter acepta \u00B7 la letra es un atajo \u00B7 Esc = no" })] }))] }));
101
101
  }
102
102
  function matches(item, query) {
103
103
  return `${item.label} ${item.description || ""}`.toLowerCase().includes(query);
@@ -154,7 +154,9 @@ export function SelectDialog(props) {
154
154
  const windowSize = 12;
155
155
  const start = Math.max(0, Math.min(selected - Math.floor(windowSize / 2), filtered.length - windowSize));
156
156
  const visible = filtered.slice(start, start + windowSize);
157
- return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: props.title }), _jsx(Text, { dimColor: true, children: filter ? `Filtro: ${filter}` : "Escribe para filtrar · ↑↓ elegir · Enter aceptar · Esc salir" }), visible.length === 0 ? _jsx(Text, { dimColor: true, children: "(nada coincide)" }) : null, visible.map((item, index) => {
157
+ // La primera línea del título va destacada; las siguientes, si las hay, explican la pregunta
158
+ const [heading, ...body] = props.title.split("\n");
159
+ return (_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "cyan", paddingX: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: heading }), body.length > 0 ? (_jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: body.join("\n") }) })) : null, _jsx(Text, { dimColor: true, children: filter ? `Filtro: ${filter}` : "Escribe para filtrar · ↑↓ elegir · Enter aceptar · Esc salir" }), visible.length === 0 ? _jsx(Text, { dimColor: true, children: "(nada coincide)" }) : null, visible.map((item, index) => {
158
160
  const active = start + index === selected;
159
161
  return (_jsxs(Text, { color: active ? "cyan" : undefined, wrap: "truncate-end", children: [active ? "❯ " : " ", stripControl(item.label), item.description ? _jsxs(Text, { dimColor: true, children: [" ", stripControl(item.description)] }) : null] }, `${start + index}-${item.label}`));
160
162
  }), filtered.length > windowSize ? (_jsxs(Text, { dimColor: true, children: [selected + 1, "/", filtered.length] })) : null] }));