chocolatito-code 1.0.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 +155 -0
- package/dist/agent/compaction.d.ts +65 -0
- package/dist/agent/compaction.js +134 -0
- package/dist/agent/context.d.ts +3 -0
- package/dist/agent/context.js +35 -0
- package/dist/agent/loop.d.ts +91 -2
- package/dist/agent/loop.js +642 -91
- package/dist/agent/loopDetector.d.ts +57 -0
- package/dist/agent/loopDetector.js +0 -0
- package/dist/agent/mentions.d.ts +13 -0
- package/dist/agent/mentions.js +49 -0
- package/dist/agent/repoMap.d.ts +117 -0
- package/dist/agent/repoMap.js +560 -0
- package/dist/agent/result.d.ts +64 -0
- package/dist/agent/result.js +48 -0
- package/dist/agent/retry.d.ts +35 -0
- package/dist/agent/retry.js +123 -4
- package/dist/agent/salidaDelAgente.d.ts +43 -0
- package/dist/agent/salidaDelAgente.js +53 -0
- package/dist/agent/subagent.js +62 -15
- package/dist/agent/syntaxValidator.js +288 -52
- package/dist/agent/toolGate.d.ts +58 -0
- package/dist/agent/toolGate.js +111 -0
- package/dist/agent/tracker.js +4 -7
- package/dist/agent/undoManager.d.ts +44 -11
- package/dist/agent/undoManager.js +95 -24
- package/dist/agent/usageMeter.d.ts +56 -0
- package/dist/agent/usageMeter.js +51 -0
- package/dist/agent/verifier.d.ts +49 -0
- package/dist/agent/verifier.js +158 -0
- package/dist/config/constants.js +14 -0
- package/dist/config/engine.d.ts +19 -0
- package/dist/config/engine.js +35 -3
- package/dist/config/license.d.ts +5 -0
- package/dist/config/license.js +107 -9
- package/dist/config/limits.d.ts +28 -0
- package/dist/config/limits.js +51 -0
- package/dist/config/nodeVersion.d.ts +13 -0
- package/dist/config/nodeVersion.js +26 -0
- package/dist/config/permissions.d.ts +76 -1
- package/dist/config/permissions.js +379 -23
- package/dist/config/quota.js +24 -1
- package/dist/config/updater.d.ts +68 -0
- package/dist/config/updater.js +215 -0
- package/dist/hooks/manager.js +159 -14
- package/dist/index.js +240 -37
- package/dist/mcp/client.d.ts +50 -0
- package/dist/mcp/client.js +155 -19
- package/dist/mcp/ide.d.ts +76 -0
- package/dist/mcp/ide.js +160 -0
- package/dist/mcp/manager.js +9 -0
- package/dist/prompts/systemPrompt.js +12 -1
- package/dist/sessions/manager.d.ts +12 -0
- package/dist/sessions/manager.js +33 -5
- package/dist/sessions/resume.d.ts +36 -0
- package/dist/sessions/resume.js +43 -0
- package/dist/skills/manager.d.ts +1 -0
- package/dist/skills/manager.js +9 -0
- package/dist/tools/binary.d.ts +46 -0
- package/dist/tools/binary.js +100 -0
- package/dist/tools/browserCdp.js +13 -1
- package/dist/tools/browserExtension.d.ts +5 -0
- package/dist/tools/browserExtension.js +237 -22
- package/dist/tools/browserSession.d.ts +23 -0
- package/dist/tools/browserSession.js +28 -0
- package/dist/tools/computerUse.js +33 -1
- package/dist/tools/createImage.d.ts +11 -0
- package/dist/tools/createImage.js +13 -1
- package/dist/tools/definitions.js +17 -4
- package/dist/tools/editDiagnosis.d.ts +54 -0
- package/dist/tools/editDiagnosis.js +142 -0
- package/dist/tools/editFile.d.ts +1 -0
- package/dist/tools/editFile.js +118 -62
- package/dist/tools/findFiles.d.ts +21 -0
- package/dist/tools/findFiles.js +75 -6
- package/dist/tools/getSystemInfo.js +15 -7
- package/dist/tools/gitTools.d.ts +24 -0
- package/dist/tools/gitTools.js +87 -26
- package/dist/tools/grepSearch.js +111 -23
- package/dist/tools/moveCopyFile.d.ts +18 -1
- package/dist/tools/moveCopyFile.js +74 -4
- package/dist/tools/patchCascade.d.ts +57 -0
- package/dist/tools/patchCascade.js +338 -0
- package/dist/tools/runCommand.js +213 -25
- package/dist/tools/runner.js +58 -5
- package/dist/tools/toolDefsComputer.js +12 -2
- package/dist/tools/truncator.d.ts +27 -1
- package/dist/tools/truncator.js +117 -11
- package/dist/tools/viewFile.js +15 -11
- package/dist/tools/visionBridge.js +7 -0
- package/dist/tools/webSearch.d.ts +21 -0
- package/dist/tools/webSearch.js +125 -14
- package/dist/tools/writeFile.d.ts +16 -1
- package/dist/tools/writeFile.js +40 -2
- package/dist/ui/interrupt.d.ts +4 -1
- package/dist/ui/interrupt.js +59 -7
- package/dist/ui/keyboardGuard.d.ts +76 -0
- package/dist/ui/keyboardGuard.js +94 -0
- package/dist/ui/loopPrompt.d.ts +17 -0
- package/dist/ui/loopPrompt.js +59 -0
- package/dist/ui/pegados.d.ts +50 -0
- package/dist/ui/pegados.js +83 -0
- package/dist/ui/permissionPrompt.js +84 -2
- package/dist/ui/prompt.d.ts +40 -0
- package/dist/ui/prompt.js +436 -131
- package/dist/ui/reasoningStream.d.ts +8 -0
- package/dist/ui/reasoningStream.js +99 -7
- package/dist/ui/salida.d.ts +10 -0
- package/dist/ui/salida.js +50 -0
- package/extension/README.md +95 -74
- package/extension/background.js +772 -181
- package/extension/content.js +527 -46
- 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/logotipo.png +0 -0
- package/extension/manifest.json +28 -4
- package/extension/popup.html +16 -6
- package/extension/popup.js +28 -12
- package/package.json +3 -2
package/dist/skills/manager.js
CHANGED
|
@@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url";
|
|
|
5
5
|
export class SkillManager {
|
|
6
6
|
skillDirs;
|
|
7
7
|
skillsCache = new Map();
|
|
8
|
+
scanned = false;
|
|
8
9
|
constructor(cwd = process.cwd()) {
|
|
9
10
|
const home = os.homedir();
|
|
10
11
|
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -50,6 +51,7 @@ export class SkillManager {
|
|
|
50
51
|
}
|
|
51
52
|
catch { }
|
|
52
53
|
}
|
|
54
|
+
this.scanned = true;
|
|
53
55
|
return Array.from(this.skillsCache.values());
|
|
54
56
|
}
|
|
55
57
|
getSkillPromptListing() {
|
|
@@ -62,6 +64,13 @@ export class SkillManager {
|
|
|
62
64
|
.join("\n");
|
|
63
65
|
}
|
|
64
66
|
loadSkillBody(skillName) {
|
|
67
|
+
// El constructor no escanea, solo arma la lista de directorios. Quien
|
|
68
|
+
// llegaba aqui sin haber pedido antes el listado se encontraba el cache
|
|
69
|
+
// vacio y recibia "no existe" sobre un skill que estaba en disco. Que el
|
|
70
|
+
// metodo funcione depende de que el llamante haya invocado scanSkills() a
|
|
71
|
+
// mano es una trampa: se escanea aqui la primera vez.
|
|
72
|
+
if (!this.scanned)
|
|
73
|
+
this.scanSkills();
|
|
65
74
|
const skill = this.skillsCache.get(skillName.toLowerCase());
|
|
66
75
|
if (!skill || !fs.existsSync(skill.filePath)) {
|
|
67
76
|
return null;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deteccion de archivos que no son texto.
|
|
3
|
+
*
|
|
4
|
+
* Existe porque edit_file, write_file y el snapshot de /undo leian cualquier
|
|
5
|
+
* archivo con `readFileSync(p, "utf-8")` y lo reescribian entero desde ese
|
|
6
|
+
* string. Cada byte que no fuera UTF-8 valido se convertia en U+FFFD al leer y
|
|
7
|
+
* se guardaba como EF BF BD (3 bytes) al escribir: un mp4 de 503.232 bytes salia
|
|
8
|
+
* con 878.282 bytes de basura mientras el agente respondia "editado
|
|
9
|
+
* exitosamente". Ninguna de esas tres rutas pide confirmacion al usuario en
|
|
10
|
+
* ningun modo de permisos, y /undo -que es la red de seguridad en la que se
|
|
11
|
+
* apoya esa decision- guardaba el binario ya corrupto. Asi que la unica defensa
|
|
12
|
+
* posible es negarse antes de tocar el archivo.
|
|
13
|
+
*/
|
|
14
|
+
/** Extensiones que nunca contienen texto plano. */
|
|
15
|
+
export declare const BINARY_EXTENSIONS: Set<string>;
|
|
16
|
+
/** Devuelve la etiqueta de la extension binaria (MP4, PNG...) o null. */
|
|
17
|
+
export declare function binaryExtensionLabel(filePath: string): string | null;
|
|
18
|
+
/**
|
|
19
|
+
* Un 0x00 en los primeros kilobytes. Es el mismo criterio que usa git para
|
|
20
|
+
* decidir que un blob es binario: no existe texto en UTF-8 con un byte nulo.
|
|
21
|
+
*/
|
|
22
|
+
export declare function looksBinary(buf: Buffer): boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Igual que looksBinary pero leyendo solo la cabecera del archivo: cargar 500 MB
|
|
25
|
+
* de video en memoria para mirar el primer kilobyte es justo lo que hay que
|
|
26
|
+
* evitar cuando se sospecha que el archivo es enorme.
|
|
27
|
+
*/
|
|
28
|
+
export declare function fileLooksBinary(resolved: string): boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Comprobacion barata, sin leer el archivo entero: extension conocida o bytes
|
|
31
|
+
* nulos en la cabecera. Sirve para negarse ANTES de un readFileSync que podria
|
|
32
|
+
* cargar cientos de megas.
|
|
33
|
+
*/
|
|
34
|
+
export declare function quickBinaryReason(resolved: string): string | null;
|
|
35
|
+
/**
|
|
36
|
+
* Motivo por el que reescribir este contenido como texto UTF-8 lo destruiria, o
|
|
37
|
+
* null si es seguro hacerlo.
|
|
38
|
+
*
|
|
39
|
+
* El tercer caso -decodificar y volver a codificar y comparar bytes- es el que
|
|
40
|
+
* cierra el agujero de verdad: cubre los binarios sin bytes nulos en la cabecera
|
|
41
|
+
* y sin extension conocida, y tambien el texto guardado en otra codificacion (un
|
|
42
|
+
* .txt en cp1252 con acentos), que al reescribirlo saldria con rombos en vez de
|
|
43
|
+
* letras. Un archivo de texto UTF-8 legitimo siempre sobrevive a esa ida y
|
|
44
|
+
* vuelta.
|
|
45
|
+
*/
|
|
46
|
+
export declare function binaryRefusalReason(filePath: string, buf: Buffer): string | null;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Deteccion de archivos que no son texto.
|
|
5
|
+
*
|
|
6
|
+
* Existe porque edit_file, write_file y el snapshot de /undo leian cualquier
|
|
7
|
+
* archivo con `readFileSync(p, "utf-8")` y lo reescribian entero desde ese
|
|
8
|
+
* string. Cada byte que no fuera UTF-8 valido se convertia en U+FFFD al leer y
|
|
9
|
+
* se guardaba como EF BF BD (3 bytes) al escribir: un mp4 de 503.232 bytes salia
|
|
10
|
+
* con 878.282 bytes de basura mientras el agente respondia "editado
|
|
11
|
+
* exitosamente". Ninguna de esas tres rutas pide confirmacion al usuario en
|
|
12
|
+
* ningun modo de permisos, y /undo -que es la red de seguridad en la que se
|
|
13
|
+
* apoya esa decision- guardaba el binario ya corrupto. Asi que la unica defensa
|
|
14
|
+
* posible es negarse antes de tocar el archivo.
|
|
15
|
+
*/
|
|
16
|
+
/** Extensiones que nunca contienen texto plano. */
|
|
17
|
+
export const BINARY_EXTENSIONS = new Set([
|
|
18
|
+
".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svgz",
|
|
19
|
+
".zip", ".tar", ".gz", ".7z", ".rar",
|
|
20
|
+
".pdf", ".exe", ".dll", ".so", ".dylib", ".bin",
|
|
21
|
+
".mp4", ".mov", ".avi", ".mkv", ".mp3", ".wav",
|
|
22
|
+
".woff", ".woff2", ".ttf", ".eot",
|
|
23
|
+
]);
|
|
24
|
+
/** Devuelve la etiqueta de la extension binaria (MP4, PNG...) o null. */
|
|
25
|
+
export function binaryExtensionLabel(filePath) {
|
|
26
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
27
|
+
return BINARY_EXTENSIONS.has(ext) ? ext.slice(1).toUpperCase() : null;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Un 0x00 en los primeros kilobytes. Es el mismo criterio que usa git para
|
|
31
|
+
* decidir que un blob es binario: no existe texto en UTF-8 con un byte nulo.
|
|
32
|
+
*/
|
|
33
|
+
export function looksBinary(buf) {
|
|
34
|
+
const limite = Math.min(buf.length, 8192);
|
|
35
|
+
for (let i = 0; i < limite; i++) {
|
|
36
|
+
if (buf[i] === 0)
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Igual que looksBinary pero leyendo solo la cabecera del archivo: cargar 500 MB
|
|
43
|
+
* de video en memoria para mirar el primer kilobyte es justo lo que hay que
|
|
44
|
+
* evitar cuando se sospecha que el archivo es enorme.
|
|
45
|
+
*/
|
|
46
|
+
export function fileLooksBinary(resolved) {
|
|
47
|
+
let fd;
|
|
48
|
+
try {
|
|
49
|
+
fd = fs.openSync(resolved, "r");
|
|
50
|
+
const cabecera = Buffer.alloc(8192);
|
|
51
|
+
const leidos = fs.readSync(fd, cabecera, 0, 8192, 0);
|
|
52
|
+
return looksBinary(cabecera.subarray(0, leidos));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
finally {
|
|
58
|
+
if (fd !== undefined) {
|
|
59
|
+
try {
|
|
60
|
+
fs.closeSync(fd);
|
|
61
|
+
}
|
|
62
|
+
catch { /* da igual: solo se estaba mirando */ }
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Comprobacion barata, sin leer el archivo entero: extension conocida o bytes
|
|
68
|
+
* nulos en la cabecera. Sirve para negarse ANTES de un readFileSync que podria
|
|
69
|
+
* cargar cientos de megas.
|
|
70
|
+
*/
|
|
71
|
+
export function quickBinaryReason(resolved) {
|
|
72
|
+
const etiqueta = binaryExtensionLabel(resolved);
|
|
73
|
+
if (etiqueta)
|
|
74
|
+
return `es un archivo binario (${etiqueta})`;
|
|
75
|
+
if (fileLooksBinary(resolved))
|
|
76
|
+
return "contiene bytes nulos, asi que no es texto";
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Motivo por el que reescribir este contenido como texto UTF-8 lo destruiria, o
|
|
81
|
+
* null si es seguro hacerlo.
|
|
82
|
+
*
|
|
83
|
+
* El tercer caso -decodificar y volver a codificar y comparar bytes- es el que
|
|
84
|
+
* cierra el agujero de verdad: cubre los binarios sin bytes nulos en la cabecera
|
|
85
|
+
* y sin extension conocida, y tambien el texto guardado en otra codificacion (un
|
|
86
|
+
* .txt en cp1252 con acentos), que al reescribirlo saldria con rombos en vez de
|
|
87
|
+
* letras. Un archivo de texto UTF-8 legitimo siempre sobrevive a esa ida y
|
|
88
|
+
* vuelta.
|
|
89
|
+
*/
|
|
90
|
+
export function binaryRefusalReason(filePath, buf) {
|
|
91
|
+
const etiqueta = binaryExtensionLabel(filePath);
|
|
92
|
+
if (etiqueta)
|
|
93
|
+
return `es un archivo binario (${etiqueta})`;
|
|
94
|
+
if (looksBinary(buf))
|
|
95
|
+
return "contiene bytes nulos, asi que no es texto";
|
|
96
|
+
if (!Buffer.from(buf.toString("utf-8"), "utf-8").equals(buf)) {
|
|
97
|
+
return "no es texto UTF-8 valido y se corromperia al reescribirlo";
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
package/dist/tools/browserCdp.js
CHANGED
|
@@ -10,6 +10,7 @@ import { spawn } from "node:child_process";
|
|
|
10
10
|
// convertia "npm install -g chocolatito-code" en una espera de varios minutos.
|
|
11
11
|
import puppeteer from "puppeteer-core";
|
|
12
12
|
import { describeScreen } from "./visionBridge.js";
|
|
13
|
+
import { setBrowserAttached } from "./browserSession.js";
|
|
13
14
|
/**
|
|
14
15
|
* CONTROL DE NAVEGADOR EN SEGUNDO PLANO
|
|
15
16
|
*
|
|
@@ -80,10 +81,15 @@ async function connectTo(port) {
|
|
|
80
81
|
const version = await httpJson(`http://127.0.0.1:${port}/json/version`);
|
|
81
82
|
if (!version?.webSocketDebuggerUrl)
|
|
82
83
|
return null;
|
|
83
|
-
|
|
84
|
+
const b = await puppeteer.connect({
|
|
84
85
|
browserWSEndpoint: version.webSocketDebuggerUrl,
|
|
85
86
|
defaultViewport: null,
|
|
86
87
|
});
|
|
88
|
+
// Si el usuario cierra ese Chrome, la siguiente accion vuelve a poder
|
|
89
|
+
// LANZAR uno. Sin esto el flag se quedaria en true y el relanzamiento
|
|
90
|
+
// pasaria por "observar", que es justo lo que no debe pasar sin preguntar.
|
|
91
|
+
b.on("disconnected", () => setBrowserAttached(false));
|
|
92
|
+
return b;
|
|
87
93
|
}
|
|
88
94
|
catch {
|
|
89
95
|
return null;
|
|
@@ -92,12 +98,16 @@ async function connectTo(port) {
|
|
|
92
98
|
async function ensureBrowser(params) {
|
|
93
99
|
if (browser && browser.connected)
|
|
94
100
|
return { ok: true };
|
|
101
|
+
// Sesion caida: a partir de aqui cualquier accion puede arrancar un navegador.
|
|
102
|
+
if (browser)
|
|
103
|
+
setBrowserAttached(false);
|
|
95
104
|
const port = params.port || DEFAULT_PORT;
|
|
96
105
|
// 1. Si ya hay un Chrome escuchando el puerto de depuracion, nos enganchamos.
|
|
97
106
|
const existing = await connectTo(port);
|
|
98
107
|
if (existing) {
|
|
99
108
|
browser = existing;
|
|
100
109
|
launchedByUs = false;
|
|
110
|
+
setBrowserAttached(true);
|
|
101
111
|
await pickPage();
|
|
102
112
|
return { ok: true, note: `enganchado a Chrome existente en el puerto ${port}` };
|
|
103
113
|
}
|
|
@@ -148,6 +158,7 @@ async function ensureBrowser(params) {
|
|
|
148
158
|
if (b) {
|
|
149
159
|
browser = b;
|
|
150
160
|
launchedByUs = true;
|
|
161
|
+
setBrowserAttached(true);
|
|
151
162
|
await pickPage();
|
|
152
163
|
return {
|
|
153
164
|
ok: true,
|
|
@@ -390,6 +401,7 @@ export async function browserCdp(params, cwd = process.cwd(), apiKey) {
|
|
|
390
401
|
}
|
|
391
402
|
browser = null;
|
|
392
403
|
page = null;
|
|
404
|
+
setBrowserAttached(false);
|
|
393
405
|
return launchedByUs ? "Navegador del agente cerrado." : "Desconectado del Chrome del usuario (sigue abierto).";
|
|
394
406
|
}
|
|
395
407
|
const conn = await ensureBrowser(params);
|
|
@@ -5,6 +5,11 @@ declare class ExtensionBridge {
|
|
|
5
5
|
private pending;
|
|
6
6
|
private starting;
|
|
7
7
|
private portTaken;
|
|
8
|
+
/**
|
|
9
|
+
* Identifica a ESTE proceso de la CLI frente a la extension, para que pueda
|
|
10
|
+
* recuperar su estado si Chrome apaga el service worker a mitad de tarea.
|
|
11
|
+
*/
|
|
12
|
+
private readonly sessionId;
|
|
8
13
|
/** Origen de la extension con la que se emparejo esta sesion. */
|
|
9
14
|
private pairedOrigin;
|
|
10
15
|
/** Ultima extension distinta que intento conectarse; se le avisa al usuario. */
|
|
@@ -35,12 +35,19 @@ class ExtensionBridge {
|
|
|
35
35
|
pending = new Map();
|
|
36
36
|
starting = null;
|
|
37
37
|
portTaken = false;
|
|
38
|
+
/**
|
|
39
|
+
* Identifica a ESTE proceso de la CLI frente a la extension, para que pueda
|
|
40
|
+
* recuperar su estado si Chrome apaga el service worker a mitad de tarea.
|
|
41
|
+
*/
|
|
42
|
+
sessionId = `cli-${process.pid}-${Date.now().toString(36)}`;
|
|
38
43
|
/** Origen de la extension con la que se emparejo esta sesion. */
|
|
39
44
|
pairedOrigin = null;
|
|
40
45
|
/** Ultima extension distinta que intento conectarse; se le avisa al usuario. */
|
|
41
46
|
rejectedOrigin = null;
|
|
42
47
|
port = 0;
|
|
43
48
|
start() {
|
|
49
|
+
if (this.wss)
|
|
50
|
+
return Promise.resolve();
|
|
44
51
|
if (this.starting)
|
|
45
52
|
return this.starting;
|
|
46
53
|
this.starting = (async () => {
|
|
@@ -49,11 +56,14 @@ class ExtensionBridge {
|
|
|
49
56
|
if (ok) {
|
|
50
57
|
this.port = port;
|
|
51
58
|
this.portTaken = false;
|
|
59
|
+
this.starting = null;
|
|
52
60
|
return;
|
|
53
61
|
}
|
|
54
62
|
}
|
|
55
|
-
// Todos ocupados
|
|
63
|
+
// #78: Todos ocupados temporalmente. Reseteamos starting para que
|
|
64
|
+
// una llamada posterior pueda reintentar si otra sesión liberó su puerto.
|
|
56
65
|
this.portTaken = true;
|
|
66
|
+
this.starting = null;
|
|
57
67
|
})();
|
|
58
68
|
return this.starting;
|
|
59
69
|
}
|
|
@@ -101,6 +111,17 @@ class ExtensionBridge {
|
|
|
101
111
|
}
|
|
102
112
|
this.pairedOrigin = origin;
|
|
103
113
|
this.client = ws;
|
|
114
|
+
// Chrome apaga el service worker de la extension cuando le parece, y con
|
|
115
|
+
// el se va su estado: la pestana de trabajo, el grupo y el indice de
|
|
116
|
+
// refs. Al volver, la extension no tiene forma de saber si quien se
|
|
117
|
+
// conecta es esta misma sesion (y entonces debe recuperar lo suyo) o una
|
|
118
|
+
// CLI nueva (y entonces debe empezar limpia, porque heredar la pestana
|
|
119
|
+
// de otra sesion es como se acaba actuando sobre una pestana ajena).
|
|
120
|
+
// Se lo decimos: mismo id, misma sesion.
|
|
121
|
+
try {
|
|
122
|
+
ws.send(JSON.stringify({ type: "session", sessionId: this.sessionId }));
|
|
123
|
+
}
|
|
124
|
+
catch { }
|
|
104
125
|
ws.on("message", (raw) => {
|
|
105
126
|
let msg;
|
|
106
127
|
try {
|
|
@@ -119,12 +140,29 @@ class ExtensionBridge {
|
|
|
119
140
|
waiter.resolve(msg);
|
|
120
141
|
});
|
|
121
142
|
ws.on("close", () => {
|
|
122
|
-
if (this.client === ws)
|
|
143
|
+
if (this.client === ws) {
|
|
123
144
|
this.client = null;
|
|
145
|
+
// #52: Si la conexión se cae a media acción, rechazar de inmediato
|
|
146
|
+
// las peticiones en vuelo en lugar de esperar 45 s al timeout.
|
|
147
|
+
const errDesconexion = "La conexión con la extensión de Chrome se cerró inesperadamente durante la ejecución de la acción. " +
|
|
148
|
+
"Comprueba si la pestaña o el navegador se han cerrado.";
|
|
149
|
+
for (const [id, waiter] of this.pending.entries()) {
|
|
150
|
+
clearTimeout(waiter.timer);
|
|
151
|
+
waiter.resolve({ ok: false, error: errDesconexion });
|
|
152
|
+
}
|
|
153
|
+
this.pending.clear();
|
|
154
|
+
}
|
|
124
155
|
});
|
|
125
|
-
ws.on("error", () => {
|
|
126
|
-
if (this.client === ws)
|
|
156
|
+
ws.on("error", (err) => {
|
|
157
|
+
if (this.client === ws) {
|
|
127
158
|
this.client = null;
|
|
159
|
+
const msgError = `Error en el socket con la extensión de Chrome: ${err?.message || err}`;
|
|
160
|
+
for (const [id, waiter] of this.pending.entries()) {
|
|
161
|
+
clearTimeout(waiter.timer);
|
|
162
|
+
waiter.resolve({ ok: false, error: msgError });
|
|
163
|
+
}
|
|
164
|
+
this.pending.clear();
|
|
165
|
+
}
|
|
128
166
|
});
|
|
129
167
|
});
|
|
130
168
|
});
|
|
@@ -151,6 +189,10 @@ class ExtensionBridge {
|
|
|
151
189
|
* eso la primera espera es generosa y las siguientes, cortas.
|
|
152
190
|
*/
|
|
153
191
|
async waitForClient(ms) {
|
|
192
|
+
// #78: Si estaba marcado como ocupado pero otra sesión se cerró, reintentar enlazar
|
|
193
|
+
if (this.portTaken && !this.wss) {
|
|
194
|
+
await this.start();
|
|
195
|
+
}
|
|
154
196
|
const budget = ms ?? (this.everConnected ? 4000 : 12_000);
|
|
155
197
|
return this.waitUpTo(budget);
|
|
156
198
|
}
|
|
@@ -208,30 +250,151 @@ class ExtensionBridge {
|
|
|
208
250
|
try {
|
|
209
251
|
this.client?.close();
|
|
210
252
|
this.wss?.close();
|
|
253
|
+
this.wss = null;
|
|
254
|
+
this.client = null;
|
|
255
|
+
this.starting = null;
|
|
256
|
+
this.portTaken = false;
|
|
257
|
+
this.port = 0;
|
|
211
258
|
}
|
|
212
259
|
catch { }
|
|
213
260
|
}
|
|
214
261
|
}
|
|
215
262
|
export const extensionBridge = new ExtensionBridge();
|
|
216
263
|
process.on("exit", () => extensionBridge.shutdown());
|
|
264
|
+
/** Empareja un elemento de una numeracion con el mismo elemento en la otra. */
|
|
265
|
+
function emparejar(objetivo, origen, destino) {
|
|
266
|
+
const mismos = (lista) => lista.filter((e) => e.role === objetivo.role && e.name === objetivo.name);
|
|
267
|
+
const candidatos = mismos(destino);
|
|
268
|
+
if (candidatos.length === 0)
|
|
269
|
+
return null;
|
|
270
|
+
if (candidatos.length === 1)
|
|
271
|
+
return candidatos[0].ref;
|
|
272
|
+
// Varios homonimos (tres botones "Descargar"): solo se puede desempatar por
|
|
273
|
+
// posicion si la lista de iguales es la misma en las dos numeraciones. Si no
|
|
274
|
+
// cuadra, no hay forma de saber cual queria el modelo.
|
|
275
|
+
const iguales = mismos(origen);
|
|
276
|
+
const pos = iguales.findIndex((e) => e.ref === objetivo.ref);
|
|
277
|
+
if (pos >= 0 && iguales.length === candidatos.length)
|
|
278
|
+
return candidatos[pos].ref;
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
class CatalogoDeRefs {
|
|
282
|
+
/** Lo que vio el modelo: los numeros en los que habla. */
|
|
283
|
+
vista = [];
|
|
284
|
+
/** Filtro con el que se genero esa vista; hace falta para reproducirla. */
|
|
285
|
+
filtroVista = "";
|
|
286
|
+
/** Numeracion que tiene ahora mismo la extension. */
|
|
287
|
+
vivo = [];
|
|
288
|
+
/** true cuando la numeracion viva ya no es la que vio el modelo. */
|
|
289
|
+
renumerado = false;
|
|
290
|
+
static limpiar(els) {
|
|
291
|
+
return (els || [])
|
|
292
|
+
.filter((e) => typeof e?.ref === "number")
|
|
293
|
+
.map((e) => ({ ref: e.ref, role: String(e.role || ""), name: String(e.name || "") }));
|
|
294
|
+
}
|
|
295
|
+
/** Snapshot que se le enseña al modelo: fija el idioma de los refs. */
|
|
296
|
+
verVista(els, filtro) {
|
|
297
|
+
this.vista = CatalogoDeRefs.limpiar(els);
|
|
298
|
+
this.filtroVista = filtro || "";
|
|
299
|
+
this.vivo = this.vista.slice();
|
|
300
|
+
this.renumerado = false;
|
|
301
|
+
}
|
|
302
|
+
/** Snapshot hecho por dentro: el modelo no lo ve, pero renumera la extension. */
|
|
303
|
+
verInterno(els) {
|
|
304
|
+
this.vivo = CatalogoDeRefs.limpiar(els);
|
|
305
|
+
this.renumerado = true;
|
|
306
|
+
}
|
|
307
|
+
/** Al cambiar de pagina o de pestaña los refs anteriores no significan nada. */
|
|
308
|
+
olvidar() {
|
|
309
|
+
this.vista = [];
|
|
310
|
+
this.vivo = [];
|
|
311
|
+
this.filtroVista = "";
|
|
312
|
+
this.renumerado = false;
|
|
313
|
+
}
|
|
314
|
+
get filtro() {
|
|
315
|
+
return this.filtroVista;
|
|
316
|
+
}
|
|
317
|
+
/** Nombre que tenia ese ref cuando el modelo lo vio. */
|
|
318
|
+
nombreDe(ref) {
|
|
319
|
+
return this.vista.find((e) => e.ref === ref)?.name || "";
|
|
320
|
+
}
|
|
321
|
+
/** Ref del modelo -> ref vivo. null = no se puede saber cual es. */
|
|
322
|
+
traducir(ref) {
|
|
323
|
+
// Nadie ha renumerado por debajo: el numero del modelo es el bueno.
|
|
324
|
+
if (!this.renumerado)
|
|
325
|
+
return ref;
|
|
326
|
+
const objetivo = this.vista.find((e) => e.ref === ref);
|
|
327
|
+
if (!objetivo)
|
|
328
|
+
return null;
|
|
329
|
+
return emparejar(objetivo, this.vista, this.vivo);
|
|
330
|
+
}
|
|
331
|
+
/** Ref vivo -> ref del modelo, para no enseñarle numeros de otra numeracion. */
|
|
332
|
+
aVista(el) {
|
|
333
|
+
if (typeof el?.ref !== "number")
|
|
334
|
+
return null;
|
|
335
|
+
if (!this.renumerado)
|
|
336
|
+
return el.ref;
|
|
337
|
+
const objetivo = { ref: el.ref, role: String(el.role || ""), name: String(el.name || "") };
|
|
338
|
+
return emparejar(objetivo, this.vivo, this.vista);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
const catalogo = new CatalogoDeRefs();
|
|
342
|
+
const REF_CADUCADO = /ref .*no (existe|encontrado)|vuelve a hacer snapshot/i;
|
|
343
|
+
function refPerdido(ref) {
|
|
344
|
+
return (`el ref ${ref} ya no se puede localizar en la pagina. Los refs se renumeran en CADA ` +
|
|
345
|
+
`snapshot, asi que no se puede reutilizar el numero a ciegas: haz snapshot otra vez y ` +
|
|
346
|
+
`usa el numero nuevo.`);
|
|
347
|
+
}
|
|
348
|
+
/** Rehace el snapshot y apunta la numeracion resultante como la viva. */
|
|
349
|
+
async function refrescarCatalogo(filtro) {
|
|
350
|
+
const fresh = await extensionBridge.send("snapshot", { filter: filtro || "", max: 200 });
|
|
351
|
+
if (!fresh.ok)
|
|
352
|
+
return false;
|
|
353
|
+
catalogo.verInterno(fresh.data.elements || []);
|
|
354
|
+
return true;
|
|
355
|
+
}
|
|
217
356
|
/**
|
|
218
|
-
* Ejecuta una accion sobre un ref
|
|
219
|
-
*
|
|
357
|
+
* Ejecuta una accion sobre un ref del snapshot del modelo, traduciendolo al
|
|
358
|
+
* numero que la extension tiene vivo ahora mismo. Si el ref ha caducado (el
|
|
359
|
+
* service worker de MV3 se durmio y reinicio, o se recargo el content script)
|
|
360
|
+
* se rehace el snapshot CON EL MISMO FILTRO que uso el modelo y se vuelve a
|
|
361
|
+
* buscar el elemento por identidad, no por numero.
|
|
220
362
|
*
|
|
221
363
|
* Las aplicaciones vivas (Flow re-renderiza su composer cada pocos segundos)
|
|
222
364
|
* invalidan los refs constantemente. Devolver "ref no encontrado" y quedarse
|
|
223
365
|
* ahi obliga al modelo a repetir el snapshot a mano en cada paso; hacerlo aqui
|
|
224
|
-
* es lo que convierte la herramienta en algo usable.
|
|
366
|
+
* es lo que convierte la herramienta en algo usable. Lo que no vale es
|
|
367
|
+
* reintentar con el mismo numero: despues de renumerar, ese numero es otro
|
|
368
|
+
* elemento.
|
|
225
369
|
*/
|
|
226
|
-
async function
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (
|
|
370
|
+
async function accionSobreRef(cmd, args, refModelo, filtro) {
|
|
371
|
+
const filtroBase = filtro ?? catalogo.filtro;
|
|
372
|
+
let vivo = catalogo.traducir(refModelo);
|
|
373
|
+
if (vivo === null && (await refrescarCatalogo(filtroBase))) {
|
|
374
|
+
vivo = catalogo.traducir(refModelo);
|
|
375
|
+
}
|
|
376
|
+
if (vivo === null)
|
|
377
|
+
return { ok: false, error: refPerdido(refModelo) };
|
|
378
|
+
const res = await extensionBridge.send(cmd, { ...args, ref: vivo });
|
|
379
|
+
if (res.ok || !REF_CADUCADO.test(String(res.error || "")))
|
|
230
380
|
return res;
|
|
231
|
-
|
|
232
|
-
if (!fresh.ok)
|
|
381
|
+
if (!(await refrescarCatalogo(filtroBase)))
|
|
233
382
|
return res;
|
|
234
|
-
|
|
383
|
+
const reintento = catalogo.traducir(refModelo);
|
|
384
|
+
if (reintento === null)
|
|
385
|
+
return { ok: false, error: refPerdido(refModelo) };
|
|
386
|
+
return await extensionBridge.send(cmd, { ...args, ref: reintento });
|
|
387
|
+
}
|
|
388
|
+
/** Compara lo que se pidio pulsar con lo que se pulso de verdad. */
|
|
389
|
+
function mismoElemento(a, b) {
|
|
390
|
+
const n = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
|
|
391
|
+
const x = n(a);
|
|
392
|
+
const y = n(b);
|
|
393
|
+
if (!x || !y)
|
|
394
|
+
return true; // sin nombre en alguno de los dos no hay nada que comparar
|
|
395
|
+
// El snapshot corta el nombre a 120 y la etiqueta del clic a 60: se compara
|
|
396
|
+
// por prefijo para no acusar de discrepancia a un mismo elemento recortado.
|
|
397
|
+
return x.startsWith(y) || y.startsWith(x);
|
|
235
398
|
}
|
|
236
399
|
function fail(action, msg) {
|
|
237
400
|
return `Error en chrome (${action}): ${msg}`;
|
|
@@ -275,6 +438,9 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
|
|
|
275
438
|
case "open": {
|
|
276
439
|
if (!params.url)
|
|
277
440
|
return fail(action, 'falta "url".');
|
|
441
|
+
// Cambiar de pagina o de pestaña deja sin sentido los refs anteriores:
|
|
442
|
+
// se olvidan para que no se traduzcan contra la pagina nueva.
|
|
443
|
+
catalogo.olvidar();
|
|
278
444
|
const res = await extensionBridge.send("open", { url: params.url, note: params.note }, 60_000);
|
|
279
445
|
if (!res.ok)
|
|
280
446
|
return fail(action, res.error);
|
|
@@ -283,6 +449,9 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
|
|
|
283
449
|
`Esta agrupada en el grupo naranja "Chocolatito Code" y muestra un aviso en la pagina.`);
|
|
284
450
|
}
|
|
285
451
|
case "select_tab": {
|
|
452
|
+
// Cambiar de pagina o de pestaña deja sin sentido los refs anteriores:
|
|
453
|
+
// se olvidan para que no se traduzcan contra la pagina nueva.
|
|
454
|
+
catalogo.olvidar();
|
|
286
455
|
const res = await extensionBridge.send("select", { match: params.match || params.url || "" });
|
|
287
456
|
if (!res.ok)
|
|
288
457
|
return fail(action, res.error);
|
|
@@ -291,6 +460,9 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
|
|
|
291
460
|
case "navigate": {
|
|
292
461
|
if (!params.url)
|
|
293
462
|
return fail(action, 'falta "url".');
|
|
463
|
+
// Cambiar de pagina o de pestaña deja sin sentido los refs anteriores:
|
|
464
|
+
// se olvidan para que no se traduzcan contra la pagina nueva.
|
|
465
|
+
catalogo.olvidar();
|
|
294
466
|
const res = await extensionBridge.send("navigate", { url: params.url, tabId: params.tabId }, 60_000);
|
|
295
467
|
if (!res.ok)
|
|
296
468
|
return fail(action, res.error);
|
|
@@ -305,6 +477,9 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
|
|
|
305
477
|
if (!res.ok)
|
|
306
478
|
return fail(action, res.error);
|
|
307
479
|
const els = res.data.elements || [];
|
|
480
|
+
// Esta es la numeracion que va a ver el modelo: a partir de aqui, sus
|
|
481
|
+
// refs se interpretan contra ella y no contra la que quede viva luego.
|
|
482
|
+
catalogo.verVista(els, params.filter || "");
|
|
308
483
|
if (els.length === 0) {
|
|
309
484
|
return (`Pagina "${res.data.title}": sin elementos accionables` +
|
|
310
485
|
`${params.filter ? ` que contengan "${params.filter}"` : ""}. ` +
|
|
@@ -325,10 +500,19 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
|
|
|
325
500
|
case "click": {
|
|
326
501
|
if (params.ref === undefined)
|
|
327
502
|
return fail(action, 'indica el "ref" del snapshot.');
|
|
328
|
-
const res = await
|
|
503
|
+
const res = await accionSobreRef("click", {}, params.ref, params.filter);
|
|
329
504
|
if (!res.ok)
|
|
330
505
|
return fail(action, res.error);
|
|
331
|
-
|
|
506
|
+
// Un clic no se deshace. Si lo pulsado no se llama como lo que el modelo
|
|
507
|
+
// creia pulsar, se dice aqui: el mensaje trae la etiqueta del elemento
|
|
508
|
+
// REAL (background.js la saca de clickTrusted), asi que la discrepancia
|
|
509
|
+
// se puede detectar en vez de dejarsela al modelo.
|
|
510
|
+
const pulsado = String(res.data.label || "");
|
|
511
|
+
const pedido = catalogo.nombreDe(params.ref);
|
|
512
|
+
const aviso = mismoElemento(pedido, pulsado)
|
|
513
|
+
? ""
|
|
514
|
+
: `\nAVISO: pediste "${pedido}" y se pulso "${pulsado}". Haz snapshot y comprueba en que estado quedo la pagina.`;
|
|
515
|
+
return (`Pulsado [${params.ref}] "${pulsado}". Pagina ahora: "${res.data.title}" (${res.data.url})${aviso}`);
|
|
332
516
|
}
|
|
333
517
|
case "type": {
|
|
334
518
|
if (params.ref === undefined)
|
|
@@ -345,12 +529,11 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
|
|
|
345
529
|
const want = params.text;
|
|
346
530
|
const quiere = want.slice(0, Math.min(30, want.length));
|
|
347
531
|
for (let intento = 1; intento <= 2; intento++) {
|
|
348
|
-
res = await
|
|
349
|
-
ref: params.ref,
|
|
532
|
+
res = await accionSobreRef("type", {
|
|
350
533
|
text: params.text,
|
|
351
534
|
submit: params.submit === true,
|
|
352
535
|
replace: params.replace !== false,
|
|
353
|
-
}, params.filter);
|
|
536
|
+
}, params.ref, params.filter);
|
|
354
537
|
if (!res.ok)
|
|
355
538
|
return fail(action, res.error);
|
|
356
539
|
got = String(res.data.value || "");
|
|
@@ -383,6 +566,11 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
|
|
|
383
566
|
const check = await extensionBridge.send("snapshot", { max: 80 });
|
|
384
567
|
let botones = "";
|
|
385
568
|
if (check.ok) {
|
|
569
|
+
// Este snapshot es INTERNO: el modelo no lo ve, pero en la extension
|
|
570
|
+
// borra el indice y renumera todo desde 1. Si no se apunta como la
|
|
571
|
+
// numeracion viva, el siguiente click con un ref del snapshot que SI
|
|
572
|
+
// vio el modelo cae en otro elemento. Apuntandolo, se puede traducir.
|
|
573
|
+
catalogo.verInterno(check.data.elements || []);
|
|
386
574
|
const enviar = (check.data.elements || []).filter((e) => /crear|enviar|generar|submit|send/i.test(e.name || ""));
|
|
387
575
|
const apagados = enviar.filter((e) => e.disabled);
|
|
388
576
|
if (enviar.length > 0 && apagados.length === enviar.length) {
|
|
@@ -393,14 +581,36 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
|
|
|
393
581
|
`Haz snapshot y revisa si falta seleccionar algo (modo, modelo) antes de enviar.`);
|
|
394
582
|
}
|
|
395
583
|
if (enviar.length > 0) {
|
|
396
|
-
|
|
584
|
+
// El ref que trae este snapshot es de la numeracion interna, no de
|
|
585
|
+
// la del modelo: darselo tal cual es darle un numero de otro idioma.
|
|
586
|
+
// Se traduce al suyo, y si el boton no salia en su snapshot se dice
|
|
587
|
+
// en vez de inventarle un ref.
|
|
588
|
+
botones =
|
|
589
|
+
` El boton de enviar esta activo: ` +
|
|
590
|
+
enviar
|
|
591
|
+
.map((e) => {
|
|
592
|
+
const suyo = catalogo.aVista(e);
|
|
593
|
+
return suyo === null
|
|
594
|
+
? `"${e.name}" (no estaba en tu snapshot: hazlo otra vez para pulsarlo)`
|
|
595
|
+
: `[${suyo}] "${e.name}"`;
|
|
596
|
+
})
|
|
597
|
+
.join(", ") +
|
|
598
|
+
`.`;
|
|
397
599
|
}
|
|
398
600
|
}
|
|
399
601
|
return (`Escrito en [${params.ref}]${via} y verificado en el campo: "${corto}"` +
|
|
400
602
|
`${params.submit ? " (enviado con Enter)" : ""}.${botones}`);
|
|
401
603
|
}
|
|
402
604
|
case "press": {
|
|
403
|
-
|
|
605
|
+
// El tabId viajaba solo en el resto de acciones: sin el, un press sin
|
|
606
|
+
// ref se resolvia en la extension como "la pestana que haya" y la tecla
|
|
607
|
+
// acababa en la del usuario. Ahora se manda, y sin pestana de trabajo la
|
|
608
|
+
// extension falla en vez de escribir donde no debe.
|
|
609
|
+
// Con ref, la tecla va a un elemento concreto: el numero pasa por la
|
|
610
|
+
// misma traduccion que click y type, o acabaria en otro control.
|
|
611
|
+
const res = params.ref === undefined
|
|
612
|
+
? await extensionBridge.send("press", { tabId: params.tabId, keys: params.keys || "Enter" })
|
|
613
|
+
: await accionSobreRef("press", { tabId: params.tabId, keys: params.keys || "Enter" }, params.ref, params.filter);
|
|
404
614
|
if (!res.ok)
|
|
405
615
|
return fail(action, res.error);
|
|
406
616
|
return `Tecla "${params.keys || "Enter"}" enviada a la pagina.`;
|
|
@@ -447,7 +657,12 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
|
|
|
447
657
|
fs.mkdirSync(outDir, { recursive: true });
|
|
448
658
|
fs.writeFileSync(out, Buffer.from(res.data.data, "base64"));
|
|
449
659
|
const kb = (fs.statSync(out).size / 1024).toFixed(1);
|
|
450
|
-
|
|
660
|
+
// La extension avisa cuando ha tenido que usar la via alternativa
|
|
661
|
+
// (depurador ocupado, normalmente por tener DevTools abierto): esa
|
|
662
|
+
// captura exige activar la pestana un instante, asi que conviene que se
|
|
663
|
+
// sepa por que la pantalla parpadeo.
|
|
664
|
+
const aviso = res.data.warning ? ` (${res.data.warning})` : "";
|
|
665
|
+
const meta = `Captura de la pestana (en segundo plano) guardada en ${out} (${kb} KB).${aviso}`;
|
|
451
666
|
if (params.analyze === false || !apiKey)
|
|
452
667
|
return meta;
|
|
453
668
|
const desc = await describeScreen(apiKey, out, params.question);
|