chocolatito-code 1.1.0 → 1.3.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.
Files changed (55) hide show
  1. package/README.md +66 -0
  2. package/dist/agent/huecos.d.ts +58 -0
  3. package/dist/agent/huecos.js +81 -0
  4. package/dist/agent/loop.js +218 -41
  5. package/dist/agent/salidaDelAgente.d.ts +43 -0
  6. package/dist/agent/salidaDelAgente.js +39 -0
  7. package/dist/agent/toolGate.js +6 -1
  8. package/dist/config/engine.d.ts +21 -1
  9. package/dist/config/engine.js +22 -1
  10. package/dist/config/nodeVersion.d.ts +13 -0
  11. package/dist/config/nodeVersion.js +26 -0
  12. package/dist/index.js +22 -7
  13. package/dist/mcp/client.d.ts +28 -0
  14. package/dist/mcp/client.js +62 -8
  15. package/dist/mcp/ide.d.ts +76 -0
  16. package/dist/mcp/ide.js +160 -0
  17. package/dist/mcp/manager.js +9 -0
  18. package/dist/tools/browserExtension.js +153 -7
  19. package/dist/tools/computerUse.d.ts +1 -1
  20. package/dist/tools/computerUse.js +183 -2
  21. package/dist/tools/definitions.js +9 -2
  22. package/dist/tools/grepSearch.js +111 -23
  23. package/dist/tools/runner.js +1 -1
  24. package/dist/tools/toolDefsComputer.js +6 -2
  25. package/dist/tools/visionBridge.js +60 -3
  26. package/dist/tools/win/hostScript.js +154 -0
  27. package/dist/tools/writeFile.d.ts +16 -1
  28. package/dist/tools/writeFile.js +22 -1
  29. package/dist/ui/interrupt.d.ts +10 -0
  30. package/dist/ui/interrupt.js +53 -4
  31. package/dist/ui/keyboardGuard.d.ts +21 -0
  32. package/dist/ui/keyboardGuard.js +30 -1
  33. package/dist/ui/pegados.d.ts +50 -0
  34. package/dist/ui/pegados.js +83 -0
  35. package/dist/ui/pieFijo.d.ts +56 -0
  36. package/dist/ui/pieFijo.js +309 -0
  37. package/dist/ui/prompt.d.ts +5 -0
  38. package/dist/ui/prompt.js +109 -16
  39. package/dist/ui/reasoningStream.js +22 -12
  40. package/dist/ui/salida.d.ts +10 -0
  41. package/dist/ui/salida.js +50 -0
  42. package/dist/ui/spinner.d.ts +15 -8
  43. package/dist/ui/spinner.js +80 -42
  44. package/extension/background.js +191 -14
  45. package/extension/content.js +446 -48
  46. package/extension/fuentes/Chocolatito-Marca.ttf +0 -0
  47. package/extension/iconos/128.png +0 -0
  48. package/extension/iconos/16.png +0 -0
  49. package/extension/iconos/32.png +0 -0
  50. package/extension/iconos/48.png +0 -0
  51. package/extension/iconos/ORIGEN.md +48 -0
  52. package/extension/iconos/logotipo.png +0 -0
  53. package/extension/manifest.json +27 -4
  54. package/extension/popup.html +16 -6
  55. package/package.json +3 -2
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import os from "node:os";
4
+ import { fileURLToPath } from "node:url";
4
5
  import { WebSocketServer, WebSocket } from "ws";
5
6
  import { describeScreen } from "./visionBridge.js";
6
7
  /**
@@ -46,6 +47,8 @@ class ExtensionBridge {
46
47
  rejectedOrigin = null;
47
48
  port = 0;
48
49
  start() {
50
+ if (this.wss)
51
+ return Promise.resolve();
49
52
  if (this.starting)
50
53
  return this.starting;
51
54
  this.starting = (async () => {
@@ -54,11 +57,14 @@ class ExtensionBridge {
54
57
  if (ok) {
55
58
  this.port = port;
56
59
  this.portTaken = false;
60
+ this.starting = null;
57
61
  return;
58
62
  }
59
63
  }
60
- // Todos ocupados: hay tantas sesiones como puertos.
64
+ // #78: Todos ocupados temporalmente. Reseteamos starting para que
65
+ // una llamada posterior pueda reintentar si otra sesión liberó su puerto.
61
66
  this.portTaken = true;
67
+ this.starting = null;
62
68
  })();
63
69
  return this.starting;
64
70
  }
@@ -135,12 +141,29 @@ class ExtensionBridge {
135
141
  waiter.resolve(msg);
136
142
  });
137
143
  ws.on("close", () => {
138
- if (this.client === ws)
144
+ if (this.client === ws) {
139
145
  this.client = null;
146
+ // #52: Si la conexión se cae a media acción, rechazar de inmediato
147
+ // las peticiones en vuelo en lugar de esperar 45 s al timeout.
148
+ const errDesconexion = "La conexión con la extensión de Chrome se cerró inesperadamente durante la ejecución de la acción. " +
149
+ "Comprueba si la pestaña o el navegador se han cerrado.";
150
+ for (const [id, waiter] of this.pending.entries()) {
151
+ clearTimeout(waiter.timer);
152
+ waiter.resolve({ ok: false, error: errDesconexion });
153
+ }
154
+ this.pending.clear();
155
+ }
140
156
  });
141
- ws.on("error", () => {
142
- if (this.client === ws)
157
+ ws.on("error", (err) => {
158
+ if (this.client === ws) {
143
159
  this.client = null;
160
+ const msgError = `Error en el socket con la extensión de Chrome: ${err?.message || err}`;
161
+ for (const [id, waiter] of this.pending.entries()) {
162
+ clearTimeout(waiter.timer);
163
+ waiter.resolve({ ok: false, error: msgError });
164
+ }
165
+ this.pending.clear();
166
+ }
144
167
  });
145
168
  });
146
169
  });
@@ -167,6 +190,10 @@ class ExtensionBridge {
167
190
  * eso la primera espera es generosa y las siguientes, cortas.
168
191
  */
169
192
  async waitForClient(ms) {
193
+ // #78: Si estaba marcado como ocupado pero otra sesión se cerró, reintentar enlazar
194
+ if (this.portTaken && !this.wss) {
195
+ await this.start();
196
+ }
170
197
  const budget = ms ?? (this.everConnected ? 4000 : 12_000);
171
198
  return this.waitUpTo(budget);
172
199
  }
@@ -224,6 +251,11 @@ class ExtensionBridge {
224
251
  try {
225
252
  this.client?.close();
226
253
  this.wss?.close();
254
+ this.wss = null;
255
+ this.client = null;
256
+ this.starting = null;
257
+ this.portTaken = false;
258
+ this.port = 0;
227
259
  }
228
260
  catch { }
229
261
  }
@@ -247,6 +279,55 @@ function emparejar(objetivo, origen, destino) {
247
279
  return candidatos[pos].ref;
248
280
  return null;
249
281
  }
282
+ /**
283
+ * Avisar cuando la extension de Chrome se quedo atras.
284
+ *
285
+ * POR QUE HACE FALTA
286
+ *
287
+ * El CLI se actualiza solo, y la extension viaja DENTRO del paquete npm. Asi
288
+ * que al actualizarse, los archivos de la extension en el disco pasan a ser
289
+ * nuevos... pero Chrome sigue ejecutando los que cargo en su dia. No se entera
290
+ * hasta que alguien pulsa "recargar" en chrome://extensions.
291
+ *
292
+ * Eso deja al usuario con una version del CLI que espera cosas que su extension
293
+ * no sabe hacer, y los fallos que salen de ahi no se parecen en nada a su causa:
294
+ * una captura que llega en PNG cuando el CLI ya cuenta con JPEG, una accion que
295
+ * contesta distinto de lo que se espera. Sintomas raros, causa invisible.
296
+ *
297
+ * La extension ya decia su version en cada `status` y nadie la miraba. Ahora se
298
+ * compara con la que trae este CLI, y si no cuadran se dice una vez.
299
+ */
300
+ let yaAvisadoDeVersion = false;
301
+ /** La version de extension que trae ESTE CLI, leida del paquete instalado. */
302
+ function versionEsperadaDeExtension() {
303
+ try {
304
+ const aqui = path.dirname(fileURLToPath(import.meta.url));
305
+ const manifiesto = path.resolve(aqui, "..", "..", "extension", "manifest.json");
306
+ return String(JSON.parse(fs.readFileSync(manifiesto, "utf-8")).version || "");
307
+ }
308
+ catch {
309
+ // Sin manifiesto no se puede comparar, y adivinar seria peor que callar.
310
+ return "";
311
+ }
312
+ }
313
+ /**
314
+ * Aviso si la extension va por detras. Cadena vacia si todo cuadra o no se sabe.
315
+ *
316
+ * Solo se avisa UNA vez por sesion: repetirlo en cada accion seria ruido que
317
+ * acaba ignorandose, que es como se pierden los avisos que importan.
318
+ */
319
+ function avisoDeVersion(deLaExtension) {
320
+ if (yaAvisadoDeVersion)
321
+ return "";
322
+ const esperada = versionEsperadaDeExtension();
323
+ if (!esperada || !deLaExtension || esperada === deLaExtension)
324
+ return "";
325
+ yaAvisadoDeVersion = true;
326
+ return (`\n\nAVISO: tu extension de Chrome es la v${deLaExtension} y este Chocolatito trae la v${esperada}. ` +
327
+ `Chrome sigue ejecutando la que cargo en su dia aunque los archivos del disco ya sean nuevos. ` +
328
+ `Recargala en chrome://extensions (boton de recargar en la tarjeta de Chocolatito Code) o ` +
329
+ `veras fallos raros que no se parecen a su causa.`);
330
+ }
250
331
  class CatalogoDeRefs {
251
332
  /** Lo que vio el modelo: los numeros en los que habla. */
252
333
  vista = [];
@@ -284,6 +365,10 @@ class CatalogoDeRefs {
284
365
  return this.filtroVista;
285
366
  }
286
367
  /** Nombre que tenia ese ref cuando el modelo lo vio. */
368
+ /** Huella de lo que vio el modelo, para poder comparar despues de actuar. */
369
+ huellaVista() {
370
+ return this.vista.map((e) => `${e.role}::${e.name}`);
371
+ }
287
372
  nombreDe(ref) {
288
373
  return this.vista.find((e) => e.ref === ref)?.name || "";
289
374
  }
@@ -322,6 +407,61 @@ async function refrescarCatalogo(filtro) {
322
407
  catalogo.verInterno(fresh.data.elements || []);
323
408
  return true;
324
409
  }
410
+ /**
411
+ * Comprobar que el clic hizo ALGO, en vez de dar por hecho que si.
412
+ *
413
+ * EL FALLO, VISTO EN UNA SESION REAL
414
+ *
415
+ * En Google Flow, el agente pulso el boton de generar y la herramienta contesto:
416
+ *
417
+ * Pulsado [19] "Iniciar generación". Pagina ahora: "Google Flow: sept 06..."
418
+ *
419
+ * El modelo leyo eso como "hecho" y se puso a esperar el resultado. Pero el clic
420
+ * no habia disparado nada: la pagina seguia igual. Espero, no vio nada, volvio a
421
+ * pulsar, volvio a esperar, gasto una captura de pantalla, y solo se desatasco
422
+ * cuando el usuario le dijo a mano "dale al boton de la flechita".
423
+ *
424
+ * El problema no fue del modelo: la herramienta le habia dicho que el clic se
425
+ * habia dado. Y "se envio el evento" no es lo mismo que "la pagina reacciono".
426
+ * Una web puede ignorar un clic sintetico por mil motivos -el manejador esta en
427
+ * otro elemento, hay un overlay delante, el framework escucha pointerdown y no
428
+ * click-, y ninguno de esos se ve desde fuera.
429
+ *
430
+ * QUE SE HACE AHORA
431
+ *
432
+ * Se mira la pagina antes y despues. Si no cambio absolutamente nada, se dice —
433
+ * que es informacion util y no un error: significa "el evento salio pero nadie
434
+ * lo recogio, prueba otra cosa". Es la diferencia entre que el agente se quede
435
+ * quince segundos esperando algo que no va a pasar, y que cambie de estrategia
436
+ * en el turno siguiente.
437
+ */
438
+ async function reaccionAlClic(filtro, antes) {
439
+ let fresh;
440
+ try {
441
+ fresh = await extensionBridge.send("snapshot", { filter: filtro || "", max: 200 });
442
+ }
443
+ catch {
444
+ return "";
445
+ }
446
+ if (!fresh?.ok)
447
+ return "";
448
+ const els = fresh.data.elements || [];
449
+ catalogo.verInterno(els);
450
+ const ahora = els.map((e) => `${e.role}::${e.name}`);
451
+ const nuevos = ahora.filter((k) => !antes.includes(k));
452
+ const idos = antes.filter((k) => !ahora.includes(k));
453
+ if (nuevos.length === 0 && idos.length === 0) {
454
+ return (`\nOJO: el clic se envio, pero la pagina NO cambio en nada. Eso suele significar que ` +
455
+ `el elemento pulsado no es el que dispara la accion (el manejador esta en otro, hay algo ` +
456
+ `delante, o el framework escucha otro evento). No esperes un resultado: haz snapshot y ` +
457
+ `busca el control de verdad, o usa computer_use para un clic real del sistema.`);
458
+ }
459
+ const resumen = [
460
+ ...nuevos.slice(0, 6).map((k) => ` + ${k.replace("::", " ")}`),
461
+ ...idos.slice(0, 4).map((k) => ` - ${k.replace("::", " ")} (ya no esta)`),
462
+ ].join("\n");
463
+ return `\nLa pagina reacciono (${nuevos.length} nuevos, ${idos.length} fuera):\n${resumen}`;
464
+ }
325
465
  /**
326
466
  * Ejecuta una accion sobre un ref del snapshot del modelo, traduciendolo al
327
467
  * numero que la extension tiene vivo ahora mismo. Si el ref ha caducado (el
@@ -390,7 +530,8 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
390
530
  `Si no la reconoces, revisala en chrome://extensions.`
391
531
  : "";
392
532
  return (`Extension conectada (v${res.data.version}), emparejada con ${origin}. ` +
393
- `Puedo trabajar en tus pestanas en segundo plano, sin robarte el foco.${aviso}`);
533
+ `Puedo trabajar en tus pestanas en segundo plano, sin robarte el foco.${aviso}` +
534
+ avisoDeVersion(String(res.data.version || "")));
394
535
  }
395
536
  case "tabs": {
396
537
  const res = await extensionBridge.send("tabs");
@@ -469,6 +610,9 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
469
610
  case "click": {
470
611
  if (params.ref === undefined)
471
612
  return fail(action, 'indica el "ref" del snapshot.');
613
+ // Se apunta como estaba la pagina ANTES: sin eso no hay con que comparar
614
+ // despues, y "se envio el clic" seguiria pasando por "la pagina reacciono".
615
+ const antesDelClic = catalogo.huellaVista();
472
616
  const res = await accionSobreRef("click", {}, params.ref, params.filter);
473
617
  if (!res.ok)
474
618
  return fail(action, res.error);
@@ -481,7 +625,9 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
481
625
  const aviso = mismoElemento(pedido, pulsado)
482
626
  ? ""
483
627
  : `\nAVISO: pediste "${pedido}" y se pulso "${pulsado}". Haz snapshot y comprueba en que estado quedo la pagina.`;
484
- return (`Pulsado [${params.ref}] "${pulsado}". Pagina ahora: "${res.data.title}" (${res.data.url})${aviso}`);
628
+ // Y lo que de verdad importa: si la pagina hizo algo o no. Ver reaccionAlClic.
629
+ const reaccion = antesDelClic.length > 0 ? await reaccionAlClic(params.filter || "", antesDelClic) : "";
630
+ return (`Pulsado [${params.ref}] "${pulsado}". Pagina ahora: "${res.data.title}" (${res.data.url})${aviso}${reaccion}`);
485
631
  }
486
632
  case "type": {
487
633
  if (params.ref === undefined)
@@ -620,7 +766,7 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
620
766
  ? path.isAbsolute(params.outputPath)
621
767
  ? params.outputPath
622
768
  : path.resolve(cwd, params.outputPath)
623
- : path.join(dir, "chrome_tab.png");
769
+ : path.join(dir, "chrome_tab.jpg");
624
770
  const outDir = path.dirname(out);
625
771
  if (!fs.existsSync(outDir))
626
772
  fs.mkdirSync(outDir, { recursive: true });
@@ -23,7 +23,7 @@
23
23
  * "background": se actua sobre una ventana concreta sin traerla al frente y
24
24
  * sin mover el raton del usuario.
25
25
  */
26
- export type ComputerAction = "screenshot" | "ui_snapshot" | "ui_click" | "ui_type" | "ui_focus" | "find_element" | "left_click" | "click" | "double_click" | "triple_click" | "right_click" | "middle_click" | "mouse_move" | "move" | "left_click_drag" | "scroll" | "type" | "key" | "hotkey" | "wait" | "sleep" | "cursor_position" | "list_windows" | "focus_window" | "get_active_window" | "open_app";
26
+ export type ComputerAction = "screenshot" | "ui_snapshot" | "ui_click" | "ui_type" | "ui_focus" | "find_element" | "left_click" | "click" | "double_click" | "triple_click" | "right_click" | "middle_click" | "mouse_move" | "move" | "left_click_drag" | "scroll" | "type" | "key" | "hotkey" | "wait" | "wait_change" | "sleep" | "cursor_position" | "list_windows" | "focus_window" | "get_active_window" | "open_app";
27
27
  export interface ComputerUseParams {
28
28
  action: ComputerAction;
29
29
  /** Ventana objetivo por titulo o proceso (ej. "chrome", "Flow"). */
@@ -68,6 +68,155 @@ function ambiguityNote(p, choice) {
68
68
  function fail(action, msg) {
69
69
  return `Error en computer_use (${action}): ${msg}`;
70
70
  }
71
+ /**
72
+ * El estado de la ventana DESPUES de actuar, en el mismo resultado.
73
+ *
74
+ * POR QUE
75
+ *
76
+ * El ciclo de trabajo del agente es mirar, actuar, mirar. Hasta ahora eso
77
+ * costaba TRES viajes al modelo:
78
+ *
79
+ * ui_snapshot -> llamada al modelo
80
+ * ui_click -> llamada al modelo (devolvia solo "Activado [3]")
81
+ * ui_snapshot -> llamada al modelo (para ver que habia pasado)
82
+ *
83
+ * Y cada viaje son entre tres y diez segundos. Medido en esta maquina, un
84
+ * ui_snapshot cuesta 43 ms: es GRATIS al lado de una llamada al modelo. Asi que
85
+ * el tercer viaje se hacia solo para pedir algo que el ordenador podia haber
86
+ * adjuntado desde el principio.
87
+ *
88
+ * Devolviendo el estado nuevo con la accion, el ciclo pasa de tres viajes a
89
+ * dos. En una tarea de diez pasos son diez viajes menos: medio minuto largo.
90
+ *
91
+ * CUANDO NO SE HACE
92
+ *
93
+ * Si la accion fallo no se adjunta nada: lo que hace falta entonces es el
94
+ * motivo del fallo, y una lista de controles solo lo entierra.
95
+ */
96
+ /**
97
+ * Mirar una ventana muchas veces sin pagarlo muchas veces.
98
+ *
99
+ * EL PROBLEMA
100
+ *
101
+ * Leer el arbol de accesibilidad cuesta 43 ms: es gratis. Lo que no es gratis
102
+ * es MANDARSELO al modelo. Un ui_snapshot de una ventana normal son cien lineas
103
+ * que entran en el historial y se vuelven a pagar en cada turno siguiente. Asi
104
+ * que mirar seguido -que es lo que hace falta para trabajar en tiempo real- sale
105
+ * caro por el lado del contexto, no por el de la maquina.
106
+ *
107
+ * LA IDEA
108
+ *
109
+ * Casi nunca cambia la ventana entera. Se pulsa un boton y se habilita otro; se
110
+ * escribe en un campo y cambia su valor. De cien controles cambian dos.
111
+ *
112
+ * Guardando la ultima foto de cada ventana se puede devolver solo la diferencia:
113
+ *
114
+ * CAMBIOS EN "Calculadora" (3 de 47 controles):
115
+ * ~ [12] Text "0" -> "42"
116
+ * + [31] Button "Igual" (nuevo)
117
+ * - Button "Limpiar" (ya no esta)
118
+ *
119
+ * Tres lineas en vez de cuarenta y siete. Eso es lo que hace viable mirar
120
+ * despues de cada accion sin inflar el contexto.
121
+ *
122
+ * CUANDO SE DEVUELVE LA FOTO ENTERA
123
+ *
124
+ * La primera vez, cuando no hay con que comparar. Y cuando cambia tanto que el
125
+ * resumen ya no es mas corto que la lista: si cambio media ventana, es que se
126
+ * abrio otra pantalla, y ahi lo util es verla entera.
127
+ */
128
+ /** La ultima foto de cada ventana, para poder contar solo lo que cambio. */
129
+ const ultimaFoto = new Map();
130
+ /** Identidad de un control que NO depende de su numero de ref, que se renumera. */
131
+ function claveDe(e) {
132
+ return `${e.role}::${e.name}`;
133
+ }
134
+ function compararCon(clave, els) {
135
+ const ahora = new Map();
136
+ for (const e of els)
137
+ ahora.set(claveDe(e), String(e.value ?? ""));
138
+ const antes = ultimaFoto.get(clave);
139
+ ultimaFoto.set(clave, ahora);
140
+ if (!antes)
141
+ return { hayFoto: false, texto: "", cuantos: 0 };
142
+ const lineas = [];
143
+ const porClave = new Map(els.map((e) => [claveDe(e), e]));
144
+ for (const [k, valor] of ahora) {
145
+ const e = porClave.get(k);
146
+ if (!antes.has(k)) {
147
+ lineas.push(` + [${e.ref}] ${e.role} "${e.name}" (nuevo)`);
148
+ }
149
+ else if (antes.get(k) !== valor) {
150
+ lineas.push(` ~ [${e.ref}] ${e.role} "${e.name}": "${antes.get(k)}" -> "${valor}"`);
151
+ }
152
+ }
153
+ for (const k of antes.keys()) {
154
+ if (!ahora.has(k)) {
155
+ const [role, name] = k.split("::");
156
+ lineas.push(` - ${role} "${name}" (ya no esta)`);
157
+ }
158
+ }
159
+ return { hayFoto: true, texto: lineas.join("\n"), cuantos: lineas.length };
160
+ }
161
+ /** Cuantos controles se adjuntan. Bastantes para decidir, pocos para no inundar. */
162
+ const CONTROLES_TRAS_ACTUAR = 40;
163
+ /**
164
+ * Espera a que la ventana se quede quieta, y no un tiempo fijo.
165
+ *
166
+ * Un clic puede abrir un menu, cargar una lista o no hacer nada visible. Dormir
167
+ * 700 ms "por si acaso" es lento cuando no pasa nada y corto cuando si pasa.
168
+ * Se mira dos veces seguidas: si el arbol de controles sale igual, la ventana
169
+ * ya termino.
170
+ */
171
+ async function esperarQuieto(args) {
172
+ let anterior = "";
173
+ for (let intento = 0; intento < 4; intento++) {
174
+ const res = await winHost.send("ui_snapshot", { ...args, max: CONTROLES_TRAS_ACTUAR, interactiveOnly: true }, 20_000);
175
+ if (!res.ok)
176
+ return null;
177
+ const huella = JSON.stringify((res.elements || []).map((e) => [e.role, e.name, e.value]));
178
+ if (huella === anterior)
179
+ return res;
180
+ anterior = huella;
181
+ await new Promise((r) => setTimeout(r, 120));
182
+ }
183
+ // Cuatro vueltas sin quedarse quieta: la ventana esta animando algo. Se
184
+ // devuelve lo ultimo visto, que es mas util que nada.
185
+ return null;
186
+ }
187
+ /** Lo que se pega al resultado de una accion. Cadena vacia si no hay nada que contar. */
188
+ async function estadoTrasActuar(args) {
189
+ let res;
190
+ try {
191
+ res = await esperarQuieto(args);
192
+ }
193
+ catch {
194
+ return "";
195
+ }
196
+ const els = res?.elements || [];
197
+ if (els.length === 0)
198
+ return "";
199
+ // Si hay foto anterior de esta ventana, con decir lo que cambio basta: tres
200
+ // lineas en vez de cuarenta, y el contexto no se infla en cada clic.
201
+ const cambios = compararCon(String(res.title || ""), els);
202
+ if (cambios.hayFoto && cambios.cuantos > 0 && cambios.cuantos <= els.length / 2) {
203
+ return `\n\nCAMBIOS EN "${res.title}" (${cambios.cuantos} de ${els.length} controles):\n${cambios.texto}`;
204
+ }
205
+ if (cambios.hayFoto && cambios.cuantos === 0) {
206
+ return `\n\nLa ventana "${res.title}" no cambio en nada visible.`;
207
+ }
208
+ // Primera vez, o cambio tanto que el resumen ya no seria mas corto: entonces
209
+ // lo util es la lista entera, porque eso significa que se abrio otra pantalla.
210
+ const filas = els
211
+ .map((e) => {
212
+ const val = e.value ? ` = "${e.value}"` : "";
213
+ const off = e.enabled ? "" : " [deshabilitado]";
214
+ return ` [${e.ref}] ${e.role} "${e.name}"${val}${off} @(${e.x}, ${e.y})`;
215
+ })
216
+ .join("\n");
217
+ return (`\n\nASI HA QUEDADO "${res.title}" (los numeros son NUEVOS, usa estos):\n${filas}` +
218
+ (res.truncated ? `\n … hay mas controles; pide ui_snapshot con 'max' mas alto si te falta alguno.` : ""));
219
+ }
71
220
  export async function computerUse(params, cwd = process.cwd(), apiKey) {
72
221
  const { action } = params;
73
222
  const mode = params.mode || "background";
@@ -151,6 +300,10 @@ export async function computerUse(params, cwd = process.cwd(), apiKey) {
151
300
  `Si es un lienzo grafico, usa screenshot con analyze.` +
152
301
  ambiguityNote(params, choice));
153
302
  }
303
+ // Se apunta esta foto como referencia: si no, el resumen de cambios de la
304
+ // siguiente accion comparara contra una foto vieja y contara cambios que
305
+ // el modelo ya ha visto.
306
+ compararCon(String(res.title || ""), els);
154
307
  const rows = els
155
308
  .map((e) => {
156
309
  const val = e.value ? ` = "${e.value}"` : "";
@@ -163,13 +316,38 @@ export async function computerUse(params, cwd = process.cwd(), apiKey) {
163
316
  `Actua con ui_click / ui_type usando el numero entre corchetes. No hace falta mover el raton.` +
164
317
  ambiguityNote(params, choice));
165
318
  }
319
+ // Esperar a que la ventana haga algo, en vez de dormir un rato y mirar.
320
+ //
321
+ // Antes solo existia "wait", que duerme un numero fijo de milisegundos:
322
+ // corto cuando la aplicacion tarda y desperdiciado cuando no tarda nada.
323
+ // Esto se suscribe a los avisos de la propia ventana y vuelve en cuanto
324
+ // pasa algo. Medido en vivo: 275 ms contra los 2 s que se habrian dormido.
325
+ case "wait_change": {
326
+ const elegida = await resolveWindow(params);
327
+ if (!elegida.ok)
328
+ return fail(action, elegida.error);
329
+ const ms = Math.min(Math.max(params.ms ?? 3000, 100), 30_000);
330
+ const res = await winHost.send("wait_change", { ...pinned(elegida), ...(elegida.hwnd ? {} : windowArgs(params)), timeoutMs: ms }, ms + 10_000);
331
+ if (!res.ok)
332
+ return fail(action, res.error || "fallo desconocido");
333
+ if (!res.changed) {
334
+ return (`La ventana "${elegida.title || params.window}" no cambio en ${ms} ms. ` +
335
+ `Si esperabas una reaccion, puede que la accion anterior no surtiera efecto.`);
336
+ }
337
+ // Y ya que se vuelve, se dice COMO quedo: si no, hace falta otro viaje.
338
+ const despues = await estadoTrasActuar(windowArgs(params));
339
+ return `Cambio: ${res.what}.${despues}`;
340
+ }
166
341
  case "ui_click": {
167
342
  if (!params.ref)
168
343
  return fail(action, 'indica el "ref" que devolvio ui_snapshot.');
169
344
  const res = await winHost.send("ui_click", { ref: params.ref }, 20_000);
170
345
  if (!res.ok)
171
346
  return fail(action, res.error || "fallo desconocido");
172
- return `Activado [${params.ref}] ${res.label || ""} (via ${res.via}, sin mover el raton).`;
347
+ // El estado nuevo va aqui y no en otro viaje al modelo: cuesta 43 ms en
348
+ // la maquina contra varios segundos de ida y vuelta. Ver estadoTrasActuar.
349
+ const despues = await estadoTrasActuar(windowArgs(params));
350
+ return `Activado [${params.ref}] ${res.label || ""} (via ${res.via}, sin mover el raton).${despues}`;
173
351
  }
174
352
  case "ui_type": {
175
353
  if (!params.ref)
@@ -221,8 +399,11 @@ export async function computerUse(params, cwd = process.cwd(), apiKey) {
221
399
  interactiveOnly: false,
222
400
  });
223
401
  const landed = (check.elements || []).length > 0;
402
+ // Igual que en ui_click: el estado nuevo viaja con la accion en vez de
403
+ // costar otra ida y vuelta al modelo.
404
+ const tras = landed ? await estadoTrasActuar(windowArgs(params)) : "";
224
405
  return landed
225
- ? `Texto escrito en [${params.ref}] con clic y teclado reales, y verificado en el control: "${preview(params.text)}"`
406
+ ? `Texto escrito en [${params.ref}] con clic y teclado reales, y verificado en el control: "${preview(params.text)}"${tras}`
226
407
  : `Aviso: se escribio en [${params.ref}] pero el control no muestra el texto. ` +
227
408
  `Puede que el foco se perdiera. Haz ui_snapshot y reintenta antes de dar por hecho que funciono.`;
228
409
  }
@@ -205,7 +205,9 @@ export const TOOLS = [
205
205
  type: "function",
206
206
  function: {
207
207
  name: "write_file",
208
- description: "Crea un archivo nuevo o sobreescribe uno existente con el contenido provisto. Si el archivo ya existe hay que haberlo leido antes con view_file: sobreescribir borra todo lo que hubiera dentro.",
208
+ description: "Crea un archivo nuevo o sobreescribe uno existente. Si el archivo ya existe hay que haberlo leido antes con view_file: sobreescribir borra todo lo que hubiera dentro. " +
209
+ "ARCHIVOS LARGOS: tu respuesta tiene un techo de salida y una pagina entera no cabe de una vez; si se corta, no se escribe NADA. " +
210
+ "Cuando el archivo pase de unas 400 lineas, parte el trabajo: primera llamada con el principio, y las siguientes con modo=\"anadir\" hasta terminarlo.",
209
211
  parameters: {
210
212
  type: "object",
211
213
  properties: {
@@ -215,7 +217,12 @@ export const TOOLS = [
215
217
  },
216
218
  content: {
217
219
  type: "string",
218
- description: "El contenido completo del archivo.",
220
+ description: "El contenido del archivo, o el trozo que toca si vas por partes.",
221
+ },
222
+ modo: {
223
+ type: "string",
224
+ enum: ["sobrescribir", "anadir"],
225
+ description: "sobrescribir (por defecto) reemplaza el archivo entero. anadir pega el contenido al final, para escribir un archivo largo en varias llamadas sin que la segunda borre la primera.",
219
226
  },
220
227
  },
221
228
  required: ["path", "content"],
@@ -1,37 +1,125 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import fg from "fast-glob";
4
+ /**
5
+ * Buscar texto dentro de los archivos.
6
+ *
7
+ * TRES FALLOS QUE TENIA, LOS TRES VISTOS EN USO REAL
8
+ *
9
+ * 1. Apuntar a UN archivo la reventaba. `fast-glob` recibia ese archivo como
10
+ * `cwd` y tiraba `ENOTDIR: not a directory`, que llegaba al modelo como una
11
+ * excepcion cruda. Paso en directo: el agente quiso mirar dentro de un .md
12
+ * concreto y se comio el error. Buscar dentro de un archivo es lo mas normal
13
+ * del mundo y era justo lo que no se podia hacer.
14
+ *
15
+ * 2. La consulta se metia tal cual en `new RegExp()`. Buscar "calcular(" o
16
+ * "array[0]" -texto plano, lo que escribe cualquiera- lanzaba un
17
+ * SyntaxError sin capturar. El agente no tiene por que saber que su busqueda
18
+ * era tambien una expresion regular invalida.
19
+ *
20
+ * 3. Cortaba en 30 coincidencias y las presentaba como si fueran todas. El
21
+ * agente concluia "solo hay 30 usos" y decidia sobre eso.
22
+ */
23
+ /** Cuantas coincidencias se detallan. Mas no cabe en un resultado de herramienta. */
24
+ const MAX_COINCIDENCIAS = 100;
25
+ /** Tope de archivos que se abren. Un repositorio enorme no puede colgar el turno. */
26
+ const MAX_ARCHIVOS = 3000;
27
+ const IGNORAR = [
28
+ "**/node_modules/**",
29
+ "**/.git/**",
30
+ "**/dist/**",
31
+ "**/*.lock",
32
+ "**/*.png",
33
+ "**/*.jpg",
34
+ "**/*.mp4",
35
+ "**/*.webm",
36
+ "**/*.zip",
37
+ "**/*.bin",
38
+ ];
39
+ /** Escapa el texto para buscarlo literal, cuando no es una regex valida. */
40
+ function comoLiteral(texto) {
41
+ return texto.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42
+ }
43
+ /**
44
+ * La consulta, entendida como expresion regular si se puede y como texto si no.
45
+ *
46
+ * Se avisa de cual de las dos cosas se hizo: buscar "coste(" literalmente y
47
+ * buscarlo como regex dan resultados distintos, y el agente decide sobre lo que
48
+ * lee.
49
+ */
50
+ function construirRegex(query) {
51
+ try {
52
+ return { regex: new RegExp(query, "i"), literal: false };
53
+ }
54
+ catch {
55
+ return { regex: new RegExp(comoLiteral(query), "i"), literal: true };
56
+ }
57
+ }
4
58
  export async function grepSearch(query, searchPath = ".", cwd = process.cwd()) {
59
+ if (!query)
60
+ return "Error: no se dijo que buscar.";
5
61
  const resolved = path.isAbsolute(searchPath) ? searchPath : path.resolve(cwd, searchPath);
6
- if (!fs.existsSync(resolved)) {
62
+ let stat;
63
+ try {
64
+ stat = fs.statSync(resolved);
65
+ }
66
+ catch {
7
67
  return `Error: La ruta "${searchPath}" no existe.`;
8
68
  }
9
- const files = await fg(["**/*"], {
10
- cwd: resolved,
11
- onlyFiles: true,
12
- ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/*.lock", "**/*.png", "**/*.jpg", "**/*.mp4", "**/*.webm", "**/*.zip", "**/*.bin"],
13
- });
14
- const matches = [];
15
- const regex = new RegExp(query, "i");
16
- for (const relFile of files) {
17
- if (matches.length >= 30)
18
- break; // Limit to 30 matches
19
- const fullPath = path.join(resolved, relFile);
69
+ // Un archivo suelto se busca dentro de el, que es lo que pide quien lo apunta.
70
+ // Antes esto llegaba a fast-glob como `cwd` y reventaba con ENOTDIR.
71
+ let raiz;
72
+ let archivos;
73
+ if (stat.isFile()) {
74
+ raiz = path.dirname(resolved);
75
+ archivos = [path.basename(resolved)];
76
+ }
77
+ else {
78
+ raiz = resolved;
79
+ archivos = await fg(["**/*"], { cwd: raiz, onlyFiles: true, ignore: IGNORAR });
80
+ }
81
+ const { regex, literal } = construirRegex(query);
82
+ const coincidencias = [];
83
+ let total = 0;
84
+ let archivosMirados = 0;
85
+ let quedaronArchivos = false;
86
+ for (const relFile of archivos) {
87
+ if (archivosMirados >= MAX_ARCHIVOS) {
88
+ quedaronArchivos = true;
89
+ break;
90
+ }
91
+ archivosMirados++;
92
+ let contenido;
20
93
  try {
21
- const content = fs.readFileSync(fullPath, "utf-8");
22
- const lines = content.split(/\r?\n/);
23
- lines.forEach((line, idx) => {
24
- if (regex.test(line) && matches.length < 30) {
25
- matches.push(`${relFile}:${idx + 1} | ${line.trim()}`);
26
- }
27
- });
94
+ contenido = fs.readFileSync(path.join(raiz, relFile), "utf-8");
28
95
  }
29
96
  catch {
30
- // Ignore binary files or read errors
97
+ // Binario o sin permiso: no es un fallo de la busqueda.
98
+ continue;
99
+ }
100
+ const lineas = contenido.split(/\r?\n/);
101
+ for (let i = 0; i < lineas.length; i++) {
102
+ if (!regex.test(lineas[i]))
103
+ continue;
104
+ total++;
105
+ // Se sigue CONTANDO despues del tope: decir "30" cuando hay 400 es lo que
106
+ // hacia que el agente decidiera sobre un numero falso.
107
+ if (coincidencias.length < MAX_COINCIDENCIAS) {
108
+ coincidencias.push(`${relFile}:${i + 1} | ${lineas[i].trim()}`);
109
+ }
31
110
  }
32
111
  }
33
- if (matches.length === 0) {
34
- return `No se encontraron coincidencias para "${query}" en "${searchPath}".`;
112
+ const nota = literal
113
+ ? `\n\n(La consulta no era una expresion regular valida, asi que se busco como texto literal.)`
114
+ : "";
115
+ if (total === 0) {
116
+ return `No se encontraron coincidencias para "${query}" en "${searchPath}".${nota}`;
35
117
  }
36
- return `Coincidencias encontradas (${matches.length}):\n${matches.join("\n")}`;
118
+ const cabecera = total > coincidencias.length
119
+ ? `Coincidencias: ${total} en total, se muestran las primeras ${coincidencias.length}. Afina la busqueda o apunta a una carpeta mas concreta.`
120
+ : `Coincidencias encontradas (${total}):`;
121
+ const aviso = quedaronArchivos
122
+ ? `\n\n(Se pararon en ${MAX_ARCHIVOS} archivos: puede haber mas fuera de esa cuenta.)`
123
+ : "";
124
+ return `${cabecera}\n${coincidencias.join("\n")}${aviso}${nota}`;
37
125
  }
@@ -164,7 +164,7 @@ export async function executeToolCall(name, args, cwd = process.cwd(), apiKey, m
164
164
  break;
165
165
  }
166
166
  globalUndoManager.snapshotFile(args.path, cwd);
167
- rawResult = await writeFile(args.path, args.content, cwd);
167
+ rawResult = await writeFile(args.path, args.content, cwd, args.modo === "anadir" ? "anadir" : "sobrescribir");
168
168
  fileTracker.markWritten(args.path, cwd);
169
169
  const check = validateFileSyntax(args.path, args.content || "");
170
170
  if (!check.valid && check.warning) {