chocolatito-code 1.0.0 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/README.md +155 -0
  2. package/dist/agent/compaction.d.ts +65 -0
  3. package/dist/agent/compaction.js +134 -0
  4. package/dist/agent/context.d.ts +3 -0
  5. package/dist/agent/context.js +35 -0
  6. package/dist/agent/loop.d.ts +91 -2
  7. package/dist/agent/loop.js +642 -91
  8. package/dist/agent/loopDetector.d.ts +57 -0
  9. package/dist/agent/loopDetector.js +0 -0
  10. package/dist/agent/mentions.d.ts +13 -0
  11. package/dist/agent/mentions.js +49 -0
  12. package/dist/agent/repoMap.d.ts +117 -0
  13. package/dist/agent/repoMap.js +560 -0
  14. package/dist/agent/result.d.ts +64 -0
  15. package/dist/agent/result.js +48 -0
  16. package/dist/agent/retry.d.ts +35 -0
  17. package/dist/agent/retry.js +123 -4
  18. package/dist/agent/salidaDelAgente.d.ts +43 -0
  19. package/dist/agent/salidaDelAgente.js +53 -0
  20. package/dist/agent/subagent.js +62 -15
  21. package/dist/agent/syntaxValidator.js +288 -52
  22. package/dist/agent/toolGate.d.ts +58 -0
  23. package/dist/agent/toolGate.js +111 -0
  24. package/dist/agent/tracker.js +4 -7
  25. package/dist/agent/undoManager.d.ts +44 -11
  26. package/dist/agent/undoManager.js +95 -24
  27. package/dist/agent/usageMeter.d.ts +56 -0
  28. package/dist/agent/usageMeter.js +51 -0
  29. package/dist/agent/verifier.d.ts +49 -0
  30. package/dist/agent/verifier.js +158 -0
  31. package/dist/config/constants.js +14 -0
  32. package/dist/config/engine.d.ts +19 -0
  33. package/dist/config/engine.js +35 -3
  34. package/dist/config/license.d.ts +5 -0
  35. package/dist/config/license.js +107 -9
  36. package/dist/config/limits.d.ts +28 -0
  37. package/dist/config/limits.js +51 -0
  38. package/dist/config/nodeVersion.d.ts +13 -0
  39. package/dist/config/nodeVersion.js +26 -0
  40. package/dist/config/permissions.d.ts +76 -1
  41. package/dist/config/permissions.js +379 -23
  42. package/dist/config/quota.js +24 -1
  43. package/dist/config/updater.d.ts +68 -0
  44. package/dist/config/updater.js +215 -0
  45. package/dist/hooks/manager.js +159 -14
  46. package/dist/index.js +240 -37
  47. package/dist/mcp/client.d.ts +50 -0
  48. package/dist/mcp/client.js +155 -19
  49. package/dist/mcp/ide.d.ts +76 -0
  50. package/dist/mcp/ide.js +160 -0
  51. package/dist/mcp/manager.js +9 -0
  52. package/dist/prompts/systemPrompt.js +12 -1
  53. package/dist/sessions/manager.d.ts +12 -0
  54. package/dist/sessions/manager.js +33 -5
  55. package/dist/sessions/resume.d.ts +36 -0
  56. package/dist/sessions/resume.js +43 -0
  57. package/dist/skills/manager.d.ts +1 -0
  58. package/dist/skills/manager.js +9 -0
  59. package/dist/tools/binary.d.ts +46 -0
  60. package/dist/tools/binary.js +100 -0
  61. package/dist/tools/browserCdp.js +13 -1
  62. package/dist/tools/browserExtension.d.ts +5 -0
  63. package/dist/tools/browserExtension.js +237 -22
  64. package/dist/tools/browserSession.d.ts +23 -0
  65. package/dist/tools/browserSession.js +28 -0
  66. package/dist/tools/computerUse.js +33 -1
  67. package/dist/tools/createImage.d.ts +11 -0
  68. package/dist/tools/createImage.js +13 -1
  69. package/dist/tools/definitions.js +17 -4
  70. package/dist/tools/editDiagnosis.d.ts +54 -0
  71. package/dist/tools/editDiagnosis.js +142 -0
  72. package/dist/tools/editFile.d.ts +1 -0
  73. package/dist/tools/editFile.js +118 -62
  74. package/dist/tools/findFiles.d.ts +21 -0
  75. package/dist/tools/findFiles.js +75 -6
  76. package/dist/tools/getSystemInfo.js +15 -7
  77. package/dist/tools/gitTools.d.ts +24 -0
  78. package/dist/tools/gitTools.js +87 -26
  79. package/dist/tools/grepSearch.js +111 -23
  80. package/dist/tools/moveCopyFile.d.ts +18 -1
  81. package/dist/tools/moveCopyFile.js +74 -4
  82. package/dist/tools/patchCascade.d.ts +57 -0
  83. package/dist/tools/patchCascade.js +338 -0
  84. package/dist/tools/runCommand.js +213 -25
  85. package/dist/tools/runner.js +58 -5
  86. package/dist/tools/toolDefsComputer.js +12 -2
  87. package/dist/tools/truncator.d.ts +27 -1
  88. package/dist/tools/truncator.js +117 -11
  89. package/dist/tools/viewFile.js +15 -11
  90. package/dist/tools/visionBridge.js +7 -0
  91. package/dist/tools/webSearch.d.ts +21 -0
  92. package/dist/tools/webSearch.js +125 -14
  93. package/dist/tools/writeFile.d.ts +16 -1
  94. package/dist/tools/writeFile.js +40 -2
  95. package/dist/ui/interrupt.d.ts +4 -1
  96. package/dist/ui/interrupt.js +59 -7
  97. package/dist/ui/keyboardGuard.d.ts +76 -0
  98. package/dist/ui/keyboardGuard.js +94 -0
  99. package/dist/ui/loopPrompt.d.ts +17 -0
  100. package/dist/ui/loopPrompt.js +59 -0
  101. package/dist/ui/pegados.d.ts +50 -0
  102. package/dist/ui/pegados.js +83 -0
  103. package/dist/ui/permissionPrompt.js +84 -2
  104. package/dist/ui/prompt.d.ts +40 -0
  105. package/dist/ui/prompt.js +436 -131
  106. package/dist/ui/reasoningStream.d.ts +8 -0
  107. package/dist/ui/reasoningStream.js +99 -7
  108. package/dist/ui/salida.d.ts +10 -0
  109. package/dist/ui/salida.js +50 -0
  110. package/extension/README.md +95 -74
  111. package/extension/background.js +772 -181
  112. package/extension/content.js +527 -46
  113. package/extension/fuentes/Chocolatito-Marca.ttf +0 -0
  114. package/extension/iconos/128.png +0 -0
  115. package/extension/iconos/16.png +0 -0
  116. package/extension/iconos/32.png +0 -0
  117. package/extension/iconos/48.png +0 -0
  118. package/extension/iconos/logotipo.png +0 -0
  119. package/extension/manifest.json +28 -4
  120. package/extension/popup.html +16 -6
  121. package/extension/popup.js +28 -12
  122. package/package.json +3 -2
@@ -0,0 +1,23 @@
1
+ /**
2
+ * ESTADO DE LA SESION DE NAVEGADOR
3
+ *
4
+ * Un solo booleano, en un modulo propio, por una razon concreta: la tabla de
5
+ * permisos necesita saber si hay un Chrome ya enganchado y no puede importar
6
+ * browserCdp para averiguarlo (arrastraria puppeteer-core entero dentro de
7
+ * config/, y ademas browserCdp acabaria importando permisos: ciclo).
8
+ *
9
+ * Por que hace falta el dato: browserCdp clasificaba `attach`, `tabs`,
10
+ * `snapshot`, `get_text`, `screenshot`, `wait_for` y `console` como "solo
11
+ * observar", pero TODAS pasan por ensureBrowser(), que si no encuentra un Chrome
12
+ * escuchando el puerto de depuracion LANZA uno con spawn(detached). O sea que la
13
+ * accion mas inocente de la lista arrancaba un proceso que sobrevive a la CLI, y
14
+ * con useSystemProfile lo arrancaba sobre el perfil real del usuario con el
15
+ * 9222 abierto en 127.0.0.1, sin un solo dialogo y tambien en modo plan.
16
+ *
17
+ * Mirar solo es gratis cuando NO hay nada que arrancar. Ese es el matiz que
18
+ * guarda este flag.
19
+ */
20
+ /** Lo llama browserCdp cada vez que se conecta o se suelta un navegador. */
21
+ export declare function setBrowserAttached(valor: boolean): void;
22
+ /** true solo si hay una sesion CDP viva: entonces observar no arranca nada. */
23
+ export declare function isBrowserAttached(): boolean;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * ESTADO DE LA SESION DE NAVEGADOR
3
+ *
4
+ * Un solo booleano, en un modulo propio, por una razon concreta: la tabla de
5
+ * permisos necesita saber si hay un Chrome ya enganchado y no puede importar
6
+ * browserCdp para averiguarlo (arrastraria puppeteer-core entero dentro de
7
+ * config/, y ademas browserCdp acabaria importando permisos: ciclo).
8
+ *
9
+ * Por que hace falta el dato: browserCdp clasificaba `attach`, `tabs`,
10
+ * `snapshot`, `get_text`, `screenshot`, `wait_for` y `console` como "solo
11
+ * observar", pero TODAS pasan por ensureBrowser(), que si no encuentra un Chrome
12
+ * escuchando el puerto de depuracion LANZA uno con spawn(detached). O sea que la
13
+ * accion mas inocente de la lista arrancaba un proceso que sobrevive a la CLI, y
14
+ * con useSystemProfile lo arrancaba sobre el perfil real del usuario con el
15
+ * 9222 abierto en 127.0.0.1, sin un solo dialogo y tambien en modo plan.
16
+ *
17
+ * Mirar solo es gratis cuando NO hay nada que arrancar. Ese es el matiz que
18
+ * guarda este flag.
19
+ */
20
+ let enganchado = false;
21
+ /** Lo llama browserCdp cada vez que se conecta o se suelta un navegador. */
22
+ export function setBrowserAttached(valor) {
23
+ enganchado = valor;
24
+ }
25
+ /** true solo si hay una sesion CDP viva: entonces observar no arranca nada. */
26
+ export function isBrowserAttached() {
27
+ return enganchado;
28
+ }
@@ -412,10 +412,42 @@ export async function computerUse(params, cwd = process.cwd(), apiKey) {
412
412
  const combo = normalizeKeys(params.keys || params.key || params.text || "");
413
413
  if (!combo)
414
414
  return fail(action, 'indica la tecla en "keys" (ej. "Return", "ctrl+t", "alt+Tab").');
415
+ // Un atajo sale por SendInput, que entra SIEMPRE por la ventana con el
416
+ // foco. No hay atajo "de fondo": PostMessage no deja las modificadoras
417
+ // en el estado de teclado del destino, asi que ctrl+s llegaria como una
418
+ // "s" suelta. Hasta aqui `window` se aceptaba y se tiraba a la basura:
419
+ // el modelo pedia ctrl+w "en el Bloc de notas" y la combinacion caia en
420
+ // el Word que el usuario tenia delante; con alt+F4 le cerraba la
421
+ // aplicacion. Ahora, si se pide una ventana, se enfoca antes de teclear
422
+ // y si no se puede no se manda nada.
423
+ let destino = "la ventana enfocada";
424
+ if (params.window) {
425
+ const elegida = await resolveWindow(params);
426
+ if (!elegida.ok)
427
+ return fail(action, elegida.error);
428
+ // Mismo corte que focus_window: enfocar y teclear a ciegas entre
429
+ // ventanas empatadas es como se cierra la que no era.
430
+ if (elegida.matches > 1) {
431
+ return fail(action, `"${params.window}" coincide con ${elegida.matches} ventanas (${elegida.candidates}). ` +
432
+ `Un atajo no se deshace: usa list_windows y pasa un titulo mas concreto.`);
433
+ }
434
+ const enfocada = await winHost.send("focus_window", {
435
+ ...pinned(elegida),
436
+ ...(elegida.hwnd ? {} : windowArgs(params)),
437
+ });
438
+ if (!enfocada.ok) {
439
+ return fail(action, `no se pudo poner delante "${elegida.title || params.window}" para mandarle "${combo}" ` +
440
+ `(ahora manda "${enfocada.foreground || "?"}"). No se envia nada: iria a la ventana equivocada.`);
441
+ }
442
+ // El foco no cambia en el mismo instante en que la API dice que si:
443
+ // sin esta pausa la primera pulsacion se la queda la ventana anterior.
444
+ await new Promise((r) => setTimeout(r, 250));
445
+ destino = `"${enfocada.title || elegida.title || params.window}"`;
446
+ }
415
447
  const res = await winHost.send("key", { keys: combo, repeat: params.repeat ?? 1 });
416
448
  if (!res.ok)
417
449
  return fail(action, res.error || "fallo desconocido");
418
- return `Tecla "${combo}"${res.repeat > 1 ? ` x${res.repeat}` : ""} enviada a la ventana enfocada.`;
450
+ return `Tecla "${combo}"${res.repeat > 1 ? ` x${res.repeat}` : ""} enviada a ${destino}.`;
419
451
  }
420
452
  case "open_app": {
421
453
  const target = params.appName || params.text;
@@ -1 +1,12 @@
1
+ /**
2
+ * Genera una imagen a partir de un SVG o de un script de Python.
3
+ *
4
+ * La segunda rama no dibuja: guarda el texto que ha escrito el modelo en un .py
5
+ * y lo ejecuta entero. Un `shutil.rmtree(...)` metido ahi borra el arbol igual
6
+ * que por run_command, solo que con nombre de herramienta de dibujo. Por eso
7
+ * `permissions.ts` la clasifica como `exec` (pregunta salvo allowlist) y la
8
+ * bloquea en modo plan, y por eso el SVG se detecta con `isSvgSource`, la misma
9
+ * funcion que mira la tabla de permisos: dos detecciones distintas serian dos
10
+ * criterios distintos sobre que se ejecuta y que no.
11
+ */
1
12
  export declare function createImage(outputPath: string, generatorScript: string, cwd?: string): Promise<string>;
@@ -1,6 +1,18 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { execa } from "execa";
4
+ import { isSvgSource } from "../config/permissions.js";
5
+ /**
6
+ * Genera una imagen a partir de un SVG o de un script de Python.
7
+ *
8
+ * La segunda rama no dibuja: guarda el texto que ha escrito el modelo en un .py
9
+ * y lo ejecuta entero. Un `shutil.rmtree(...)` metido ahi borra el arbol igual
10
+ * que por run_command, solo que con nombre de herramienta de dibujo. Por eso
11
+ * `permissions.ts` la clasifica como `exec` (pregunta salvo allowlist) y la
12
+ * bloquea en modo plan, y por eso el SVG se detecta con `isSvgSource`, la misma
13
+ * funcion que mira la tabla de permisos: dos detecciones distintas serian dos
14
+ * criterios distintos sobre que se ejecuta y que no.
15
+ */
4
16
  export async function createImage(outputPath, generatorScript, cwd = process.cwd()) {
5
17
  const resolved = path.isAbsolute(outputPath) ? outputPath : path.resolve(cwd, outputPath);
6
18
  const outDir = path.dirname(resolved);
@@ -8,7 +20,7 @@ export async function createImage(outputPath, generatorScript, cwd = process.cwd
8
20
  fs.mkdirSync(outDir, { recursive: true });
9
21
  }
10
22
  // If generatorScript is SVG, save directly
11
- if (generatorScript.trim().startsWith("<svg") || generatorScript.trim().startsWith("<?xml")) {
23
+ if (isSvgSource(generatorScript)) {
12
24
  fs.writeFileSync(resolved, generatorScript, "utf-8");
13
25
  return `✔ Imagen SVG generada y guardada en "${outputPath}" (${fs.statSync(resolved).size} bytes).`;
14
26
  }
@@ -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.",
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"],
@@ -226,7 +233,7 @@ export const TOOLS = [
226
233
  type: "function",
227
234
  function: {
228
235
  name: "delete_file",
229
- description: "Elimina un archivo o directorio de forma segura.",
236
+ description: "Elimina un archivo o directorio. El borrado es permanente: /undo solo puede devolver archivos sueltos, nunca un directorio ni un archivo bloqueado por otro proceso.",
230
237
  parameters: {
231
238
  type: "object",
232
239
  properties: {
@@ -243,7 +250,8 @@ export const TOOLS = [
243
250
  type: "function",
244
251
  function: {
245
252
  name: "move_copy_file",
246
- description: "Mueve, renombra o copia archivos y directorios.",
253
+ description: "Mueve, renombra o copia archivos y directorios. Si el destino ya existe la llamada se corta sin tocar el disco: " +
254
+ "reemplazarlo borra lo que hubiera ahi y exige overwrite=true, que el usuario tiene que confirmar.",
247
255
  parameters: {
248
256
  type: "object",
249
257
  properties: {
@@ -260,6 +268,11 @@ export const TOOLS = [
260
268
  enum: ["move", "copy"],
261
269
  description: "Operación a realizar.",
262
270
  },
271
+ overwrite: {
272
+ type: "boolean",
273
+ description: "Solo si de verdad quieres reemplazar un destino que ya existe: borra su contenido anterior. " +
274
+ "Por defecto false, y sin él la operación se rechaza en vez de pisar nada.",
275
+ },
263
276
  },
264
277
  required: ["sourcePath", "destinationPath"],
265
278
  },
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Que se le contesta al modelo cuando un edit_file no encuentra su fragmento.
3
+ *
4
+ * El mensaje anterior era "No se encontro el fragmento de texto. Asegurate de
5
+ * copiar las lineas exactas": cierto, inutil y, sobre todo, IDENTICO cada vez.
6
+ * Un mensaje que no aporta informacion nueva invita a reintentar lo mismo, y
7
+ * cada reintento cuesta una iteracion y dinero de la cuota.
8
+ *
9
+ * Aider resuelve esto devolviendo el tramo real mas parecido ("did you mean
10
+ * these actual lines?"), y Crush pintando los espacios invisibles. Las dos
11
+ * cosas atacan el mismo problema: el modelo no puede corregir una diferencia
12
+ * que no ve.
13
+ */
14
+ /**
15
+ * Coeficiente de Dice sobre bigramas de caracteres.
16
+ *
17
+ * Se usa en vez de una distancia de edicion porque es lineal y aqui hay que
18
+ * puntuar una ventana por cada linea del archivo. Da lo mismo que el ratio de
19
+ * SequenceMatcher para lo unico que se necesita: ordenar candidatos.
20
+ */
21
+ export declare function similitud(a: string, b: string): number;
22
+ export interface TramoParecido {
23
+ /** Numero de la primera linea del tramo, 1-based. */
24
+ primeraLinea: number;
25
+ lineas: string[];
26
+ parecido: number;
27
+ }
28
+ /**
29
+ * Ventana del archivo mas parecida al fragmento buscado.
30
+ *
31
+ * Se comparan ventanas del MISMO numero de lineas que el fragmento, deslizando
32
+ * una a una. Si el mejor candidato no llega al umbral, no se devuelve nada:
33
+ * ensenar un tramo al azar es peor que no ensenar ninguno, porque el modelo se
34
+ * lo cree y edita ahi.
35
+ */
36
+ export declare function tramoMasParecido(contenido: string, fragmento: string, umbral?: number): TramoParecido | null;
37
+ /**
38
+ * Pinta la sangria y los espacios finales, que son invisibles y son justo lo
39
+ * que falla. El interior de la linea se deja tal cual: marcarlo entero hace el
40
+ * texto ilegible y no anade informacion.
41
+ */
42
+ export declare function marcarEspacios(texto: string): string;
43
+ /**
44
+ * Mensaje de error de edit_file cuando no hay coincidencia.
45
+ *
46
+ * Tres piezas, en orden de utilidad:
47
+ * 1. "esto ya esta aplicado" corta en seco el bucle de reaplicar un cambio que
48
+ * ya se hizo, que es el atasco mas caro y el mas frecuente;
49
+ * 2. el tramo real mas parecido, con numeros de linea y los espacios pintados;
50
+ * 3. que reenvie SOLO este bloque.
51
+ */
52
+ export declare function diagnosticarFallo(rutaMostrada: string, contenido: string, viejo: string, nuevo: string): string;
53
+ /** Mensaje cuando el fragmento aparece varias veces. */
54
+ export declare function diagnosticarAmbiguedad(rutaMostrada: string, ocurrencias: number, estrategia: string): string;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * Que se le contesta al modelo cuando un edit_file no encuentra su fragmento.
3
+ *
4
+ * El mensaje anterior era "No se encontro el fragmento de texto. Asegurate de
5
+ * copiar las lineas exactas": cierto, inutil y, sobre todo, IDENTICO cada vez.
6
+ * Un mensaje que no aporta informacion nueva invita a reintentar lo mismo, y
7
+ * cada reintento cuesta una iteracion y dinero de la cuota.
8
+ *
9
+ * Aider resuelve esto devolviendo el tramo real mas parecido ("did you mean
10
+ * these actual lines?"), y Crush pintando los espacios invisibles. Las dos
11
+ * cosas atacan el mismo problema: el modelo no puede corregir una diferencia
12
+ * que no ve.
13
+ */
14
+ /** Numero de lineas de contexto que se ensenan alrededor del tramo candidato. */
15
+ const CONTEXTO = 3;
16
+ /** Similitud minima para molestarse en ensenar un tramo. */
17
+ const UMBRAL_PARECIDO = 0.6;
18
+ /**
19
+ * Coeficiente de Dice sobre bigramas de caracteres.
20
+ *
21
+ * Se usa en vez de una distancia de edicion porque es lineal y aqui hay que
22
+ * puntuar una ventana por cada linea del archivo. Da lo mismo que el ratio de
23
+ * SequenceMatcher para lo unico que se necesita: ordenar candidatos.
24
+ */
25
+ export function similitud(a, b) {
26
+ if (a === b)
27
+ return 1;
28
+ if (a.length < 2 || b.length < 2)
29
+ return 0;
30
+ const bigramas = new Map();
31
+ for (let i = 0; i < a.length - 1; i++) {
32
+ const par = a.slice(i, i + 2);
33
+ bigramas.set(par, (bigramas.get(par) || 0) + 1);
34
+ }
35
+ let comunes = 0;
36
+ for (let i = 0; i < b.length - 1; i++) {
37
+ const par = b.slice(i, i + 2);
38
+ const quedan = bigramas.get(par) || 0;
39
+ if (quedan > 0) {
40
+ bigramas.set(par, quedan - 1);
41
+ comunes++;
42
+ }
43
+ }
44
+ return (2 * comunes) / (a.length - 1 + b.length - 1);
45
+ }
46
+ /**
47
+ * Ventana del archivo mas parecida al fragmento buscado.
48
+ *
49
+ * Se comparan ventanas del MISMO numero de lineas que el fragmento, deslizando
50
+ * una a una. Si el mejor candidato no llega al umbral, no se devuelve nada:
51
+ * ensenar un tramo al azar es peor que no ensenar ninguno, porque el modelo se
52
+ * lo cree y edita ahi.
53
+ */
54
+ export function tramoMasParecido(contenido, fragmento, umbral = UMBRAL_PARECIDO) {
55
+ const lineasArchivo = contenido.split("\n");
56
+ const lineasFragmento = fragmento.split("\n");
57
+ const ventana = lineasFragmento.length;
58
+ if (ventana === 0 || lineasArchivo.length < ventana)
59
+ return null;
60
+ const buscado = lineasFragmento.map((l) => l.trim()).join("\n");
61
+ if (!buscado.trim())
62
+ return null;
63
+ let mejor = null;
64
+ for (let i = 0; i + ventana <= lineasArchivo.length; i++) {
65
+ const trozo = lineasArchivo.slice(i, i + ventana);
66
+ const parecido = similitud(buscado, trozo.map((l) => l.trim()).join("\n"));
67
+ if (!mejor || parecido > mejor.parecido) {
68
+ mejor = { primeraLinea: i + 1, lineas: trozo, parecido };
69
+ }
70
+ }
71
+ if (!mejor || mejor.parecido < umbral)
72
+ return null;
73
+ // Se amplia con contexto para que el modelo pueda reconstruir un fragmento
74
+ // unico, no solo el tramo justo.
75
+ const desde = Math.max(0, mejor.primeraLinea - 1 - CONTEXTO);
76
+ const hasta = Math.min(lineasArchivo.length, mejor.primeraLinea - 1 + ventana + CONTEXTO);
77
+ return {
78
+ primeraLinea: desde + 1,
79
+ lineas: lineasArchivo.slice(desde, hasta),
80
+ parecido: mejor.parecido,
81
+ };
82
+ }
83
+ /**
84
+ * Pinta la sangria y los espacios finales, que son invisibles y son justo lo
85
+ * que falla. El interior de la linea se deja tal cual: marcarlo entero hace el
86
+ * texto ilegible y no anade informacion.
87
+ */
88
+ export function marcarEspacios(texto) {
89
+ return texto
90
+ .split("\n")
91
+ .map((linea) => {
92
+ const inicio = linea.match(/^[ \t]*/)?.[0] ?? "";
93
+ const resto = linea.slice(inicio.length);
94
+ const finalEspacios = resto.match(/[ \t]*$/)?.[0] ?? "";
95
+ const cuerpo = finalEspacios ? resto.slice(0, resto.length - finalEspacios.length) : resto;
96
+ const pintar = (s) => s.replace(/\t/g, "→").replace(/ /g, "·");
97
+ return pintar(inicio) + cuerpo + pintar(finalEspacios);
98
+ })
99
+ .join("\n");
100
+ }
101
+ /**
102
+ * Mensaje de error de edit_file cuando no hay coincidencia.
103
+ *
104
+ * Tres piezas, en orden de utilidad:
105
+ * 1. "esto ya esta aplicado" corta en seco el bucle de reaplicar un cambio que
106
+ * ya se hizo, que es el atasco mas caro y el mas frecuente;
107
+ * 2. el tramo real mas parecido, con numeros de linea y los espacios pintados;
108
+ * 3. que reenvie SOLO este bloque.
109
+ */
110
+ export function diagnosticarFallo(rutaMostrada, contenido, viejo, nuevo) {
111
+ const partes = [
112
+ `Error: no se encontro el fragmento a reemplazar en "${rutaMostrada}".`,
113
+ ];
114
+ if (nuevo.trim() && contenido.includes(nuevo)) {
115
+ partes.push(`El texto NUEVO ya esta en el archivo: este cambio parece hecho. ` +
116
+ `Comprueba con view_file antes de volver a intentarlo, y si ya esta, sigue con lo siguiente.`);
117
+ return partes.join("\n\n");
118
+ }
119
+ const tramo = tramoMasParecido(contenido, viejo);
120
+ if (tramo) {
121
+ const numeradas = tramo.lineas
122
+ .map((l, i) => `${String(tramo.primeraLinea + i).padStart(5)} | ${marcarEspacios(l)}`)
123
+ .join("\n");
124
+ partes.push(`Lo mas parecido que hay en el archivo (${Math.round(tramo.parecido * 100)}% de coincidencia) ` +
125
+ `esta en la linea ${tramo.primeraLinea}. ¿Querias decir esto?\n\n${numeradas}`);
126
+ partes.push(`Los espacios se ven como "·" y los tabuladores como "→": la diferencia suele estar ahi. ` +
127
+ `Copia las lineas tal cual aparecen arriba.`);
128
+ }
129
+ else {
130
+ partes.push(`No hay ningun tramo parecido: puede que el archivo no sea el que crees, o que haya ` +
131
+ `cambiado. Leelo con view_file antes de volver a editar.`);
132
+ }
133
+ partes.push(`Reenvia SOLO este bloque corregido. Las demas ediciones que hayan funcionado ya estan ` +
134
+ `aplicadas: no las repitas.`);
135
+ return partes.join("\n\n");
136
+ }
137
+ /** Mensaje cuando el fragmento aparece varias veces. */
138
+ export function diagnosticarAmbiguedad(rutaMostrada, ocurrencias, estrategia) {
139
+ return (`Error: el fragmento aparece ${ocurrencias} veces en "${rutaMostrada}" (coincidencia por ` +
140
+ `${estrategia}). No voy a adivinar cual. Anade lineas de alrededor —la firma de la funcion, ` +
141
+ `el comentario de encima— hasta que el fragmento sea unico.`);
142
+ }
@@ -2,4 +2,5 @@ export declare function editFile(filePath: string, oldText: string, newText: str
2
2
  success: boolean;
3
3
  message: string;
4
4
  diffText?: string;
5
+ estrategia?: string;
5
6
  }>;
@@ -1,6 +1,17 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { formatNumberedDiff } from "../ui/diffViewer.js";
4
+ import { binaryRefusalReason, quickBinaryReason } from "./binary.js";
5
+ import { localizarFragmento, esRechazo } from "./patchCascade.js";
6
+ import { diagnosticarFallo, diagnosticarAmbiguedad } from "./editDiagnosis.js";
7
+ /**
8
+ * El mensaje empieza por "Error" a proposito: es lo que mira runner.ts para no
9
+ * dar la edicion por buena.
10
+ */
11
+ function mensajeBinario(filePath, motivo) {
12
+ return (`Error: "${filePath}" ${motivo}. edit_file lee y reescribe el archivo entero como texto UTF-8, ` +
13
+ `asi que editarlo lo dejaria irrecuperable. Usa run_command con la herramienta que corresponda.`);
14
+ }
4
15
  export async function editFile(filePath, oldText, newText, cwd = process.cwd()) {
5
16
  const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
6
17
  if (!fs.existsSync(resolved)) {
@@ -9,80 +20,125 @@ export async function editFile(filePath, oldText, newText, cwd = process.cwd())
9
20
  message: `Error: El archivo "${filePath}" no existe.`,
10
21
  };
11
22
  }
12
- const currentContent = fs.readFileSync(resolved, "utf-8");
13
- // Normalize line endings for comparison
14
- const normalizedCurrent = currentContent.replace(/\r\n/g, "\n");
15
- const normalizedOld = oldText.replace(/\r\n/g, "\n");
16
- const normalizedNew = newText.replace(/\r\n/g, "\n");
17
- let matchIndex = normalizedCurrent.indexOf(normalizedOld);
18
- let finalOldText = normalizedOld;
19
- // Si el fragmento aparece varias veces, editar la primera ocurrencia es una
20
- // apuesta: puede tocar la funcion equivocada y el usuario no se entera hasta
21
- // mucho despues. Mejor negarse y pedir mas contexto.
22
- if (matchIndex !== -1 && normalizedOld.length > 0) {
23
- let count = 0;
24
- let from = 0;
25
- while (true) {
26
- const at = normalizedCurrent.indexOf(normalizedOld, from);
27
- if (at === -1)
28
- break;
29
- count++;
30
- if (count > 1)
31
- break;
32
- from = at + normalizedOld.length;
33
- }
34
- if (count > 1) {
35
- return {
36
- success: false,
37
- message: `Error: el fragmento aparece mas de una vez en "${filePath}". No voy a adivinar cual. ` +
38
- `Incluye lineas de alrededor para que el fragmento sea unico.`,
39
- };
40
- }
23
+ // Antes se leia directamente con "utf-8" y se reescribia el archivo entero
24
+ // desde ese string: cada byte invalido volvia al disco como EF BF BD, asi que
25
+ // un edit_file con exito sobre un mp4 o un png lo dejaba irrecuperable y
26
+ // respondia "editado exitosamente". Nadie confirma esta escritura en ningun
27
+ // modo de permisos, asi que la unica defensa es negarse aqui.
28
+ //
29
+ // Primero la comprobacion barata (extension y cabecera), porque el propio
30
+ // readFileSync de un video de 500 MB ya es el problema.
31
+ const motivoRapido = quickBinaryReason(resolved);
32
+ if (motivoRapido) {
33
+ return { success: false, message: mensajeBinario(filePath, motivoRapido) };
41
34
  }
42
- // Fallback: If exact match fails, try line-by-line trimmed matching
43
- if (matchIndex === -1) {
44
- const fileLines = normalizedCurrent.split("\n");
45
- const targetLines = normalizedOld.split("\n").map((l) => l.trimEnd());
46
- let foundLineIdx = -1;
47
- for (let i = 0; i <= fileLines.length - targetLines.length; i++) {
48
- let isMatch = true;
49
- for (let j = 0; j < targetLines.length; j++) {
50
- if (fileLines[i + j].trimEnd() !== targetLines[j]) {
51
- isMatch = false;
52
- break;
53
- }
54
- }
55
- if (isMatch) {
56
- if (foundLineIdx === -1) {
57
- foundLineIdx = i;
58
- }
59
- else {
60
- // Multiple ambiguous matches found, don't guess
61
- foundLineIdx = -2;
62
- break;
63
- }
35
+ const buffer = fs.readFileSync(resolved);
36
+ const motivoBinario = binaryRefusalReason(resolved, buffer);
37
+ if (motivoBinario) {
38
+ return { success: false, message: mensajeBinario(filePath, motivoBinario) };
39
+ }
40
+ const currentContent = buffer.toString("utf-8");
41
+ // El BOM se aparta aqui y se vuelve a poner al escribir. Si se quedara dentro,
42
+ // ensuciaria el primer caracter del archivo y el emparejamiento por lineas
43
+ // nunca casaria con la primera linea.
44
+ const tieneBom = currentContent.charCodeAt(0) === 0xfeff;
45
+ const bom = tieneBom ? currentContent[0] : "";
46
+ const original = bom ? currentContent.slice(1) : currentContent;
47
+ // Terminador dominante del archivo. Solo se usa para las lineas NUEVAS: lo que
48
+ // no se edita se copia byte a byte, incluidos los finales mezclados.
49
+ const crlfCount = (original.match(/\r\n/g) || []).length;
50
+ const lfCount = (original.match(/\n/g) || []).length - crlfCount;
51
+ const eol = crlfCount > lfCount ? "\r\n" : "\n";
52
+ // Normalizar a LF sirve para BUSCAR: el fragmento que manda el modelo casi
53
+ // nunca trae los \r. Pero antes se escribia esa version normalizada, asi que
54
+ // tocar tres lineas de un repo con CRLF reescribia el archivo entero y el diff
55
+ // salia completo. Ahora se guarda a que posicion del original corresponde cada
56
+ // caracter del normalizado, para recortar sobre el original y dejar intacto
57
+ // todo lo que no se edita.
58
+ //
59
+ // Un "\n" que venia de un "\r\n" apunta al "\r", no al "\n": asi un fragmento
60
+ // que termina justo antes del salto no deja huerfano el retorno de carro.
61
+ let normalizedCurrent;
62
+ let mapaOriginal = null;
63
+ if (crlfCount > 0) {
64
+ const trozos = [];
65
+ mapaOriginal = [];
66
+ for (let i = 0; i < original.length; i++) {
67
+ if (original.charCodeAt(i) === 13 && original.charCodeAt(i + 1) === 10) {
68
+ trozos.push("\n");
69
+ mapaOriginal.push(i);
70
+ i++;
71
+ continue;
64
72
  }
73
+ trozos.push(original[i]);
74
+ mapaOriginal.push(i);
65
75
  }
66
- if (foundLineIdx >= 0) {
67
- const matchedBlock = fileLines.slice(foundLineIdx, foundLineIdx + targetLines.length).join("\n");
68
- matchIndex = normalizedCurrent.indexOf(matchedBlock);
69
- finalOldText = matchedBlock;
70
- }
76
+ normalizedCurrent = trozos.join("");
77
+ }
78
+ else {
79
+ normalizedCurrent = original;
80
+ }
81
+ /** Posicion en el archivo original del caracter normalizado numero idx. */
82
+ const aPosicionOriginal = (idx) => {
83
+ if (!mapaOriginal)
84
+ return Math.min(idx, original.length);
85
+ return idx < mapaOriginal.length ? mapaOriginal[idx] : original.length;
86
+ };
87
+ const normalizedOld = oldText.replace(/\r\n/g, "\n");
88
+ const normalizedNew = newText.replace(/\r\n/g, "\n");
89
+ // Un oldText vacio no identifica ningun sitio: "reemplaza la nada" es una
90
+ // orden sin respuesta. Antes se resolvia con indexOf("") === 0, que inserta al
91
+ // principio del archivo y responde "editado exitosamente" en un sitio que
92
+ // nadie ha pedido.
93
+ if (normalizedOld.length === 0) {
94
+ return {
95
+ success: false,
96
+ message: `Error: oldText esta vacio, asi que no señala ningun sitio del archivo. ` +
97
+ `Si quieres anadir texto, incluye en oldText la linea de alrededor donde va (y repitela ` +
98
+ `en newText); si quieres crear o reescribir el archivo entero, usa write_file.`,
99
+ };
100
+ }
101
+ // La busqueda ya no es un indexOf con un parche detras, sino una cascada de
102
+ // estrategias que va de la mas estricta a la mas flexible (ver patchCascade).
103
+ // Cada una lleva su propio cerrojo de unicidad, asi que tolerar mas formas de
104
+ // equivocarse no relaja la garantia de "o el sitio es inequivoco, o no se
105
+ // escribe".
106
+ const coincidencia = localizarFragmento(normalizedCurrent, normalizedOld, normalizedNew);
107
+ if (esRechazo(coincidencia)) {
108
+ return {
109
+ success: false,
110
+ message: diagnosticarAmbiguedad(filePath, coincidencia.ocurrencias, coincidencia.estrategia),
111
+ };
71
112
  }
72
- if (matchIndex === -1) {
113
+ if (!coincidencia) {
114
+ // Antes se devolvia siempre el mismo texto generico, que no le decia al
115
+ // modelo nada que no supiera y le invitaba a reintentar identico.
73
116
  return {
74
117
  success: false,
75
- message: `Error: No se encontró el fragmento de texto en "${filePath}". Asegúrate de copiar las líneas exactas a reemplazar.`,
118
+ message: diagnosticarFallo(filePath, normalizedCurrent, normalizedOld, normalizedNew),
76
119
  };
77
120
  }
121
+ const { inicio, fin, textoNuevo: reemplazo, estrategia } = coincidencia;
122
+ const finalOldText = normalizedCurrent.slice(inicio, fin);
78
123
  // Calculate real starting line number in the original file
79
- const startLine = normalizedCurrent.slice(0, matchIndex).split("\n").length;
80
- const updatedContent = normalizedCurrent.slice(0, matchIndex) + normalizedNew + normalizedCurrent.slice(matchIndex + finalOldText.length);
124
+ const startLine = normalizedCurrent.slice(0, inicio).split("\n").length;
125
+ // Se recorta sobre el ORIGINAL, no sobre el normalizado: lo de fuera del
126
+ // fragmento vuelve al disco tal cual estaba. Solo el texto nuevo se escribe con
127
+ // el terminador dominante.
128
+ const inicioOriginal = aPosicionOriginal(inicio);
129
+ const finOriginal = aPosicionOriginal(fin);
130
+ const textoNuevo = eol === "\n" ? reemplazo : reemplazo.replace(/\n/g, "\r\n");
131
+ const updatedContent = bom + original.slice(0, inicioOriginal) + textoNuevo + original.slice(finOriginal);
81
132
  fs.writeFileSync(resolved, updatedContent, "utf-8");
82
- const diffText = formatNumberedDiff(path.relative(cwd, resolved) || filePath, finalOldText, normalizedNew, startLine);
133
+ const diffText = formatNumberedDiff(path.relative(cwd, resolved) || filePath, finalOldText, reemplazo, startLine);
134
+ // Que estrategia acerto no es cosmetico: si el modelo empieza a depender del
135
+ // ancla de bloque es que esta copiando mal los fragmentos, y eso se puede
136
+ // medir en vez de intuirlo.
137
+ const nota = estrategia === "exacta" ? "" : ` [coincidencia por ${estrategia}]`;
83
138
  return {
84
139
  success: true,
85
- message: `✔ Archivo "${filePath}" editado exitosamente (Línea ${startLine}+).`,
140
+ message: `✔ Archivo "${filePath}" editado exitosamente (Línea ${startLine}+).${nota}`,
86
141
  diffText,
142
+ estrategia,
87
143
  };
88
144
  }
@@ -1 +1,22 @@
1
+ /**
2
+ * Traduce lo que escribe el modelo al glob que fast-glob necesita.
3
+ *
4
+ * Dos trampas, y las dos daban cero resultados sin decir nada:
5
+ *
6
+ * 1. En glob, `*.ts` significa ".ts en el nivel raiz", no "en todo el arbol".
7
+ * La descripcion de la herramienta pone `'*.tsx'` como ejemplo, asi que el
8
+ * modelo lo escribe tal cual, recibe cero y concluye que el proyecto no
9
+ * tiene TypeScript. El codigo viejo hacia justo lo contrario de lo que hace
10
+ * falta: solo volvia recursivos los patrones SIN comodin.
11
+ *
12
+ * 2. fast-glob trata `\` como caracter de escape, no como separador. Un
13
+ * patron con rutas de Windows (`src\**\*.ts`) se convertia en `src**.ts` y
14
+ * no casaba con nada.
15
+ *
16
+ * Devuelve una lista porque para un patron con ruta se prueban dos: la ruta tal
17
+ * cual (relativa a la carpeta de busqueda) y la misma anclada a cualquier nivel,
18
+ * que es lo que suele querer decir quien escribe `src/*.ts` sin saber desde
19
+ * donde se esta buscando. fast-glob deduplica.
20
+ */
21
+ export declare function normalizarPatron(pattern: string): string[];
1
22
  export declare function findFiles(pattern: string, searchDirectory?: string, cwd?: string): Promise<string>;