chocolatito-code 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -0
- package/dist/agent/loop.js +129 -36
- package/dist/agent/salidaDelAgente.d.ts +43 -0
- package/dist/agent/salidaDelAgente.js +53 -0
- package/dist/config/nodeVersion.d.ts +13 -0
- package/dist/config/nodeVersion.js +26 -0
- package/dist/index.js +22 -7
- package/dist/mcp/client.d.ts +28 -0
- package/dist/mcp/client.js +62 -8
- package/dist/mcp/ide.d.ts +76 -0
- package/dist/mcp/ide.js +160 -0
- package/dist/mcp/manager.js +9 -0
- package/dist/tools/browserExtension.js +35 -4
- package/dist/tools/definitions.js +9 -2
- package/dist/tools/grepSearch.js +111 -23
- package/dist/tools/runner.js +1 -1
- package/dist/tools/writeFile.d.ts +16 -1
- package/dist/tools/writeFile.js +22 -1
- package/dist/ui/interrupt.js +3 -2
- package/dist/ui/keyboardGuard.d.ts +21 -0
- package/dist/ui/keyboardGuard.js +30 -1
- package/dist/ui/pegados.d.ts +50 -0
- package/dist/ui/pegados.js +83 -0
- package/dist/ui/prompt.d.ts +5 -0
- package/dist/ui/prompt.js +101 -16
- package/dist/ui/salida.d.ts +10 -0
- package/dist/ui/salida.js +50 -0
- package/extension/background.js +182 -11
- package/extension/content.js +446 -48
- package/extension/fuentes/Chocolatito-Marca.ttf +0 -0
- package/extension/iconos/128.png +0 -0
- package/extension/iconos/16.png +0 -0
- package/extension/iconos/32.png +0 -0
- package/extension/iconos/48.png +0 -0
- package/extension/iconos/logotipo.png +0 -0
- package/extension/manifest.json +26 -3
- package/extension/popup.html +16 -6
- package/package.json +3 -2
package/dist/mcp/client.d.ts
CHANGED
|
@@ -68,7 +68,35 @@ export declare class McpClient {
|
|
|
68
68
|
private spawnServer;
|
|
69
69
|
private onMessage;
|
|
70
70
|
private request;
|
|
71
|
+
/**
|
|
72
|
+
* Identificador de sesion que asigna el servidor HTTP.
|
|
73
|
+
*
|
|
74
|
+
* El transporte "streamable HTTP" del protocolo permite que el servidor
|
|
75
|
+
* devuelva una cabecera `Mcp-Session-Id` al responder al `initialize`, y a
|
|
76
|
+
* partir de ahi EXIGE que el cliente la mande en todas las peticiones
|
|
77
|
+
* siguientes. Sin esto, un servidor con sesion acepta el saludo y luego
|
|
78
|
+
* rechaza absolutamente todo lo demas: la conexion parece buena, la lista de
|
|
79
|
+
* herramientas llega vacia o con un 400, y desde fuera solo se ve un servidor
|
|
80
|
+
* que "no funciona" sin decir por que.
|
|
81
|
+
*/
|
|
82
|
+
private sessionId;
|
|
83
|
+
/** Las mismas cabeceras para peticiones y notificaciones, sesion incluida. */
|
|
84
|
+
private cabecerasHttp;
|
|
71
85
|
private notify;
|
|
86
|
+
/**
|
|
87
|
+
* Una notificacion por HTTP.
|
|
88
|
+
*
|
|
89
|
+
* Antes esto no existia y `notify()` se iba de vacio en cuanto habia una URL,
|
|
90
|
+
* asi que `notifications/initialized` solo viajaba por stdio. El protocolo la
|
|
91
|
+
* exige despues del `initialize`, y hay servidores que no atienden nada mas
|
|
92
|
+
* hasta recibirla: quedaban colgados en el saludo.
|
|
93
|
+
*
|
|
94
|
+
* No lleva id y no espera resultado -el servidor contesta 202 con el cuerpo
|
|
95
|
+
* vacio-, asi que no se parsea nada. Un fallo aqui tampoco se propaga: si el
|
|
96
|
+
* servidor no la queria, se vera en la peticion siguiente, que si tiene a
|
|
97
|
+
* quien quejarse.
|
|
98
|
+
*/
|
|
99
|
+
private httpNotify;
|
|
72
100
|
private httpRequest;
|
|
73
101
|
private initialize;
|
|
74
102
|
private listTools;
|
package/dist/mcp/client.js
CHANGED
|
@@ -272,13 +272,62 @@ export class McpClient {
|
|
|
272
272
|
}
|
|
273
273
|
});
|
|
274
274
|
}
|
|
275
|
+
/**
|
|
276
|
+
* Identificador de sesion que asigna el servidor HTTP.
|
|
277
|
+
*
|
|
278
|
+
* El transporte "streamable HTTP" del protocolo permite que el servidor
|
|
279
|
+
* devuelva una cabecera `Mcp-Session-Id` al responder al `initialize`, y a
|
|
280
|
+
* partir de ahi EXIGE que el cliente la mande en todas las peticiones
|
|
281
|
+
* siguientes. Sin esto, un servidor con sesion acepta el saludo y luego
|
|
282
|
+
* rechaza absolutamente todo lo demas: la conexion parece buena, la lista de
|
|
283
|
+
* herramientas llega vacia o con un 400, y desde fuera solo se ve un servidor
|
|
284
|
+
* que "no funciona" sin decir por que.
|
|
285
|
+
*/
|
|
286
|
+
sessionId = null;
|
|
287
|
+
/** Las mismas cabeceras para peticiones y notificaciones, sesion incluida. */
|
|
288
|
+
cabecerasHttp() {
|
|
289
|
+
return {
|
|
290
|
+
"Content-Type": "application/json",
|
|
291
|
+
Accept: "application/json, text/event-stream",
|
|
292
|
+
...(this.sessionId ? { "Mcp-Session-Id": this.sessionId } : {}),
|
|
293
|
+
// Las del usuario van al final: si configura una a mano, manda la suya.
|
|
294
|
+
...(this.config.headers || {}),
|
|
295
|
+
};
|
|
296
|
+
}
|
|
275
297
|
notify(method, params) {
|
|
276
|
-
if (this.config.url
|
|
277
|
-
return;
|
|
298
|
+
if (this.config.url)
|
|
299
|
+
return this.httpNotify(method, params);
|
|
300
|
+
if (!this.proc)
|
|
301
|
+
return Promise.resolve();
|
|
278
302
|
try {
|
|
279
303
|
this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n");
|
|
280
304
|
}
|
|
281
305
|
catch { }
|
|
306
|
+
return Promise.resolve();
|
|
307
|
+
}
|
|
308
|
+
/**
|
|
309
|
+
* Una notificacion por HTTP.
|
|
310
|
+
*
|
|
311
|
+
* Antes esto no existia y `notify()` se iba de vacio en cuanto habia una URL,
|
|
312
|
+
* asi que `notifications/initialized` solo viajaba por stdio. El protocolo la
|
|
313
|
+
* exige despues del `initialize`, y hay servidores que no atienden nada mas
|
|
314
|
+
* hasta recibirla: quedaban colgados en el saludo.
|
|
315
|
+
*
|
|
316
|
+
* No lleva id y no espera resultado -el servidor contesta 202 con el cuerpo
|
|
317
|
+
* vacio-, asi que no se parsea nada. Un fallo aqui tampoco se propaga: si el
|
|
318
|
+
* servidor no la queria, se vera en la peticion siguiente, que si tiene a
|
|
319
|
+
* quien quejarse.
|
|
320
|
+
*/
|
|
321
|
+
async httpNotify(method, params) {
|
|
322
|
+
try {
|
|
323
|
+
await fetch(this.config.url, {
|
|
324
|
+
method: "POST",
|
|
325
|
+
headers: this.cabecerasHttp(),
|
|
326
|
+
body: JSON.stringify({ jsonrpc: "2.0", method, params }),
|
|
327
|
+
signal: AbortSignal.timeout(5000),
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
catch { }
|
|
282
331
|
}
|
|
283
332
|
async httpRequest(method, params, timeoutMs) {
|
|
284
333
|
const controller = new AbortController();
|
|
@@ -287,14 +336,16 @@ export class McpClient {
|
|
|
287
336
|
try {
|
|
288
337
|
const res = await fetch(this.config.url, {
|
|
289
338
|
method: "POST",
|
|
290
|
-
headers:
|
|
291
|
-
"Content-Type": "application/json",
|
|
292
|
-
Accept: "application/json, text/event-stream",
|
|
293
|
-
...(this.config.headers || {}),
|
|
294
|
-
},
|
|
339
|
+
headers: this.cabecerasHttp(),
|
|
295
340
|
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
|
|
296
341
|
signal: controller.signal,
|
|
297
342
|
});
|
|
343
|
+
// La sesion se apunta ANTES de mirar si la respuesta fue buena: hay
|
|
344
|
+
// servidores que la asignan y en la misma respuesta se quejan de algo
|
|
345
|
+
// menor, y tirarla aqui condenaria todas las peticiones siguientes.
|
|
346
|
+
const sesion = res.headers.get("mcp-session-id");
|
|
347
|
+
if (sesion)
|
|
348
|
+
this.sessionId = sesion;
|
|
298
349
|
if (!res.ok)
|
|
299
350
|
throw new Error(`HTTP ${res.status}`);
|
|
300
351
|
const text = await res.text();
|
|
@@ -314,7 +365,10 @@ export class McpClient {
|
|
|
314
365
|
capabilities: { tools: {} },
|
|
315
366
|
clientInfo: { name: "chocolatito-code", version: "1.0.0" },
|
|
316
367
|
}, timeoutMs);
|
|
317
|
-
|
|
368
|
+
// Se ESPERA a que salga, no se lanza y se olvida. Por HTTP la notificacion
|
|
369
|
+
// es otra peticion, y si `tools/list` sale antes de que el servidor haya
|
|
370
|
+
// visto el "ya estoy listo", hay servidores que la rechazan.
|
|
371
|
+
await this.notify("notifications/initialized", {});
|
|
318
372
|
}
|
|
319
373
|
async listTools(timeoutMs) {
|
|
320
374
|
const out = [];
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { McpServerConfig } from "./client.js";
|
|
2
|
+
/**
|
|
3
|
+
* Encontrar el editor que hay delante, sin que el usuario configure nada.
|
|
4
|
+
*
|
|
5
|
+
* COMO SE HABLAN
|
|
6
|
+
*
|
|
7
|
+
* Al reves de lo que parece: la extension del editor es el SERVIDOR MCP y el
|
|
8
|
+
* agente es el cliente. La extension levanta un servidor en localhost y deja
|
|
9
|
+
* aqui una ficha con el puerto; el agente la lee al arrancar y se conecta solo.
|
|
10
|
+
* Asi el editor le presta al agente lo unico que el agente no puede tener por su
|
|
11
|
+
* cuenta: los diagnosticos del servidor de lenguaje, lo que hay seleccionado, y
|
|
12
|
+
* un sitio donde pintar los diffs de verdad.
|
|
13
|
+
*
|
|
14
|
+
* POR QUE UNA FICHA POR VENTANA Y NO UN PUERTO FIJO
|
|
15
|
+
*
|
|
16
|
+
* Porque casi nadie tiene un solo VSCode abierto. Con un puerto fijo, el segundo
|
|
17
|
+
* que arranca no puede escuchar, y el agente de un proyecto acabaria pintando
|
|
18
|
+
* sus diffs en la ventana de otro. Cada ventana deja su ficha con su carpeta, y
|
|
19
|
+
* aqui se elige la que de verdad corresponde al directorio de trabajo.
|
|
20
|
+
*
|
|
21
|
+
* SEGURIDAD
|
|
22
|
+
*
|
|
23
|
+
* El servidor escucha solo en 127.0.0.1 y la ficha lleva un token que hay que
|
|
24
|
+
* mandar en cada peticion. Sin eso, cualquier programa de la maquina podria
|
|
25
|
+
* pedirle al editor del usuario que abriera y modificara archivos. La ficha vive
|
|
26
|
+
* en el home, que es donde ya viven las credenciales.
|
|
27
|
+
*/
|
|
28
|
+
/** Donde deja su ficha cada ventana del editor. */
|
|
29
|
+
export declare function carpetaDeFichas(): string;
|
|
30
|
+
export interface FichaDeEditor {
|
|
31
|
+
/** Puerto del servidor MCP de esa ventana. */
|
|
32
|
+
puerto: number;
|
|
33
|
+
/** Secreto que hay que presentar en cada peticion. */
|
|
34
|
+
token: string;
|
|
35
|
+
/** Carpeta abierta en esa ventana. Es lo que decide cual toca. */
|
|
36
|
+
carpeta: string;
|
|
37
|
+
/** Proceso de la ventana, para saber si sigue viva. */
|
|
38
|
+
pid: number;
|
|
39
|
+
/** "Visual Studio Code", "Cursor"... solo para poder decirlo por pantalla. */
|
|
40
|
+
editor?: string;
|
|
41
|
+
}
|
|
42
|
+
/** Si `hijo` esta dentro de `padre` (o es el mismo). */
|
|
43
|
+
export declare function estaDentro(hijo: string, padre: string): boolean;
|
|
44
|
+
/**
|
|
45
|
+
* Lee las fichas que hay ahora mismo, tirando las de ventanas ya cerradas.
|
|
46
|
+
*
|
|
47
|
+
* `limpiar` borra las huerfanas del disco. Se hace de paso, no en un proceso
|
|
48
|
+
* aparte: si no, se acumulan para siempre y cada arranque tiene que descartar
|
|
49
|
+
* mas basura.
|
|
50
|
+
*/
|
|
51
|
+
export declare function leerFichas(dir?: string, limpiar?: boolean): FichaDeEditor[];
|
|
52
|
+
/**
|
|
53
|
+
* De todas las ventanas abiertas, la que corresponde a este directorio.
|
|
54
|
+
*
|
|
55
|
+
* Nunca devuelve "la primera que haya": si ninguna contiene al directorio de
|
|
56
|
+
* trabajo, se devuelve null y el agente sigue sin editor. Conectarse a una
|
|
57
|
+
* ventana ajena seria peor que no conectarse a ninguna, porque los diffs
|
|
58
|
+
* apareceran en el proyecto equivocado y el usuario los aceptaria sin mirar.
|
|
59
|
+
*
|
|
60
|
+
* Cuando varias contienen al directorio (un monorepo abierto entero y un
|
|
61
|
+
* paquete suyo abierto aparte), gana LA MAS CERCANA: la carpeta mas larga es la
|
|
62
|
+
* mas especifica.
|
|
63
|
+
*/
|
|
64
|
+
export declare function elegirFicha(cwd: string, fichas: FichaDeEditor[]): FichaDeEditor | null;
|
|
65
|
+
/** La configuracion de servidor MCP que representa a ese editor. */
|
|
66
|
+
export declare function configDeFicha(ficha: FichaDeEditor): McpServerConfig;
|
|
67
|
+
/**
|
|
68
|
+
* Lo que hay que anadir a la configuracion MCP por tener un editor delante.
|
|
69
|
+
*
|
|
70
|
+
* Devuelve un objeto vacio si no hay ninguno, que es el caso normal de quien usa
|
|
71
|
+
* solo la terminal: nadie tiene que configurar nada para que esto no le moleste.
|
|
72
|
+
*/
|
|
73
|
+
export declare function servidorDelEditor(cwd: string, dir?: string): {
|
|
74
|
+
config: Record<string, McpServerConfig>;
|
|
75
|
+
ficha: FichaDeEditor | null;
|
|
76
|
+
};
|
package/dist/mcp/ide.js
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* Encontrar el editor que hay delante, sin que el usuario configure nada.
|
|
6
|
+
*
|
|
7
|
+
* COMO SE HABLAN
|
|
8
|
+
*
|
|
9
|
+
* Al reves de lo que parece: la extension del editor es el SERVIDOR MCP y el
|
|
10
|
+
* agente es el cliente. La extension levanta un servidor en localhost y deja
|
|
11
|
+
* aqui una ficha con el puerto; el agente la lee al arrancar y se conecta solo.
|
|
12
|
+
* Asi el editor le presta al agente lo unico que el agente no puede tener por su
|
|
13
|
+
* cuenta: los diagnosticos del servidor de lenguaje, lo que hay seleccionado, y
|
|
14
|
+
* un sitio donde pintar los diffs de verdad.
|
|
15
|
+
*
|
|
16
|
+
* POR QUE UNA FICHA POR VENTANA Y NO UN PUERTO FIJO
|
|
17
|
+
*
|
|
18
|
+
* Porque casi nadie tiene un solo VSCode abierto. Con un puerto fijo, el segundo
|
|
19
|
+
* que arranca no puede escuchar, y el agente de un proyecto acabaria pintando
|
|
20
|
+
* sus diffs en la ventana de otro. Cada ventana deja su ficha con su carpeta, y
|
|
21
|
+
* aqui se elige la que de verdad corresponde al directorio de trabajo.
|
|
22
|
+
*
|
|
23
|
+
* SEGURIDAD
|
|
24
|
+
*
|
|
25
|
+
* El servidor escucha solo en 127.0.0.1 y la ficha lleva un token que hay que
|
|
26
|
+
* mandar en cada peticion. Sin eso, cualquier programa de la maquina podria
|
|
27
|
+
* pedirle al editor del usuario que abriera y modificara archivos. La ficha vive
|
|
28
|
+
* en el home, que es donde ya viven las credenciales.
|
|
29
|
+
*/
|
|
30
|
+
/** Donde deja su ficha cada ventana del editor. */
|
|
31
|
+
export function carpetaDeFichas() {
|
|
32
|
+
return path.join(os.homedir(), ".chocolatito", "ide");
|
|
33
|
+
}
|
|
34
|
+
function normalizar(p) {
|
|
35
|
+
const limpio = path.resolve(p).replace(/[\\/]+$/, "");
|
|
36
|
+
// Windows no distingue mayusculas en las rutas, y comparar sin normalizar
|
|
37
|
+
// hace que "C:\Users" y "c:\users" parezcan proyectos distintos.
|
|
38
|
+
return process.platform === "win32" ? limpio.toLowerCase() : limpio;
|
|
39
|
+
}
|
|
40
|
+
/** Si `hijo` esta dentro de `padre` (o es el mismo). */
|
|
41
|
+
export function estaDentro(hijo, padre) {
|
|
42
|
+
const h = normalizar(hijo);
|
|
43
|
+
const p = normalizar(padre);
|
|
44
|
+
if (h === p)
|
|
45
|
+
return true;
|
|
46
|
+
// El separador importa: sin el, "/proyecto-viejo" pasaria por estar dentro de
|
|
47
|
+
// "/proyecto".
|
|
48
|
+
return h.startsWith(p + path.sep) || h.startsWith(p + "/");
|
|
49
|
+
}
|
|
50
|
+
/** Si el proceso de esa ventana sigue vivo. Una ficha huerfana no vale. */
|
|
51
|
+
function sigueViva(pid) {
|
|
52
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
53
|
+
return false;
|
|
54
|
+
try {
|
|
55
|
+
// La senal 0 no manda nada: solo pregunta si existe y si se puede tocar.
|
|
56
|
+
process.kill(pid, 0);
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
// EPERM significa que existe pero es de otro usuario: existe, que es lo que
|
|
61
|
+
// se preguntaba.
|
|
62
|
+
return err?.code === "EPERM";
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
function esFicha(x) {
|
|
66
|
+
return (x &&
|
|
67
|
+
Number.isInteger(x.puerto) &&
|
|
68
|
+
x.puerto > 0 &&
|
|
69
|
+
typeof x.token === "string" &&
|
|
70
|
+
x.token.length > 0 &&
|
|
71
|
+
typeof x.carpeta === "string" &&
|
|
72
|
+
x.carpeta.length > 0);
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Lee las fichas que hay ahora mismo, tirando las de ventanas ya cerradas.
|
|
76
|
+
*
|
|
77
|
+
* `limpiar` borra las huerfanas del disco. Se hace de paso, no en un proceso
|
|
78
|
+
* aparte: si no, se acumulan para siempre y cada arranque tiene que descartar
|
|
79
|
+
* mas basura.
|
|
80
|
+
*/
|
|
81
|
+
export function leerFichas(dir = carpetaDeFichas(), limpiar = true) {
|
|
82
|
+
let nombres;
|
|
83
|
+
try {
|
|
84
|
+
nombres = fs.readdirSync(dir).filter((n) => n.endsWith(".json"));
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
return [];
|
|
88
|
+
}
|
|
89
|
+
const vivas = [];
|
|
90
|
+
for (const nombre of nombres) {
|
|
91
|
+
const completo = path.join(dir, nombre);
|
|
92
|
+
let ficha;
|
|
93
|
+
try {
|
|
94
|
+
ficha = JSON.parse(fs.readFileSync(completo, "utf-8"));
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// Una ficha rota no puede impedir encontrar las buenas.
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (!esFicha(ficha))
|
|
101
|
+
continue;
|
|
102
|
+
if (!sigueViva(ficha.pid)) {
|
|
103
|
+
if (limpiar) {
|
|
104
|
+
try {
|
|
105
|
+
fs.unlinkSync(completo);
|
|
106
|
+
}
|
|
107
|
+
catch { }
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
vivas.push(ficha);
|
|
112
|
+
}
|
|
113
|
+
return vivas;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* De todas las ventanas abiertas, la que corresponde a este directorio.
|
|
117
|
+
*
|
|
118
|
+
* Nunca devuelve "la primera que haya": si ninguna contiene al directorio de
|
|
119
|
+
* trabajo, se devuelve null y el agente sigue sin editor. Conectarse a una
|
|
120
|
+
* ventana ajena seria peor que no conectarse a ninguna, porque los diffs
|
|
121
|
+
* apareceran en el proyecto equivocado y el usuario los aceptaria sin mirar.
|
|
122
|
+
*
|
|
123
|
+
* Cuando varias contienen al directorio (un monorepo abierto entero y un
|
|
124
|
+
* paquete suyo abierto aparte), gana LA MAS CERCANA: la carpeta mas larga es la
|
|
125
|
+
* mas especifica.
|
|
126
|
+
*/
|
|
127
|
+
export function elegirFicha(cwd, fichas) {
|
|
128
|
+
const candidatas = fichas.filter((f) => estaDentro(cwd, f.carpeta));
|
|
129
|
+
if (candidatas.length === 0)
|
|
130
|
+
return null;
|
|
131
|
+
return candidatas.sort((a, b) => normalizar(b.carpeta).length - normalizar(a.carpeta).length)[0];
|
|
132
|
+
}
|
|
133
|
+
/** La configuracion de servidor MCP que representa a ese editor. */
|
|
134
|
+
export function configDeFicha(ficha) {
|
|
135
|
+
return {
|
|
136
|
+
type: "http",
|
|
137
|
+
// Solo el bucle local: nunca la interfaz de red, que expondria el editor
|
|
138
|
+
// del usuario a cualquiera que comparta su wifi.
|
|
139
|
+
url: `http://127.0.0.1:${ficha.puerto}/mcp`,
|
|
140
|
+
headers: { Authorization: `Bearer ${ficha.token}` },
|
|
141
|
+
// Corto a proposito: es un proceso local. Si no contesta en 5 s es que la
|
|
142
|
+
// ventana esta colgada, y esperar 20 retrasaria el arranque para nada.
|
|
143
|
+
timeout: 5000,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Lo que hay que anadir a la configuracion MCP por tener un editor delante.
|
|
148
|
+
*
|
|
149
|
+
* Devuelve un objeto vacio si no hay ninguno, que es el caso normal de quien usa
|
|
150
|
+
* solo la terminal: nadie tiene que configurar nada para que esto no le moleste.
|
|
151
|
+
*/
|
|
152
|
+
export function servidorDelEditor(cwd, dir = carpetaDeFichas()) {
|
|
153
|
+
// Puerta de salida para quien no lo quiera, y para poder reproducir fallos.
|
|
154
|
+
if (process.env.CHOCOLATITO_SIN_EDITOR === "1")
|
|
155
|
+
return { config: {}, ficha: null };
|
|
156
|
+
const ficha = elegirFicha(cwd, leerFichas(dir));
|
|
157
|
+
if (!ficha)
|
|
158
|
+
return { config: {}, ficha: null };
|
|
159
|
+
return { config: { ide: configDeFicha(ficha) }, ficha };
|
|
160
|
+
}
|
package/dist/mcp/manager.js
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import { McpClient } from "./client.js";
|
|
5
|
+
import { servidorDelEditor } from "./ide.js";
|
|
5
6
|
/**
|
|
6
7
|
* Gestor de servidores MCP.
|
|
7
8
|
*
|
|
@@ -49,6 +50,14 @@ export class McpManager {
|
|
|
49
50
|
// Un JSON roto no debe impedir arrancar el agente.
|
|
50
51
|
}
|
|
51
52
|
}
|
|
53
|
+
// El editor que haya delante se anade solo, sin que nadie lo configure. Va
|
|
54
|
+
// el ultimo a proposito: si el usuario define un servidor llamado "ide" a
|
|
55
|
+
// mano, gana el suyo. Ver ide.ts.
|
|
56
|
+
const { config: delEditor } = servidorDelEditor(this.cwd);
|
|
57
|
+
for (const [name, cfg] of Object.entries(delEditor)) {
|
|
58
|
+
if (!merged[name])
|
|
59
|
+
merged[name] = cfg;
|
|
60
|
+
}
|
|
52
61
|
return merged;
|
|
53
62
|
}
|
|
54
63
|
/** Conecta todos los servidores configurados. Devuelve un resumen legible. */
|
|
@@ -46,6 +46,8 @@ class ExtensionBridge {
|
|
|
46
46
|
rejectedOrigin = null;
|
|
47
47
|
port = 0;
|
|
48
48
|
start() {
|
|
49
|
+
if (this.wss)
|
|
50
|
+
return Promise.resolve();
|
|
49
51
|
if (this.starting)
|
|
50
52
|
return this.starting;
|
|
51
53
|
this.starting = (async () => {
|
|
@@ -54,11 +56,14 @@ class ExtensionBridge {
|
|
|
54
56
|
if (ok) {
|
|
55
57
|
this.port = port;
|
|
56
58
|
this.portTaken = false;
|
|
59
|
+
this.starting = null;
|
|
57
60
|
return;
|
|
58
61
|
}
|
|
59
62
|
}
|
|
60
|
-
// Todos ocupados
|
|
63
|
+
// #78: Todos ocupados temporalmente. Reseteamos starting para que
|
|
64
|
+
// una llamada posterior pueda reintentar si otra sesión liberó su puerto.
|
|
61
65
|
this.portTaken = true;
|
|
66
|
+
this.starting = null;
|
|
62
67
|
})();
|
|
63
68
|
return this.starting;
|
|
64
69
|
}
|
|
@@ -135,12 +140,29 @@ class ExtensionBridge {
|
|
|
135
140
|
waiter.resolve(msg);
|
|
136
141
|
});
|
|
137
142
|
ws.on("close", () => {
|
|
138
|
-
if (this.client === ws)
|
|
143
|
+
if (this.client === ws) {
|
|
139
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
|
+
}
|
|
140
155
|
});
|
|
141
|
-
ws.on("error", () => {
|
|
142
|
-
if (this.client === ws)
|
|
156
|
+
ws.on("error", (err) => {
|
|
157
|
+
if (this.client === ws) {
|
|
143
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
|
+
}
|
|
144
166
|
});
|
|
145
167
|
});
|
|
146
168
|
});
|
|
@@ -167,6 +189,10 @@ class ExtensionBridge {
|
|
|
167
189
|
* eso la primera espera es generosa y las siguientes, cortas.
|
|
168
190
|
*/
|
|
169
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
|
+
}
|
|
170
196
|
const budget = ms ?? (this.everConnected ? 4000 : 12_000);
|
|
171
197
|
return this.waitUpTo(budget);
|
|
172
198
|
}
|
|
@@ -224,6 +250,11 @@ class ExtensionBridge {
|
|
|
224
250
|
try {
|
|
225
251
|
this.client?.close();
|
|
226
252
|
this.wss?.close();
|
|
253
|
+
this.wss = null;
|
|
254
|
+
this.client = null;
|
|
255
|
+
this.starting = null;
|
|
256
|
+
this.portTaken = false;
|
|
257
|
+
this.port = 0;
|
|
227
258
|
}
|
|
228
259
|
catch { }
|
|
229
260
|
}
|
|
@@ -205,7 +205,9 @@ export const TOOLS = [
|
|
|
205
205
|
type: "function",
|
|
206
206
|
function: {
|
|
207
207
|
name: "write_file",
|
|
208
|
-
description: "Crea un archivo nuevo o sobreescribe uno existente
|
|
208
|
+
description: "Crea un archivo nuevo o sobreescribe uno existente. Si el archivo ya existe hay que haberlo leido antes con view_file: sobreescribir borra todo lo que hubiera dentro. " +
|
|
209
|
+
"ARCHIVOS LARGOS: tu respuesta tiene un techo de salida y una pagina entera no cabe de una vez; si se corta, no se escribe NADA. " +
|
|
210
|
+
"Cuando el archivo pase de unas 400 lineas, parte el trabajo: primera llamada con el principio, y las siguientes con modo=\"anadir\" hasta terminarlo.",
|
|
209
211
|
parameters: {
|
|
210
212
|
type: "object",
|
|
211
213
|
properties: {
|
|
@@ -215,7 +217,12 @@ export const TOOLS = [
|
|
|
215
217
|
},
|
|
216
218
|
content: {
|
|
217
219
|
type: "string",
|
|
218
|
-
description: "El contenido
|
|
220
|
+
description: "El contenido del archivo, o el trozo que toca si vas por partes.",
|
|
221
|
+
},
|
|
222
|
+
modo: {
|
|
223
|
+
type: "string",
|
|
224
|
+
enum: ["sobrescribir", "anadir"],
|
|
225
|
+
description: "sobrescribir (por defecto) reemplaza el archivo entero. anadir pega el contenido al final, para escribir un archivo largo en varias llamadas sin que la segunda borre la primera.",
|
|
219
226
|
},
|
|
220
227
|
},
|
|
221
228
|
required: ["path", "content"],
|
package/dist/tools/grepSearch.js
CHANGED
|
@@ -1,37 +1,125 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import fg from "fast-glob";
|
|
4
|
+
/**
|
|
5
|
+
* Buscar texto dentro de los archivos.
|
|
6
|
+
*
|
|
7
|
+
* TRES FALLOS QUE TENIA, LOS TRES VISTOS EN USO REAL
|
|
8
|
+
*
|
|
9
|
+
* 1. Apuntar a UN archivo la reventaba. `fast-glob` recibia ese archivo como
|
|
10
|
+
* `cwd` y tiraba `ENOTDIR: not a directory`, que llegaba al modelo como una
|
|
11
|
+
* excepcion cruda. Paso en directo: el agente quiso mirar dentro de un .md
|
|
12
|
+
* concreto y se comio el error. Buscar dentro de un archivo es lo mas normal
|
|
13
|
+
* del mundo y era justo lo que no se podia hacer.
|
|
14
|
+
*
|
|
15
|
+
* 2. La consulta se metia tal cual en `new RegExp()`. Buscar "calcular(" o
|
|
16
|
+
* "array[0]" -texto plano, lo que escribe cualquiera- lanzaba un
|
|
17
|
+
* SyntaxError sin capturar. El agente no tiene por que saber que su busqueda
|
|
18
|
+
* era tambien una expresion regular invalida.
|
|
19
|
+
*
|
|
20
|
+
* 3. Cortaba en 30 coincidencias y las presentaba como si fueran todas. El
|
|
21
|
+
* agente concluia "solo hay 30 usos" y decidia sobre eso.
|
|
22
|
+
*/
|
|
23
|
+
/** Cuantas coincidencias se detallan. Mas no cabe en un resultado de herramienta. */
|
|
24
|
+
const MAX_COINCIDENCIAS = 100;
|
|
25
|
+
/** Tope de archivos que se abren. Un repositorio enorme no puede colgar el turno. */
|
|
26
|
+
const MAX_ARCHIVOS = 3000;
|
|
27
|
+
const IGNORAR = [
|
|
28
|
+
"**/node_modules/**",
|
|
29
|
+
"**/.git/**",
|
|
30
|
+
"**/dist/**",
|
|
31
|
+
"**/*.lock",
|
|
32
|
+
"**/*.png",
|
|
33
|
+
"**/*.jpg",
|
|
34
|
+
"**/*.mp4",
|
|
35
|
+
"**/*.webm",
|
|
36
|
+
"**/*.zip",
|
|
37
|
+
"**/*.bin",
|
|
38
|
+
];
|
|
39
|
+
/** Escapa el texto para buscarlo literal, cuando no es una regex valida. */
|
|
40
|
+
function comoLiteral(texto) {
|
|
41
|
+
return texto.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* La consulta, entendida como expresion regular si se puede y como texto si no.
|
|
45
|
+
*
|
|
46
|
+
* Se avisa de cual de las dos cosas se hizo: buscar "coste(" literalmente y
|
|
47
|
+
* buscarlo como regex dan resultados distintos, y el agente decide sobre lo que
|
|
48
|
+
* lee.
|
|
49
|
+
*/
|
|
50
|
+
function construirRegex(query) {
|
|
51
|
+
try {
|
|
52
|
+
return { regex: new RegExp(query, "i"), literal: false };
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { regex: new RegExp(comoLiteral(query), "i"), literal: true };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
4
58
|
export async function grepSearch(query, searchPath = ".", cwd = process.cwd()) {
|
|
59
|
+
if (!query)
|
|
60
|
+
return "Error: no se dijo que buscar.";
|
|
5
61
|
const resolved = path.isAbsolute(searchPath) ? searchPath : path.resolve(cwd, searchPath);
|
|
6
|
-
|
|
62
|
+
let stat;
|
|
63
|
+
try {
|
|
64
|
+
stat = fs.statSync(resolved);
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
7
67
|
return `Error: La ruta "${searchPath}" no existe.`;
|
|
8
68
|
}
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
69
|
+
// Un archivo suelto se busca dentro de el, que es lo que pide quien lo apunta.
|
|
70
|
+
// Antes esto llegaba a fast-glob como `cwd` y reventaba con ENOTDIR.
|
|
71
|
+
let raiz;
|
|
72
|
+
let archivos;
|
|
73
|
+
if (stat.isFile()) {
|
|
74
|
+
raiz = path.dirname(resolved);
|
|
75
|
+
archivos = [path.basename(resolved)];
|
|
76
|
+
}
|
|
77
|
+
else {
|
|
78
|
+
raiz = resolved;
|
|
79
|
+
archivos = await fg(["**/*"], { cwd: raiz, onlyFiles: true, ignore: IGNORAR });
|
|
80
|
+
}
|
|
81
|
+
const { regex, literal } = construirRegex(query);
|
|
82
|
+
const coincidencias = [];
|
|
83
|
+
let total = 0;
|
|
84
|
+
let archivosMirados = 0;
|
|
85
|
+
let quedaronArchivos = false;
|
|
86
|
+
for (const relFile of archivos) {
|
|
87
|
+
if (archivosMirados >= MAX_ARCHIVOS) {
|
|
88
|
+
quedaronArchivos = true;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
archivosMirados++;
|
|
92
|
+
let contenido;
|
|
20
93
|
try {
|
|
21
|
-
|
|
22
|
-
const lines = content.split(/\r?\n/);
|
|
23
|
-
lines.forEach((line, idx) => {
|
|
24
|
-
if (regex.test(line) && matches.length < 30) {
|
|
25
|
-
matches.push(`${relFile}:${idx + 1} | ${line.trim()}`);
|
|
26
|
-
}
|
|
27
|
-
});
|
|
94
|
+
contenido = fs.readFileSync(path.join(raiz, relFile), "utf-8");
|
|
28
95
|
}
|
|
29
96
|
catch {
|
|
30
|
-
//
|
|
97
|
+
// Binario o sin permiso: no es un fallo de la busqueda.
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const lineas = contenido.split(/\r?\n/);
|
|
101
|
+
for (let i = 0; i < lineas.length; i++) {
|
|
102
|
+
if (!regex.test(lineas[i]))
|
|
103
|
+
continue;
|
|
104
|
+
total++;
|
|
105
|
+
// Se sigue CONTANDO despues del tope: decir "30" cuando hay 400 es lo que
|
|
106
|
+
// hacia que el agente decidiera sobre un numero falso.
|
|
107
|
+
if (coincidencias.length < MAX_COINCIDENCIAS) {
|
|
108
|
+
coincidencias.push(`${relFile}:${i + 1} | ${lineas[i].trim()}`);
|
|
109
|
+
}
|
|
31
110
|
}
|
|
32
111
|
}
|
|
33
|
-
|
|
34
|
-
|
|
112
|
+
const nota = literal
|
|
113
|
+
? `\n\n(La consulta no era una expresion regular valida, asi que se busco como texto literal.)`
|
|
114
|
+
: "";
|
|
115
|
+
if (total === 0) {
|
|
116
|
+
return `No se encontraron coincidencias para "${query}" en "${searchPath}".${nota}`;
|
|
35
117
|
}
|
|
36
|
-
|
|
118
|
+
const cabecera = total > coincidencias.length
|
|
119
|
+
? `Coincidencias: ${total} en total, se muestran las primeras ${coincidencias.length}. Afina la busqueda o apunta a una carpeta mas concreta.`
|
|
120
|
+
: `Coincidencias encontradas (${total}):`;
|
|
121
|
+
const aviso = quedaronArchivos
|
|
122
|
+
? `\n\n(Se pararon en ${MAX_ARCHIVOS} archivos: puede haber mas fuera de esa cuenta.)`
|
|
123
|
+
: "";
|
|
124
|
+
return `${cabecera}\n${coincidencias.join("\n")}${aviso}${nota}`;
|
|
37
125
|
}
|
package/dist/tools/runner.js
CHANGED
|
@@ -164,7 +164,7 @@ export async function executeToolCall(name, args, cwd = process.cwd(), apiKey, m
|
|
|
164
164
|
break;
|
|
165
165
|
}
|
|
166
166
|
globalUndoManager.snapshotFile(args.path, cwd);
|
|
167
|
-
rawResult = await writeFile(args.path, args.content, cwd);
|
|
167
|
+
rawResult = await writeFile(args.path, args.content, cwd, args.modo === "anadir" ? "anadir" : "sobrescribir");
|
|
168
168
|
fileTracker.markWritten(args.path, cwd);
|
|
169
169
|
const check = validateFileSyntax(args.path, args.content || "");
|
|
170
170
|
if (!check.valid && check.warning) {
|