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.
- package/README.md +66 -0
- package/dist/agent/huecos.d.ts +58 -0
- package/dist/agent/huecos.js +81 -0
- package/dist/agent/loop.js +218 -41
- package/dist/agent/salidaDelAgente.d.ts +43 -0
- package/dist/agent/salidaDelAgente.js +39 -0
- package/dist/agent/toolGate.js +6 -1
- package/dist/config/engine.d.ts +21 -1
- package/dist/config/engine.js +22 -1
- package/dist/config/nodeVersion.d.ts +13 -0
- package/dist/config/nodeVersion.js +26 -0
- package/dist/index.js +22 -7
- package/dist/mcp/client.d.ts +28 -0
- package/dist/mcp/client.js +62 -8
- package/dist/mcp/ide.d.ts +76 -0
- package/dist/mcp/ide.js +160 -0
- package/dist/mcp/manager.js +9 -0
- package/dist/tools/browserExtension.js +153 -7
- package/dist/tools/computerUse.d.ts +1 -1
- package/dist/tools/computerUse.js +183 -2
- package/dist/tools/definitions.js +9 -2
- package/dist/tools/grepSearch.js +111 -23
- package/dist/tools/runner.js +1 -1
- package/dist/tools/toolDefsComputer.js +6 -2
- package/dist/tools/visionBridge.js +60 -3
- package/dist/tools/win/hostScript.js +154 -0
- package/dist/tools/writeFile.d.ts +16 -1
- package/dist/tools/writeFile.js +22 -1
- package/dist/ui/interrupt.d.ts +10 -0
- package/dist/ui/interrupt.js +53 -4
- package/dist/ui/keyboardGuard.d.ts +21 -0
- package/dist/ui/keyboardGuard.js +30 -1
- package/dist/ui/pegados.d.ts +50 -0
- package/dist/ui/pegados.js +83 -0
- package/dist/ui/pieFijo.d.ts +56 -0
- package/dist/ui/pieFijo.js +309 -0
- package/dist/ui/prompt.d.ts +5 -0
- package/dist/ui/prompt.js +109 -16
- package/dist/ui/reasoningStream.js +22 -12
- package/dist/ui/salida.d.ts +10 -0
- package/dist/ui/salida.js +50 -0
- package/dist/ui/spinner.d.ts +15 -8
- package/dist/ui/spinner.js +80 -42
- package/extension/background.js +191 -14
- package/extension/content.js +446 -48
- package/extension/fuentes/Chocolatito-Marca.ttf +0 -0
- package/extension/iconos/128.png +0 -0
- package/extension/iconos/16.png +0 -0
- package/extension/iconos/32.png +0 -0
- package/extension/iconos/48.png +0 -0
- package/extension/iconos/ORIGEN.md +48 -0
- package/extension/iconos/logotipo.png +0 -0
- package/extension/manifest.json +27 -4
- package/extension/popup.html +16 -6
- 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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
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"],
|
package/dist/tools/grepSearch.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/tools/runner.js
CHANGED
|
@@ -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) {
|