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.
Files changed (122) hide show
  1. package/README.md +155 -0
  2. package/dist/agent/compaction.d.ts +65 -0
  3. package/dist/agent/compaction.js +134 -0
  4. package/dist/agent/context.d.ts +3 -0
  5. package/dist/agent/context.js +35 -0
  6. package/dist/agent/loop.d.ts +91 -2
  7. package/dist/agent/loop.js +642 -91
  8. package/dist/agent/loopDetector.d.ts +57 -0
  9. package/dist/agent/loopDetector.js +0 -0
  10. package/dist/agent/mentions.d.ts +13 -0
  11. package/dist/agent/mentions.js +49 -0
  12. package/dist/agent/repoMap.d.ts +117 -0
  13. package/dist/agent/repoMap.js +560 -0
  14. package/dist/agent/result.d.ts +64 -0
  15. package/dist/agent/result.js +48 -0
  16. package/dist/agent/retry.d.ts +35 -0
  17. package/dist/agent/retry.js +123 -4
  18. package/dist/agent/salidaDelAgente.d.ts +43 -0
  19. package/dist/agent/salidaDelAgente.js +53 -0
  20. package/dist/agent/subagent.js +62 -15
  21. package/dist/agent/syntaxValidator.js +288 -52
  22. package/dist/agent/toolGate.d.ts +58 -0
  23. package/dist/agent/toolGate.js +111 -0
  24. package/dist/agent/tracker.js +4 -7
  25. package/dist/agent/undoManager.d.ts +44 -11
  26. package/dist/agent/undoManager.js +95 -24
  27. package/dist/agent/usageMeter.d.ts +56 -0
  28. package/dist/agent/usageMeter.js +51 -0
  29. package/dist/agent/verifier.d.ts +49 -0
  30. package/dist/agent/verifier.js +158 -0
  31. package/dist/config/constants.js +14 -0
  32. package/dist/config/engine.d.ts +19 -0
  33. package/dist/config/engine.js +35 -3
  34. package/dist/config/license.d.ts +5 -0
  35. package/dist/config/license.js +107 -9
  36. package/dist/config/limits.d.ts +28 -0
  37. package/dist/config/limits.js +51 -0
  38. package/dist/config/nodeVersion.d.ts +13 -0
  39. package/dist/config/nodeVersion.js +26 -0
  40. package/dist/config/permissions.d.ts +76 -1
  41. package/dist/config/permissions.js +379 -23
  42. package/dist/config/quota.js +24 -1
  43. package/dist/config/updater.d.ts +68 -0
  44. package/dist/config/updater.js +215 -0
  45. package/dist/hooks/manager.js +159 -14
  46. package/dist/index.js +240 -37
  47. package/dist/mcp/client.d.ts +50 -0
  48. package/dist/mcp/client.js +155 -19
  49. package/dist/mcp/ide.d.ts +76 -0
  50. package/dist/mcp/ide.js +160 -0
  51. package/dist/mcp/manager.js +9 -0
  52. package/dist/prompts/systemPrompt.js +12 -1
  53. package/dist/sessions/manager.d.ts +12 -0
  54. package/dist/sessions/manager.js +33 -5
  55. package/dist/sessions/resume.d.ts +36 -0
  56. package/dist/sessions/resume.js +43 -0
  57. package/dist/skills/manager.d.ts +1 -0
  58. package/dist/skills/manager.js +9 -0
  59. package/dist/tools/binary.d.ts +46 -0
  60. package/dist/tools/binary.js +100 -0
  61. package/dist/tools/browserCdp.js +13 -1
  62. package/dist/tools/browserExtension.d.ts +5 -0
  63. package/dist/tools/browserExtension.js +237 -22
  64. package/dist/tools/browserSession.d.ts +23 -0
  65. package/dist/tools/browserSession.js +28 -0
  66. package/dist/tools/computerUse.js +33 -1
  67. package/dist/tools/createImage.d.ts +11 -0
  68. package/dist/tools/createImage.js +13 -1
  69. package/dist/tools/definitions.js +17 -4
  70. package/dist/tools/editDiagnosis.d.ts +54 -0
  71. package/dist/tools/editDiagnosis.js +142 -0
  72. package/dist/tools/editFile.d.ts +1 -0
  73. package/dist/tools/editFile.js +118 -62
  74. package/dist/tools/findFiles.d.ts +21 -0
  75. package/dist/tools/findFiles.js +75 -6
  76. package/dist/tools/getSystemInfo.js +15 -7
  77. package/dist/tools/gitTools.d.ts +24 -0
  78. package/dist/tools/gitTools.js +87 -26
  79. package/dist/tools/grepSearch.js +111 -23
  80. package/dist/tools/moveCopyFile.d.ts +18 -1
  81. package/dist/tools/moveCopyFile.js +74 -4
  82. package/dist/tools/patchCascade.d.ts +57 -0
  83. package/dist/tools/patchCascade.js +338 -0
  84. package/dist/tools/runCommand.js +213 -25
  85. package/dist/tools/runner.js +58 -5
  86. package/dist/tools/toolDefsComputer.js +12 -2
  87. package/dist/tools/truncator.d.ts +27 -1
  88. package/dist/tools/truncator.js +117 -11
  89. package/dist/tools/viewFile.js +15 -11
  90. package/dist/tools/visionBridge.js +7 -0
  91. package/dist/tools/webSearch.d.ts +21 -0
  92. package/dist/tools/webSearch.js +125 -14
  93. package/dist/tools/writeFile.d.ts +16 -1
  94. package/dist/tools/writeFile.js +40 -2
  95. package/dist/ui/interrupt.d.ts +4 -1
  96. package/dist/ui/interrupt.js +59 -7
  97. package/dist/ui/keyboardGuard.d.ts +76 -0
  98. package/dist/ui/keyboardGuard.js +94 -0
  99. package/dist/ui/loopPrompt.d.ts +17 -0
  100. package/dist/ui/loopPrompt.js +59 -0
  101. package/dist/ui/pegados.d.ts +50 -0
  102. package/dist/ui/pegados.js +83 -0
  103. package/dist/ui/permissionPrompt.js +84 -2
  104. package/dist/ui/prompt.d.ts +40 -0
  105. package/dist/ui/prompt.js +436 -131
  106. package/dist/ui/reasoningStream.d.ts +8 -0
  107. package/dist/ui/reasoningStream.js +99 -7
  108. package/dist/ui/salida.d.ts +10 -0
  109. package/dist/ui/salida.js +50 -0
  110. package/extension/README.md +95 -74
  111. package/extension/background.js +772 -181
  112. package/extension/content.js +527 -46
  113. package/extension/fuentes/Chocolatito-Marca.ttf +0 -0
  114. package/extension/iconos/128.png +0 -0
  115. package/extension/iconos/16.png +0 -0
  116. package/extension/iconos/32.png +0 -0
  117. package/extension/iconos/48.png +0 -0
  118. package/extension/iconos/logotipo.png +0 -0
  119. package/extension/manifest.json +28 -4
  120. package/extension/popup.html +16 -6
  121. package/extension/popup.js +28 -12
  122. package/package.json +3 -2
@@ -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
+ }
@@ -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
- return await puppeteer.connect({
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: hay tantas sesiones como puertos.
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 y, si el ref se ha quedado obsoleto porque la
219
- * pagina se re-renderizo, rehace el snapshot y lo intenta una vez mas.
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 withRefRetry(cmd, args, snapshotFilter) {
227
- let res = await extensionBridge.send(cmd, args);
228
- const obsoleto = !res.ok && /ref .*no (existe|encontrado)|vuelve a hacer snapshot/i.test(String(res.error || ""));
229
- if (!obsoleto)
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
- const fresh = await extensionBridge.send("snapshot", { filter: snapshotFilter || "", max: 120 });
232
- if (!fresh.ok)
381
+ if (!(await refrescarCatalogo(filtroBase)))
233
382
  return res;
234
- return await extensionBridge.send(cmd, args);
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 withRefRetry("click", { ref: params.ref }, params.filter);
503
+ const res = await accionSobreRef("click", {}, params.ref, params.filter);
329
504
  if (!res.ok)
330
505
  return fail(action, res.error);
331
- return `Pulsado [${params.ref}] "${res.data.label || ""}". Pagina ahora: "${res.data.title}" (${res.data.url})`;
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 withRefRetry("type", {
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
- botones = ` El boton de enviar esta activo: ${enviar.map((e) => `[${e.ref}] "${e.name}"`).join(", ")}.`;
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
- const res = await extensionBridge.send("press", { ref: params.ref, keys: params.keys || "Enter" });
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
- const meta = `Captura de la pestana (en segundo plano) guardada en ${out} (${kb} KB).`;
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);