@orxataguy/tyr 1.0.31 → 1.0.33

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.
@@ -1,27 +1,42 @@
1
1
  import path from 'path';
2
+ import { statSync } from 'fs';
2
3
 
3
4
  import { FileSystemManager } from './FileSystemManager.js';
4
- import { AIVendorManager, AIMessage } from './AIVendorManager.js';
5
+ import { ShellManager } from './ShellManager.js';
6
+ import { AIVendorManager, AIMessage, AIContentBlock, AITool, TaskPriority } from './AIVendorManager.js';
7
+ import { TokenManager } from './TokenManager.js';
5
8
  import { Logger } from '../core/Logger.js';
6
9
  import { TyrError } from '../core/TyrError.js';
7
10
 
8
11
  import { getEnvInt } from '../core/util/getenv.js';
9
12
 
13
+ // --- Context files (CLAUDE.md / AGENTS.md style guidelines) --------------------------------------
10
14
 
11
15
  const CONTEXT_FILENAMES = [
12
16
  'CLAUDE.md',
13
17
  'AGENTS.md',
14
18
  'CONTEXT.md',
19
+ 'README.md',
15
20
  '.cursorrules',
16
21
  path.join('.github', 'copilot-instructions.md'),
17
22
  ];
18
23
 
19
24
  const GENERATED_FILENAME = 'CLAUDE.md';
20
25
 
21
- const SNAPSHOT_TREE_MAX_DEPTH = getEnvInt('SNAPSHOT_TREE_MAX_DEPTH', 5);
26
+ const SNAPSHOT_TREE_MAX_DEPTH = getEnvInt('SNAPSHOT_TREE_MAX_DEPTH', 5);
22
27
  const SNAPSHOT_TREE_MAX_ENTRIES_PER_DIR = getEnvInt('SNAPSHOT_TREE_MAX_ENTRIES_PER_DIR', 250);
23
28
  const SNAPSHOT_SECTION_MAX_CHARS = getEnvInt('SNAPSHOT_SECTION_MAX_CHARS', 40000);
24
29
 
30
+ // How far findContextFiles() looks for guideline files OUTSIDE the exact target directory: upward
31
+ // into ancestor directories (toward the repo root — a monorepo root AGENTS.md still applies to a
32
+ // package inside it) and downward into subdirectories (a nested package may document itself).
33
+ // Both are bounded so a huge repo doesn't turn every command into a full filesystem crawl.
34
+ const CONTEXT_SEARCH_UPWARD_LEVELS = getEnvInt('CONTEXT_SEARCH_UPWARD_LEVELS', 4);
35
+ const CONTEXT_SEARCH_DOWNWARD_LEVELS = getEnvInt('CONTEXT_SEARCH_DOWNWARD_LEVELS', 3);
36
+ // Cap on how many context files are actually used, once found — closest to `dir` wins. Keeps a
37
+ // monorepo with many nested AGENTS.md files from flooding the prompt with everything it can reach.
38
+ const MAX_CONTEXT_FILES = getEnvInt('MAX_CONTEXT_FILES', 6);
39
+
25
40
  const DEFAULT_IGNORED_DIRS = new Set<string>([
26
41
  'node_modules', '.git', '.hg', '.svn',
27
42
  '.turbo', '.next', '.nuxt', '.cache', '.parcel-cache',
@@ -44,36 +59,352 @@ interface GuidelinesBlock {
44
59
  content: string;
45
60
  }
46
61
 
62
+ // --- Agentic exploration (tool use) ---------------------------------------------------------------
63
+ //
64
+ // Everything below powers the tool-use agent loop shared by ai:code and ai:describe: the model is
65
+ // seeded with only a tree + AGENTS.md, and pulls in anything else (a specific file, a directory
66
+ // listing, a dependency's real source) itself via these tools, instead of the command layer trying
67
+ // to guess what's relevant ahead of time (see runAgentLoop / AGENT_TOOLS).
68
+
69
+ const AGENTS_MD_FILENAME = 'AGENTS.md';
70
+ const MANIFEST_FILENAMES = new Set(['package.json', 'composer.json']);
71
+ const README_PATTERN = /^readme(\.[a-z0-9]+)?$/i;
72
+
73
+ const READABLE_EXTENSIONS = new Set([
74
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs',
75
+ '.json', '.md', '.yml', '.yaml',
76
+ '.py', '.go', '.rb', '.php', '.java', '.rs',
77
+ '.sh',
78
+ ]);
79
+
80
+ const TREE_MAX_ENTRIES_PER_DIR = 200;
81
+
82
+ // How many levels upward from the starting directory we search for a repo root (marked by a .git
83
+ // folder) to decide how far the model's READ-ONLY exploration tools can reach. Does not affect
84
+ // where WRITES are allowed — that stays confined to the target directory (see applyPatches).
85
+ const MAX_UPWARD_SEARCH_LEVELS = 4;
86
+
87
+ const MAX_TOOL_RESULT_CHARS = 12000;
88
+ const MAX_DEPENDENCY_MATCHES = 25;
89
+ const DEFAULT_MAX_AGENT_ITERATIONS = 6;
90
+
91
+ // Deliberately more lenient than DEFAULT_IGNORED_DIRS when listing inside an installed dependency:
92
+ // "dist/build" is generated noise in the project itself, but it's exactly the real, compiled code
93
+ // that needs reading inside node_modules.
94
+ const PACKAGE_IGNORED_DIRS = new Set<string>(['node_modules', '.git', '.hg', '.svn', '.github', 'coverage', '.nyc_output']);
95
+
96
+ // How many levels upward, from the project directory, we search for node_modules/<package>.
97
+ // Deliberately more generous than MAX_UPWARD_SEARCH_LEVELS: mimics real Node.js module
98
+ // resolution, which can land above the repo root in hoisted monorepos.
99
+ const NODE_MODULES_SEARCH_MAX_LEVELS = 15;
100
+
101
+ // Loop Detector: abort the agent loop if the same tool is called with identical arguments this
102
+ // many times in a row, to avoid infinite loops and token draining.
103
+ const LOOP_DETECTOR_REPEAT_THRESHOLD = 3;
104
+
105
+ // Post-write validation / self-healing: how many extra agent turns we allow to fix a broken
106
+ // compile/lint before giving up and returning whatever was last applied.
107
+ const MAX_SELF_HEAL_ATTEMPTS = 2;
108
+
109
+ /** Shared phrase describing the exploration tools, appended to every agentic system prompt. */
110
+ export const AGENT_TOOLS_DESCRIPTION =
111
+ 'You have tools available to request additional context that is not already included in the ' +
112
+ 'message: use read_manifest to learn the project\'s real dependencies (npm/composer) and ' +
113
+ 'search_dependency_usage to see how relevant dependencies are actually used; use find_agents_md ' +
114
+ 'to locate existing context documentation in subfolders or sibling packages of a monorepo, and ' +
115
+ 'read_file to read it or any other existing relevant file that has not already been shown to you ' +
116
+ '(for example, one omitted for size); use list_directory if you need to see the contents of a ' +
117
+ 'folder not included in the tree. If the project uses an installed framework or library (for ' +
118
+ 'example, something AGENTS.md only names without detailing its API) and you need to know exactly ' +
119
+ 'how it works before using it, do NOT assume its behaviour: use read_dependency_manifest to ' +
120
+ 'locate it in node_modules and see its entry points, then list_dependency_files / ' +
121
+ 'read_dependency_file to inspect its real code. Call whatever tools you need, as many times as ' +
122
+ 'you need, before answering.';
123
+
124
+ /** Git-like Search/Replace patch format the model must use instead of full-file rewrites, shared
125
+ * by every prompt that may end up calling AIContextManager.runCodeAgent(). */
126
+ export const SEARCH_REPLACE_FORMAT_INSTRUCTIONS =
127
+ 'For every file you create or modify, output it using EXACTLY this format, with no other text ' +
128
+ 'before, between or after the blocks:\n\n' +
129
+ '>>>FILE: <path relative to the project root>\n' +
130
+ '<<<<<<< SEARCH\n' +
131
+ '<exact existing code snippet to find — leave EMPTY only when creating a brand-new file>\n' +
132
+ '=======\n' +
133
+ '<the new replacement code snippet>\n' +
134
+ '>>>>>>> REPLACE\n' +
135
+ '<<<END\n\n' +
136
+ 'A single >>>FILE block may contain more than one SEARCH/REPLACE pair if you need to make ' +
137
+ 'several separate edits to the same file. Never output the file\'s full content — only the ' +
138
+ 'minimal snippets that need to change. The SEARCH snippet must match real, existing text from ' +
139
+ 'the file (read it with read_file first if you have not already seen its exact current content ' +
140
+ 'in this conversation); do not guess it. To create a new file, use a single block with an empty ' +
141
+ 'SEARCH section and the full new file content as REPLACE. When you have everything you need, ' +
142
+ 'respond ONLY with the >>>FILE / <<<END blocks for the files you create or modify — no other ' +
143
+ 'commentary.';
144
+
145
+ interface AgentToolContext {
146
+ /** Starting directory (targetDir in ai:code, projectDir/cwd in ai:describe), BEFORE widening
147
+ * it with computeExplorationRoot(). Used to locate node_modules the way Node.js itself would
148
+ * (searching upward from here), rather than from explorationRoot which may sit higher up. */
149
+ projectDir: string;
150
+ explorationRoot: string;
151
+ ignoredDirs: Set<string>;
152
+ /** Absolute paths of files whose FULL (untruncated) content the model has seen this session,
153
+ * either because they were included whole in the seed message or read in full via read_file.
154
+ * This is the basis of the "never write blind" guarantee: applyPatches() refuses to overwrite
155
+ * any existing file that is not in this set. */
156
+ seenFiles: Set<string>;
157
+ }
158
+
159
+ export const AGENT_TOOLS: AITool[] = [
160
+ {
161
+ name: 'list_directory',
162
+ description:
163
+ 'Lists the immediate files and subdirectories of a path within the project\'s explorable ' +
164
+ 'area. Useful for deciding whether to look deeper into a folder not already in the tree.',
165
+ input_schema: {
166
+ type: 'object',
167
+ properties: { path: { type: 'string', description: 'Path relative to the explorable area, or "." for the root.' } },
168
+ required: ['path'],
169
+ },
170
+ },
171
+ {
172
+ name: 'read_file',
173
+ description:
174
+ 'Reads the full content of a text file within the explorable area. Use it for files ' +
175
+ 'omitted from the seed message for size, or outside the original directory but relevant ' +
176
+ '(e.g. a sibling package\'s AGENTS.md in a monorepo, or an existing file you need to see ' +
177
+ 'before modifying it).',
178
+ input_schema: {
179
+ type: 'object',
180
+ properties: { path: { type: 'string', description: 'Path relative to the explorable area.' } },
181
+ required: ['path'],
182
+ },
183
+ },
184
+ {
185
+ name: 'find_agents_md',
186
+ description:
187
+ 'Finds every existing AGENTS.md file within the explorable area (including subfolders ' +
188
+ 'and, for a monorepo, sibling packages). Returns their relative paths; use read_file to ' +
189
+ 'read the one you care about.',
190
+ input_schema: { type: 'object', properties: {} },
191
+ },
192
+ {
193
+ name: 'read_manifest',
194
+ description:
195
+ 'Finds and summarises every package.json / composer.json in the explorable area: name, ' +
196
+ 'version, workspaces/autoload and dependency lists (production and development). Useful ' +
197
+ 'to know what libraries the project uses before documenting or using them.',
198
+ input_schema: { type: 'object', properties: {} },
199
+ },
200
+ {
201
+ name: 'search_dependency_usage',
202
+ description:
203
+ 'Searches the source code for import/require/use lines referencing a specific dependency ' +
204
+ '(by its package name), to understand how and where it is actually used in the project.',
205
+ input_schema: {
206
+ type: 'object',
207
+ properties: { dependency: { type: 'string', description: 'Package name as it appears in package.json or composer.json.' } },
208
+ required: ['dependency'],
209
+ },
210
+ },
211
+ {
212
+ name: 'read_dependency_manifest',
213
+ description:
214
+ 'Locates an installed dependency in node_modules (by its exact package name, e.g. ' +
215
+ '"@orxataguy/tyr" or "react") and summarises its own package.json: version, description ' +
216
+ 'and entry points (main, module, types, exports, bin). The recommended first step before ' +
217
+ 'exploring a dependency\'s code.',
218
+ input_schema: {
219
+ type: 'object',
220
+ properties: { packageName: { type: 'string', description: 'Exact package name, with scope if any (e.g. "@orxataguy/tyr").' } },
221
+ required: ['packageName'],
222
+ },
223
+ },
224
+ {
225
+ name: 'list_dependency_files',
226
+ description:
227
+ 'Lists the files and subdirectories of a folder inside an installed dependency in ' +
228
+ 'node_modules. Unlike list_directory (for the project itself), this DOES show folders ' +
229
+ 'like dist/build, since in an installed dependency that is usually the real code to inspect.',
230
+ input_schema: {
231
+ type: 'object',
232
+ properties: {
233
+ packageName: { type: 'string', description: 'Exact package name, with scope if any.' },
234
+ path: { type: 'string', description: 'Path relative to the package root, or "." for its root.' },
235
+ },
236
+ required: ['packageName'],
237
+ },
238
+ },
239
+ {
240
+ name: 'read_dependency_file',
241
+ description:
242
+ 'Reads the full content of a file inside an installed dependency in node_modules. Use it ' +
243
+ 'to understand how a library or framework actually works (types, public API, ' +
244
+ 'implementation) instead of assuming its behaviour from the package name or from what ' +
245
+ 'AGENTS.md says.',
246
+ input_schema: {
247
+ type: 'object',
248
+ properties: {
249
+ packageName: { type: 'string', description: 'Exact package name, with scope if any.' },
250
+ path: { type: 'string', description: 'Path of the file relative to the package root (use list_dependency_files or read_dependency_manifest to locate it).' },
251
+ },
252
+ required: ['packageName', 'path'],
253
+ },
254
+ },
255
+ ];
256
+
257
+ export interface AgentRunOptions {
258
+ priority?: TaskPriority;
259
+ maxIterations?: number;
260
+ maxTokens?: number;
261
+ }
262
+
263
+ export interface AgentRunResult {
264
+ content: string;
265
+ promptTokens: number;
266
+ completionTokens: number;
267
+ toolCallsUsed: number;
268
+ priorityUsed: TaskPriority;
269
+ }
270
+
271
+ export interface ValidationResult {
272
+ ran: boolean;
273
+ ok: boolean;
274
+ output?: string;
275
+ }
276
+
277
+ export interface CodeAgentResult extends AgentRunResult {
278
+ filesChanged: string[];
279
+ blockedWrites: string[];
280
+ failedEdits: string[];
281
+ validation: ValidationResult;
282
+ }
283
+
284
+ interface FilePatchEdit {
285
+ search: string;
286
+ replace: string;
287
+ }
288
+
289
+ interface FilePatchBlock {
290
+ relPath: string;
291
+ edits: FilePatchEdit[];
292
+ }
293
+
294
+ const FILE_BLOCK_REGEX = />>>FILE:\s*(.+?)\s*\n([\s\S]*?)\n<<<END/g;
295
+ const SEARCH_REPLACE_REGEX = /<<<<<<<\s*SEARCH\s*\n([\s\S]*?)\n=======\s*\n([\s\S]*?)\n>>>>>>>\s*REPLACE/g;
296
+
47
297
  /**
48
298
  * @class AIContextManager
49
- * @description Encuentra, valida y lee los archivos de directrices del proyecto (CLAUDE.md,
50
- * AGENTS.md, etc.). Si no existen, escanea el proyecto (100% Node.js nativo, sin subprocesos de
51
- * shell) y le pide al vendor de IA configurado que genere un archivo de directrices óptimo.
52
- * Expone tanto el texto plano de las directrices (para sesiones de chat que gestionan su propia
53
- * ventana de contexto) como un mensaje de sistema listo para usar (para completions puntuales).
299
+ * @description Owns everything an AI coding assistant needs to safely operate on a project:
300
+ * finding/generating guideline files (CLAUDE.md/AGENTS.md), and the bulk of this class running
301
+ * a tool-using agent loop that starts from a minimal seed (project tree + guidelines) and lets the
302
+ * model pull in whatever else it needs (a file, a directory listing, a dependency's real source)
303
+ * via AGENT_TOOLS. It also owns the Search/Replace patch engine used to apply the model's edits
304
+ * (instead of full-file rewrites), a Loop Detector that aborts runaway tool-call loops, and a
305
+ * post-write validation step that feeds compiler/linter errors back to the model and escalates the
306
+ * routing priority (see AIVendorManager.bumpPriority) when a fix attempt breaks the build.
307
+ *
308
+ * Commands built on top of this manager (ai:code, ai:describe) should stay thin: resolve paths,
309
+ * build the seed messages via PromptTemplateManager + buildExplorationTree()/getContext(), call
310
+ * runCodeAgent()/runDescribeAgent(), and log the result.
54
311
  */
55
312
  export class AIContextManager {
56
313
  private fs: FileSystemManager;
314
+ private shell: ShellManager;
57
315
  private ai: AIVendorManager;
58
316
  private logger: Logger;
59
317
 
60
- constructor(fs: FileSystemManager, ai: AIVendorManager, logger: Logger) {
318
+ constructor(fs: FileSystemManager, shell: ShellManager, ai: AIVendorManager, logger: Logger) {
61
319
  this.fs = fs;
320
+ this.shell = shell;
62
321
  this.ai = ai;
63
322
  this.logger = logger;
64
323
  }
65
324
 
325
+ // === Guideline files (CLAUDE.md / AGENTS.md) =================================================
326
+
66
327
  /**
67
328
  * @method findContextFiles
68
- * @description Busca los archivos de directrices conocidos directamente dentro del directorio
69
- * del proyecto.
70
- * @param {string} dir - Ruta absoluta a la raíz del proyecto.
71
- * @returns {string[]} Rutas absolutas de los archivos de directrices que existen.
329
+ * @description Busca los archivos de directrices conocidos (README.md, CLAUDE.md, AGENTS.md,
330
+ * CONTEXT.md, .cursorrules, copilot-instructions.md) no solo dentro de `dir`, sino tanto hacia
331
+ * fuera (directorios ancestros, hasta CONTEXT_SEARCH_UPWARD_LEVELS niveles un AGENTS.md en la
332
+ * raíz de un monorepo sigue aplicando a un paquete dentro de él) como hacia dentro (subcarpetas,
333
+ * hasta CONTEXT_SEARCH_DOWNWARD_LEVELS niveles — un paquete anidado puede documentarse a sí
334
+ * mismo). La "relevancia" se determina de forma determinista, sin llamar a la IA (esto se
335
+ * ejecuta antes de cada comando, así que tiene que ser barato):
336
+ * - Hacia fuera: se detiene en cuanto se cruza la raíz del repo (una carpeta .git), además del
337
+ * tope de niveles — más allá de eso nunca es relevante.
338
+ * - Hacia dentro: NO se desciende más allá del primer directorio que tenga su propio
339
+ * package.json/composer.json (un límite de paquete independiente) salvo para recoger el
340
+ * archivo de contexto que esté justo en ese límite; lo que hay más adentro pertenece a ese
341
+ * otro paquete, no a `dir`.
342
+ * - Si aun así se encuentran más de MAX_CONTEXT_FILES candidatos, ganan los más cercanos a `dir`.
343
+ * @param {string} dir - Ruta absoluta a la raíz del proyecto (o subcarpeta) desde la que buscar.
344
+ * @returns {Promise<string[]>} Rutas absolutas de los archivos de directrices relevantes, del más al menos cercano.
72
345
  */
73
- public findContextFiles(dir: string): string[] {
74
- return CONTEXT_FILENAMES
75
- .map(name => path.join(dir, name))
76
- .filter(fullPath => this.fs.exists(fullPath));
346
+ public async findContextFiles(dir: string): Promise<string[]> {
347
+ const found = new Map<string, number>();
348
+
349
+ const record = (fullPath: string, distance: number) => {
350
+ if (!this.fs.exists(fullPath)) return;
351
+ const previous = found.get(fullPath);
352
+ if (previous === undefined || distance < previous) found.set(fullPath, distance);
353
+ };
354
+
355
+ // Here (distance 0).
356
+ for (const name of CONTEXT_FILENAMES) record(path.join(dir, name), 0);
357
+
358
+ // Outward: ancestor directories, stopping at the repo root once reached.
359
+ let upDir = dir;
360
+ for (let level = 1; level <= CONTEXT_SEARCH_UPWARD_LEVELS; level++) {
361
+ const parent = path.dirname(upDir);
362
+ if (parent === upDir) break; // filesystem root
363
+ upDir = parent;
364
+ for (const name of CONTEXT_FILENAMES) record(path.join(upDir, name), level);
365
+ if (this.fs.exists(path.join(upDir, '.git'))) break;
366
+ }
367
+
368
+ // Inward: subdirectories, pruned at package boundaries.
369
+ await this.walkDownForContextFiles(dir, 1, false, record);
370
+
371
+ return [...found.entries()]
372
+ .sort((a, b) => a[1] - b[1])
373
+ .slice(0, MAX_CONTEXT_FILES)
374
+ .map(([fullPath]) => fullPath);
375
+ }
376
+
377
+ /** Recursive helper for the inward half of findContextFiles(). `crossedPackageBoundary` starts
378
+ * false and flips to true once we step into a directory with its own manifest — from that
379
+ * point on we keep recursing (so deeper package boundaries are still detected and stopped at
380
+ * in turn) but stop *recording* candidates, since anything past that boundary belongs to a
381
+ * separately versioned sub-package rather than `dir`. */
382
+ private async walkDownForContextFiles(
383
+ currentDir: string,
384
+ level: number,
385
+ crossedPackageBoundary: boolean,
386
+ record: (fullPath: string, distance: number) => void
387
+ ): Promise<void> {
388
+ if (level > CONTEXT_SEARCH_DOWNWARD_LEVELS) return;
389
+
390
+ let entries;
391
+ try {
392
+ entries = await this.fs.readdir(currentDir, { withFileTypes: true });
393
+ } catch {
394
+ return;
395
+ }
396
+
397
+ for (const entry of entries) {
398
+ if (!entry.isDirectory() || DEFAULT_IGNORED_DIRS.has(entry.name)) continue;
399
+ const childDir = path.join(currentDir, entry.name);
400
+
401
+ if (!crossedPackageBoundary) {
402
+ for (const name of CONTEXT_FILENAMES) record(path.join(childDir, name), level);
403
+ }
404
+
405
+ const childHasOwnManifest = [...MANIFEST_FILENAMES].some(name => this.fs.exists(path.join(childDir, name)));
406
+ await this.walkDownForContextFiles(childDir, level + 1, crossedPackageBoundary || childHasOwnManifest, record);
407
+ }
77
408
  }
78
409
 
79
410
  private async readAndValidate(filePath: string): Promise<string | null> {
@@ -114,9 +445,9 @@ export class AIContextManager {
114
445
  /**
115
446
  * @method scanDirectoryTree
116
447
  * @description Escaneo recursivo de directorios agnóstico al SO usando la API nativa de
117
- * Node.js (sin subprocesos de shell, por lo que funciona igual en Windows, macOS y Linux).
118
- * Genera un árbol de texto indentado, ignora directorios de ruido y limita las entradas por
119
- * carpeta para que directorios con cientos de archivos generados no colapsen el snapshot.
448
+ * Node.js (sin subprocesos de shell), acotado en profundidad pensado para el snapshot que
449
+ * alimenta la generación de directrices. Para el árbol usado como semilla del agente (sin tope
450
+ * de profundidad), ver buildExplorationTree().
120
451
  * @param {string} rootDir - Ruta absoluta desde la que empezar a escanear.
121
452
  * @param {number} maxDepth - Profundidad máxima de recursión.
122
453
  * @param {number} maxEntriesPerDir - Máximo de entradas listadas por carpeta antes de truncar.
@@ -138,8 +469,6 @@ export class AIContextManager {
138
469
  try {
139
470
  entries = await this.fs.readdir(currentDir, { withFileTypes: true });
140
471
  } catch {
141
- // Errores de permisos o carpetas borradas a mitad de escaneo no deben abortar
142
- // todo el snapshot, simplemente se omiten.
143
472
  return;
144
473
  }
145
474
 
@@ -184,7 +513,6 @@ export class AIContextManager {
184
513
  const readmePath = path.join(dir, 'README.md');
185
514
  if (this.fs.exists(readmePath)) {
186
515
  const readme = await this.fs.read(readmePath);
187
- // El README es texto libre, no estructurado: aquí el truncado por longitud sí es seguro.
188
516
  if (readme) parts.push(`# README.md\n${readme.slice(0, SNAPSHOT_SECTION_MAX_CHARS)}`);
189
517
  }
190
518
 
@@ -231,16 +559,12 @@ export class AIContextManager {
231
559
  /**
232
560
  * @method readGuidelines
233
561
  * @description Encuentra los archivos de directrices existentes (generando uno vía IA si no
234
- * hay ninguno), los lee y valida, y los devuelve como bloques discretos etiquetados — sin
235
- * colapsarlos en un único string de mensaje de sistema. Esta es la pieza clave para sesiones
236
- * de chat de larga duración: quien necesite gestionar una ventana de contexto continua (p. ej.
237
- * un AIChatSessionManager) puede inspeccionar, cachear o descartar bloques individuales en
238
- * lugar de releer disco y reinyectar un system prompt duplicado en cada turno.
562
+ * hay ninguno), los lee y valida, y los devuelve como bloques discretos etiquetados.
239
563
  * @param {string} dir - Ruta absoluta a la raíz del proyecto.
240
564
  * @returns {Promise<GuidelinesBlock[]>}
241
565
  */
242
566
  public async readGuidelines(dir: string): Promise<GuidelinesBlock[]> {
243
- let files = this.findContextFiles(dir);
567
+ let files = await this.findContextFiles(dir);
244
568
 
245
569
  if (files.length === 0) {
246
570
  const generated = await this.generateContextFile(dir);
@@ -250,7 +574,10 @@ export class AIContextManager {
250
574
  const blocks: GuidelinesBlock[] = [];
251
575
  for (const file of files) {
252
576
  const content = await this.readAndValidate(file);
253
- if (content) blocks.push({ fileName: path.basename(file), content });
577
+ // Relative to `dir` rather than just the basename: with the search now reaching
578
+ // outward/inward, two blocks can share a filename (e.g. an AGENTS.md here AND one two
579
+ // levels up) — the path disambiguates which is which for whoever reads getGuidelinesText().
580
+ if (content) blocks.push({ fileName: path.relative(dir, file) || path.basename(file), content });
254
581
  }
255
582
 
256
583
  if (blocks.length === 0) {
@@ -267,10 +594,7 @@ export class AIContextManager {
267
594
  /**
268
595
  * @method getGuidelinesText
269
596
  * @description Envoltorio de conveniencia sobre readGuidelines() que devuelve el texto plano
270
- * combinado de las directrices, sin envolver en roles/mensajes. Pensado para un
271
- * AIChatSessionManager: se obtiene una vez por sesión, se cachea, y se gestiona dentro de la
272
- * ventana de contexto de la conversación en lugar de llamar a getContext() (con su relectura
273
- * de disco y reenvío de un system message completo) en cada turno.
597
+ * combinado de las directrices, sin envolver en roles/mensajes.
274
598
  * @param {string} dir - Ruta absoluta a la raíz del proyecto.
275
599
  * @returns {Promise<string>}
276
600
  */
@@ -282,9 +606,10 @@ export class AIContextManager {
282
606
  /**
283
607
  * @method getContext
284
608
  * @description Punto de entrada para uso puntual: encuentra/genera archivos de directrices y
285
- * los empaqueta como un único mensaje 'system' listo para prepender a un prompt. Para sesiones
286
- * de chat continuas, usa getGuidelinesText() junto con tu propia gestión de ventana de contexto
287
- * en lugar de llamar a este método en cada turno.
609
+ * los empaqueta como un único mensaje 'system' listo para prepender a un prompt. Es también la
610
+ * pieza clave de la guía de exploración dinámica del agente: junto con buildExplorationTree(),
611
+ * es lo ÚNICO con lo que se siembra el contexto inicial de runCodeAgent()/runDescribeAgent() —
612
+ * el resto lo pide el propio modelo con AGENT_TOOLS.
288
613
  * @param {string} dir - Ruta absoluta a la raíz del proyecto.
289
614
  * @returns {Promise<AIMessage[]>}
290
615
  * @example
@@ -295,8 +620,744 @@ export class AIContextManager {
295
620
  const text = await this.getGuidelinesText(dir);
296
621
  return [{ role: 'system', content: text }];
297
622
  }
623
+
624
+ // === Exploration tree (agent seed) ============================================================
625
+
626
+ private isDirectorySync(target: string): boolean {
627
+ try {
628
+ return statSync(target).isDirectory();
629
+ } catch {
630
+ return false;
631
+ }
632
+ }
633
+
634
+ /**
635
+ * @method buildExplorationTree
636
+ * @description Full-depth directory tree (only capped by entries-per-folder, not depth),
637
+ * used to seed the agent loop. Unlike scanDirectoryTree() (capped depth, used for guideline
638
+ * generation), the agent can always ask for more with list_directory, so this favours breadth
639
+ * over an artificial depth limit.
640
+ * @param {string} rootDir - Absolute path to start scanning from.
641
+ * @param {Set<string>} ignoredDirs - Directory names to skip entirely.
642
+ * @param {number} maxEntriesPerDir - Max entries listed per folder before truncating.
643
+ * @returns {Promise<string>} Indented text tree.
644
+ * @example
645
+ * const tree = await aiContext.buildExplorationTree(targetDir);
646
+ */
647
+ public async buildExplorationTree(
648
+ rootDir: string,
649
+ ignoredDirs: Set<string> = DEFAULT_IGNORED_DIRS,
650
+ maxEntriesPerDir: number = TREE_MAX_ENTRIES_PER_DIR
651
+ ): Promise<string> {
652
+ const lines: string[] = [`${path.basename(rootDir) || rootDir}/`];
653
+
654
+ const walk = async (dir: string, prefix: string): Promise<void> => {
655
+ let entries = await this.fs.readdir(dir, { withFileTypes: true });
656
+
657
+ entries = entries
658
+ .filter(entry => !(entry.isDirectory() && ignoredDirs.has(entry.name)))
659
+ .sort((a, b) => {
660
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
661
+ return a.name.localeCompare(b.name);
662
+ });
663
+
664
+ const visible = entries.slice(0, maxEntriesPerDir);
665
+ const hiddenCount = entries.length - visible.length;
666
+
667
+ for (const entry of visible) {
668
+ lines.push(`${prefix}${entry.name}${entry.isDirectory() ? '/' : ''}`);
669
+ if (entry.isDirectory()) {
670
+ await walk(path.join(dir, entry.name), `${prefix} `);
671
+ }
672
+ }
673
+
674
+ if (hiddenCount > 0) {
675
+ lines.push(`${prefix}… and ${hiddenCount} more item(s)`);
676
+ }
677
+ };
678
+
679
+ await walk(rootDir, ' ');
680
+ return lines.join('\n');
681
+ }
682
+
683
+ /**
684
+ * @method findReadme
685
+ * @description Finds the project's README (any common extension) and returns its name and
686
+ * content, or null if there isn't one / it's empty.
687
+ * @param {string} dir - Absolute path to the project root.
688
+ * @returns {Promise<{name: string; content: string} | null>}
689
+ * @example
690
+ * const readme = await aiContext.findReadme(projectDir);
691
+ */
692
+ public async findReadme(dir: string): Promise<{ name: string; content: string } | null> {
693
+ const entries = await this.fs.readdir(dir, { withFileTypes: true });
694
+ const match = entries.find(entry => entry.isFile() && README_PATTERN.test(entry.name));
695
+ if (!match) return null;
696
+
697
+ const content = await this.fs.read(path.join(dir, match.name));
698
+ if (!content || !content.trim()) return null;
699
+
700
+ return { name: match.name, content };
701
+ }
702
+
703
+ // === Exploration tools (tool use) =============================================================
704
+
705
+ private computeExplorationRoot(startDir: string, maxUpwardLevels: number = MAX_UPWARD_SEARCH_LEVELS): string {
706
+ let dir = startDir;
707
+ for (let i = 0; i < maxUpwardLevels; i++) {
708
+ if (this.fs.exists(path.join(dir, '.git'))) return dir;
709
+ const parent = path.dirname(dir);
710
+ if (parent === dir) break;
711
+ dir = parent;
712
+ }
713
+ return startDir;
714
+ }
715
+
716
+ private resolveSafePath(explorationRoot: string, requested: string): string {
717
+ const target = requested && requested.trim() ? requested.trim() : '.';
718
+ const resolved = path.resolve(explorationRoot, target);
719
+ const rel = path.relative(explorationRoot, resolved);
720
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
721
+ throw new TyrError(`Path outside the allowed area: ${requested}`);
722
+ }
723
+ return resolved;
724
+ }
725
+
726
+ /** Mirrors Node.js module resolution: walks up from startDir looking for node_modules/<pkg>,
727
+ * not limited to the explorable area, since hoisted monorepos can place it above the repo
728
+ * root. Read-only and strictly scoped to "node_modules/<that exact package>", so this is not
729
+ * a sandbox escape even though it can walk above the repo. */
730
+ private resolveDependencyDir(startDir: string, packageName: string): string | null {
731
+ let dir = startDir;
732
+ for (let i = 0; i <= NODE_MODULES_SEARCH_MAX_LEVELS; i++) {
733
+ const candidate = path.join(dir, 'node_modules', packageName);
734
+ if (this.fs.exists(candidate) && this.isDirectorySync(candidate)) return candidate;
735
+ const parent = path.dirname(dir);
736
+ if (parent === dir) break;
737
+ dir = parent;
738
+ }
739
+ return null;
740
+ }
741
+
742
+ private truncateForTool(text: string, maxChars: number = MAX_TOOL_RESULT_CHARS): string {
743
+ if (text.length <= maxChars) return text;
744
+ const omitted = text.length - maxChars;
745
+ return `${text.slice(0, maxChars)}\n… (truncated, ${omitted} characters omitted)`;
746
+ }
747
+
748
+ private async collectFiles(rootDir: string, ignoredDirs: Set<string>): Promise<string[]> {
749
+ const results: string[] = [];
750
+
751
+ const walk = async (dir: string): Promise<void> => {
752
+ const entries = await this.fs.readdir(dir, { withFileTypes: true });
753
+ for (const entry of entries) {
754
+ const fullPath = path.join(dir, entry.name);
755
+ if (entry.isDirectory()) {
756
+ if (ignoredDirs.has(entry.name)) continue;
757
+ await walk(fullPath);
758
+ } else if (entry.isFile()) {
759
+ results.push(fullPath);
760
+ }
761
+ }
762
+ };
763
+
764
+ await walk(rootDir);
765
+ return results;
766
+ }
767
+
768
+ private async findFilesByName(root: string, ignoredDirs: Set<string>, names: Set<string>): Promise<string[]> {
769
+ const results: string[] = [];
770
+
771
+ const walk = async (dir: string): Promise<void> => {
772
+ const entries = await this.fs.readdir(dir, { withFileTypes: true });
773
+ for (const entry of entries) {
774
+ const fullPath = path.join(dir, entry.name);
775
+ if (entry.isDirectory()) {
776
+ if (ignoredDirs.has(entry.name)) continue;
777
+ await walk(fullPath);
778
+ } else if (entry.isFile() && names.has(entry.name)) {
779
+ results.push(fullPath);
780
+ }
781
+ }
782
+ };
783
+
784
+ await walk(root);
785
+ return results;
786
+ }
787
+
788
+ private summarizeManifest(filename: string, data: any): string {
789
+ if (filename === 'package.json') {
790
+ const deps = Object.keys(data.dependencies || {});
791
+ const devDeps = Object.keys(data.devDependencies || {});
792
+ const lines = [`Name: ${data.name ?? '(unnamed)'}`, `Version: ${data.version ?? '?'}`];
793
+ if (data.workspaces) lines.push(`Workspaces: ${JSON.stringify(data.workspaces)}`);
794
+ if (deps.length) lines.push(`Dependencies: ${deps.join(', ')}`);
795
+ if (devDeps.length) lines.push(`Dev dependencies: ${devDeps.join(', ')}`);
796
+ return lines.join('\n');
797
+ }
798
+
799
+ if (filename === 'composer.json') {
800
+ const req = Object.keys(data.require || {});
801
+ const reqDev = Object.keys(data['require-dev'] || {});
802
+ const lines = [`Name: ${data.name ?? '(unnamed)'}`];
803
+ if (req.length) lines.push(`require: ${req.join(', ')}`);
804
+ if (reqDev.length) lines.push(`require-dev: ${reqDev.join(', ')}`);
805
+ if (data.autoload) lines.push(`Autoload: ${JSON.stringify(data.autoload)}`);
806
+ return lines.join('\n');
807
+ }
808
+
809
+ return JSON.stringify(data);
810
+ }
811
+
812
+ private summarizeDependencyManifest(data: any): string {
813
+ const lines = [`Name: ${data.name ?? '(unnamed)'}`, `Version: ${data.version ?? '?'}`];
814
+ if (data.description) lines.push(`Description: ${data.description}`);
815
+ if (data.main) lines.push(`main: ${data.main}`);
816
+ if (data.module) lines.push(`module: ${data.module}`);
817
+ if (data.types || data.typings) lines.push(`types: ${data.types ?? data.typings}`);
818
+ if (data.exports) lines.push(`exports: ${JSON.stringify(data.exports, null, 2)}`);
819
+ if (data.bin) lines.push(`bin: ${JSON.stringify(data.bin)}`);
820
+ return lines.join('\n');
821
+ }
822
+
823
+ private async toolListDirectory(ctx: AgentToolContext, input: any): Promise<string> {
824
+ const target = this.resolveSafePath(ctx.explorationRoot, input?.path);
825
+ if (!this.isDirectorySync(target)) return `Error: "${input?.path}" is not a directory within the explorable area.`;
826
+
827
+ const entries = await this.fs.readdir(target, { withFileTypes: true });
828
+ const visible = entries.filter(e => !(e.isDirectory() && ctx.ignoredDirs.has(e.name)));
829
+ if (visible.length === 0) return '(empty directory)';
830
+
831
+ return visible.map(e => `${e.name}${e.isDirectory() ? '/' : ''}`).join('\n');
832
+ }
833
+
834
+ private async toolReadFile(ctx: AgentToolContext, input: any): Promise<string> {
835
+ if (!input?.path) return 'Error: missing "path" parameter.';
836
+ const target = this.resolveSafePath(ctx.explorationRoot, input.path);
837
+
838
+ if (!this.fs.exists(target)) return `Error: file does not exist: ${input.path}`;
839
+ if (this.isDirectorySync(target)) return `Error: "${input.path}" is a directory, use list_directory.`;
840
+
841
+ const content = await this.fs.read(target);
842
+ if (!content || !content.trim()) {
843
+ ctx.seenFiles.add(target);
844
+ return '(empty file)';
845
+ }
846
+
847
+ if (content.length > MAX_TOOL_RESULT_CHARS) {
848
+ // Truncated: the model has NOT seen the full file, so it is not marked as "seen" —
849
+ // applyPatches() will refuse to blindly overwrite it later.
850
+ return this.truncateForTool(content);
851
+ }
852
+
853
+ ctx.seenFiles.add(target);
854
+ return content;
855
+ }
856
+
857
+ private async toolFindAgentsMd(ctx: AgentToolContext): Promise<string> {
858
+ const found = await this.findFilesByName(ctx.explorationRoot, ctx.ignoredDirs, new Set([AGENTS_MD_FILENAME]));
859
+ if (found.length === 0) return 'No existing AGENTS.md files found in the explorable area.';
860
+ return found.map(p => path.relative(ctx.explorationRoot, p)).join('\n');
861
+ }
862
+
863
+ private async toolReadManifest(ctx: AgentToolContext): Promise<string> {
864
+ const manifests = await this.findFilesByName(ctx.explorationRoot, ctx.ignoredDirs, MANIFEST_FILENAMES);
865
+ if (manifests.length === 0) return 'No package.json or composer.json found in the explorable area.';
866
+
867
+ const parts: string[] = [];
868
+ for (const manifestPath of manifests) {
869
+ const rel = path.relative(ctx.explorationRoot, manifestPath);
870
+ const raw = await this.fs.read(manifestPath);
871
+ try {
872
+ const data = JSON.parse(raw ?? '{}');
873
+ parts.push(`# ${rel}\n${this.summarizeManifest(path.basename(manifestPath), data)}`);
874
+ } catch {
875
+ parts.push(`# ${rel}\n(could not be parsed as valid JSON)`);
876
+ }
877
+ }
878
+
879
+ return this.truncateForTool(parts.join('\n\n'));
880
+ }
881
+
882
+ private async toolSearchDependencyUsage(ctx: AgentToolContext, input: any): Promise<string> {
883
+ const dependency: string | undefined = input?.dependency;
884
+ if (!dependency) return 'Error: missing "dependency" parameter.';
885
+
886
+ const files = await this.collectFiles(ctx.explorationRoot, ctx.ignoredDirs);
887
+ const matches: string[] = [];
888
+
889
+ for (const filePath of files) {
890
+ if (matches.length >= MAX_DEPENDENCY_MATCHES) break;
891
+ if (!READABLE_EXTENSIONS.has(path.extname(filePath))) continue;
892
+
893
+ const content = await this.fs.read(filePath);
894
+ if (!content) continue;
895
+
896
+ const rel = path.relative(ctx.explorationRoot, filePath);
897
+ const lines = content.split('\n');
898
+
899
+ for (let i = 0; i < lines.length && matches.length < MAX_DEPENDENCY_MATCHES; i++) {
900
+ const line = lines[i];
901
+ if (line.includes(dependency) && /\b(import|require|use|from)\b/.test(line)) {
902
+ matches.push(`${rel}:${i + 1}: ${line.trim()}`);
903
+ }
904
+ }
905
+ }
906
+
907
+ if (matches.length === 0) return `No import/require/use references to "${dependency}" found in the code.`;
908
+ return this.truncateForTool(matches.join('\n'));
909
+ }
910
+
911
+ private async toolReadDependencyManifest(ctx: AgentToolContext, input: any): Promise<string> {
912
+ const packageName = input?.packageName;
913
+ if (!packageName) return 'Error: missing "packageName" parameter.';
914
+
915
+ const depDir = this.resolveDependencyDir(ctx.projectDir, packageName);
916
+ if (!depDir) return `Error: "${packageName}" was not found in any node_modules reachable from the project.`;
917
+
918
+ const manifestPath = path.join(depDir, 'package.json');
919
+ if (!this.fs.exists(manifestPath)) return `Error: "${packageName}" has no package.json in ${depDir}.`;
920
+
921
+ const raw = await this.fs.read(manifestPath);
922
+ try {
923
+ return this.summarizeDependencyManifest(JSON.parse(raw ?? '{}'));
924
+ } catch {
925
+ return `Error: "${packageName}"'s package.json is not valid JSON.`;
926
+ }
927
+ }
928
+
929
+ private async toolListDependencyFiles(ctx: AgentToolContext, input: any): Promise<string> {
930
+ const packageName = input?.packageName;
931
+ if (!packageName) return 'Error: missing "packageName" parameter.';
932
+
933
+ const depDir = this.resolveDependencyDir(ctx.projectDir, packageName);
934
+ if (!depDir) return `Error: "${packageName}" was not found in any node_modules reachable from the project.`;
935
+
936
+ let target: string;
937
+ try {
938
+ target = this.resolveSafePath(depDir, input?.path);
939
+ } catch (err: any) {
940
+ return `Error: ${err.message}`;
941
+ }
942
+
943
+ if (!this.isDirectorySync(target)) return `Error: "${input?.path ?? '.'}" is not a directory within "${packageName}".`;
944
+
945
+ const entries = await this.fs.readdir(target, { withFileTypes: true });
946
+ const visible = entries.filter(e => !(e.isDirectory() && PACKAGE_IGNORED_DIRS.has(e.name)));
947
+ if (visible.length === 0) return '(empty directory)';
948
+
949
+ return visible.map(e => `${e.name}${e.isDirectory() ? '/' : ''}`).join('\n');
950
+ }
951
+
952
+ private async toolReadDependencyFile(ctx: AgentToolContext, input: any): Promise<string> {
953
+ const packageName = input?.packageName;
954
+ if (!packageName) return 'Error: missing "packageName" parameter.';
955
+ if (!input?.path) return 'Error: missing "path" parameter.';
956
+
957
+ const depDir = this.resolveDependencyDir(ctx.projectDir, packageName);
958
+ if (!depDir) return `Error: "${packageName}" was not found in any node_modules reachable from the project.`;
959
+
960
+ let target: string;
961
+ try {
962
+ target = this.resolveSafePath(depDir, input.path);
963
+ } catch (err: any) {
964
+ return `Error: ${err.message}`;
965
+ }
966
+
967
+ if (!this.fs.exists(target)) return `Error: file does not exist in "${packageName}": ${input.path}`;
968
+ if (this.isDirectorySync(target)) return `Error: "${input.path}" is a directory within "${packageName}", use list_dependency_files.`;
969
+
970
+ const content = await this.fs.read(target);
971
+ if (!content || !content.trim()) return '(empty file)';
972
+ return this.truncateForTool(content);
973
+ }
974
+
975
+ private async executeAgentTool(ctx: AgentToolContext, name: string, input: any): Promise<string> {
976
+ try {
977
+ switch (name) {
978
+ case 'list_directory': return await this.toolListDirectory(ctx, input);
979
+ case 'read_file': return await this.toolReadFile(ctx, input);
980
+ case 'find_agents_md': return await this.toolFindAgentsMd(ctx);
981
+ case 'read_manifest': return await this.toolReadManifest(ctx);
982
+ case 'search_dependency_usage': return await this.toolSearchDependencyUsage(ctx, input);
983
+ case 'read_dependency_manifest': return await this.toolReadDependencyManifest(ctx, input);
984
+ case 'list_dependency_files': return await this.toolListDependencyFiles(ctx, input);
985
+ case 'read_dependency_file': return await this.toolReadDependencyFile(ctx, input);
986
+ default: return `Error: unknown tool "${name}".`;
987
+ }
988
+ } catch (err: any) {
989
+ return `Error running tool "${name}": ${err?.message ?? String(err)}`;
990
+ }
991
+ }
992
+
993
+ // === Agent loop (tool use + Loop Detector + priority routing) ================================
994
+
995
+ /**
996
+ * Runs the conversation with the model, letting it call AGENT_TOOLS for extra context before
997
+ * delivering a final answer. Mutates `messages` in place (pushes assistant/tool turns onto it)
998
+ * so a caller doing self-healing retries can keep extending the same conversation. Stops when
999
+ * the model answers without requesting a tool, when maxIterations is reached (a final answer is
1000
+ * then forced), or when the Loop Detector aborts a runaway sequence of identical tool calls.
1001
+ */
1002
+ private async runAgentLoop(
1003
+ messages: AIMessage[],
1004
+ ctx: AgentToolContext,
1005
+ tokens: TokenManager,
1006
+ options: AgentRunOptions = {}
1007
+ ): Promise<AgentRunResult> {
1008
+ const priority = options.priority ?? 'media-prioridad';
1009
+ const maxIterations = options.maxIterations ?? DEFAULT_MAX_AGENT_ITERATIONS;
1010
+ const completeOptions = { tools: AGENT_TOOLS, ...(options.maxTokens ? { maxTokens: options.maxTokens } : {}) };
1011
+
1012
+ let totalPromptTokens = 0;
1013
+ let totalCompletionTokens = 0;
1014
+ let toolCallsUsed = 0;
1015
+ let lastCallSignature: string | null = null;
1016
+ let consecutiveCount = 0;
1017
+
1018
+ for (let iteration = 0; iteration < maxIterations; iteration++) {
1019
+ tokens.assertWithinLimit(messages);
1020
+ const result = await this.ai.completeWithPriority(messages, priority, completeOptions);
1021
+
1022
+ totalPromptTokens += result.promptTokens ?? 0;
1023
+ totalCompletionTokens += result.completionTokens ?? 0;
1024
+ tokens.recordUsage(result.vendor, result.model, result.promptTokens ?? 0, result.completionTokens ?? 0);
1025
+
1026
+ const toolUses = (result.blocks ?? []).filter(
1027
+ (b): b is Extract<AIContentBlock, { type: 'tool_use' }> => b.type === 'tool_use'
1028
+ );
1029
+
1030
+ if (toolUses.length === 0) {
1031
+ return { content: result.content, promptTokens: totalPromptTokens, completionTokens: totalCompletionTokens, toolCallsUsed, priorityUsed: priority };
1032
+ }
1033
+
1034
+ messages.push({ role: 'assistant', content: result.blocks });
1035
+
1036
+ const toolResults: AIContentBlock[] = [];
1037
+ let aborted = false;
1038
+
1039
+ for (const call of toolUses) {
1040
+ toolCallsUsed++;
1041
+ const signature = `${call.name}(${JSON.stringify(call.input ?? {})})`;
1042
+ consecutiveCount = signature === lastCallSignature ? consecutiveCount + 1 : 1;
1043
+ lastCallSignature = signature;
1044
+
1045
+ if (consecutiveCount >= LOOP_DETECTOR_REPEAT_THRESHOLD) {
1046
+ this.logger.warn(`Loop detector: '${call.name}' called with identical arguments ${consecutiveCount} times in a row — aborting the agent loop.`);
1047
+ toolResults.push({
1048
+ type: 'tool_result',
1049
+ tool_use_id: call.id,
1050
+ content: 'Error: aborted by the loop detector (identical tool call repeated too many times).',
1051
+ is_error: true,
1052
+ });
1053
+ aborted = true;
1054
+ break;
1055
+ }
1056
+
1057
+ this.logger.info(`Agent tool call → ${signature}`);
1058
+ const output = await this.executeAgentTool(ctx, call.name, call.input);
1059
+ toolResults.push({ type: 'tool_result', tool_use_id: call.id, content: output });
1060
+ }
1061
+
1062
+ messages.push({ role: 'user', content: toolResults });
1063
+
1064
+ if (aborted) {
1065
+ return {
1066
+ content: '(stopped early: the loop detector aborted the session after a repeated tool call)',
1067
+ promptTokens: totalPromptTokens,
1068
+ completionTokens: totalCompletionTokens,
1069
+ toolCallsUsed,
1070
+ priorityUsed: priority,
1071
+ };
1072
+ }
1073
+ }
1074
+
1075
+ this.logger.info(`Agent iteration limit reached (${maxIterations}); forcing a final answer.`);
1076
+ messages.push({ role: 'user', content: 'No more tool calls are available. Deliver your final answer now with whatever information you have.' });
1077
+
1078
+ tokens.assertWithinLimit(messages);
1079
+ const final = await this.ai.completeWithPriority(messages, priority, options.maxTokens ? { maxTokens: options.maxTokens } : {});
1080
+ totalPromptTokens += final.promptTokens ?? 0;
1081
+ totalCompletionTokens += final.completionTokens ?? 0;
1082
+ tokens.recordUsage(final.vendor, final.model, final.promptTokens ?? 0, final.completionTokens ?? 0);
1083
+
1084
+ return { content: final.content, promptTokens: totalPromptTokens, completionTokens: totalCompletionTokens, toolCallsUsed, priorityUsed: priority };
1085
+ }
1086
+
1087
+ // === Search/Replace patch engine ===============================================================
1088
+
1089
+ private parsePatchBlocks(responseText: string): FilePatchBlock[] {
1090
+ const blocks: FilePatchBlock[] = [];
1091
+
1092
+ FILE_BLOCK_REGEX.lastIndex = 0;
1093
+ let fileMatch: RegExpExecArray | null;
1094
+ while ((fileMatch = FILE_BLOCK_REGEX.exec(responseText)) !== null) {
1095
+ const relPath = fileMatch[1].trim();
1096
+ const body = fileMatch[2];
1097
+ const edits: FilePatchEdit[] = [];
1098
+
1099
+ SEARCH_REPLACE_REGEX.lastIndex = 0;
1100
+ let editMatch: RegExpExecArray | null;
1101
+ while ((editMatch = SEARCH_REPLACE_REGEX.exec(body)) !== null) {
1102
+ edits.push({ search: editMatch[1], replace: editMatch[2] });
1103
+ }
1104
+
1105
+ if (edits.length > 0) blocks.push({ relPath, edits });
1106
+ }
1107
+
1108
+ return blocks;
1109
+ }
1110
+
1111
+ /**
1112
+ * Applies one SEARCH/REPLACE edit to `original`. Tries an exact substring match first; if that
1113
+ * fails, falls back to a line-by-line match that ignores leading/trailing whitespace per line,
1114
+ * so the model doesn't have to reproduce indentation byte-for-byte. Returns applied: false if
1115
+ * neither matches, so the caller can reject the edit instead of corrupting the file.
1116
+ */
1117
+ private applySearchReplace(original: string, search: string, replace: string): { content: string; applied: boolean } {
1118
+ if (search.trim() === '') {
1119
+ // Only meaningful for brand-new files; callers must guard this case before writing to
1120
+ // an existing file with an empty SEARCH block.
1121
+ return { content: replace, applied: true };
1122
+ }
1123
+
1124
+ const exactIndex = original.indexOf(search);
1125
+ if (exactIndex !== -1) {
1126
+ return { content: original.slice(0, exactIndex) + replace + original.slice(exactIndex + search.length), applied: true };
1127
+ }
1128
+
1129
+ const originalLines = original.split('\n');
1130
+ const searchLines = search.split('\n');
1131
+ const normalize = (line: string) => line.trim();
1132
+
1133
+ for (let start = 0; start <= originalLines.length - searchLines.length; start++) {
1134
+ let matches = true;
1135
+ for (let i = 0; i < searchLines.length; i++) {
1136
+ if (normalize(originalLines[start + i]) !== normalize(searchLines[i])) { matches = false; break; }
1137
+ }
1138
+ if (!matches) continue;
1139
+
1140
+ const before = originalLines.slice(0, start);
1141
+ const after = originalLines.slice(start + searchLines.length);
1142
+ return { content: [...before, replace, ...after].join('\n'), applied: true };
1143
+ }
1144
+
1145
+ return { content: original, applied: false };
1146
+ }
1147
+
1148
+ /**
1149
+ * Applies every parsed patch block within `targetDir`. Enforces two safety rules regardless of
1150
+ * what the model asked for: writes may never resolve outside targetDir, and an existing file is
1151
+ * never overwritten unless its full content was actually seen this session (seenFiles) — a
1152
+ * brand-new file (empty SEARCH, no existing file at that path) is exempt from the second rule.
1153
+ */
1154
+ private async applyPatches(
1155
+ targetDir: string,
1156
+ blocks: FilePatchBlock[],
1157
+ seenFiles: Set<string>
1158
+ ): Promise<{ filesChanged: string[]; blockedWrites: string[]; failedEdits: string[] }> {
1159
+ const filesChanged: string[] = [];
1160
+ const blockedWrites: string[] = [];
1161
+ const failedEdits: string[] = [];
1162
+
1163
+ for (const block of blocks) {
1164
+ const resolvedPath = path.resolve(targetDir, block.relPath);
1165
+ const withinTarget = resolvedPath === targetDir || resolvedPath.startsWith(targetDir + path.sep);
1166
+
1167
+ if (!withinTarget) {
1168
+ this.logger.warn(`Patch ignored, outside the working directory: ${block.relPath}`);
1169
+ continue;
1170
+ }
1171
+
1172
+ const existed = this.fs.exists(resolvedPath);
1173
+ const isNewFileEdit = block.edits.length === 1 && block.edits[0].search.trim() === '';
1174
+
1175
+ if (existed && !seenFiles.has(resolvedPath)) {
1176
+ this.logger.warn(`Patch REJECTED (no blind overwrite): "${block.relPath}" already exists and its full content was not read this session.`);
1177
+ blockedWrites.push(block.relPath);
1178
+ continue;
1179
+ }
1180
+
1181
+ if (!existed && !isNewFileEdit) {
1182
+ this.logger.warn(`Patch ignored: "${block.relPath}" does not exist and the block is not a valid new-file creation (empty SEARCH).`);
1183
+ failedEdits.push(block.relPath);
1184
+ continue;
1185
+ }
1186
+
1187
+ let content = existed ? (await this.fs.read(resolvedPath)) ?? '' : '';
1188
+ let ok = true;
1189
+
1190
+ for (const edit of block.edits) {
1191
+ const result = this.applySearchReplace(content, edit.search, edit.replace);
1192
+ if (!result.applied) { ok = false; break; }
1193
+ content = result.content;
1194
+ }
1195
+
1196
+ if (!ok) {
1197
+ this.logger.warn(`Patch failed to apply (SEARCH text not found, even fuzzily): ${block.relPath}`);
1198
+ failedEdits.push(block.relPath);
1199
+ continue;
1200
+ }
1201
+
1202
+ await this.fs.write(resolvedPath, content.endsWith('\n') ? content : content + '\n');
1203
+ seenFiles.add(resolvedPath);
1204
+ this.logger.success(`${existed ? 'Modified' : 'Created'}: ${resolvedPath}`);
1205
+ filesChanged.push(block.relPath);
1206
+ }
1207
+
1208
+ return { filesChanged, blockedWrites, failedEdits };
1209
+ }
1210
+
1211
+ // === Post-write validation (self-healing) ======================================================
1212
+
1213
+ /**
1214
+ * Best-effort, non-destructive validation: prefers `tsc --noEmit` when a tsconfig.json is
1215
+ * present, otherwise runs the project's `typecheck` or `lint` npm script if one exists. Returns
1216
+ * { ran: false } when no validator could be detected, which callers should treat as "assume ok"
1217
+ * rather than as a failure.
1218
+ */
1219
+ private async runValidation(dir: string): Promise<ValidationResult> {
1220
+ const originalCwd = this.shell.getCwd();
1221
+
1222
+ try {
1223
+ if (this.fs.exists(path.join(dir, 'tsconfig.json'))) {
1224
+ this.shell.cd(dir);
1225
+ await this.shell.exec('npx tsc --noEmit');
1226
+ return { ran: true, ok: true };
1227
+ }
1228
+
1229
+ const pkgPath = path.join(dir, 'package.json');
1230
+ if (this.fs.exists(pkgPath)) {
1231
+ const raw = await this.fs.read(pkgPath);
1232
+ let scripts: Record<string, string> = {};
1233
+ try {
1234
+ scripts = raw ? (JSON.parse(raw).scripts ?? {}) : {};
1235
+ } catch {
1236
+ scripts = {};
1237
+ }
1238
+
1239
+ const script = scripts.typecheck ? 'typecheck' : scripts.lint ? 'lint' : null;
1240
+ if (script) {
1241
+ this.shell.cd(dir);
1242
+ await this.shell.exec(`npm run ${script}`);
1243
+ return { ran: true, ok: true };
1244
+ }
1245
+ }
1246
+
1247
+ return { ran: false, ok: true };
1248
+ } catch (err: any) {
1249
+ const raw = err?.originalError?.stderr || err?.originalError?.stdout || err?.originalError?.message || err?.message;
1250
+ return { ran: true, ok: false, output: this.truncateForTool(String(raw ?? 'Unknown validation error')) };
1251
+ } finally {
1252
+ this.shell.cd(originalCwd);
1253
+ }
1254
+ }
1255
+
1256
+ // === High-level entry points ===================================================================
1257
+
1258
+ /**
1259
+ * @method runCodeAgent
1260
+ * @description Runs the full ai:code pipeline: the tool-use agent loop (seeded only with
1261
+ * `messages`, typically tree + AGENTS.md + the task, built by the caller via
1262
+ * PromptTemplateManager + buildExplorationTree()/getContext()), parses the model's
1263
+ * Search/Replace blocks, applies them under the blind-overwrite guard, and — if any file
1264
+ * changed — runs post-write validation. On a validation failure, the error is fed back into
1265
+ * the same conversation as a message and the routing priority is bumped one level (see
1266
+ * AIVendorManager.bumpPriority), up to a small retry budget.
1267
+ * @param {string} targetDir - Directory the agent is allowed to write to.
1268
+ * @param {AIMessage[]} messages - Seed conversation (system + user), mutated in place.
1269
+ * @param {TokenManager} tokens - Injected by the caller so usage is tracked centrally.
1270
+ * @param {AgentRunOptions} options - priority (default 'media-prioridad'), maxIterations, maxTokens.
1271
+ * @returns {Promise<CodeAgentResult>}
1272
+ * @example
1273
+ * const messages = await prompts.build('ai-code', { task: prompt, tree }, targetDir);
1274
+ * const result = await aiContext.runCodeAgent(targetDir, messages, tokens, { priority: 'media-prioridad' });
1275
+ */
1276
+ public async runCodeAgent(
1277
+ targetDir: string,
1278
+ messages: AIMessage[],
1279
+ tokens: TokenManager,
1280
+ options: AgentRunOptions = {}
1281
+ ): Promise<CodeAgentResult> {
1282
+ let priority = options.priority ?? 'media-prioridad';
1283
+ const explorationRoot = this.computeExplorationRoot(targetDir);
1284
+ const ctx: AgentToolContext = { projectDir: targetDir, explorationRoot, ignoredDirs: DEFAULT_IGNORED_DIRS, seenFiles: new Set<string>() };
1285
+
1286
+ let promptTokens = 0;
1287
+ let completionTokens = 0;
1288
+ let toolCallsUsed = 0;
1289
+ let lastContent = '';
1290
+ let filesChanged: string[] = [];
1291
+ let blockedWrites: string[] = [];
1292
+ let failedEdits: string[] = [];
1293
+ let validation: ValidationResult = { ran: false, ok: true };
1294
+
1295
+ for (let attempt = 0; attempt <= MAX_SELF_HEAL_ATTEMPTS; attempt++) {
1296
+ const run = await this.runAgentLoop(messages, ctx, tokens, { ...options, priority });
1297
+ promptTokens += run.promptTokens;
1298
+ completionTokens += run.completionTokens;
1299
+ toolCallsUsed += run.toolCallsUsed;
1300
+ lastContent = run.content;
1301
+
1302
+ const blocks = this.parsePatchBlocks(run.content);
1303
+ if (blocks.length === 0) break;
1304
+
1305
+ const applied = await this.applyPatches(targetDir, blocks, ctx.seenFiles);
1306
+ filesChanged = applied.filesChanged;
1307
+ blockedWrites = applied.blockedWrites;
1308
+ failedEdits = applied.failedEdits;
1309
+ if (filesChanged.length === 0) break;
1310
+
1311
+ validation = await this.runValidation(targetDir);
1312
+ if (validation.ok || attempt === MAX_SELF_HEAL_ATTEMPTS) break;
1313
+
1314
+ const nextPriority = this.ai.bumpPriority(priority);
1315
+ this.logger.warn(
1316
+ `Post-write validation failed (attempt ${attempt + 1}/${MAX_SELF_HEAL_ATTEMPTS + 1}); asking the model to fix it and bumping priority: ${priority} → ${nextPriority}`
1317
+ );
1318
+ priority = nextPriority;
1319
+
1320
+ messages.push({ role: 'assistant', content: run.content });
1321
+ messages.push({
1322
+ role: 'user',
1323
+ content:
1324
+ `SYSTEM: Your change broke compilation/lint with the following error:\n\n${validation.output ?? '(no output captured)'}\n\n` +
1325
+ 'Fix it using more SEARCH/REPLACE blocks for the affected file(s). If you are not certain of a file\'s current ' +
1326
+ 'exact content after your previous edit, re-read it with read_file before writing a new SEARCH block against it.',
1327
+ });
1328
+ }
1329
+
1330
+ return { content: lastContent, promptTokens, completionTokens, toolCallsUsed, priorityUsed: priority, filesChanged, blockedWrites, failedEdits, validation };
1331
+ }
1332
+
1333
+ /**
1334
+ * @method runDescribeAgent
1335
+ * @description Runs the tool-use agent loop for ai:describe: no file writing, just exploration
1336
+ * (list_directory / read_file / read_manifest / etc.) and a final Markdown answer, which the
1337
+ * caller is responsible for writing to disk (the output filename is a command-layer policy,
1338
+ * not something this manager should decide).
1339
+ * @param {string} dir - Directory the agent is allowed to explore (read-only).
1340
+ * @param {AIMessage[]} messages - Seed conversation (system + user).
1341
+ * @param {TokenManager} tokens - Injected by the caller so usage is tracked centrally.
1342
+ * @param {AgentRunOptions} options - priority (default 'baja-media-prioridad'), maxIterations, maxTokens.
1343
+ * @returns {Promise<AgentRunResult>}
1344
+ * @example
1345
+ * const messages = await prompts.build('ai-describe-project', { tree, readme }, projectDir);
1346
+ * const result = await aiContext.runDescribeAgent(projectDir, messages, tokens);
1347
+ * await fs.write(path.join(projectDir, 'AGENTS.md'), result.content.trim() + '\n');
1348
+ */
1349
+ public async runDescribeAgent(
1350
+ dir: string,
1351
+ messages: AIMessage[],
1352
+ tokens: TokenManager,
1353
+ options: AgentRunOptions = {}
1354
+ ): Promise<AgentRunResult> {
1355
+ const explorationRoot = this.computeExplorationRoot(dir);
1356
+ const ctx: AgentToolContext = { projectDir: dir, explorationRoot, ignoredDirs: DEFAULT_IGNORED_DIRS, seenFiles: new Set<string>() };
1357
+ return this.runAgentLoop(messages, ctx, tokens, { ...options, priority: options.priority ?? 'baja-media-prioridad' });
1358
+ }
298
1359
  }
299
1360
 
300
1361
  export const AIContextManagerTests = {
301
1362
  findContextFiles: { dir: '~/Projects/TyrFramework' },
302
- };
1363
+ };