chocolatito-code 1.0.0 → 1.1.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 (102) hide show
  1. package/README.md +89 -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 +536 -78
  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/subagent.js +62 -15
  19. package/dist/agent/syntaxValidator.js +288 -52
  20. package/dist/agent/toolGate.d.ts +58 -0
  21. package/dist/agent/toolGate.js +111 -0
  22. package/dist/agent/tracker.js +4 -7
  23. package/dist/agent/undoManager.d.ts +44 -11
  24. package/dist/agent/undoManager.js +95 -24
  25. package/dist/agent/usageMeter.d.ts +56 -0
  26. package/dist/agent/usageMeter.js +51 -0
  27. package/dist/agent/verifier.d.ts +49 -0
  28. package/dist/agent/verifier.js +158 -0
  29. package/dist/config/constants.js +14 -0
  30. package/dist/config/engine.d.ts +19 -0
  31. package/dist/config/engine.js +35 -3
  32. package/dist/config/license.d.ts +5 -0
  33. package/dist/config/license.js +107 -9
  34. package/dist/config/limits.d.ts +28 -0
  35. package/dist/config/limits.js +51 -0
  36. package/dist/config/permissions.d.ts +76 -1
  37. package/dist/config/permissions.js +379 -23
  38. package/dist/config/quota.js +24 -1
  39. package/dist/config/updater.d.ts +68 -0
  40. package/dist/config/updater.js +215 -0
  41. package/dist/hooks/manager.js +159 -14
  42. package/dist/index.js +222 -34
  43. package/dist/mcp/client.d.ts +22 -0
  44. package/dist/mcp/client.js +93 -11
  45. package/dist/prompts/systemPrompt.js +12 -1
  46. package/dist/sessions/manager.d.ts +12 -0
  47. package/dist/sessions/manager.js +33 -5
  48. package/dist/sessions/resume.d.ts +36 -0
  49. package/dist/sessions/resume.js +43 -0
  50. package/dist/skills/manager.d.ts +1 -0
  51. package/dist/skills/manager.js +9 -0
  52. package/dist/tools/binary.d.ts +46 -0
  53. package/dist/tools/binary.js +100 -0
  54. package/dist/tools/browserCdp.js +13 -1
  55. package/dist/tools/browserExtension.d.ts +5 -0
  56. package/dist/tools/browserExtension.js +202 -18
  57. package/dist/tools/browserSession.d.ts +23 -0
  58. package/dist/tools/browserSession.js +28 -0
  59. package/dist/tools/computerUse.js +33 -1
  60. package/dist/tools/createImage.d.ts +11 -0
  61. package/dist/tools/createImage.js +13 -1
  62. package/dist/tools/definitions.js +9 -3
  63. package/dist/tools/editDiagnosis.d.ts +54 -0
  64. package/dist/tools/editDiagnosis.js +142 -0
  65. package/dist/tools/editFile.d.ts +1 -0
  66. package/dist/tools/editFile.js +118 -62
  67. package/dist/tools/findFiles.d.ts +21 -0
  68. package/dist/tools/findFiles.js +75 -6
  69. package/dist/tools/getSystemInfo.js +15 -7
  70. package/dist/tools/gitTools.d.ts +24 -0
  71. package/dist/tools/gitTools.js +87 -26
  72. package/dist/tools/moveCopyFile.d.ts +18 -1
  73. package/dist/tools/moveCopyFile.js +74 -4
  74. package/dist/tools/patchCascade.d.ts +57 -0
  75. package/dist/tools/patchCascade.js +338 -0
  76. package/dist/tools/runCommand.js +213 -25
  77. package/dist/tools/runner.js +57 -4
  78. package/dist/tools/toolDefsComputer.js +12 -2
  79. package/dist/tools/truncator.d.ts +27 -1
  80. package/dist/tools/truncator.js +117 -11
  81. package/dist/tools/viewFile.js +15 -11
  82. package/dist/tools/visionBridge.js +7 -0
  83. package/dist/tools/webSearch.d.ts +21 -0
  84. package/dist/tools/webSearch.js +125 -14
  85. package/dist/tools/writeFile.js +18 -1
  86. package/dist/ui/interrupt.d.ts +4 -1
  87. package/dist/ui/interrupt.js +56 -5
  88. package/dist/ui/keyboardGuard.d.ts +55 -0
  89. package/dist/ui/keyboardGuard.js +65 -0
  90. package/dist/ui/loopPrompt.d.ts +17 -0
  91. package/dist/ui/loopPrompt.js +59 -0
  92. package/dist/ui/permissionPrompt.js +84 -2
  93. package/dist/ui/prompt.d.ts +35 -0
  94. package/dist/ui/prompt.js +345 -125
  95. package/dist/ui/reasoningStream.d.ts +8 -0
  96. package/dist/ui/reasoningStream.js +99 -7
  97. package/extension/README.md +95 -74
  98. package/extension/background.js +598 -178
  99. package/extension/content.js +92 -9
  100. package/extension/manifest.json +2 -1
  101. package/extension/popup.js +28 -12
  102. package/package.json +1 -1
@@ -0,0 +1,36 @@
1
+ import OpenAI from "openai";
2
+ import { SessionData } from "./manager.js";
3
+ /** Lo que hay que aplicar al agente para reanudar una sesion guardada. */
4
+ export interface RestauracionSesion {
5
+ /** Directorio en el que debe quedar el agente. */
6
+ cwd: string;
7
+ /** Motor que estaba activo al guardar, o el actual si aquel ya no existe. */
8
+ modelId: string;
9
+ /**
10
+ * Historial sin ningun mensaje de sistema. El prompt se reconstruye fuera,
11
+ * con el entorno de HOY, y se antepone a esta lista.
12
+ */
13
+ conversacion: OpenAI.Chat.ChatCompletionMessageParam[];
14
+ /** El directorio guardado ya no existe: se sigue donde se estaba. */
15
+ directorioPerdido: boolean;
16
+ /** El motor guardado ya no esta en el catalogo: se sigue con el actual. */
17
+ motorPerdido: boolean;
18
+ }
19
+ /**
20
+ * Separa lo que se restaura de una sesion (la conversacion) de lo que se
21
+ * reconstruye (el entorno).
22
+ *
23
+ * El JSON guarda el historial ENTERO y su primer mensaje es el prompt de
24
+ * sistema que se armo al abrir aquella sesion: fecha, directorio, reglas de
25
+ * CHOCOLATITO.md, indice de memoria y listado de skills de aquel dia. Volcar el
26
+ * array tal cual devolvia al modelo a un entorno que ya no existe —anunciaba el
27
+ * cwd viejo y la fecha vieja— mientras las herramientas seguian resolviendo
28
+ * rutas contra el cwd real: pedia un archivo creyendo estar en un proyecto y
29
+ * editaba el del otro. Por eso aqui se tira todo mensaje de sistema y se
30
+ * devuelven ademas el directorio y el motor de la sesion, que hasta ahora se
31
+ * escribian en el JSON y no los leia nadie.
32
+ */
33
+ export declare function prepararRestauracion(sesion: SessionData, actual: {
34
+ cwd: string;
35
+ modelId: string;
36
+ }, motores?: Record<string, unknown>): RestauracionSesion;
@@ -0,0 +1,43 @@
1
+ import fs from "node:fs";
2
+ import { ESPECTRO_MODELS } from "../config/constants.js";
3
+ /**
4
+ * Separa lo que se restaura de una sesion (la conversacion) de lo que se
5
+ * reconstruye (el entorno).
6
+ *
7
+ * El JSON guarda el historial ENTERO y su primer mensaje es el prompt de
8
+ * sistema que se armo al abrir aquella sesion: fecha, directorio, reglas de
9
+ * CHOCOLATITO.md, indice de memoria y listado de skills de aquel dia. Volcar el
10
+ * array tal cual devolvia al modelo a un entorno que ya no existe —anunciaba el
11
+ * cwd viejo y la fecha vieja— mientras las herramientas seguian resolviendo
12
+ * rutas contra el cwd real: pedia un archivo creyendo estar en un proyecto y
13
+ * editaba el del otro. Por eso aqui se tira todo mensaje de sistema y se
14
+ * devuelven ademas el directorio y el motor de la sesion, que hasta ahora se
15
+ * escribian en el JSON y no los leia nadie.
16
+ */
17
+ export function prepararRestauracion(sesion, actual, motores = ESPECTRO_MODELS) {
18
+ const mensajes = Array.isArray(sesion.messages) ? sesion.messages : [];
19
+ // El directorio guardado manda mientras siga existiendo; si lo borraron o
20
+ // esta en un disco que hoy no esta montado, quedarse en el actual es lo unico
21
+ // que no deja al agente apuntando a la nada.
22
+ const directorioPerdido = !sesion.cwd || !existeDirectorio(sesion.cwd);
23
+ const cwd = directorioPerdido ? actual.cwd : sesion.cwd;
24
+ // Igual con el motor: un id de una version vieja del catalogo no puede
25
+ // dejar `currentModel` en undefined.
26
+ const motorPerdido = !sesion.modelId || !motores[sesion.modelId];
27
+ const modelId = motorPerdido ? actual.modelId : sesion.modelId;
28
+ return {
29
+ cwd,
30
+ modelId,
31
+ conversacion: mensajes.filter((m) => m.role !== "system"),
32
+ directorioPerdido,
33
+ motorPerdido,
34
+ };
35
+ }
36
+ function existeDirectorio(ruta) {
37
+ try {
38
+ return fs.statSync(ruta).isDirectory();
39
+ }
40
+ catch {
41
+ return false;
42
+ }
43
+ }
@@ -6,6 +6,7 @@ export interface SkillSummary {
6
6
  export declare class SkillManager {
7
7
  private skillDirs;
8
8
  private skillsCache;
9
+ private scanned;
9
10
  constructor(cwd?: string);
10
11
  scanSkills(): SkillSummary[];
11
12
  getSkillPromptListing(): string;
@@ -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,6 +35,11 @@ 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. */
@@ -101,6 +106,17 @@ class ExtensionBridge {
101
106
  }
102
107
  this.pairedOrigin = origin;
103
108
  this.client = ws;
109
+ // Chrome apaga el service worker de la extension cuando le parece, y con
110
+ // el se va su estado: la pestana de trabajo, el grupo y el indice de
111
+ // refs. Al volver, la extension no tiene forma de saber si quien se
112
+ // conecta es esta misma sesion (y entonces debe recuperar lo suyo) o una
113
+ // CLI nueva (y entonces debe empezar limpia, porque heredar la pestana
114
+ // de otra sesion es como se acaba actuando sobre una pestana ajena).
115
+ // Se lo decimos: mismo id, misma sesion.
116
+ try {
117
+ ws.send(JSON.stringify({ type: "session", sessionId: this.sessionId }));
118
+ }
119
+ catch { }
104
120
  ws.on("message", (raw) => {
105
121
  let msg;
106
122
  try {
@@ -214,24 +230,140 @@ class ExtensionBridge {
214
230
  }
215
231
  export const extensionBridge = new ExtensionBridge();
216
232
  process.on("exit", () => extensionBridge.shutdown());
233
+ /** Empareja un elemento de una numeracion con el mismo elemento en la otra. */
234
+ function emparejar(objetivo, origen, destino) {
235
+ const mismos = (lista) => lista.filter((e) => e.role === objetivo.role && e.name === objetivo.name);
236
+ const candidatos = mismos(destino);
237
+ if (candidatos.length === 0)
238
+ return null;
239
+ if (candidatos.length === 1)
240
+ return candidatos[0].ref;
241
+ // Varios homonimos (tres botones "Descargar"): solo se puede desempatar por
242
+ // posicion si la lista de iguales es la misma en las dos numeraciones. Si no
243
+ // cuadra, no hay forma de saber cual queria el modelo.
244
+ const iguales = mismos(origen);
245
+ const pos = iguales.findIndex((e) => e.ref === objetivo.ref);
246
+ if (pos >= 0 && iguales.length === candidatos.length)
247
+ return candidatos[pos].ref;
248
+ return null;
249
+ }
250
+ class CatalogoDeRefs {
251
+ /** Lo que vio el modelo: los numeros en los que habla. */
252
+ vista = [];
253
+ /** Filtro con el que se genero esa vista; hace falta para reproducirla. */
254
+ filtroVista = "";
255
+ /** Numeracion que tiene ahora mismo la extension. */
256
+ vivo = [];
257
+ /** true cuando la numeracion viva ya no es la que vio el modelo. */
258
+ renumerado = false;
259
+ static limpiar(els) {
260
+ return (els || [])
261
+ .filter((e) => typeof e?.ref === "number")
262
+ .map((e) => ({ ref: e.ref, role: String(e.role || ""), name: String(e.name || "") }));
263
+ }
264
+ /** Snapshot que se le enseña al modelo: fija el idioma de los refs. */
265
+ verVista(els, filtro) {
266
+ this.vista = CatalogoDeRefs.limpiar(els);
267
+ this.filtroVista = filtro || "";
268
+ this.vivo = this.vista.slice();
269
+ this.renumerado = false;
270
+ }
271
+ /** Snapshot hecho por dentro: el modelo no lo ve, pero renumera la extension. */
272
+ verInterno(els) {
273
+ this.vivo = CatalogoDeRefs.limpiar(els);
274
+ this.renumerado = true;
275
+ }
276
+ /** Al cambiar de pagina o de pestaña los refs anteriores no significan nada. */
277
+ olvidar() {
278
+ this.vista = [];
279
+ this.vivo = [];
280
+ this.filtroVista = "";
281
+ this.renumerado = false;
282
+ }
283
+ get filtro() {
284
+ return this.filtroVista;
285
+ }
286
+ /** Nombre que tenia ese ref cuando el modelo lo vio. */
287
+ nombreDe(ref) {
288
+ return this.vista.find((e) => e.ref === ref)?.name || "";
289
+ }
290
+ /** Ref del modelo -> ref vivo. null = no se puede saber cual es. */
291
+ traducir(ref) {
292
+ // Nadie ha renumerado por debajo: el numero del modelo es el bueno.
293
+ if (!this.renumerado)
294
+ return ref;
295
+ const objetivo = this.vista.find((e) => e.ref === ref);
296
+ if (!objetivo)
297
+ return null;
298
+ return emparejar(objetivo, this.vista, this.vivo);
299
+ }
300
+ /** Ref vivo -> ref del modelo, para no enseñarle numeros de otra numeracion. */
301
+ aVista(el) {
302
+ if (typeof el?.ref !== "number")
303
+ return null;
304
+ if (!this.renumerado)
305
+ return el.ref;
306
+ const objetivo = { ref: el.ref, role: String(el.role || ""), name: String(el.name || "") };
307
+ return emparejar(objetivo, this.vivo, this.vista);
308
+ }
309
+ }
310
+ const catalogo = new CatalogoDeRefs();
311
+ const REF_CADUCADO = /ref .*no (existe|encontrado)|vuelve a hacer snapshot/i;
312
+ function refPerdido(ref) {
313
+ return (`el ref ${ref} ya no se puede localizar en la pagina. Los refs se renumeran en CADA ` +
314
+ `snapshot, asi que no se puede reutilizar el numero a ciegas: haz snapshot otra vez y ` +
315
+ `usa el numero nuevo.`);
316
+ }
317
+ /** Rehace el snapshot y apunta la numeracion resultante como la viva. */
318
+ async function refrescarCatalogo(filtro) {
319
+ const fresh = await extensionBridge.send("snapshot", { filter: filtro || "", max: 200 });
320
+ if (!fresh.ok)
321
+ return false;
322
+ catalogo.verInterno(fresh.data.elements || []);
323
+ return true;
324
+ }
217
325
  /**
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.
326
+ * Ejecuta una accion sobre un ref del snapshot del modelo, traduciendolo al
327
+ * numero que la extension tiene vivo ahora mismo. Si el ref ha caducado (el
328
+ * service worker de MV3 se durmio y reinicio, o se recargo el content script)
329
+ * se rehace el snapshot CON EL MISMO FILTRO que uso el modelo y se vuelve a
330
+ * buscar el elemento por identidad, no por numero.
220
331
  *
221
332
  * Las aplicaciones vivas (Flow re-renderiza su composer cada pocos segundos)
222
333
  * invalidan los refs constantemente. Devolver "ref no encontrado" y quedarse
223
334
  * 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.
335
+ * es lo que convierte la herramienta en algo usable. Lo que no vale es
336
+ * reintentar con el mismo numero: despues de renumerar, ese numero es otro
337
+ * elemento.
225
338
  */
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)
339
+ async function accionSobreRef(cmd, args, refModelo, filtro) {
340
+ const filtroBase = filtro ?? catalogo.filtro;
341
+ let vivo = catalogo.traducir(refModelo);
342
+ if (vivo === null && (await refrescarCatalogo(filtroBase))) {
343
+ vivo = catalogo.traducir(refModelo);
344
+ }
345
+ if (vivo === null)
346
+ return { ok: false, error: refPerdido(refModelo) };
347
+ const res = await extensionBridge.send(cmd, { ...args, ref: vivo });
348
+ if (res.ok || !REF_CADUCADO.test(String(res.error || "")))
230
349
  return res;
231
- const fresh = await extensionBridge.send("snapshot", { filter: snapshotFilter || "", max: 120 });
232
- if (!fresh.ok)
350
+ if (!(await refrescarCatalogo(filtroBase)))
233
351
  return res;
234
- return await extensionBridge.send(cmd, args);
352
+ const reintento = catalogo.traducir(refModelo);
353
+ if (reintento === null)
354
+ return { ok: false, error: refPerdido(refModelo) };
355
+ return await extensionBridge.send(cmd, { ...args, ref: reintento });
356
+ }
357
+ /** Compara lo que se pidio pulsar con lo que se pulso de verdad. */
358
+ function mismoElemento(a, b) {
359
+ const n = (s) => s.replace(/\s+/g, " ").trim().toLowerCase();
360
+ const x = n(a);
361
+ const y = n(b);
362
+ if (!x || !y)
363
+ return true; // sin nombre en alguno de los dos no hay nada que comparar
364
+ // El snapshot corta el nombre a 120 y la etiqueta del clic a 60: se compara
365
+ // por prefijo para no acusar de discrepancia a un mismo elemento recortado.
366
+ return x.startsWith(y) || y.startsWith(x);
235
367
  }
236
368
  function fail(action, msg) {
237
369
  return `Error en chrome (${action}): ${msg}`;
@@ -275,6 +407,9 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
275
407
  case "open": {
276
408
  if (!params.url)
277
409
  return fail(action, 'falta "url".');
410
+ // Cambiar de pagina o de pestaña deja sin sentido los refs anteriores:
411
+ // se olvidan para que no se traduzcan contra la pagina nueva.
412
+ catalogo.olvidar();
278
413
  const res = await extensionBridge.send("open", { url: params.url, note: params.note }, 60_000);
279
414
  if (!res.ok)
280
415
  return fail(action, res.error);
@@ -283,6 +418,9 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
283
418
  `Esta agrupada en el grupo naranja "Chocolatito Code" y muestra un aviso en la pagina.`);
284
419
  }
285
420
  case "select_tab": {
421
+ // Cambiar de pagina o de pestaña deja sin sentido los refs anteriores:
422
+ // se olvidan para que no se traduzcan contra la pagina nueva.
423
+ catalogo.olvidar();
286
424
  const res = await extensionBridge.send("select", { match: params.match || params.url || "" });
287
425
  if (!res.ok)
288
426
  return fail(action, res.error);
@@ -291,6 +429,9 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
291
429
  case "navigate": {
292
430
  if (!params.url)
293
431
  return fail(action, 'falta "url".');
432
+ // Cambiar de pagina o de pestaña deja sin sentido los refs anteriores:
433
+ // se olvidan para que no se traduzcan contra la pagina nueva.
434
+ catalogo.olvidar();
294
435
  const res = await extensionBridge.send("navigate", { url: params.url, tabId: params.tabId }, 60_000);
295
436
  if (!res.ok)
296
437
  return fail(action, res.error);
@@ -305,6 +446,9 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
305
446
  if (!res.ok)
306
447
  return fail(action, res.error);
307
448
  const els = res.data.elements || [];
449
+ // Esta es la numeracion que va a ver el modelo: a partir de aqui, sus
450
+ // refs se interpretan contra ella y no contra la que quede viva luego.
451
+ catalogo.verVista(els, params.filter || "");
308
452
  if (els.length === 0) {
309
453
  return (`Pagina "${res.data.title}": sin elementos accionables` +
310
454
  `${params.filter ? ` que contengan "${params.filter}"` : ""}. ` +
@@ -325,10 +469,19 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
325
469
  case "click": {
326
470
  if (params.ref === undefined)
327
471
  return fail(action, 'indica el "ref" del snapshot.');
328
- const res = await withRefRetry("click", { ref: params.ref }, params.filter);
472
+ const res = await accionSobreRef("click", {}, params.ref, params.filter);
329
473
  if (!res.ok)
330
474
  return fail(action, res.error);
331
- return `Pulsado [${params.ref}] "${res.data.label || ""}". Pagina ahora: "${res.data.title}" (${res.data.url})`;
475
+ // Un clic no se deshace. Si lo pulsado no se llama como lo que el modelo
476
+ // creia pulsar, se dice aqui: el mensaje trae la etiqueta del elemento
477
+ // REAL (background.js la saca de clickTrusted), asi que la discrepancia
478
+ // se puede detectar en vez de dejarsela al modelo.
479
+ const pulsado = String(res.data.label || "");
480
+ const pedido = catalogo.nombreDe(params.ref);
481
+ const aviso = mismoElemento(pedido, pulsado)
482
+ ? ""
483
+ : `\nAVISO: pediste "${pedido}" y se pulso "${pulsado}". Haz snapshot y comprueba en que estado quedo la pagina.`;
484
+ return (`Pulsado [${params.ref}] "${pulsado}". Pagina ahora: "${res.data.title}" (${res.data.url})${aviso}`);
332
485
  }
333
486
  case "type": {
334
487
  if (params.ref === undefined)
@@ -345,12 +498,11 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
345
498
  const want = params.text;
346
499
  const quiere = want.slice(0, Math.min(30, want.length));
347
500
  for (let intento = 1; intento <= 2; intento++) {
348
- res = await withRefRetry("type", {
349
- ref: params.ref,
501
+ res = await accionSobreRef("type", {
350
502
  text: params.text,
351
503
  submit: params.submit === true,
352
504
  replace: params.replace !== false,
353
- }, params.filter);
505
+ }, params.ref, params.filter);
354
506
  if (!res.ok)
355
507
  return fail(action, res.error);
356
508
  got = String(res.data.value || "");
@@ -383,6 +535,11 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
383
535
  const check = await extensionBridge.send("snapshot", { max: 80 });
384
536
  let botones = "";
385
537
  if (check.ok) {
538
+ // Este snapshot es INTERNO: el modelo no lo ve, pero en la extension
539
+ // borra el indice y renumera todo desde 1. Si no se apunta como la
540
+ // numeracion viva, el siguiente click con un ref del snapshot que SI
541
+ // vio el modelo cae en otro elemento. Apuntandolo, se puede traducir.
542
+ catalogo.verInterno(check.data.elements || []);
386
543
  const enviar = (check.data.elements || []).filter((e) => /crear|enviar|generar|submit|send/i.test(e.name || ""));
387
544
  const apagados = enviar.filter((e) => e.disabled);
388
545
  if (enviar.length > 0 && apagados.length === enviar.length) {
@@ -393,14 +550,36 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
393
550
  `Haz snapshot y revisa si falta seleccionar algo (modo, modelo) antes de enviar.`);
394
551
  }
395
552
  if (enviar.length > 0) {
396
- botones = ` El boton de enviar esta activo: ${enviar.map((e) => `[${e.ref}] "${e.name}"`).join(", ")}.`;
553
+ // El ref que trae este snapshot es de la numeracion interna, no de
554
+ // la del modelo: darselo tal cual es darle un numero de otro idioma.
555
+ // Se traduce al suyo, y si el boton no salia en su snapshot se dice
556
+ // en vez de inventarle un ref.
557
+ botones =
558
+ ` El boton de enviar esta activo: ` +
559
+ enviar
560
+ .map((e) => {
561
+ const suyo = catalogo.aVista(e);
562
+ return suyo === null
563
+ ? `"${e.name}" (no estaba en tu snapshot: hazlo otra vez para pulsarlo)`
564
+ : `[${suyo}] "${e.name}"`;
565
+ })
566
+ .join(", ") +
567
+ `.`;
397
568
  }
398
569
  }
399
570
  return (`Escrito en [${params.ref}]${via} y verificado en el campo: "${corto}"` +
400
571
  `${params.submit ? " (enviado con Enter)" : ""}.${botones}`);
401
572
  }
402
573
  case "press": {
403
- const res = await extensionBridge.send("press", { ref: params.ref, keys: params.keys || "Enter" });
574
+ // El tabId viajaba solo en el resto de acciones: sin el, un press sin
575
+ // ref se resolvia en la extension como "la pestana que haya" y la tecla
576
+ // acababa en la del usuario. Ahora se manda, y sin pestana de trabajo la
577
+ // extension falla en vez de escribir donde no debe.
578
+ // Con ref, la tecla va a un elemento concreto: el numero pasa por la
579
+ // misma traduccion que click y type, o acabaria en otro control.
580
+ const res = params.ref === undefined
581
+ ? await extensionBridge.send("press", { tabId: params.tabId, keys: params.keys || "Enter" })
582
+ : await accionSobreRef("press", { tabId: params.tabId, keys: params.keys || "Enter" }, params.ref, params.filter);
404
583
  if (!res.ok)
405
584
  return fail(action, res.error);
406
585
  return `Tecla "${params.keys || "Enter"}" enviada a la pagina.`;
@@ -447,7 +626,12 @@ export async function chromeExtension(params, cwd = process.cwd(), apiKey) {
447
626
  fs.mkdirSync(outDir, { recursive: true });
448
627
  fs.writeFileSync(out, Buffer.from(res.data.data, "base64"));
449
628
  const kb = (fs.statSync(out).size / 1024).toFixed(1);
450
- const meta = `Captura de la pestana (en segundo plano) guardada en ${out} (${kb} KB).`;
629
+ // La extension avisa cuando ha tenido que usar la via alternativa
630
+ // (depurador ocupado, normalmente por tener DevTools abierto): esa
631
+ // captura exige activar la pestana un instante, asi que conviene que se
632
+ // sepa por que la pantalla parpadeo.
633
+ const aviso = res.data.warning ? ` (${res.data.warning})` : "";
634
+ const meta = `Captura de la pestana (en segundo plano) guardada en ${out} (${kb} KB).${aviso}`;
451
635
  if (params.analyze === false || !apiKey)
452
636
  return meta;
453
637
  const desc = await describeScreen(apiKey, out, params.question);