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
@@ -27,7 +27,34 @@ import { fileTracker } from "../agent/fileTracker.js";
27
27
  import { mcpManager } from "../mcp/manager.js";
28
28
  import fs from "node:fs";
29
29
  import path from "node:path";
30
- const skillManager = new SkillManager();
30
+ /**
31
+ * Un SkillManager por directorio de trabajo.
32
+ *
33
+ * Antes habia una unica instancia de modulo creada con `new SkillManager()`, y
34
+ * eso rompia use_skill de dos formas a la vez:
35
+ *
36
+ * 1. El constructor solo arma la lista de directorios; no escanea. Como nadie
37
+ * llamaba a scanSkills() sobre ESTA instancia (la que escanea es la de
38
+ * index.ts, que es otro objeto con otro Map), la primera use_skill de cada
39
+ * proceso caia en la rama de error y devolvia "no se encontro el skill X"
40
+ * seguido de una lista donde X aparecia. Solo acertaba al segundo intento,
41
+ * porque la propia rama de error llama a getSkillPromptListing() y esa si
42
+ * escanea.
43
+ *
44
+ * 2. Se construia con process.cwd() capturado al importar el modulo, asi que
45
+ * los skills de proyecto (./skills y ./.skills) se resolvian siempre contra
46
+ * el directorio de lanzamiento y nunca seguian a un change_dir.
47
+ */
48
+ const skillManagers = new Map();
49
+ function getSkillManager(cwd) {
50
+ const key = path.resolve(cwd);
51
+ let manager = skillManagers.get(key);
52
+ if (!manager) {
53
+ manager = new SkillManager(key);
54
+ skillManagers.set(key, manager);
55
+ }
56
+ return manager;
57
+ }
31
58
  const memoryManager = new MemoryManager();
32
59
  export async function executeToolCall(name, args, cwd = process.cwd(), apiKey, modelName) {
33
60
  try {
@@ -44,6 +71,7 @@ export async function executeToolCall(name, args, cwd = process.cwd(), apiKey, m
44
71
  rawResult = setSessionTasks(args.tasks || []);
45
72
  break;
46
73
  case "use_skill": {
74
+ const skillManager = getSkillManager(cwd);
47
75
  const body = skillManager.loadSkillBody(args.skillName);
48
76
  if (body) {
49
77
  rawResult = `--- INSTRUCCIONES DEL SKILL "${args.skillName}" ---\n${body}\n--- FIN DEL SKILL ---`;
@@ -121,8 +149,22 @@ export async function executeToolCall(name, args, cwd = process.cwd(), apiKey, m
121
149
  break;
122
150
  }
123
151
  case "write_file": {
152
+ // Sobreescribir un archivo que ya existe es la edicion mas destructiva
153
+ // que hay: se lleva por delante TODO el contenido anterior. Aun asi era
154
+ // el unico camino de escritura que no pasaba por el fileTracker, con lo
155
+ // que la ruta barata para el modelo era justo la insegura: si edit_file
156
+ // le bloqueaba por no haber leido el archivo, bastaba con reescribirlo
157
+ // entero para saltarse la comprobacion.
158
+ //
159
+ // checkBeforeEdit devuelve null cuando el archivo no existe, asi que
160
+ // crear uno nuevo sigue sin pedir permiso a nadie.
161
+ const risk = fileTracker.checkBeforeEdit(args.path, cwd);
162
+ if (risk) {
163
+ rawResult = `Escritura bloqueada: ${risk}`;
164
+ break;
165
+ }
124
166
  globalUndoManager.snapshotFile(args.path, cwd);
125
- rawResult = await writeFile(args.path, args.content, cwd);
167
+ rawResult = await writeFile(args.path, args.content, cwd, args.modo === "anadir" ? "anadir" : "sobrescribir");
126
168
  fileTracker.markWritten(args.path, cwd);
127
169
  const check = validateFileSyntax(args.path, args.content || "");
128
170
  if (!check.valid && check.warning) {
@@ -131,19 +173,30 @@ export async function executeToolCall(name, args, cwd = process.cwd(), apiKey, m
131
173
  break;
132
174
  }
133
175
  case "create_image":
176
+ // Era la unica herramienta con efecto en disco sin snapshot: si el
177
+ // generador pisaba un archivo que ya existia, /undo no lo recuperaba.
178
+ // Y /undo es justo lo que justifica que las escrituras no pregunten.
179
+ globalUndoManager.snapshotFile(args.outputPath, cwd);
134
180
  rawResult = await createImage(args.outputPath, args.generatorScript, cwd);
135
181
  break;
136
182
  case "web_search":
137
183
  rawResult = await webSearch(args.query);
138
184
  break;
139
- case "delete_file":
140
- globalUndoManager.snapshotFile(args.path, cwd);
185
+ case "delete_file": {
186
+ // El snapshot dice si hay copia. Sobre un directorio (o un archivo
187
+ // bloqueado) no la hay, y el borrado es definitivo: se avisa aqui en vez
188
+ // de dejar que /undo finja despues que lo ha devuelto.
189
+ const copia = globalUndoManager.snapshotFile(args.path, cwd);
141
190
  rawResult = await deleteFile(args.path, cwd);
191
+ if (copia === "unrestorable" && !rawResult.startsWith("Error")) {
192
+ rawResult += "\n⚠ Sin copia de seguridad: /undo no puede revertir este borrado.";
193
+ }
142
194
  break;
195
+ }
143
196
  case "move_copy_file":
144
197
  globalUndoManager.snapshotFile(args.sourcePath, cwd);
145
198
  globalUndoManager.snapshotFile(args.destinationPath, cwd);
146
- rawResult = await moveCopyFile(args.sourcePath, args.destinationPath, args.operation || "move", cwd);
199
+ rawResult = await moveCopyFile(args.sourcePath, args.destinationPath, args.operation || "move", cwd, args.overwrite === true);
147
200
  break;
148
201
  case "run_command":
149
202
  rawResult = await runCommand(args.command, cwd, args.timeout);
@@ -141,7 +141,12 @@ export const COMPUTER_USE_TOOL = {
141
141
  y: { type: "integer", description: "Coordenada Y en pixeles fisicos." },
142
142
  start_coordinate: { type: "array", items: { type: "integer" }, description: "[x, y] de inicio del arrastre." },
143
143
  text: { type: "string", description: "Texto a escribir." },
144
- keys: { type: "string", description: 'Tecla o atajo (ej. "Return", "Escape", "ctrl+s", "alt+Tab").' },
144
+ keys: {
145
+ type: "string",
146
+ description: 'Tecla o atajo (ej. "Return", "Escape", "ctrl+s", "alt+Tab"). ' +
147
+ "Un atajo no se puede mandar a una ventana de fondo: si pasas window, esa ventana se trae al frente antes de teclear. " +
148
+ "Sin window va a lo que el usuario tenga delante.",
149
+ },
145
150
  repeat: { type: "integer", description: "Veces que repetir la tecla." },
146
151
  direction: { type: "string", enum: ["up", "down", "left", "right"], description: "Direccion del scroll." },
147
152
  amount: { type: "integer", description: "Cantidad de scroll." },
@@ -182,7 +187,12 @@ export const CHROME_TOOL = {
182
187
  "notice: cambia el texto del aviso flotante. close_tab: cierra una pestaña que abriste. done: cierra el trabajo y quita el aviso.",
183
188
  },
184
189
  url: { type: "string", description: "URL para open o navigate." },
185
- tabId: { type: "integer", description: "Pestana concreta; por defecto, en la que se esta trabajando." },
190
+ tabId: {
191
+ type: "integer",
192
+ description: "Pestana concreta; por defecto, en la que se esta trabajando. " +
193
+ "Si no hay ninguna (aun no has hecho open/select_tab, o ya llamaste a done), " +
194
+ "navigate, close_tab, press y eval fallan a proposito: no se actua sobre la pestana del usuario.",
195
+ },
186
196
  ref: { type: "integer", description: "Numero de elemento devuelto por snapshot." },
187
197
  text: { type: "string", description: "Texto a escribir (type) o a esperar (wait_for)." },
188
198
  submit: { type: "boolean", description: "true para pulsar Enter despues de escribir." },
@@ -1,4 +1,27 @@
1
- export declare const MAX_TOOL_OUTPUT_CHARS = 60000;
1
+ /**
2
+ * Topes de una salida de herramienta antes de recortarla.
3
+ *
4
+ * El limite estaba en 60.000 caracteres, que es entre dos y cuatro veces lo que
5
+ * usan los demas agentes (opencode 50 KB, Cline 48 K, Crush 30 K). El motivo
6
+ * por el que importa lo explica bien un comentario de Cline: cada caracter que
7
+ * devuelve una herramienta se REENVIA al modelo en todas las peticiones
8
+ * posteriores del turno, y el bucle puede dar treinta vueltas. Una salida
9
+ * gorda no cuesta lo que ocupa: cuesta lo que ocupa multiplicado por lo que
10
+ * queda de turno.
11
+ */
12
+ export declare const MAX_TOOL_OUTPUT_CHARS = 24000;
13
+ /** Un fichero minificado de una sola linea pasaba entero hasta el tope global. */
14
+ export declare const MAX_LINES = 2000;
15
+ export declare const MAX_LINE_CHARS = 2000;
16
+ /**
17
+ * Guarda la salida entera en disco y devuelve la ruta, o null si no se pudo.
18
+ *
19
+ * Lo omitido se perdia para siempre: si el trozo que importaba estaba en el
20
+ * medio, el agente no tenia forma de recuperarlo y acababa relanzando el mismo
21
+ * comando —y volviendo a truncarlo igual—. Guardarlo cuesta un fichero y
22
+ * convierte "se perdio" en "esta ahi, ve a buscarlo".
23
+ */
24
+ export declare function volcarSalida(contenido: string): string | null;
2
25
  /**
3
26
  * Recorta la salida de una herramienta conservando el PRINCIPIO Y EL FINAL.
4
27
  *
@@ -7,5 +30,8 @@ export declare const MAX_TOOL_OUTPUT_CHARS = 60000;
7
30
  * resumen de los tests y el codigo de salida estan al final. El agente veia
8
31
  * 25.000 caracteres de compilacion y se perdia la linea que decia por que habia
9
32
  * fallado.
33
+ *
34
+ * Ahora, ademas, lo que no cabe no se tira: se escribe entero a disco y se le
35
+ * dice al agente como llegar a ello sin quemarse el contexto.
10
36
  */
11
37
  export declare function truncateOutput(output: string, maxChars?: number): string;
@@ -1,4 +1,80 @@
1
- export const MAX_TOOL_OUTPUT_CHARS = 60_000;
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ /**
6
+ * Topes de una salida de herramienta antes de recortarla.
7
+ *
8
+ * El limite estaba en 60.000 caracteres, que es entre dos y cuatro veces lo que
9
+ * usan los demas agentes (opencode 50 KB, Cline 48 K, Crush 30 K). El motivo
10
+ * por el que importa lo explica bien un comentario de Cline: cada caracter que
11
+ * devuelve una herramienta se REENVIA al modelo en todas las peticiones
12
+ * posteriores del turno, y el bucle puede dar treinta vueltas. Una salida
13
+ * gorda no cuesta lo que ocupa: cuesta lo que ocupa multiplicado por lo que
14
+ * queda de turno.
15
+ */
16
+ export const MAX_TOOL_OUTPUT_CHARS = 24_000;
17
+ /** Un fichero minificado de una sola linea pasaba entero hasta el tope global. */
18
+ export const MAX_LINES = 2_000;
19
+ export const MAX_LINE_CHARS = 2_000;
20
+ /** Dias que se conservan los volcados antes de barrerlos. */
21
+ const RETENCION_DIAS = 7;
22
+ function directorioDeVolcados() {
23
+ return path.join(os.homedir(), ".chocolatito", "outputs");
24
+ }
25
+ /**
26
+ * Borra los volcados viejos. Se llama al escribir uno nuevo: es el unico
27
+ * momento en que se sabe que el directorio existe y que a alguien le importa.
28
+ */
29
+ function barrerVolcadosViejos(dir) {
30
+ const limite = Date.now() - RETENCION_DIAS * 24 * 60 * 60 * 1000;
31
+ try {
32
+ for (const nombre of fs.readdirSync(dir)) {
33
+ const ruta = path.join(dir, nombre);
34
+ try {
35
+ if (fs.statSync(ruta).mtimeMs < limite)
36
+ fs.unlinkSync(ruta);
37
+ }
38
+ catch { }
39
+ }
40
+ }
41
+ catch { }
42
+ }
43
+ /**
44
+ * Guarda la salida entera en disco y devuelve la ruta, o null si no se pudo.
45
+ *
46
+ * Lo omitido se perdia para siempre: si el trozo que importaba estaba en el
47
+ * medio, el agente no tenia forma de recuperarlo y acababa relanzando el mismo
48
+ * comando —y volviendo a truncarlo igual—. Guardarlo cuesta un fichero y
49
+ * convierte "se perdio" en "esta ahi, ve a buscarlo".
50
+ */
51
+ export function volcarSalida(contenido) {
52
+ try {
53
+ const dir = directorioDeVolcados();
54
+ fs.mkdirSync(dir, { recursive: true });
55
+ barrerVolcadosViejos(dir);
56
+ const nombre = `${Date.now()}-${crypto.randomBytes(4).toString("hex")}.txt`;
57
+ const ruta = path.join(dir, nombre);
58
+ fs.writeFileSync(ruta, contenido, "utf-8");
59
+ return ruta;
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ }
65
+ /** Recorta las lineas absurdamente largas, avisando de cuanto se quito. */
66
+ function recortarLineasLargas(salida) {
67
+ if (!salida.includes("\n") && salida.length <= MAX_LINE_CHARS)
68
+ return salida;
69
+ let tocada = false;
70
+ const lineas = salida.split("\n").map((linea) => {
71
+ if (linea.length <= MAX_LINE_CHARS)
72
+ return linea;
73
+ tocada = true;
74
+ return `${linea.slice(0, MAX_LINE_CHARS)}… [+${(linea.length - MAX_LINE_CHARS).toLocaleString()} caracteres en esta linea]`;
75
+ });
76
+ return tocada ? lineas.join("\n") : salida;
77
+ }
2
78
  /**
3
79
  * Recorta la salida de una herramienta conservando el PRINCIPIO Y EL FINAL.
4
80
  *
@@ -7,18 +83,48 @@ export const MAX_TOOL_OUTPUT_CHARS = 60_000;
7
83
  * resumen de los tests y el codigo de salida estan al final. El agente veia
8
84
  * 25.000 caracteres de compilacion y se perdia la linea que decia por que habia
9
85
  * fallado.
86
+ *
87
+ * Ahora, ademas, lo que no cabe no se tira: se escribe entero a disco y se le
88
+ * dice al agente como llegar a ello sin quemarse el contexto.
10
89
  */
11
90
  export function truncateOutput(output, maxChars = MAX_TOOL_OUTPUT_CHARS) {
12
- if (!output || output.length <= maxChars) {
91
+ if (!output)
13
92
  return output;
93
+ const completo = output;
94
+ let trabajo = recortarLineasLargas(output);
95
+ const lineas = trabajo.split("\n");
96
+ const sobranLineas = lineas.length > MAX_LINES;
97
+ if (sobranLineas) {
98
+ const cabeza = Math.floor(MAX_LINES * 0.65);
99
+ const cola = MAX_LINES - cabeza;
100
+ trabajo =
101
+ lineas.slice(0, cabeza).join("\n") +
102
+ `\n\n[... ${(lineas.length - MAX_LINES).toLocaleString()} lineas omitidas del medio ...]\n\n` +
103
+ lineas.slice(lineas.length - cola).join("\n");
104
+ }
105
+ if (trabajo.length <= maxChars && !sobranLineas && trabajo === completo) {
106
+ return completo;
107
+ }
108
+ let recortado = trabajo;
109
+ let omitidos = 0;
110
+ if (trabajo.length > maxChars) {
111
+ // Dos tercios al principio (contexto de lo que se hizo) y un tercio al final
112
+ // (donde suele estar el desenlace).
113
+ const headChars = Math.floor(maxChars * 0.65);
114
+ const tailChars = maxChars - headChars;
115
+ omitidos = trabajo.length - maxChars;
116
+ recortado =
117
+ trabajo.slice(0, headChars) +
118
+ `\n\n[... ${omitidos.toLocaleString()} caracteres omitidos del medio ` +
119
+ `para proteger el contexto; se conserva el final ...]\n\n` +
120
+ trabajo.slice(trabajo.length - tailChars);
14
121
  }
15
- // Dos tercios al principio (contexto de lo que se hizo) y un tercio al final
16
- // (donde suele estar el desenlace).
17
- const headChars = Math.floor(maxChars * 0.65);
18
- const tailChars = maxChars - headChars;
19
- const head = output.slice(0, headChars);
20
- const tail = output.slice(output.length - tailChars);
21
- const omitted = output.length - maxChars;
22
- return (`${head}\n\n[... ${omitted.toLocaleString()} caracteres omitidos del medio ` +
23
- `para proteger el contexto; se conserva el final ...]\n\n${tail}`);
122
+ const ruta = volcarSalida(completo);
123
+ if (!ruta)
124
+ return recortado;
125
+ return (`${recortado}\n\n` +
126
+ `[Salida completa (${completo.length.toLocaleString()} caracteres) guardada en: ${ruta}\n` +
127
+ ` Para consultarla sin gastar contexto: grep_search sobre esa ruta, o view_file con startLine/endLine.\n` +
128
+ ` Si hay que revisarla entera, manda un spawn_agent que la lea y te resuma lo que buscas.\n` +
129
+ ` No la leas entera tu: volveria a ocupar lo mismo que se acaba de recortar.]`);
24
130
  }
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { binaryExtensionLabel, looksBinary } from "./binary.js";
3
4
  export async function viewFile(filePath, startLine, endLine, cwd = process.cwd()) {
4
5
  const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
5
6
  if (!fs.existsSync(resolved)) {
@@ -9,18 +10,21 @@ export async function viewFile(filePath, startLine, endLine, cwd = process.cwd()
9
10
  if (stat.isDirectory()) {
10
11
  return `Error: "${filePath}" es un directorio, usa list_dir en su lugar.`;
11
12
  }
12
- const BINARY_EXTENSIONS = new Set([
13
- ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svgz",
14
- ".zip", ".tar", ".gz", ".7z", ".rar",
15
- ".pdf", ".exe", ".dll", ".so", ".dylib", ".bin",
16
- ".mp4", ".mov", ".avi", ".mkv", ".mp3", ".wav",
17
- ".woff", ".woff2", ".ttf", ".eot"
18
- ]);
19
- const ext = path.extname(resolved).toLowerCase();
20
- if (BINARY_EXTENSIONS.has(ext)) {
21
- return `Aviso: "${filePath}" es un archivo binario (${ext.slice(1).toUpperCase()}) y no contiene texto plano legible. No intentes leerlo como texto.`;
13
+ // Esto empezaba por "Aviso:", y runner.ts solo se salta el markRead cuando el
14
+ // resultado empieza por "Error". O sea que un binario "visto" quedaba marcado
15
+ // como leido y el fileTracker dejaba pasar el edit_file que lo destruia. Es un
16
+ // error, no un aviso.
17
+ const etiqueta = binaryExtensionLabel(resolved);
18
+ if (etiqueta) {
19
+ return `Error: "${filePath}" es un archivo binario (${etiqueta}) y no contiene texto plano legible. No intentes leerlo ni editarlo como texto.`;
22
20
  }
23
- const content = fs.readFileSync(resolved, "utf-8");
21
+ const buffer = fs.readFileSync(resolved);
22
+ // La lista de extensiones no cubre lo que no tiene extension o la tiene rara
23
+ // (un .dat, un volcado sin nombre). Un byte nulo lo delata igual.
24
+ if (looksBinary(buffer)) {
25
+ return `Error: "${filePath}" contiene bytes nulos: es binario, no texto. No intentes leerlo ni editarlo como texto.`;
26
+ }
27
+ const content = buffer.toString("utf-8");
24
28
  const lines = content.split(/\r?\n/);
25
29
  const totalLines = lines.length;
26
30
  const isUnbounded = !startLine && !endLine;
@@ -1,6 +1,7 @@
1
1
  import fs from "node:fs";
2
2
  import { VISION_MODEL } from "../config/constants.js";
3
3
  import { createEngineClient } from "../config/engine.js";
4
+ import { apuntarUso } from "../agent/usageMeter.js";
4
5
  function client(apiKey) {
5
6
  return createEngineClient(apiKey, "vision");
6
7
  }
@@ -28,6 +29,12 @@ async function ask(apiKey, prompt, imagePath, maxTokens) {
28
29
  },
29
30
  ],
30
31
  });
32
+ // Se apunta aqui, no en quien llama: `usage` se venia devolviendo en el
33
+ // resultado y no lo leia nadie (computerUse, browserCdp y browserExtension
34
+ // solo usan el texto), asi que cada captura descrita durante el control del
35
+ // ordenador —y son muchas, con una imagen entera en el prompt— costaba
36
+ // dinero y sumaba cero. Contarlo donde se gasta evita repetir el olvido.
37
+ apuntarUso(res.usage, VISION_MODEL, "vision");
31
38
  const msg = res.choices[0]?.message;
32
39
  // El modelo de vision razona antes de responder. Si el presupuesto de tokens
33
40
  // se agota razonando, 'content' llega vacio y el agente se queda sin ojos sin
@@ -1 +1,22 @@
1
+ /**
2
+ * Busqueda web contra el HTML de DuckDuckGo.
3
+ *
4
+ * Lo que devuelve esto se lo traga el modelo y casi siempre lo encadena con
5
+ * web_fetch ("busca X y leelo"), asi que cada URL tiene que poder abrirse tal
6
+ * cual sale de aqui. Antes no se podia ninguna: ver resolverEnlace().
7
+ */
8
+ interface ResultadoWeb {
9
+ titulo: string;
10
+ url: string;
11
+ resumen: string;
12
+ }
13
+ /**
14
+ * Separado de la peticion para poder probarlo sin red.
15
+ *
16
+ * El titulo sale de result__a. La version anterior leia el href y el texto de
17
+ * result__url, que no es el titulo sino el dominio de adorno
18
+ * ("www.speedtest.net"): el modelo nunca llego a ver el titulo de la pagina.
19
+ */
20
+ export declare function parsearResultados(html: string, maximo?: number): ResultadoWeb[];
1
21
  export declare function webSearch(query: string): Promise<string>;
22
+ export {};
@@ -1,3 +1,124 @@
1
+ /**
2
+ * Busqueda web contra el HTML de DuckDuckGo.
3
+ *
4
+ * Lo que devuelve esto se lo traga el modelo y casi siempre lo encadena con
5
+ * web_fetch ("busca X y leelo"), asi que cada URL tiene que poder abrirse tal
6
+ * cual sale de aqui. Antes no se podia ninguna: ver resolverEnlace().
7
+ */
8
+ const ENTIDADES_NOMBRADAS = {
9
+ amp: "&",
10
+ lt: "<",
11
+ gt: ">",
12
+ quot: '"',
13
+ apos: "'",
14
+ nbsp: " ",
15
+ };
16
+ /**
17
+ * Una sola pasada a proposito: si se decodificara "&amp;" antes que el resto, un
18
+ * "&amp;#x27;" literal acabaria convertido en apostrofe, que no es lo que decia
19
+ * el HTML.
20
+ */
21
+ function decodificarEntidades(texto) {
22
+ return texto.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (completo, entidad) => {
23
+ const clave = entidad.toLowerCase();
24
+ if (clave.startsWith("#")) {
25
+ const codigo = clave.startsWith("#x") ? parseInt(clave.slice(2), 16) : Number(clave.slice(1));
26
+ if (!Number.isFinite(codigo) || codigo < 0 || codigo > 0x10ffff)
27
+ return completo;
28
+ return String.fromCodePoint(codigo);
29
+ }
30
+ return clave in ENTIDADES_NOMBRADAS ? ENTIDADES_NOMBRADAS[clave] : completo;
31
+ });
32
+ }
33
+ /**
34
+ * Etiquetas fuera primero y entidades despues. Al reves, un "&lt;b&gt;" del
35
+ * texto se decodificaria a "<b>" y el limpiador se lo comeria como si fuera
36
+ * marcado de verdad.
37
+ */
38
+ function aTextoPlano(fragmento) {
39
+ return decodificarEntidades(fragmento.replace(/<[^>]+>/g, " "))
40
+ .replace(/\s+/g, " ")
41
+ .trim();
42
+ }
43
+ /**
44
+ * En html.duckduckgo.com ningun href apunta al destino: todos van al redirector
45
+ * de DDG y ademas sin esquema ("//duckduckgo.com/l/?uddg=...&amp;rut=..."). Eso
46
+ * es inservible para web_fetch por partida doble: fetch() no sabe parsear una
47
+ * URL protocol-relative ("Failed to parse URL from //duckduckgo.com/...") y el
48
+ * redirector, aunque se le ponga el "https:" delante, contesta HTTP 400 a quien
49
+ * no llegue desde el navegador. El destino real esta en el parametro uddg.
50
+ */
51
+ function resolverEnlace(href) {
52
+ // El href viene escapado dentro del atributo ("&amp;rut="); sin desescapar,
53
+ // el separador de parametros no es un "&" y uddg se lleva el rut pegado.
54
+ const limpio = decodificarEntidades(href.trim());
55
+ if (!limpio)
56
+ return null;
57
+ let redirector;
58
+ try {
59
+ redirector = new URL(limpio, "https://html.duckduckgo.com/");
60
+ }
61
+ catch {
62
+ return null;
63
+ }
64
+ // searchParams ya hace el decodeURIComponent; repetirlo a mano romperia
65
+ // cualquier destino con un %25 legitimo.
66
+ const destinoCrudo = redirector.searchParams.get("uddg") ?? redirector.href;
67
+ let destino;
68
+ try {
69
+ destino = new URL(destinoCrudo);
70
+ }
71
+ catch {
72
+ return null;
73
+ }
74
+ if (destino.protocol !== "https:" && destino.protocol !== "http:")
75
+ return null;
76
+ // Los primeros huecos de DDG son anuncios, y su uddg no lleva a la pagina
77
+ // anunciada sino al contador de clics de DDG (/y.js?ad_domain=...). Sin este
78
+ // filtro, el resultado [1] que ve el modelo es publicidad que encima no se
79
+ // puede abrir.
80
+ if (destino.hostname.endsWith("duckduckgo.com") && destino.pathname === "/y.js")
81
+ return null;
82
+ return destino.href;
83
+ }
84
+ /**
85
+ * Separado de la peticion para poder probarlo sin red.
86
+ *
87
+ * El titulo sale de result__a. La version anterior leia el href y el texto de
88
+ * result__url, que no es el titulo sino el dominio de adorno
89
+ * ("www.speedtest.net"): el modelo nunca llego a ver el titulo de la pagina.
90
+ */
91
+ export function parsearResultados(html, maximo = 6) {
92
+ const anclaTitulo = /<a\b([^>]*\bclass="[^"]*\bresult__a\b[^"]*"[^>]*)>([\s\S]*?)<\/a>/g;
93
+ const anclas = [];
94
+ let m;
95
+ while ((m = anclaTitulo.exec(html)) !== null) {
96
+ anclas.push({ atributos: m[1], titulo: m[2], inicio: m.index, fin: anclaTitulo.lastIndex });
97
+ }
98
+ const resultados = [];
99
+ for (let i = 0; i < anclas.length && resultados.length < maximo; i++) {
100
+ const ancla = anclas[i];
101
+ // El orden de los atributos no esta garantizado, asi que el href se saca
102
+ // aparte en vez de encadenarlo a la clase dentro de la misma regex.
103
+ const href = /\bhref="([^"]*)"/.exec(ancla.atributos)?.[1];
104
+ if (!href)
105
+ continue;
106
+ const url = resolverEnlace(href);
107
+ if (!url)
108
+ continue;
109
+ const titulo = aTextoPlano(ancla.titulo);
110
+ if (!titulo)
111
+ continue;
112
+ // El resumen se busca solo hasta donde empieza el siguiente resultado: si
113
+ // uno viene sin snippet, una busqueda perezosa sin tope le robaria el del
114
+ // siguiente y emparejaria titulo y texto de paginas distintas.
115
+ const hasta = i + 1 < anclas.length ? anclas[i + 1].inicio : html.length;
116
+ const trozo = html.slice(ancla.fin, hasta);
117
+ const resumen = /<a\b[^>]*\bclass="[^"]*\bresult__snippet\b[^"]*"[^>]*>([\s\S]*?)<\/a>/.exec(trozo)?.[1] ?? "";
118
+ resultados.push({ titulo, url, resumen: aTextoPlano(resumen) });
119
+ }
120
+ return resultados;
121
+ }
1
122
  export async function webSearch(query) {
2
123
  try {
3
124
  const encoded = encodeURIComponent(query);
@@ -12,22 +133,12 @@ export async function webSearch(query) {
12
133
  return `Error al buscar en la web: HTTP ${res.status}`;
13
134
  }
14
135
  const html = await res.text();
15
- // Parse results from DuckDuckGo HTML
16
- const results = [];
17
- const regex = /<a class="result__url"[^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>[\s\S]*?<a class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
18
- let match;
19
- let count = 0;
20
- while ((match = regex.exec(html)) !== null && count < 6) {
21
- count++;
22
- const link = match[1].trim();
23
- const title = match[2].replace(/<[^>]+>/g, "").trim();
24
- const snippet = match[3].replace(/<[^>]+>/g, "").trim();
25
- results.push(`[${count}] ${title}\n URL: ${link}\n ${snippet}`);
26
- }
27
- if (results.length === 0) {
136
+ const resultados = parsearResultados(html);
137
+ if (resultados.length === 0) {
28
138
  return `No se encontraron resultados directos para "${query}".`;
29
139
  }
30
- return `Resultados de búsqueda web para "${query}":\n\n${results.join("\n\n")}`;
140
+ const lineas = resultados.map((r, i) => `[${i + 1}] ${r.titulo}\n URL: ${r.url}\n ${r.resumen}`);
141
+ return `Resultados de búsqueda web para "${query}":\n\n${lineas.join("\n\n")}`;
31
142
  }
32
143
  catch (err) {
33
144
  return `Error en búsqueda web: ${err?.message || String(err)}`;
@@ -1 +1,16 @@
1
- export declare function writeFile(filePath: string, content: string, cwd?: string): Promise<string>;
1
+ /**
2
+ * Escribe un archivo, entero o por partes.
3
+ *
4
+ * POR QUE EXISTE "anadir"
5
+ *
6
+ * La respuesta del modelo tiene un techo de salida. Cuando le piden una pagina
7
+ * entera, el contenido no cabe, la respuesta se corta a mitad de los argumentos
8
+ * y no se escribe nada. Se le decia "hazlo en varias llamadas mas pequenas"...
9
+ * y no habia forma de hacerlo: write_file solo sabia sobrescribir, asi que la
10
+ * segunda llamada borraba la primera. Le pediamos algo imposible y luego le
11
+ * echabamos la culpa.
12
+ *
13
+ * Con `modo: "anadir"` la instruccion pasa a ser ejecutable: primera llamada
14
+ * con el principio, las siguientes anadiendo, y el archivo sale entero.
15
+ */
16
+ export declare function writeFile(filePath: string, content: string, cwd?: string, modo?: "sobrescribir" | "anadir"): Promise<string>;
@@ -1,12 +1,50 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- export async function writeFile(filePath, content, cwd = process.cwd()) {
3
+ import { binaryExtensionLabel, fileLooksBinary } from "./binary.js";
4
+ /**
5
+ * Escribe un archivo, entero o por partes.
6
+ *
7
+ * POR QUE EXISTE "anadir"
8
+ *
9
+ * La respuesta del modelo tiene un techo de salida. Cuando le piden una pagina
10
+ * entera, el contenido no cabe, la respuesta se corta a mitad de los argumentos
11
+ * y no se escribe nada. Se le decia "hazlo en varias llamadas mas pequenas"...
12
+ * y no habia forma de hacerlo: write_file solo sabia sobrescribir, asi que la
13
+ * segunda llamada borraba la primera. Le pediamos algo imposible y luego le
14
+ * echabamos la culpa.
15
+ *
16
+ * Con `modo: "anadir"` la instruccion pasa a ser ejecutable: primera llamada
17
+ * con el principio, las siguientes anadiendo, y el archivo sale entero.
18
+ */
19
+ export async function writeFile(filePath, content, cwd = process.cwd(), modo = "sobrescribir") {
4
20
  const resolved = path.isAbsolute(filePath) ? filePath : path.resolve(cwd, filePath);
5
21
  const dir = path.dirname(resolved);
22
+ const isNew = !fs.existsSync(resolved);
23
+ // write_file es la ruta mas desprotegida del agente: no pasa por
24
+ // checkBeforeEdit (runner.ts no lo llama en este caso) y no pide confirmacion
25
+ // en ningun modo de permisos. Volcar un string UTF-8 sobre un png o un mp4 lo
26
+ // sustituye por basura, y ademas deja el snapshot de /undo apuntando a un
27
+ // archivo que ya no se puede reconstruir desde texto. Los bytes de un binario
28
+ // no salen de un string: si de verdad hay que reemplazarlo, se borra primero.
29
+ const etiqueta = binaryExtensionLabel(resolved);
30
+ if (etiqueta) {
31
+ return (`Error: "${filePath}" es un archivo binario (${etiqueta}). write_file escribe texto UTF-8, ` +
32
+ `asi que lo dejaria corrupto. Genera el archivo con la herramienta que corresponda ` +
33
+ `(create_image, run_command) o borralo antes si de verdad quieres reemplazarlo.`);
34
+ }
35
+ if (!isNew && fileLooksBinary(resolved)) {
36
+ return (`Error: "${filePath}" contiene bytes nulos: es binario, no texto. write_file lo dejaria corrupto. ` +
37
+ `Borralo antes si de verdad quieres reemplazarlo.`);
38
+ }
6
39
  if (!fs.existsSync(dir)) {
7
40
  fs.mkdirSync(dir, { recursive: true });
8
41
  }
9
- const isNew = !fs.existsSync(resolved);
42
+ if (modo === "anadir") {
43
+ fs.appendFileSync(resolved, content, "utf-8");
44
+ const total = fs.statSync(resolved).size;
45
+ return (`✔ Anadido a "${filePath}" (${content.length} caracteres nuevos, ${total} en total). ` +
46
+ `Sigue con otra llamada en modo "anadir" si falta contenido.`);
47
+ }
10
48
  fs.writeFileSync(resolved, content, "utf-8");
11
49
  const action = isNew ? "creado" : "sobreescrito";
12
50
  return `✔ Archivo "${filePath}" ${action} exitosamente (${content.length} caracteres).`;
@@ -1,4 +1,7 @@
1
- /** Suelta el teclado de cualquier vigilante que lo tenga. Lo llama el prompt. */
1
+ /** Lo llama el prompt al abrirse, despues de coger el modo crudo. */
2
+ export declare function tomarTecladoParaPrompt(): void;
3
+ /** Lo llama el prompt al cerrarse, para que el siguiente turno pueda restaurar. */
4
+ export declare function devolverTecladoDelPrompt(): void;
2
5
  export declare function releaseKeyboard(): void;
3
6
  export declare class InterruptWatcher {
4
7
  private controller;