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.
- package/README.md +89 -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 +536 -78
- 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/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/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 +222 -34
- package/dist/mcp/client.d.ts +22 -0
- package/dist/mcp/client.js +93 -11
- 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 +202 -18
- 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 +9 -3
- 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/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 +57 -4
- 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.js +18 -1
- package/dist/ui/interrupt.d.ts +4 -1
- package/dist/ui/interrupt.js +56 -5
- package/dist/ui/keyboardGuard.d.ts +55 -0
- package/dist/ui/keyboardGuard.js +65 -0
- package/dist/ui/loopPrompt.d.ts +17 -0
- package/dist/ui/loopPrompt.js +59 -0
- package/dist/ui/permissionPrompt.js +84 -2
- package/dist/ui/prompt.d.ts +35 -0
- package/dist/ui/prompt.js +345 -125
- package/dist/ui/reasoningStream.d.ts +8 -0
- package/dist/ui/reasoningStream.js +99 -7
- package/extension/README.md +95 -74
- package/extension/background.js +598 -178
- package/extension/content.js +92 -9
- package/extension/manifest.json +2 -1
- package/extension/popup.js +28 -12
- 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
|
+
}
|
package/dist/skills/manager.d.ts
CHANGED
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,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
|
|
219
|
-
*
|
|
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
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
if (
|
|
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
|
-
|
|
232
|
-
if (!fresh.ok)
|
|
350
|
+
if (!(await refrescarCatalogo(filtroBase)))
|
|
233
351
|
return res;
|
|
234
|
-
|
|
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
|
|
472
|
+
const res = await accionSobreRef("click", {}, params.ref, params.filter);
|
|
329
473
|
if (!res.ok)
|
|
330
474
|
return fail(action, res.error);
|
|
331
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|