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.
@@ -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;
@@ -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 || !this.proc)
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
- this.notify("notifications/initialized", {});
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
+ };
@@ -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
+ }
@@ -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: hay tantas sesiones como puertos.
63
+ // #78: Todos ocupados temporalmente. Reseteamos starting para que
64
+ // una llamada posterior pueda reintentar si otra sesión liberó su puerto.
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 con el contenido provisto. Si el archivo ya existe hay que haberlo leido antes con view_file: sobreescribir borra todo lo que hubiera dentro.",
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 completo del archivo.",
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"],
@@ -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
- if (!fs.existsSync(resolved)) {
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
- const files = await fg(["**/*"], {
10
- cwd: resolved,
11
- onlyFiles: true,
12
- ignore: ["**/node_modules/**", "**/.git/**", "**/dist/**", "**/*.lock", "**/*.png", "**/*.jpg", "**/*.mp4", "**/*.webm", "**/*.zip", "**/*.bin"],
13
- });
14
- const matches = [];
15
- const regex = new RegExp(query, "i");
16
- for (const relFile of files) {
17
- if (matches.length >= 30)
18
- break; // Limit to 30 matches
19
- const fullPath = path.join(resolved, relFile);
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
- const content = fs.readFileSync(fullPath, "utf-8");
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
- // Ignore binary files or read errors
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
- if (matches.length === 0) {
34
- return `No se encontraron coincidencias para "${query}" en "${searchPath}".`;
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
- return `Coincidencias encontradas (${matches.length}):\n${matches.join("\n")}`;
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
  }
@@ -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) {