chocolatito-code 1.1.0 → 1.2.0

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/README.md CHANGED
@@ -85,6 +85,55 @@ Si repite tres veces la misma llamada con el mismo resultado, se para y pregunta
85
85
  seguir, o le dices que cambie de enfoque. Sin terminal (CI, `chocolatito "haz X"`) se corta
86
86
  solo, para que un bucle que nadie está mirando no se coma la cuota.
87
87
 
88
+ ## 🧩 VSCode
89
+
90
+ Si tienes la extensión instalada y abres `chocolatito` en la terminal integrada, **se conecta
91
+ solo**. No hay nada que configurar.
92
+
93
+ El truco está al revés de lo que parece: la extensión es un **servidor MCP** y el agente es el
94
+ cliente. Cada ventana de VSCode levanta un servidor en `127.0.0.1` y deja una ficha en
95
+ `~/.chocolatito/ide/` con su puerto, su token y la carpeta que tiene abierta. El agente busca la
96
+ ficha cuya carpeta contiene su directorio de trabajo.
97
+
98
+ Lo que gana el agente (lo único que no puede tener por su cuenta):
99
+
100
+ | Herramienta | Para qué |
101
+ | --- | --- |
102
+ | `diagnosticos` | Los errores del servidor de lenguaje. Los subrayados rojos, sin esperar a compilar. |
103
+ | `seleccion_actual` | Qué archivo tienes abierto y qué has seleccionado. Hace que «arregla esto» signifique algo. |
104
+ | `archivos_abiertos` | Las pestañas abiertas, en orden. |
105
+ | `mostrar_diff` | Abre el visor de diferencias nativo. No escribe nada: es para que lo veas antes. |
106
+
107
+ **Con varias ventanas abiertas se elige la correcta**, no la primera: gana la carpeta más
108
+ específica que contenga el directorio de trabajo, y si ninguna lo contiene **no se conecta a
109
+ nada**. Conectarse a la ventana de otro proyecto sería peor que no conectarse: los diffs saldrían
110
+ en el sitio equivocado.
111
+
112
+ El servidor escucha solo en `127.0.0.1` y exige un token de 32 bytes que va en la ficha, dentro de
113
+ tu carpeta personal. Sin eso, cualquier programa de la máquina podría manejar tu editor.
114
+
115
+ Para apagarlo: `CHOCOLATITO_SIN_EDITOR=1`, o la opción `chocolatito.puente` en VSCode.
116
+
117
+ ### Instalarla
118
+
119
+ Mientras no esté en el marketplace, se instala desde el paquete:
120
+
121
+ ```bash
122
+ cd extension-vscode
123
+ npx @vscode/vsce package --allow-missing-repository --skip-license
124
+ code --install-extension chocolatito-code-vscode-0.1.1.vsix
125
+ ```
126
+
127
+ **Con VSCode cerrado**, y reiniciándolo después. Copiar la carpeta a mano a `~/.vscode/extensions`
128
+ **no funciona**: falta el `.vsixmanifest` y VSCode la lista pero no la carga nunca, sin dar ningún
129
+ error. Para desarrollar, `F5` sobre la carpeta abre una ventana con la extensión cargada al vuelo.
130
+
131
+ En la barra de estado, abajo a la derecha, pone **Chocolatito** con el estado del puente. Si algo
132
+ falla —sin carpeta abierta, puente apagado, puerto ocupado— lo dice ahí, y al pulsarlo se abre el
133
+ detalle.
134
+
135
+ ---
136
+
88
137
  ## 🔌 MCP (Model Context Protocol)
89
138
 
90
139
  Conecta cualquier servidor MCP y sus herramientas quedan disponibles para el agente.
@@ -141,6 +190,23 @@ solo lectura de verdad: ni comandos, ni clics, ni escrituras.
141
190
 
142
191
  `--yes` aprueba todo sin preguntar: es para CI, no para el día a día.
143
192
 
193
+ ## 📋 Pegar texto largo
194
+
195
+ Pegar un log de 300 líneas ya no mete 300 líneas en el recuadro. Se guarda aparte y en el prompt
196
+ queda una marca corta:
197
+
198
+ ```
199
+ ❯ arregla esto [pegado #1 · 347 lineas]
200
+ ```
201
+
202
+ **El modelo recibe el texto entero**, no la marca: al enviar se sustituye por lo que pegaste. Y un
203
+ solo `backspace` con el cursor detrás de la marca se la lleva completa — lo que ocupa una línea se
204
+ borra como una línea.
205
+
206
+ Por debajo de 5 líneas o 400 caracteres se pega normal, entero y a la vista.
207
+
208
+ ---
209
+
144
210
  ## ⌨️ Escribir mientras trabaja
145
211
 
146
212
  No hace falta esperar a que termine. Lo que escribas durante una generación aparece bajo
@@ -15,6 +15,7 @@ import { PermissionManager, commandSignature, isDangerousCommand } from "../conf
15
15
  import { askToolPermission } from "../ui/permissionPrompt.js";
16
16
  import { DynamicSpinner } from "../ui/spinner.js";
17
17
  import { globalUndoManager } from "./undoManager.js";
18
+ import { salida } from "./salidaDelAgente.js";
18
19
  import { InterruptWatcher, isAbortError } from "../ui/interrupt.js";
19
20
  import { HookManager } from "../hooks/manager.js";
20
21
  import { registerToolGate, autorizarHerramienta, notificarPostHerramienta } from "./toolGate.js";
@@ -33,6 +34,15 @@ const HERRAMIENTAS_QUE_ESCRIBEN = new Set([
33
34
  ]);
34
35
  /** Cortes por longitud seguidos que se toleran antes de rendirse. */
35
36
  const MAX_CORTES_POR_LONGITUD = 2;
37
+ /**
38
+ * Respuestas vacias seguidas que se toleran antes de rendirse.
39
+ *
40
+ * Dos, no una: una respuesta vacia suelta es casi siempre un hipo del motor y
41
+ * el reintento la resuelve sin que el usuario se entere. Tres seguidas ya no es
42
+ * un hipo, y seguir reintentando en silencio es lo que hacia que pareciera que
43
+ * el agente te ignora.
44
+ */
45
+ const MAX_RESPUESTAS_VACIAS = 1;
36
46
  /** Mensajes recientes que la compactacion no resume nunca. */
37
47
  const COLA_INTACTA = 4;
38
48
  /**
@@ -66,8 +76,43 @@ function parsearArgumentos(bruto) {
66
76
  /** Lo que se le dice al modelo cuando sus argumentos llegaron a medias. */
67
77
  function mensajeDeArgumentosRotos(herramienta) {
68
78
  return (`No se ejecuto "${herramienta}": los argumentos llegaron incompletos, seguramente porque la ` +
69
- `respuesta se corto por longitud. No se ha hecho nada. Vuelve a pedirlo, y si el contenido es ` +
70
- `largo, escribelo en varias llamadas mas pequenas en vez de una sola.`);
79
+ `respuesta se corto por longitud. No se ha hecho nada.
80
+
81
+ ` +
82
+ `Si era un archivo largo, hazlo por partes: una primera llamada a write_file con el principio ` +
83
+ `y las siguientes con modo="anadir" para ir pegando el resto. Cada trozo, por debajo de unas ` +
84
+ `400 lineas.`);
85
+ }
86
+ /**
87
+ * Deja una llamada a herramienta en un estado que la API acepte.
88
+ *
89
+ * EL FALLO QUE CIERRA ESTO
90
+ *
91
+ * Cuando la respuesta se corta a mitad de un `write_file` con un HTML largo
92
+ * dentro, los argumentos llegan asi:
93
+ *
94
+ * {"path":"index.html","content":"<!doctype html><html><head><style>body{ma
95
+ *
96
+ * La herramienta ya no se ejecutaba -eso se arreglo antes-, pero la llamada se
97
+ * guardaba en el historial TAL CUAL, y el historial se reenvia entero en cada
98
+ * peticion. La API valida que los argumentos sean JSON, no lo son, y contesta
99
+ * 400. Desde ese momento la sesion queda inservible: todo lo que escriba el
100
+ * usuario falla igual, la unica salida es /clear, y nadie lo sabe.
101
+ *
102
+ * Se vio en una sesion real, y encima el 400 se llevo por delante los cuatro
103
+ * huecos de peticion de la licencia, asi que al fallo se le sumo un "Tienes 4
104
+ * peticiones en curso" de dos minutos y medio.
105
+ *
106
+ * Guardando "{}" el historial sigue siendo valido y la pareja llamada/respuesta
107
+ * cuadra. Al modelo no se le oculta nada: el resultado de esa herramienta ya le
108
+ * dice que sus argumentos llegaron a medias y que no se hizo nada.
109
+ */
110
+ function sanearLlamada(tc) {
111
+ const bruto = tc?.function?.arguments;
112
+ const texto = typeof bruto === "string" ? bruto.trim() : "";
113
+ if (texto && parsearArgumentos(texto).ok)
114
+ return tc;
115
+ return { ...tc, function: { ...tc.function, arguments: "{}" } };
71
116
  }
72
117
  export class AgentLoop {
73
118
  client;
@@ -223,7 +268,7 @@ export class AgentLoop {
223
268
  const proyectado = antes > 0 ? Math.round(tokensActuales * (despues / antes)) : tokensActuales;
224
269
  this.lastTurnTokens = proyectado;
225
270
  if (proyectado < COMPACT_THRESHOLD_TOKENS * 0.8) {
226
- console.log(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
271
+ salida.linea(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
227
272
  chalk.gray(`Liberados ~${(antes - despues).toLocaleString()} tokens de salidas de herramientas antiguas. ` +
228
273
  "La conversacion se conserva entera.\n"));
229
274
  return true;
@@ -278,7 +323,7 @@ export class AgentLoop {
278
323
  // Queda una estimacion hasta la siguiente respuesta de la API: mejor eso
279
324
  // que arrastrar la cifra vieja, que dispararia otra compactacion seguida.
280
325
  this.lastTurnTokens = estimarTokens(this.messages);
281
- console.log(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
326
+ salida.linea(chalk.hex("#D97757")("\n⚡ [Auto-Compact]: ") +
282
327
  chalk.gray(`Contexto optimizado automáticamente (${this.messages.length} mensajes limpios) para mantener alta velocidad y mínimo consumo de API.\n`));
283
328
  return true;
284
329
  }
@@ -286,7 +331,7 @@ export class AgentLoop {
286
331
  spinner.stop();
287
332
  // El catch mudo era medio fallo: si el resumen no sale, el turno sigue con
288
333
  // el contexto entero y el error de despues llega sin explicacion.
289
- console.log(chalk.gray(` (no se pudo compactar el contexto: ${err?.message || String(err)})`));
334
+ salida.linea(chalk.gray(` (no se pudo compactar el contexto: ${err?.message || String(err)})`));
290
335
  return false;
291
336
  }
292
337
  }
@@ -307,7 +352,7 @@ export class AgentLoop {
307
352
  if (!cmd)
308
353
  return null;
309
354
  if (reflexiones >= MAX_REFLEXIONES) {
310
- console.log(chalk.yellow(`\n⚠ ${mensajeDeRendicion(cmd.comando)}\n`));
355
+ salida.linea(chalk.yellow(`\n⚠ ${mensajeDeRendicion(cmd.comando)}\n`));
311
356
  return null;
312
357
  }
313
358
  const spinner = new DynamicSpinner();
@@ -320,13 +365,13 @@ export class AgentLoop {
320
365
  spinner.stop();
321
366
  }
322
367
  if (resultado.ok) {
323
- console.log(chalk.gray(` ✔ Verificado con ${cmd.comando}\n`));
368
+ salida.linea(chalk.gray(` ✔ Verificado con ${cmd.comando}\n`));
324
369
  // Lo verificado deja de estar pendiente: si el modelo vuelve a editar,
325
370
  // se vuelve a comprobar; si no, no se comprueba dos veces lo mismo.
326
371
  this.archivosTocados.clear();
327
372
  return null;
328
373
  }
329
- console.log(chalk.yellow(`\n⚠ La verificacion fallo (${cmd.comando}). Se lo devuelvo al agente.\n`));
374
+ salida.linea(chalk.yellow(`\n⚠ La verificacion fallo (${cmd.comando}). Se lo devuelvo al agente.\n`));
330
375
  return mensajeDeVerificacion(cmd.comando, resultado.salida, reflexiones + 1);
331
376
  }
332
377
  /**
@@ -415,7 +460,7 @@ export class AgentLoop {
415
460
  const cuota = checkQuota();
416
461
  if (!cuota.allowed) {
417
462
  const corte = mensajeDeCorte(cuota);
418
- console.log(chalk.yellow(`\n⏸ ${corte}`) +
463
+ salida.linea(chalk.yellow(`\n⏸ ${corte}`) +
419
464
  chalk.gray("\n Tu trabajo y tu contexto siguen aqui: al liberarse, continuas donde lo dejaste.\n"));
420
465
  return parada("cuota", corte);
421
466
  }
@@ -438,6 +483,8 @@ export class AgentLoop {
438
483
  let reflexiones = 0;
439
484
  /** Veces seguidas que la respuesta se corto por longitud sin hacer nada. */
440
485
  let cortesPorLongitud = 0;
486
+ /** Veces seguidas que el turno acabo sin una sola letra que ensenar. */
487
+ let respuestasVacias = 0;
441
488
  // Se limpia aqui: lo que se edito en el turno anterior ya se verifico (o no
442
489
  // se iba a verificar nunca).
443
490
  this.archivosTocados.clear();
@@ -479,7 +526,7 @@ export class AgentLoop {
479
526
  }, { signal }), {
480
527
  onRetry: (attempt, delay, reason) => {
481
528
  spinner.stop();
482
- console.log(chalk.yellow(`\n⟳ El motor respondio ${reason}. Reintento ${attempt} en ${(delay / 1000).toFixed(1)}s...`));
529
+ salida.linea(chalk.yellow(`\n⟳ El motor respondio ${reason}. Reintento ${attempt} en ${(delay / 1000).toFixed(1)}s...`));
483
530
  spinner.start();
484
531
  },
485
532
  });
@@ -488,7 +535,7 @@ export class AgentLoop {
488
535
  spinner.stop();
489
536
  watcher.stop();
490
537
  if (isAbortError(err) || watcher.interrupted) {
491
- console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
538
+ salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
492
539
  return parada("interrumpido");
493
540
  }
494
541
  // El contexto no cabe. Reintentar la misma peticion es imposible por
@@ -497,7 +544,7 @@ export class AgentLoop {
497
544
  // sola vez: si tras compactar sigue sin caber, el problema es otro.
498
545
  if (isContextOverflow(err) && !yaCompactadoPorDesbordamiento) {
499
546
  yaCompactadoPorDesbordamiento = true;
500
- console.log(chalk.yellow("\n⚠ La peticion no cabia en el contexto. Compactando y reintentando..."));
547
+ salida.linea(chalk.yellow("\n⚠ La peticion no cabia en el contexto. Compactando y reintentando..."));
501
548
  const activo = this.messages[0]?.role === "system" ? this.messages[0].content : "";
502
549
  await this.compactHistory(activo);
503
550
  iterations--; // este intento no llego al modelo: no cuenta como vuelta
@@ -508,8 +555,8 @@ export class AgentLoop {
508
555
  // hace que el usuario piense que el producto esta roto.
509
556
  const claro = explicarErrorDelMotor(err);
510
557
  if (claro) {
511
- console.log(chalk.yellow(`
512
- ⏸ ${claro}
558
+ salida.linea(chalk.yellow(`
559
+ ⏸ ${claro}
513
560
  `));
514
561
  return parada(motivoDeParadaDelError(err), claro);
515
562
  }
@@ -589,13 +636,13 @@ export class AgentLoop {
589
636
  if (content.trim()) {
590
637
  this.messages.push({ role: "assistant", content });
591
638
  }
592
- console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
639
+ salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
593
640
  return parada("interrumpido");
594
641
  }
595
642
  const claroStream = explicarErrorDelMotor(streamErr);
596
643
  if (claroStream) {
597
- console.log(chalk.yellow(`
598
- ⏸ ${claroStream}
644
+ salida.linea(chalk.yellow(`
645
+ ⏸ ${claroStream}
599
646
  `));
600
647
  return parada(motivoDeParadaDelError(streamErr), claroStream);
601
648
  }
@@ -610,7 +657,7 @@ export class AgentLoop {
610
657
  watcher.stop();
611
658
  if (content.trim())
612
659
  this.messages.push({ role: "assistant", content });
613
- console.log(chalk.yellow("\n⏸ Interrumpido antes de ejecutar las herramientas. Dime como seguir."));
660
+ salida.linea(chalk.yellow("\n⏸ Interrumpido antes de ejecutar las herramientas. Dime como seguir."));
614
661
  return parada("interrumpido");
615
662
  }
616
663
  // Filter valid accumulated tool calls
@@ -630,19 +677,20 @@ export class AgentLoop {
630
677
  if (cortesPorLongitud > MAX_CORTES_POR_LONGITUD) {
631
678
  const aviso = `La respuesta se corto por longitud ${cortesPorLongitud} veces seguidas sin llegar a ` +
632
679
  `hacer nada. Prueba a pedirlo por partes.`;
633
- console.log(chalk.yellow(`\n ⚠ ${aviso}\n`));
680
+ salida.linea(chalk.yellow(`\n ⚠ ${aviso}\n`));
634
681
  if (content.trim())
635
682
  this.messages.push({ role: "assistant", content });
636
683
  return parada("limite-iteraciones", aviso);
637
684
  }
638
- console.log(chalk.yellow("\n ⚠ La respuesta se corto por longitud antes de hacer nada. Pidiendole que vaya al grano...\n"));
685
+ salida.linea(chalk.yellow("\n ⚠ La respuesta se corto por longitud antes de hacer nada. Pidiendole que vaya al grano...\n"));
639
686
  if (content.trim())
640
687
  this.messages.push({ role: "assistant", content });
641
688
  this.messages.push({
642
689
  role: "user",
643
690
  content: "Tu respuesta anterior se corto porque agoto el presupuesto de salida mientras razonabas, " +
644
691
  "y no llegaste a usar ninguna herramienta: no se ha hecho nada todavia. Deja de planificar " +
645
- "y actua ya. Si lo que hay que escribir es largo, hazlo en varias llamadas mas pequenas.",
692
+ "y actua ya. Si lo que hay que escribir es largo, empieza con write_file y sigue con " +
693
+ 'write_file en modo="anadir", en trozos de menos de 400 lineas.',
646
694
  });
647
695
  // Sin esto se queda un vigilante de teclado vivo por cada corte, y cada
648
696
  // uno vuelve a apuntarse como oyente: la misma tecla se procesa tantas
@@ -655,7 +703,10 @@ export class AgentLoop {
655
703
  this.messages.push({
656
704
  role: "assistant",
657
705
  content: content || null,
658
- tool_calls: toolCalls,
706
+ // Sanear NO es cosmetico: unos argumentos cortados guardados tal cual
707
+ // hacen que la API conteste 400 en TODAS las peticiones siguientes, y
708
+ // la sesion queda inservible hasta un /clear. Ver sanearLlamada().
709
+ tool_calls: toolCalls.map(sanearLlamada),
659
710
  });
660
711
  const toolResults = [];
661
712
  // Las llamadas independientes de solo lectura se lanzan a la vez. Cuando
@@ -733,7 +784,7 @@ export class AgentLoop {
733
784
  toolResults.forEach((r) => this.messages.push(r));
734
785
  watcher.stop();
735
786
  if (watcher.interrupted) {
736
- console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
787
+ salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
737
788
  return parada("interrumpido");
738
789
  }
739
790
  continue;
@@ -781,7 +832,7 @@ export class AgentLoop {
781
832
  fnArgs = { ...fnArgs, ...pre.updatedInput };
782
833
  }
783
834
  if (pre.systemMessage) {
784
- console.log(chalk.yellow(`\n⚑ ${pre.systemMessage}`));
835
+ salida.linea(chalk.yellow(`\n⚑ ${pre.systemMessage}`));
785
836
  }
786
837
  // Modo plan: bloqueo duro, no un dialogo. Lo que el modo promete es
787
838
  // solo lectura, asi que aqui no hay nada que preguntar.
@@ -848,7 +899,7 @@ export class AgentLoop {
848
899
  // El systemMessage de PreToolUse si se imprimia y el de PostToolUse
849
900
  // no: el mismo campo, del mismo hook, se veia o no segun el evento.
850
901
  if (post.systemMessage) {
851
- console.log(chalk.yellow(`\n⚑ ${post.systemMessage}`));
902
+ salida.linea(chalk.yellow(`\n⚑ ${post.systemMessage}`));
852
903
  }
853
904
  // El dialogo del bucle usa stdin, igual que el de permisos: hay que
854
905
  // soltarle el terminal al vigilante de Esc. Pero solo si de verdad se
@@ -859,7 +910,7 @@ export class AgentLoop {
859
910
  toolResults.push({
860
911
  role: "tool",
861
912
  tool_call_id: tc.id,
862
- content: post.additionalContext ? `${texto}\n\n
913
+ content: post.additionalContext ? `${texto}\n\n
863
914
  [Hook PostToolUse]: ${post.additionalContext}` : texto,
864
915
  });
865
916
  }
@@ -867,20 +918,62 @@ export class AgentLoop {
867
918
  toolResults.forEach((r) => this.messages.push(r));
868
919
  watcher.stop();
869
920
  if (watcher.interrupted) {
870
- console.log(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
921
+ salida.linea(chalk.yellow("\n⏸ Interrumpido. Dime como seguir."));
871
922
  return parada("interrumpido");
872
923
  }
873
924
  continue;
874
925
  }
875
926
  watcher.stop();
876
- // If text content was printed, ensure newline and record assistant message
877
- if (content.trim()) {
878
- process.stdout.write("\n\n");
927
+ // EL TURNO ACABA SIN UNA SOLA LETRA QUE ENSENAR
928
+ //
929
+ // Salio de un caso real: cuatro ordenes seguidas ("puedes conectarte a mi
930
+ // vscode?", "si?", "puedes?", "a mi vscode") contestadas todas con esto:
931
+ //
932
+ // 💭 penso 1s
933
+ // ⚡ 4.8s · 0 tokens salida · $0.00000 USD · 100% contexto libre
934
+ //
935
+ // Ni un caracter en pantalla, ni un error, ni un aviso. Desde fuera es
936
+ // identico a que el agente te ignore, y el usuario reescribe la orden
937
+ // pensando que no llego. Encima cada una de esas cuatro se llevo por
938
+ // delante un hueco de peticion en el proxy, asi que la quinta choco con
939
+ // "Tienes 4 peticiones en curso": el silencio no solo confunde, ademas
940
+ // deja al usuario sin servicio.
941
+ //
942
+ // Es el mismo agujero que el corte por longitud, en su otra cara: el
943
+ // codigo no distingue "el modelo termino" de "no vino nada". Sin
944
+ // herramientas y sin texto, el bucle daba el turno por hecho, imprimia el
945
+ // pie y volvia al prompt.
946
+ if (!content.trim()) {
947
+ respuestasVacias++;
948
+ // Se reintenta UNA vez, no mas, y aqui la moderacion importa: cada
949
+ // intento consume un hueco de peticion de la licencia, y solo hay
950
+ // cuatro. Insistir con un motor que no contesta es la forma mas rapida
951
+ // de quedarse sin ninguno.
952
+ if (respuestasVacias > MAX_RESPUESTAS_VACIAS) {
953
+ const aviso = `El motor devolvio ${respuestasVacias} respuestas vacias seguidas. ` +
954
+ "No es tu conexion ni tu orden: no esta llegando nada del modelo. " +
955
+ "Espera un momento y reintenta, o /clear si venias de una conversacion larga.";
956
+ salida.linea(chalk.yellow(`\n ⚠ ${aviso}\n`));
957
+ return parada("error-motor", aviso);
958
+ }
959
+ salida.linea(chalk.gray("\n ⚠ El motor no devolvio nada. Reintentando una vez…\n"));
879
960
  this.messages.push({
880
- role: "assistant",
881
- content,
961
+ role: "user",
962
+ content: "Tu respuesta anterior llego vacia: ni texto ni llamadas a herramienta. " +
963
+ "El usuario no ha visto absolutamente nada. Contesta a lo que te pidio, " +
964
+ "aunque sea para decir que no puedes hacerlo o que no lo has entendido.",
882
965
  });
966
+ continue;
883
967
  }
968
+ // Una respuesta con contenido cierra la racha: lo que cuenta son las
969
+ // vacias SEGUIDAS, no las de toda la sesion.
970
+ respuestasVacias = 0;
971
+ // If text content was printed, ensure newline and record assistant message
972
+ salida.crudo("\n\n");
973
+ this.messages.push({
974
+ role: "assistant",
975
+ content,
976
+ });
884
977
  // El modelo da el turno por cerrado. Si toco archivos y el proyecto tiene
885
978
  // verificacion automatica activada, se comprueba ANTES de creerselo: un
886
979
  // error de tipos que nadie mira se queda en el disco y los turnos
@@ -914,19 +1007,19 @@ export class AgentLoop {
914
1007
  const tras = checkQuota();
915
1008
  renderFooter(formattedCost, turnCompletionTokens, turnCachedTokens, this.currentEspectro.name, durationMs, contextPercent, quotaBadge(tras));
916
1009
  if (auxiliar.total > 0) {
917
- console.log(chalk.gray(` + $${auxiliar.total.toFixed(4)} USD fuera del bucle (${auxiliar.rutas}), ya contados en la cuota.\n`));
1010
+ salida.linea(chalk.gray(` + $${auxiliar.total.toFixed(4)} USD fuera del bucle (${auxiliar.rutas}), ya contados en la cuota.\n`));
918
1011
  }
919
1012
  // Aviso al acercarse, no solo al chocar: quedarse a medias sin previo aviso
920
1013
  // es lo que hace que la gente cancele.
921
1014
  if (tras.allowed && tras.spentWeekUsd >= tras.config.weeklyBudgetUsd * 0.8) {
922
1015
  const queda = Math.max(0, tras.config.weeklyBudgetUsd - tras.spentWeekUsd);
923
- console.log(chalk.yellow(` ⚠ Te queda poca cuota semanal (${((queda / tras.config.weeklyBudgetUsd) * 100).toFixed(0)}%).\n`));
1016
+ salida.linea(chalk.yellow(` ⚠ Te queda poca cuota semanal (${((queda / tras.config.weeklyBudgetUsd) * 100).toFixed(0)}%).\n`));
924
1017
  }
925
1018
  if (!completado) {
926
1019
  // Se agotaron las iteraciones con trabajo a medias. En interactivo se ve
927
1020
  // porque el ultimo mensaje queda cortado; en CI no lo ve nadie.
928
1021
  const aviso = `El turno se agoto en el limite de ${this.maxIterations} iteraciones sin cerrar la tarea.`;
929
- console.log(chalk.yellow(` ⚠ ${aviso}\n`));
1022
+ salida.linea(chalk.yellow(` ⚠ ${aviso}\n`));
930
1023
  return parada("limite-iteraciones", aviso);
931
1024
  }
932
1025
  return parada("completado");
@@ -948,7 +1041,7 @@ export class AgentLoop {
948
1041
  const command = typeof fnArgs?.command === "string" ? fnArgs.command : "";
949
1042
  if (command) {
950
1043
  if (isDangerousCommand(command)) {
951
- console.log(chalk.gray(" (este comando es destructivo: se seguira preguntando cada vez, no se puede recordar)\n"));
1044
+ salida.linea(chalk.gray(" (este comando es destructivo: se seguira preguntando cada vez, no se puede recordar)\n"));
952
1045
  return;
953
1046
  }
954
1047
  const firma = commandSignature(command);
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Por donde habla el bucle del agente.
3
+ *
4
+ * POR QUE
5
+ *
6
+ * El bucle escribia con `console.log` en diecisiete sitios. Mientras el unico
7
+ * sitio donde se ve al agente sea una terminal eso no molesta, pero ata el
8
+ * motor a su pantalla: para poner el agente en un panel de VSCode, en una web o
9
+ * en una app, hay que arrancarle esos diecisiete `console.log` uno a uno, y
10
+ * cada uno es una ocasion de romper algo.
11
+ *
12
+ * Con esto en medio, el bucle deja de saber donde acaba lo que dice. La
13
+ * terminal pasa a ser UNA implementacion, no LA implementacion.
14
+ *
15
+ * POR QUE RECIBE EL TEXTO YA COLOREADO
16
+ *
17
+ * Es a proposito, y es lo que hace que este cambio no pueda romper nada: lo que
18
+ * el bucle manda es exactamente la misma cadena que antes le pasaba a
19
+ * `console.log`, colores incluidos. La salida por terminal queda byte a byte
20
+ * igual que ayer, y las 538 pruebas siguen valiendo como red.
21
+ *
22
+ * Un panel que quiera pintarlo a su manera quita los codigos de color con
23
+ * `sinColores()` y se queda con el texto. Cuando haga falta distinguir un aviso
24
+ * de una nota, se anaden metodos con significado; hoy no hace falta y adivinarlo
25
+ * seria inventar trabajo.
26
+ */
27
+ export interface SalidaDelAgente {
28
+ /** Un mensaje suelto, con su salto de linea al final. Equivale a console.log. */
29
+ linea(texto: string): void;
30
+ /** Escritura sin salto, para separar bloques. Equivale a process.stdout.write. */
31
+ crudo(texto: string): void;
32
+ }
33
+ /** Lo que usa el bucle. No sabe ni le importa quien esta al otro lado. */
34
+ export declare const salida: SalidaDelAgente;
35
+ /**
36
+ * Cambia a donde habla el agente. Con `null` vuelve a la terminal.
37
+ *
38
+ * Lo usara el panel de VSCode. Devolver siempre a la terminal al terminar: un
39
+ * agente que habla a un panel cerrado no habla con nadie.
40
+ */
41
+ export declare function dirigirSalidaDelAgente(destino: SalidaDelAgente | null): void;
42
+ /** Quita los codigos de color, para quien pinte el texto a su manera. */
43
+ export declare function sinColores(texto: string): string;
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Por donde habla el bucle del agente.
3
+ *
4
+ * POR QUE
5
+ *
6
+ * El bucle escribia con `console.log` en diecisiete sitios. Mientras el unico
7
+ * sitio donde se ve al agente sea una terminal eso no molesta, pero ata el
8
+ * motor a su pantalla: para poner el agente en un panel de VSCode, en una web o
9
+ * en una app, hay que arrancarle esos diecisiete `console.log` uno a uno, y
10
+ * cada uno es una ocasion de romper algo.
11
+ *
12
+ * Con esto en medio, el bucle deja de saber donde acaba lo que dice. La
13
+ * terminal pasa a ser UNA implementacion, no LA implementacion.
14
+ *
15
+ * POR QUE RECIBE EL TEXTO YA COLOREADO
16
+ *
17
+ * Es a proposito, y es lo que hace que este cambio no pueda romper nada: lo que
18
+ * el bucle manda es exactamente la misma cadena que antes le pasaba a
19
+ * `console.log`, colores incluidos. La salida por terminal queda byte a byte
20
+ * igual que ayer, y las 538 pruebas siguen valiendo como red.
21
+ *
22
+ * Un panel que quiera pintarlo a su manera quita los codigos de color con
23
+ * `sinColores()` y se queda con el texto. Cuando haga falta distinguir un aviso
24
+ * de una nota, se anaden metodos con significado; hoy no hace falta y adivinarlo
25
+ * seria inventar trabajo.
26
+ */
27
+ const A_LA_TERMINAL = {
28
+ linea: (texto) => {
29
+ console.log(texto);
30
+ },
31
+ crudo: (texto) => {
32
+ process.stdout.write(texto);
33
+ },
34
+ };
35
+ let actual = A_LA_TERMINAL;
36
+ /** Lo que usa el bucle. No sabe ni le importa quien esta al otro lado. */
37
+ export const salida = {
38
+ linea: (texto) => actual.linea(texto),
39
+ crudo: (texto) => actual.crudo(texto),
40
+ };
41
+ /**
42
+ * Cambia a donde habla el agente. Con `null` vuelve a la terminal.
43
+ *
44
+ * Lo usara el panel de VSCode. Devolver siempre a la terminal al terminar: un
45
+ * agente que habla a un panel cerrado no habla con nadie.
46
+ */
47
+ export function dirigirSalidaDelAgente(destino) {
48
+ actual = destino ?? A_LA_TERMINAL;
49
+ }
50
+ /** Quita los codigos de color, para quien pinte el texto a su manera. */
51
+ export function sinColores(texto) {
52
+ return String(texto).replace(/\x1b\[[0-9;]*m/g, "");
53
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Un Node viejo se dice claro, no se descubre a base de errores raros.
3
+ *
4
+ * `engines` en el package.json solo hace que npm suelte un aviso que nadie lee:
5
+ * la instalacion termina bien y el fallo aparece despues, al arrancar, con un
6
+ * "fetch is not defined" o un error de sintaxis dentro de una dependencia. El
7
+ * que acaba de pagar da por hecho que el producto esta roto, y no le falta
8
+ * razon desde donde el lo ve.
9
+ *
10
+ * Se comprueba antes que nada: antes de la licencia, antes de leer la
11
+ * configuracion y antes de importar nada que pueda reventar por esto mismo.
12
+ */
13
+ export declare function comprobarNode(version?: string): string | null;
@@ -0,0 +1,26 @@
1
+ /** La version de Node mas antigua con la que esto funciona. */
2
+ const NODE_MINIMO = 20;
3
+ /**
4
+ * Un Node viejo se dice claro, no se descubre a base de errores raros.
5
+ *
6
+ * `engines` en el package.json solo hace que npm suelte un aviso que nadie lee:
7
+ * la instalacion termina bien y el fallo aparece despues, al arrancar, con un
8
+ * "fetch is not defined" o un error de sintaxis dentro de una dependencia. El
9
+ * que acaba de pagar da por hecho que el producto esta roto, y no le falta
10
+ * razon desde donde el lo ve.
11
+ *
12
+ * Se comprueba antes que nada: antes de la licencia, antes de leer la
13
+ * configuracion y antes de importar nada que pueda reventar por esto mismo.
14
+ */
15
+ export function comprobarNode(version = process.version) {
16
+ const mayor = Number.parseInt(String(version).replace(/^v/, "").split(".")[0], 10);
17
+ // Si no se entiende la version, no es motivo para no dejar trabajar a nadie.
18
+ if (!Number.isFinite(mayor))
19
+ return null;
20
+ if (mayor >= NODE_MINIMO)
21
+ return null;
22
+ return (`Chocolatito Code necesita Node ${NODE_MINIMO} o superior, y estas con ${version}.\n\n` +
23
+ ` Descargalo en https://nodejs.org (la version LTS vale).\n` +
24
+ ` Despues, vuelve a instalarlo: npm i -g chocolatito-code\n\n` +
25
+ `No es un problema de tu licencia ni de tu conexion.`);
26
+ }
package/dist/index.js CHANGED
@@ -29,6 +29,8 @@ import { releaseKeyboard } from "./ui/interrupt.js";
29
29
  import { takeQueued, resetTypeAhead } from "./ui/typeAhead.js";
30
30
  import { mcpManager } from "./mcp/manager.js";
31
31
  import { comprobarActualizacion } from "./config/updater.js";
32
+ import { comprobarNode } from "./config/nodeVersion.js";
33
+ import { imprimirSobreElPrompt } from "./ui/prompt.js";
32
34
  /**
33
35
  * Version leida del package.json instalado, no escrita a mano: una constante
34
36
  * duplicada aqui se queda vieja en cuanto se publica una version nueva.
@@ -178,6 +180,15 @@ async function dispararStop(hooks, datos) {
178
180
  return null;
179
181
  }
180
182
  async function main() {
183
+ // Lo primero de todo: un Node viejo se dice claro, no se descubre a base de
184
+ // errores raros dentro de una dependencia. Ver config/nodeVersion.ts.
185
+ const nodeViejo = comprobarNode();
186
+ if (nodeViejo) {
187
+ console.error(chalk.red(`
188
+ ${nodeViejo}
189
+ `));
190
+ process.exit(EXIT_USO);
191
+ }
181
192
  const rawArgs = process.argv.slice(2);
182
193
  if (handleFlags(rawArgs))
183
194
  return;
@@ -237,9 +248,13 @@ async function main() {
237
248
  // Si tardan, el primer turno los espera; el usuario ya puede escribir.
238
249
  const mcpReady = mcpManager.loadAll().then((resumen) => {
239
250
  if (resumen) {
240
- console.log(chalk.hex("#D97757")("\n🔌 Servidores MCP:"));
241
- console.log(resumen);
242
- console.log(chalk.gray(` ${mcpManager.toolCount} herramientas MCP disponibles.\n`));
251
+ // Con `console.log` esto se escribia ENCIMA del marco del prompt y lo
252
+ // destrozaba: los servidores tardan, y para cuando contestan el prompt ya
253
+ // esta dibujado. Se vio asi en una sesion real del usuario:
254
+ // ✔ ide (http): 4 herramientasb modo · esc cancelar
255
+ imprimirSobreElPrompt(chalk.hex("#D97757")("\n🔌 Servidores MCP:") +
256
+ `\n${resumen}\n` +
257
+ chalk.gray(` ${mcpManager.toolCount} herramientas MCP disponibles.\n`));
243
258
  }
244
259
  return resumen;
245
260
  });
@@ -256,11 +271,11 @@ async function main() {
256
271
  })
257
272
  .then((aviso) => {
258
273
  avisoDeActualizacion = aviso;
259
- // Se escribe solo si hay algo que decir y el usuario no esta a mitad de
260
- // frase: el prompt se redibuja entero en la siguiente tecla, asi que una
261
- // linea de mas aqui no rompe el marco.
274
+ // Llega tarde a proposito, y para entonces el prompt ya suele estar
275
+ // dibujado: por eso NO se usa console.log, que lo pisaria. Ver
276
+ // imprimirSobreElPrompt en ui/prompt.ts.
262
277
  if (aviso)
263
- console.log(chalk.gray(`\n ⬆ ${aviso}\n`));
278
+ imprimirSobreElPrompt(chalk.gray(`\n ⬆ ${aviso}\n`));
264
279
  return aviso;
265
280
  })
266
281
  .catch(() => "");