@orxataguy/tyr 1.0.28 → 1.0.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@orxataguy/tyr",
3
- "version": "1.0.28",
3
+ "version": "1.0.30",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "tyr": "./bin/tyr.js"
@@ -10,6 +10,10 @@ import { WebManager } from '../lib/WebManager.js';
10
10
  import { WorkspaceManager } from '../lib/WorkspaceManager.js';
11
11
  import { JiraManager } from '../lib/JiraManager.js';
12
12
  import { SetupManager } from '../lib/SetupManager.js';
13
+ import { AIVendorManager } from '../lib/AIVendorManager.js';
14
+ import { AIContextManager } from '../lib/AIContextManager.js';
15
+ import { PromptTemplateManager } from '../lib/PromptTemplateManager.js';
16
+ import { TokenManager } from '../lib/TokenManager.js';
13
17
  import { Logger, createLogger } from './Logger.js';
14
18
 
15
19
  import path from 'path';
@@ -31,6 +35,10 @@ export interface ServiceContainer {
31
35
  workspace: WorkspaceManager;
32
36
  jira: JiraManager;
33
37
  setup: SetupManager;
38
+ aiVendor: AIVendorManager;
39
+ aiContext: AIContextManager;
40
+ prompts: PromptTemplateManager;
41
+ tokens: TokenManager;
34
42
  }
35
43
 
36
44
  export class Container {
@@ -47,6 +55,8 @@ export class Container {
47
55
  const mongo = new MongoManager();
48
56
  const web = new WebManager(logger);
49
57
  const fs = new FileSystemManager(logger);
58
+ const aiVendor = new AIVendorManager(logger);
59
+ const aiContext = new AIContextManager(fs, aiVendor, logger);
50
60
 
51
61
  this.services = {
52
62
  logger,
@@ -63,6 +73,10 @@ export class Container {
63
73
  workspace: new WorkspaceManager(shell, fs, logger),
64
74
  jira: new JiraManager(web, shell, logger),
65
75
  setup: new SetupManager(shell, fs, logger),
76
+ aiVendor,
77
+ aiContext,
78
+ prompts: new PromptTemplateManager(aiContext, logger),
79
+ tokens: new TokenManager(logger),
66
80
  };
67
81
  }
68
82
 
@@ -1,4 +1,5 @@
1
1
  import path from 'path';
2
+ import { getEnvString } from '../util/getenv.js';
2
3
  import { homedir, platform } from 'os';
3
4
  import { existsSync, cpSync, rmSync, mkdirSync, readdirSync } from 'fs';
4
5
  import { execSync } from 'child_process';
@@ -48,7 +49,7 @@ function backupUserRoot(userRoot: string, backupPath: string): void {
48
49
  }
49
50
 
50
51
  function detectShellRcFile(homeDir: string): string | null {
51
- const shell = process.env.SHELL || '';
52
+ const shell = getEnvString('SHELL', '');
52
53
  if (shell.includes('zsh')) return path.join(homeDir, '.zshrc');
53
54
  if (shell.includes('fish')) return path.join(homeDir, '.config', 'fish', 'config.fish');
54
55
  if (shell.includes('bash')) {
@@ -60,7 +61,7 @@ function detectShellRcFile(homeDir: string): string | null {
60
61
  }
61
62
 
62
63
  function getWindowsProfilePaths(): string[] {
63
- const userProfile = process.env.USERPROFILE;
64
+ const userProfile = getEnvString('USERPROFILE');
64
65
  if (!userProfile) return [];
65
66
  return [
66
67
  path.join(userProfile, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1'),
@@ -76,13 +77,13 @@ async function checkWindowsExecutionPolicy(shell: any, logger: any): Promise<voi
76
77
  const policy = String(output).trim();
77
78
 
78
79
  if (policy === 'Restricted' || policy === 'AllSigned' || policy === 'Undefined') {
79
- logger.warn(`\nExecution Policy actual: ${policy || 'Undefined'}`);
80
- logger.warn('Con esta política, PowerShell puede bloquear la carga de tu perfil (y por tanto las funciones/alias de Tyr).');
81
- logger.info('Para permitirlo, ejecuta en una consola de PowerShell:');
80
+ logger.warn(`\nCurrent Execution Policy: ${policy || 'Undefined'}`);
81
+ logger.warn('With this policy, PowerShell may block loading your profile (and therefore Tyr functions/aliases).');
82
+ logger.info('To allow it, run in a PowerShell console:');
82
83
  logger.info(' Set-ExecutionPolicy RemoteSigned -Scope CurrentUser');
83
84
  }
84
85
  } catch {
85
- logger.warn('No se pudo comprobar la Execution Policy de PowerShell. Verifícala manualmente si los comandos no cargan.');
86
+ logger.warn('Could not check PowerShell Execution Policy. Verify it manually if commands do not load.');
86
87
  }
87
88
  }
88
89
 
@@ -123,6 +124,14 @@ MSSQL_DATABASE=
123
124
  # MongoDB database
124
125
  MONGO_URI=
125
126
  MONGO_DATABASE=
127
+ # AI vendor (anthropic | openai | gemini)
128
+ AI_VENDOR=anthropic
129
+ ANTHROPIC_API_KEY=
130
+ OPENAI_API_KEY=
131
+ GEMINI_API_KEY=
132
+ AI_MODEL=
133
+ AI_TEMPERATURE=
134
+ AI_MAX_TOKENS=
126
135
  `;
127
136
 
128
137
  const SH_ALIASES_TEMPLATE = `# Add your custom aliases here.
@@ -176,15 +185,15 @@ async function configureUnixShell(tyrFs: any, logger: any, homeDir: string, alia
176
185
  }
177
186
  await tyrFs.ensureLine(rcFile, `source "${aliasesPath}"`);
178
187
  await tyrFs.ensureLine(rcFile, `source "${pluginsPath}"`);
179
- logger.success(`Shell configurado: ${rcFile}`);
180
- logger.info(`Ejecuta: source ${rcFile} (o abre una nueva terminal)`);
188
+ logger.success(`Shell configured: ${rcFile}`);
189
+ logger.info(`Run: source ${rcFile} (or open a new terminal)`);
181
190
  }
182
191
 
183
192
  async function configureWindowsShell(tyrFs: any, logger: any, shell: any, aliasesPath: string, pluginsPath: string): Promise<void> {
184
193
  const profiles = getWindowsProfilePaths();
185
194
 
186
195
  if (profiles.length === 0) {
187
- logger.warn('Could not detect PowerShell profile (USERPROFILE no está definido).');
196
+ logger.warn('Could not detect PowerShell profile (USERPROFILE is not defined).');
188
197
  logger.info(`Add manually:\n . "${aliasesPath}"\n . "${pluginsPath}"`);
189
198
  return;
190
199
  }
@@ -197,7 +206,7 @@ async function configureWindowsShell(tyrFs: any, logger: any, shell: any, aliase
197
206
  logger.success(`PowerShell profile configured: ${psProfile}`);
198
207
  }
199
208
 
200
- logger.info('Restart PowerShell (o abre una nueva consola) para aplicar los cambios.');
209
+ logger.info('Restart PowerShell (or open a new console) to apply the changes.');
201
210
 
202
211
  await checkWindowsExecutionPolicy(shell, logger);
203
212
  }
@@ -322,21 +331,19 @@ export default function config({ logger, fs: tyrFs, frameworkRoot, shell }: TyrC
322
331
  }
323
332
  }
324
333
 
325
- // Garantizar package.json + tsconfig.json + npm install siempre,
326
- // tanto en init fresh como al clonar un repo existente.
327
334
  const packageJsonPath = path.join(userRoot, 'package.json');
328
335
  const tsconfigPath = path.join(userRoot, 'tsconfig.json');
329
336
  let needsInstall = false;
330
337
 
331
338
  if (!tyrFs.exists(packageJsonPath)) {
332
339
  await tyrFs.write(packageJsonPath, PACKAGE_JSON_TEMPLATE);
333
- logger.success(`Archivo creado: ${packageJsonPath}`);
340
+ logger.success(`File created: ${packageJsonPath}`);
334
341
  needsInstall = true;
335
342
  }
336
343
 
337
344
  if (!tyrFs.exists(tsconfigPath)) {
338
345
  await tyrFs.write(tsconfigPath, TSCONFIG_TEMPLATE);
339
- logger.success(`Archivo creado: ${tsconfigPath}`);
346
+ logger.success(`File created: ${tsconfigPath}`);
340
347
  needsInstall = true;
341
348
  }
342
349
 
@@ -355,7 +362,7 @@ export default function config({ logger, fs: tyrFs, frameworkRoot, shell }: TyrC
355
362
  const pluginsPath = path.join(userRoot, `plugins${ext}`);
356
363
 
357
364
  if (tyrFs.exists(aliasesPath) || tyrFs.exists(pluginsPath)) {
358
- logger.info('\nConfigurando shell...');
365
+ logger.info('\nConfiguring shell...');
359
366
  if (isWindows) {
360
367
  await configureWindowsShell(tyrFs, logger, shell, aliasesPath, pluginsPath);
361
368
  } else {
@@ -0,0 +1,57 @@
1
+
2
+ export function getEnvString(name: string, fallback: string): string;
3
+ export function getEnvString(name: string, fallback?: string): string | undefined;
4
+ export function getEnvString(name: string, fallback?: string): string | undefined {
5
+ const value = process.env[name];
6
+ return value === undefined || value === '' ? fallback : value;
7
+ }
8
+
9
+
10
+ export function getEnvInt(name: string, fallback: number): number;
11
+ export function getEnvInt(name: string, fallback?: number): number | undefined;
12
+ export function getEnvInt(name: string, fallback?: number): number | undefined {
13
+ const raw = getEnvString(name);
14
+ if (raw === undefined) return fallback;
15
+ const value = Number.parseInt(raw, 10);
16
+ return Number.isNaN(value) ? fallback : value;
17
+ }
18
+
19
+
20
+ export function getEnvDouble(name: string, fallback: number): number;
21
+ export function getEnvDouble(name: string, fallback?: number): number | undefined;
22
+ export function getEnvDouble(name: string, fallback?: number): number | undefined {
23
+ const raw = getEnvString(name);
24
+ if (raw === undefined) return fallback;
25
+ const value = Number.parseFloat(raw);
26
+ return Number.isNaN(value) ? fallback : value;
27
+ }
28
+
29
+ const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on']);
30
+ const FALSE_VALUES = new Set(['0', 'false', 'no', 'off']);
31
+
32
+
33
+ export function getEnvBool(name: string, fallback: boolean): boolean;
34
+ export function getEnvBool(name: string, fallback?: boolean): boolean | undefined;
35
+ export function getEnvBool(name: string, fallback?: boolean): boolean | undefined {
36
+ const raw = getEnvString(name);
37
+ if (raw === undefined) return fallback;
38
+
39
+ const normalized = raw.trim().toLowerCase();
40
+ if (TRUE_VALUES.has(normalized)) return true;
41
+ if (FALSE_VALUES.has(normalized)) return false;
42
+ return fallback;
43
+ }
44
+
45
+ export function getEnvArray(name: string, fallback: string[], separator?: string): string[];
46
+ export function getEnvArray(name: string, fallback?: string[], separator?: string): string[] | undefined;
47
+ export function getEnvArray(name: string, fallback?: string[], separator: string = ','): string[] | undefined {
48
+ const raw = getEnvString(name);
49
+ if (raw === undefined) return fallback;
50
+
51
+ const values = raw
52
+ .split(separator)
53
+ .map((entry) => entry.trim())
54
+ .filter((entry) => entry.length > 0);
55
+
56
+ return values.length > 0 ? values : fallback;
57
+ }
@@ -0,0 +1,302 @@
1
+ import path from 'path';
2
+
3
+ import { FileSystemManager } from './FileSystemManager.js';
4
+ import { AIVendorManager, AIMessage } from './AIVendorManager.js';
5
+ import { Logger } from '../core/Logger.js';
6
+ import { TyrError } from '../core/TyrError.js';
7
+
8
+ import { getEnvInt } from '../core/util/getenv.js';
9
+
10
+
11
+ const CONTEXT_FILENAMES = [
12
+ 'CLAUDE.md',
13
+ 'AGENTS.md',
14
+ 'CONTEXT.md',
15
+ '.cursorrules',
16
+ path.join('.github', 'copilot-instructions.md'),
17
+ ];
18
+
19
+ const GENERATED_FILENAME = 'CLAUDE.md';
20
+
21
+ const SNAPSHOT_TREE_MAX_DEPTH = getEnvInt('SNAPSHOT_TREE_MAX_DEPTH', 5);
22
+ const SNAPSHOT_TREE_MAX_ENTRIES_PER_DIR = getEnvInt('SNAPSHOT_TREE_MAX_ENTRIES_PER_DIR', 250);
23
+ const SNAPSHOT_SECTION_MAX_CHARS = getEnvInt('SNAPSHOT_SECTION_MAX_CHARS', 40000);
24
+
25
+ const DEFAULT_IGNORED_DIRS = new Set<string>([
26
+ 'node_modules', '.git', '.hg', '.svn',
27
+ '.turbo', '.next', '.nuxt', '.cache', '.parcel-cache',
28
+ '.vscode', '.idea',
29
+ 'dist', 'build', 'out', 'coverage', '.nyc_output',
30
+ 'target', 'vendor',
31
+ ]);
32
+
33
+ const PACKAGE_JSON_FIELDS = ['name', 'version', 'type', 'dependencies', 'devDependencies', 'scripts'] as const;
34
+
35
+ const GUIDELINES_SYSTEM_PROMPT =
36
+ 'You are a senior software architect. Given a snapshot of a project (package manifest, README ' +
37
+ 'and file structure), produce a concise, well-structured Markdown file of coding guidelines and ' +
38
+ 'project conventions (stack, architecture, naming, testing, error handling) meant to be read by ' +
39
+ 'an AI coding assistant before making changes. Be specific to this project and avoid generic ' +
40
+ 'advice. Output only the Markdown content, with no surrounding commentary.';
41
+
42
+ interface GuidelinesBlock {
43
+ fileName: string;
44
+ content: string;
45
+ }
46
+
47
+ /**
48
+ * @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).
54
+ */
55
+ export class AIContextManager {
56
+ private fs: FileSystemManager;
57
+ private ai: AIVendorManager;
58
+ private logger: Logger;
59
+
60
+ constructor(fs: FileSystemManager, ai: AIVendorManager, logger: Logger) {
61
+ this.fs = fs;
62
+ this.ai = ai;
63
+ this.logger = logger;
64
+ }
65
+
66
+ /**
67
+ * @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.
72
+ */
73
+ public findContextFiles(dir: string): string[] {
74
+ return CONTEXT_FILENAMES
75
+ .map(name => path.join(dir, name))
76
+ .filter(fullPath => this.fs.exists(fullPath));
77
+ }
78
+
79
+ private async readAndValidate(filePath: string): Promise<string | null> {
80
+ const content = await this.fs.read(filePath);
81
+ if (!content || !content.trim()) return null;
82
+ return content.trim();
83
+ }
84
+
85
+ /**
86
+ * @method buildPackageJsonSummary
87
+ * @description Parsea package.json de forma segura (try/catch) y vuelve a serializar
88
+ * únicamente los campos relevantes para el contexto arquitectónico. A diferencia de un
89
+ * `.slice()` a ciegas, nunca entrega al vendor de IA un JSON truncado y sintácticamente roto.
90
+ * @param {string} dir - Ruta absoluta a la raíz del proyecto.
91
+ * @returns {Promise<string | null>} Sección Markdown lista para el snapshot, o null si no
92
+ * hay package.json legible o válido.
93
+ */
94
+ private async buildPackageJsonSummary(dir: string): Promise<string | null> {
95
+ const pkgPath = path.join(dir, 'package.json');
96
+ if (!this.fs.exists(pkgPath)) return null;
97
+
98
+ const raw = await this.fs.read(pkgPath);
99
+ if (!raw) return null;
100
+
101
+ try {
102
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
103
+ const summary: Record<string, unknown> = {};
104
+ for (const field of PACKAGE_JSON_FIELDS) {
105
+ if (parsed[field] !== undefined) summary[field] = parsed[field];
106
+ }
107
+ return `# package.json (summary)\n\`\`\`json\n${JSON.stringify(summary, null, 2)}\n\`\`\``;
108
+ } catch {
109
+ this.logger.info(`package.json en ${pkgPath} no es JSON válido, se omite del snapshot.`);
110
+ return null;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * @method scanDirectoryTree
116
+ * @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.
120
+ * @param {string} rootDir - Ruta absoluta desde la que empezar a escanear.
121
+ * @param {number} maxDepth - Profundidad máxima de recursión.
122
+ * @param {number} maxEntriesPerDir - Máximo de entradas listadas por carpeta antes de truncar.
123
+ * @param {Set<string>} ignoredDirs - Nombres de directorios a omitir por completo.
124
+ * @returns {Promise<string>} Representación en texto indentado del árbol.
125
+ */
126
+ private async scanDirectoryTree(
127
+ rootDir: string,
128
+ maxDepth: number = SNAPSHOT_TREE_MAX_DEPTH,
129
+ maxEntriesPerDir: number = SNAPSHOT_TREE_MAX_ENTRIES_PER_DIR,
130
+ ignoredDirs: Set<string> = DEFAULT_IGNORED_DIRS
131
+ ): Promise<string> {
132
+ const lines: string[] = [path.basename(rootDir) || rootDir];
133
+
134
+ const walk = async (currentDir: string, depth: number, prefix: string): Promise<void> => {
135
+ if (depth > maxDepth) return;
136
+
137
+ let entries;
138
+ try {
139
+ entries = await this.fs.readdir(currentDir, { withFileTypes: true });
140
+ } catch {
141
+ // Errores de permisos o carpetas borradas a mitad de escaneo no deben abortar
142
+ // todo el snapshot, simplemente se omiten.
143
+ return;
144
+ }
145
+
146
+ entries = entries
147
+ .filter(entry => !(entry.isDirectory() && ignoredDirs.has(entry.name)))
148
+ .sort((a, b) => {
149
+ if (a.isDirectory() !== b.isDirectory()) return a.isDirectory() ? -1 : 1;
150
+ return a.name.localeCompare(b.name);
151
+ });
152
+
153
+ const visible = entries.slice(0, maxEntriesPerDir);
154
+ const hiddenCount = entries.length - visible.length;
155
+
156
+ for (const entry of visible) {
157
+ lines.push(`${prefix}${entry.name}${entry.isDirectory() ? '/' : ''}`);
158
+ if (entry.isDirectory()) {
159
+ await walk(path.join(currentDir, entry.name), depth + 1, `${prefix} `);
160
+ }
161
+ }
162
+
163
+ if (hiddenCount > 0) {
164
+ lines.push(`${prefix}… y ${hiddenCount} elemento(s) más`);
165
+ }
166
+ };
167
+
168
+ await walk(rootDir, 1, ' ');
169
+ return lines.join('\n');
170
+ }
171
+
172
+ /**
173
+ * @method buildProjectSnapshot
174
+ * @description Ensambla el material que necesita el vendor de IA para escribir directrices:
175
+ * un resumen seguro de package.json, el README y un árbol de directorios nativo. Sin
176
+ * subprocesos de shell.
177
+ */
178
+ private async buildProjectSnapshot(dir: string): Promise<string> {
179
+ const parts: string[] = [];
180
+
181
+ const pkgSummary = await this.buildPackageJsonSummary(dir);
182
+ if (pkgSummary) parts.push(pkgSummary);
183
+
184
+ const readmePath = path.join(dir, 'README.md');
185
+ if (this.fs.exists(readmePath)) {
186
+ const readme = await this.fs.read(readmePath);
187
+ // El README es texto libre, no estructurado: aquí el truncado por longitud sí es seguro.
188
+ if (readme) parts.push(`# README.md\n${readme.slice(0, SNAPSHOT_SECTION_MAX_CHARS)}`);
189
+ }
190
+
191
+ const tree = await this.scanDirectoryTree(dir).catch(() => '');
192
+ if (tree) parts.push(`# Project structure\n${tree.slice(0, SNAPSHOT_SECTION_MAX_CHARS)}`);
193
+
194
+ return parts.join('\n\n');
195
+ }
196
+
197
+ /**
198
+ * @method generateContextFile
199
+ * @description Escanea el proyecto y le pide al vendor de IA configurado que sintetice un
200
+ * archivo de directrices, luego lo escribe como CLAUDE.md en la raíz del proyecto.
201
+ * @param {string} dir - Ruta absoluta a la raíz del proyecto.
202
+ * @returns {Promise<string>} Ruta absoluta del archivo generado.
203
+ */
204
+ public async generateContextFile(dir: string): Promise<string> {
205
+ this.logger.info('No se encontró archivo de contexto. Analizando el proyecto para generar uno...');
206
+
207
+ const snapshot = await this.buildProjectSnapshot(dir);
208
+ const messages: AIMessage[] = [
209
+ { role: 'system', content: GUIDELINES_SYSTEM_PROMPT },
210
+ { role: 'user', content: snapshot || 'The project has no readable package.json, README or file tree.' },
211
+ ];
212
+
213
+ let result;
214
+ try {
215
+ result = await this.ai.complete(messages, { temperature: 0.2 });
216
+ } catch (err) {
217
+ throw new TyrError(
218
+ `No se pudo generar el archivo de contexto para ${dir}`,
219
+ err,
220
+ 'Revisa la configuración de tu vendor de IA y la conectividad de red, luego reintenta.'
221
+ );
222
+ }
223
+
224
+ const targetPath = path.join(dir, GENERATED_FILENAME);
225
+ await this.fs.write(targetPath, result.content.trim() + '\n');
226
+
227
+ this.logger.success(`Archivo de contexto generado en: ${targetPath}`);
228
+ return targetPath;
229
+ }
230
+
231
+ /**
232
+ * @method readGuidelines
233
+ * @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.
239
+ * @param {string} dir - Ruta absoluta a la raíz del proyecto.
240
+ * @returns {Promise<GuidelinesBlock[]>}
241
+ */
242
+ public async readGuidelines(dir: string): Promise<GuidelinesBlock[]> {
243
+ let files = this.findContextFiles(dir);
244
+
245
+ if (files.length === 0) {
246
+ const generated = await this.generateContextFile(dir);
247
+ files = [generated];
248
+ }
249
+
250
+ const blocks: GuidelinesBlock[] = [];
251
+ for (const file of files) {
252
+ const content = await this.readAndValidate(file);
253
+ if (content) blocks.push({ fileName: path.basename(file), content });
254
+ }
255
+
256
+ if (blocks.length === 0) {
257
+ throw new TyrError(
258
+ `Se encontraron archivos de contexto pero están vacíos o son ilegibles en: ${dir}`,
259
+ null,
260
+ 'Revisa el contenido de tu archivo CLAUDE.md/AGENTS.md.'
261
+ );
262
+ }
263
+
264
+ return blocks;
265
+ }
266
+
267
+ /**
268
+ * @method getGuidelinesText
269
+ * @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.
274
+ * @param {string} dir - Ruta absoluta a la raíz del proyecto.
275
+ * @returns {Promise<string>}
276
+ */
277
+ public async getGuidelinesText(dir: string): Promise<string> {
278
+ const blocks = await this.readGuidelines(dir);
279
+ return blocks.map(b => `<!-- ${b.fileName} -->\n${b.content}`).join('\n\n');
280
+ }
281
+
282
+ /**
283
+ * @method getContext
284
+ * @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.
288
+ * @param {string} dir - Ruta absoluta a la raíz del proyecto.
289
+ * @returns {Promise<AIMessage[]>}
290
+ * @example
291
+ * const contextMessages = await context.getContext(process.cwd());
292
+ * const result = await ai.complete([...contextMessages, { role: 'user', content: 'Fix this bug...' }]);
293
+ */
294
+ public async getContext(dir: string): Promise<AIMessage[]> {
295
+ const text = await this.getGuidelinesText(dir);
296
+ return [{ role: 'system', content: text }];
297
+ }
298
+ }
299
+
300
+ export const AIContextManagerTests = {
301
+ findContextFiles: { dir: '~/Projects/TyrFramework' },
302
+ };
@@ -0,0 +1,388 @@
1
+ import axios from 'axios';
2
+
3
+ import { Logger } from '../core/Logger.js';
4
+ import { TyrError } from '../core/TyrError.js';
5
+
6
+ import {getEnvString, getEnvInt, getEnvDouble} from '../core/util/getenv.js';
7
+
8
+ export type AIVendor = 'anthropic' | 'openai' | 'gemini';
9
+ export type AIRole = 'system' | 'user' | 'assistant';
10
+
11
+ export interface AIMessage {
12
+ role: AIRole;
13
+ content: string;
14
+ }
15
+
16
+ export interface AICompletionOptions {
17
+ vendor?: AIVendor;
18
+ model?: string;
19
+ temperature?: number;
20
+ maxTokens?: number;
21
+ maxRetries?: number;
22
+ }
23
+
24
+ export interface AICompletionResult {
25
+ content: string;
26
+ vendor: AIVendor;
27
+ model: string;
28
+ promptTokens?: number;
29
+ completionTokens?: number;
30
+ }
31
+
32
+ interface VendorConfig {
33
+ vendor: AIVendor;
34
+ apiKey: string;
35
+ model: string;
36
+ temperature: number;
37
+ maxTokens: number;
38
+ maxRetries: number;
39
+ }
40
+
41
+ interface VendorRequest {
42
+ url: string;
43
+ headers: Record<string, string>;
44
+ body: any;
45
+ }
46
+
47
+ const DEFAULT_MODELS: Record<AIVendor, string> = {
48
+ anthropic: 'claude-sonnet-5',
49
+ openai: 'gpt-4o-mini',
50
+ gemini: 'gemini-2.5-flash',
51
+ };
52
+
53
+ const API_KEY_ENV: Record<AIVendor, string> = {
54
+ anthropic: 'ANTHROPIC_API_KEY',
55
+ openai: 'OPENAI_API_KEY',
56
+ gemini: 'GEMINI_API_KEY',
57
+ };
58
+
59
+ const DEFAULT_TEMPERATURE = 0.3;
60
+ const DEFAULT_MAX_TOKENS = 4096;
61
+ const DEFAULT_MAX_RETRIES = 3;
62
+ const RETRY_BASE_DELAY_MS = 500;
63
+
64
+ /**
65
+ * @class AIVendorManager
66
+ * @description Unified client for AI chat-completion APIs (Anthropic, OpenAI, Gemini).
67
+ * Resolves the API key and technical defaults (model, temperature, max tokens) from
68
+ * environment variables / Tyr configuration, retries transient failures with exponential
69
+ * backoff, and supports both blocking and streaming responses.
70
+ *
71
+ * Environment variables:
72
+ * AI_VENDOR – 'anthropic' | 'openai' | 'gemini' (default: 'anthropic')
73
+ * ANTHROPIC_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY – API key for the selected vendor
74
+ * AI_MODEL – overrides the vendor's default model
75
+ * AI_TEMPERATURE – overrides the default temperature (0.3)
76
+ * AI_MAX_TOKENS – overrides the default max output tokens (4096)
77
+ * AI_MAX_RETRIES – overrides the default retry count (3)
78
+ */
79
+ export class AIVendorManager {
80
+ private logger: Logger;
81
+
82
+ constructor(logger: Logger) {
83
+ this.logger = logger;
84
+ }
85
+
86
+ private resolveConfig(options?: AICompletionOptions): VendorConfig {
87
+ const vendor = (options?.vendor ?? (getEnvString('AI_VENDOR') as AIVendor | undefined) ?? 'anthropic')
88
+ .toString()
89
+ .toLowerCase() as AIVendor;
90
+
91
+ if (!DEFAULT_MODELS[vendor]) {
92
+ throw new TyrError(
93
+ `Unsupported AI vendor: '${vendor}'`,
94
+ null,
95
+ `Set AI_VENDOR to one of: ${Object.keys(DEFAULT_MODELS).join(', ')}`
96
+ );
97
+ }
98
+
99
+ const apiKey = getEnvString(API_KEY_ENV[vendor]);
100
+ if (!apiKey) {
101
+ throw new TyrError(
102
+ `Missing API key for vendor '${vendor}'`,
103
+ null,
104
+ `Set ${API_KEY_ENV[vendor]} in ~/.tyr/.env`
105
+ );
106
+ }
107
+
108
+ return {
109
+ vendor,
110
+ apiKey,
111
+ model: options?.model ?? getEnvString('AI_MODEL') ?? DEFAULT_MODELS[vendor],
112
+ temperature: options?.temperature ?? getEnvDouble('AI_TEMPERATURE', DEFAULT_TEMPERATURE),
113
+ maxTokens: options?.maxTokens ?? getEnvInt('AI_MAX_TOKENS', DEFAULT_MAX_TOKENS),
114
+ maxRetries: options?.maxRetries ?? getEnvInt('AI_MAX_RETRIES', DEFAULT_MAX_RETRIES),
115
+ };
116
+ }
117
+
118
+ private splitSystem(messages: AIMessage[]): { system: string; turns: AIMessage[] } {
119
+ const system = messages.filter(m => m.role === 'system').map(m => m.content).join('\n\n');
120
+ const turns = messages.filter(m => m.role !== 'system');
121
+ return { system, turns };
122
+ }
123
+
124
+ private buildRequest(config: VendorConfig, messages: AIMessage[], stream: boolean): VendorRequest {
125
+ const { system, turns } = this.splitSystem(messages);
126
+
127
+ switch (config.vendor) {
128
+ case 'anthropic':
129
+ return {
130
+ url: 'https://api.anthropic.com/v1/messages',
131
+ headers: {
132
+ 'x-api-key': config.apiKey,
133
+ 'anthropic-version': '2023-06-01',
134
+ 'content-type': 'application/json',
135
+ },
136
+ body: {
137
+ model: config.model,
138
+ system,
139
+ messages: turns.map(m => ({ role: m.role, content: m.content })),
140
+ temperature: config.temperature,
141
+ max_tokens: config.maxTokens,
142
+ stream,
143
+ },
144
+ };
145
+
146
+ case 'openai':
147
+ return {
148
+ url: 'https://api.openai.com/v1/chat/completions',
149
+ headers: {
150
+ Authorization: `Bearer ${config.apiKey}`,
151
+ 'content-type': 'application/json',
152
+ },
153
+ body: {
154
+ model: config.model,
155
+ messages: [
156
+ ...(system ? [{ role: 'system', content: system }] : []),
157
+ ...turns.map(m => ({ role: m.role, content: m.content })),
158
+ ],
159
+ temperature: config.temperature,
160
+ max_tokens: config.maxTokens,
161
+ stream,
162
+ ...(stream ? { stream_options: { include_usage: true } } : {}),
163
+ },
164
+ };
165
+
166
+ case 'gemini': {
167
+ const action = stream ? 'streamGenerateContent?alt=sse&' : 'generateContent?';
168
+ return {
169
+ url: `https://generativelanguage.googleapis.com/v1beta/models/${config.model}:${action}key=${config.apiKey}`,
170
+ headers: { 'content-type': 'application/json' },
171
+ body: {
172
+ ...(system ? { systemInstruction: { parts: [{ text: system }] } } : {}),
173
+ contents: turns.map(m => ({
174
+ role: m.role === 'assistant' ? 'model' : 'user',
175
+ parts: [{ text: m.content }],
176
+ })),
177
+ generationConfig: {
178
+ temperature: config.temperature,
179
+ maxOutputTokens: config.maxTokens,
180
+ },
181
+ },
182
+ };
183
+ }
184
+
185
+ default:
186
+ throw new TyrError(`Unsupported AI vendor: '${config.vendor}'`);
187
+ }
188
+ }
189
+
190
+ private sleep(ms: number): Promise<void> {
191
+ return new Promise(resolve => setTimeout(resolve, ms));
192
+ }
193
+
194
+ private isRetryable(status: number | undefined): boolean {
195
+ return status === 429 || (status !== undefined && status >= 500);
196
+ }
197
+
198
+ private async withRetries<T>(fn: () => Promise<T>, maxRetries: number): Promise<T> {
199
+ let lastError: unknown;
200
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
201
+ try {
202
+ return await fn();
203
+ } catch (e: any) {
204
+ lastError = e;
205
+ const status = e?.response?.status;
206
+ if (attempt === maxRetries || !this.isRetryable(status)) throw e;
207
+ const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
208
+ this.logger.warn(`AI request failed (status ${status ?? 'unknown'}). Retrying in ${delay}ms...`);
209
+ await this.sleep(delay);
210
+ }
211
+ }
212
+ throw lastError;
213
+ }
214
+
215
+ private parseCompletion(config: VendorConfig, data: any): AICompletionResult {
216
+ switch (config.vendor) {
217
+ case 'anthropic':
218
+ return {
219
+ content: (data.content ?? []).map((b: any) => b.text ?? '').join(''),
220
+ vendor: config.vendor,
221
+ model: config.model,
222
+ promptTokens: data.usage?.input_tokens,
223
+ completionTokens: data.usage?.output_tokens,
224
+ };
225
+ case 'openai':
226
+ return {
227
+ content: data.choices?.[0]?.message?.content ?? '',
228
+ vendor: config.vendor,
229
+ model: config.model,
230
+ promptTokens: data.usage?.prompt_tokens,
231
+ completionTokens: data.usage?.completion_tokens,
232
+ };
233
+ case 'gemini':
234
+ return {
235
+ content: (data.candidates?.[0]?.content?.parts ?? []).map((p: any) => p.text ?? '').join(''),
236
+ vendor: config.vendor,
237
+ model: config.model,
238
+ promptTokens: data.usageMetadata?.promptTokenCount,
239
+ completionTokens: data.usageMetadata?.candidatesTokenCount,
240
+ };
241
+ }
242
+ }
243
+
244
+ /**
245
+ * @method complete
246
+ * @description Sends a chat-completion request and returns the full response once ready.
247
+ * Retries automatically on rate limiting (429) or server errors (5xx).
248
+ * @param {AIMessage[]} messages - Conversation messages ('system', 'user', 'assistant').
249
+ * @param {AICompletionOptions} options - Optional overrides (vendor, model, temperature, maxTokens).
250
+ * @returns {Promise<AICompletionResult>} The generated content plus vendor/model/usage metadata.
251
+ * @example
252
+ * const result = await ai.complete([{ role: 'user', content: 'Explain this bug...' }]);
253
+ * console.log(result.content);
254
+ */
255
+ public async complete(messages: AIMessage[], options?: AICompletionOptions): Promise<AICompletionResult> {
256
+ const config = this.resolveConfig(options);
257
+ const { url, headers, body } = this.buildRequest(config, messages, false);
258
+
259
+ try {
260
+ const response = await this.withRetries(
261
+ () => axios.post(url, body, { headers }),
262
+ config.maxRetries
263
+ );
264
+ return this.parseCompletion(config, response.data);
265
+ } catch (e: any) {
266
+ if (e instanceof TyrError) throw e;
267
+ const status = e?.response?.status;
268
+ throw new TyrError(
269
+ `AI request to '${config.vendor}' failed (${status ?? 'network error'})`,
270
+ e,
271
+ 'Check your API key, network connection, and the vendor status page.'
272
+ );
273
+ }
274
+ }
275
+
276
+ /**
277
+ * @method stream
278
+ * @description Sends a chat-completion request and streams the response as it is generated.
279
+ * Retries are only applied before any data has been received; once streaming has started,
280
+ * failures are surfaced immediately to avoid emitting duplicated content.
281
+ * @param {AIMessage[]} messages - Conversation messages ('system', 'user', 'assistant').
282
+ * @param {(chunk: string) => void} onChunk - Called with each incremental text fragment.
283
+ * @param {AICompletionOptions} options - Optional overrides (vendor, model, temperature, maxTokens).
284
+ * @returns {Promise<AICompletionResult>} The full accumulated content plus vendor/model/usage metadata.
285
+ * @example
286
+ * const result = await ai.stream(messages, (chunk) => process.stdout.write(chunk));
287
+ */
288
+ public async stream(
289
+ messages: AIMessage[],
290
+ onChunk: (chunk: string) => void,
291
+ options?: AICompletionOptions
292
+ ): Promise<AICompletionResult> {
293
+ const config = this.resolveConfig(options);
294
+ const { url, headers, body } = this.buildRequest(config, messages, true);
295
+
296
+ let content = '';
297
+ let promptTokens: number | undefined;
298
+ let completionTokens: number | undefined;
299
+ let hasStreamedAny = false;
300
+
301
+ const attemptStream = async (): Promise<void> => {
302
+ const response = await axios.post(url, body, { headers, responseType: 'stream' });
303
+ let buffer = '';
304
+
305
+ for await (const chunk of response.data) {
306
+ hasStreamedAny = true;
307
+ buffer += chunk.toString('utf-8');
308
+
309
+ let boundary: number;
310
+ while ((boundary = buffer.indexOf('\n\n')) !== -1) {
311
+ const rawEvent = buffer.slice(0, boundary);
312
+ buffer = buffer.slice(boundary + 2);
313
+
314
+ for (const line of rawEvent.split('\n')) {
315
+ const trimmed = line.trim();
316
+ if (!trimmed.startsWith('data:')) continue;
317
+
318
+ const payload = trimmed.slice(5).trim();
319
+ if (payload === '[DONE]') continue;
320
+
321
+ let event: any;
322
+ try {
323
+ event = JSON.parse(payload);
324
+ } catch {
325
+ continue;
326
+ }
327
+
328
+ if (config.vendor === 'anthropic') {
329
+ if (event.type === 'content_block_delta' && event.delta?.text) {
330
+ content += event.delta.text;
331
+ onChunk(event.delta.text);
332
+ } else if (event.type === 'message_start') {
333
+ promptTokens = event.message?.usage?.input_tokens;
334
+ } else if (event.type === 'message_delta') {
335
+ completionTokens = event.usage?.output_tokens;
336
+ }
337
+ } else if (config.vendor === 'openai') {
338
+ const delta = event.choices?.[0]?.delta?.content;
339
+ if (delta) {
340
+ content += delta;
341
+ onChunk(delta);
342
+ }
343
+ if (event.usage) {
344
+ promptTokens = event.usage.prompt_tokens;
345
+ completionTokens = event.usage.completion_tokens;
346
+ }
347
+ } else if (config.vendor === 'gemini') {
348
+ const text = event.candidates?.[0]?.content?.parts?.[0]?.text;
349
+ if (text) {
350
+ content += text;
351
+ onChunk(text);
352
+ }
353
+ if (event.usageMetadata) {
354
+ promptTokens = event.usageMetadata.promptTokenCount;
355
+ completionTokens = event.usageMetadata.candidatesTokenCount;
356
+ }
357
+ }
358
+ }
359
+ }
360
+ }
361
+ };
362
+
363
+ let attempt = 0;
364
+ for (;;) {
365
+ try {
366
+ await attemptStream();
367
+ break;
368
+ } catch (e: any) {
369
+ const status = e?.response?.status;
370
+ if (hasStreamedAny || attempt >= config.maxRetries || !this.isRetryable(status)) {
371
+ throw new TyrError(
372
+ `AI streaming request to '${config.vendor}' failed (${status ?? 'network error'})`,
373
+ e,
374
+ 'Check your API key, network connection, and the vendor status page.'
375
+ );
376
+ }
377
+ attempt++;
378
+ const delay = RETRY_BASE_DELAY_MS * 2 ** attempt;
379
+ this.logger.warn(`AI stream request failed (status ${status ?? 'unknown'}). Retrying in ${delay}ms...`);
380
+ await this.sleep(delay);
381
+ }
382
+ }
383
+
384
+ return { content, vendor: config.vendor, model: config.model, promptTokens, completionTokens };
385
+ }
386
+ }
387
+
388
+ export const AIVendorManagerTests = {};
@@ -1,5 +1,5 @@
1
1
  import fs from 'fs/promises';
2
- import { existsSync } from 'fs';
2
+ import { existsSync, Dirent } from 'fs';
3
3
  import { homedir } from 'os';
4
4
  import path from 'path';
5
5
 
@@ -29,7 +29,7 @@ export class FileSystemManager {
29
29
  * @param {string} filePath - The path to expand.
30
30
  * @returns {string} The expanded absolute path.
31
31
  * @example
32
- * const dir = fs.expandPath(process.env.INTEGRATIONS_DIR!);
32
+ * const dir = fs.expandPath(getEnvString('INTEGRATIONS_DIR'));
33
33
  * // "~/dev/datosBroker" → "/Users/mandreu/dev/datosBroker"
34
34
  */
35
35
  public expandPath(filePath: string): string {
@@ -76,6 +76,26 @@ export class FileSystemManager {
76
76
  }
77
77
  }
78
78
 
79
+ /**
80
+ * @method readdir
81
+ * @description Reads the content of a directory. Returns null if the directory does not exist.
82
+ * @param {string} dirPath - Path to the directory.
83
+ * @returns {Promise<string[]|null>} List of entries or null if it does not exist.
84
+ * @example
85
+ * const entries = await fs.readdir('./src');
86
+ */
87
+ public async readdir(dirPath: string, options: { withFileTypes: true } & Record<string, any>): Promise<Dirent[]>;
88
+ public async readdir(dirPath: string, options?: ({ withFileTypes?: false } & Record<string, any>) | undefined): Promise<string[]>;
89
+ public async readdir(dirPath: string, options?: any): Promise<string[] | Dirent[]> {
90
+ const resolvedPath = this.resolvePath(dirPath);
91
+ try {
92
+ return await fs.readdir(resolvedPath, options);
93
+ } catch (e) {
94
+ if ((e as NodeJS.ErrnoException).code === 'ENOENT') return [];
95
+ throw new TyrError(`Could not read directory: ${dirPath}`, e, 'Check that the directory exists and has read permissions.');
96
+ }
97
+ }
98
+
79
99
  /**
80
100
  * @method delete
81
101
  * @description Deletes a file if it exists.
@@ -2,6 +2,9 @@ import { WebManager } from './WebManager.js';
2
2
  import { ShellManager } from './ShellManager.js';
3
3
  import { Logger } from '../core/Logger.js';
4
4
 
5
+ import {getEnvString} from '../core/util/getenv.js';
6
+
7
+
5
8
  interface JiraIssue {
6
9
  key: string;
7
10
  summary: string;
@@ -29,11 +32,11 @@ export class JiraManager {
29
32
  }
30
33
 
31
34
  private get jiraUrl(): string | undefined {
32
- return process.env.JIRA_URL;
35
+ return getEnvString('JIRA_URL');
33
36
  }
34
37
 
35
38
  private get jiraToken(): string | undefined {
36
- return process.env.JIRA_TOKEN;
39
+ return getEnvString('JIRA_TOKEN');
37
40
  }
38
41
 
39
42
  private async fetchMyIssues(): Promise<JiraIssue[]> {
@@ -1,4 +1,5 @@
1
1
  import { MongoClient, Db, Document, Filter, UpdateFilter, InsertOneResult, InsertManyResult, UpdateResult, DeleteResult, WithId, OptionalUnlessRequiredId, FindOptions } from 'mongodb';
2
+ import { getEnvString } from '../core/util/getenv.js';
2
3
 
3
4
  /**
4
5
  * @class MongoManager
@@ -13,8 +14,8 @@ export class MongoManager {
13
14
 
14
15
  private async init(): Promise<void> {
15
16
  if (!this.connected) {
16
- const uri = process.env.MONGO_URI || 'mongodb://localhost:27017';
17
- const dbName = process.env.MONGO_DATABASE || '';
17
+ const uri = getEnvString('MONGO_URI') || 'mongodb://localhost:27017';
18
+ const dbName = getEnvString('MONGO_DATABASE') || '';
18
19
 
19
20
  this.client = new MongoClient(uri);
20
21
  await this.client.connect();
@@ -0,0 +1,135 @@
1
+ import { AIContextManager } from './AIContextManager.js';
2
+ import { AIMessage } from './AIVendorManager.js';
3
+ import { Logger } from '../core/Logger.js';
4
+ import { TyrError } from '../core/TyrError.js';
5
+
6
+ export interface PromptTemplate {
7
+ system: string;
8
+ user: string;
9
+ }
10
+
11
+ const DEFAULT_TEMPLATES: Record<string, PromptTemplate> = {
12
+ 'analyze-bug': {
13
+ system:
14
+ 'You are a senior software engineer specialised in debugging. Analyse the given code and ' +
15
+ 'describe the root cause of the bug, then propose a minimal, correct fix. Be precise and ' +
16
+ 'reference exact lines when possible.',
17
+ user: 'Bug description:\n{{description}}\n\nRelevant code:\n```\n{{code}}\n```',
18
+ },
19
+ 'generate-command': {
20
+ system:
21
+ 'You are a senior TypeScript engineer generating a Tyr Framework command. Follow the ' +
22
+ 'existing conventions exactly: dependency injection via TyrContext, TyrError for failures, ' +
23
+ 'JSDoc comments on public methods. Output only the TypeScript code for the file, no commentary.',
24
+ user: 'Command name: {{name}}\nRequested behaviour:\n{{description}}',
25
+ },
26
+ 'explain-code': {
27
+ system:
28
+ 'You are a senior software engineer. Explain what the given code does clearly and ' +
29
+ 'concisely, for a developer unfamiliar with it.',
30
+ user: 'Code:\n```\n{{code}}\n```',
31
+ },
32
+ 'refactor-code': {
33
+ system:
34
+ 'You are a senior software engineer. Refactor the given code for clarity and ' +
35
+ 'maintainability without changing its behaviour. Explain the key changes briefly, then ' +
36
+ 'provide the full refactored code.',
37
+ user: 'Code:\n```\n{{code}}\n```\n\nGoal:\n{{goal}}',
38
+ },
39
+ };
40
+
41
+ /**
42
+ * @class PromptTemplateManager
43
+ * @description Manages the system's prompt templates. Exposes a base template for common tasks
44
+ * (analysing a bug, generating a command, explaining or refactoring code) and fills in the
45
+ * placeholders with the user's code and the project context, so the AI always receives its role
46
+ * instructions in a uniform way.
47
+ */
48
+ export class PromptTemplateManager {
49
+ private context: AIContextManager;
50
+ private logger: Logger;
51
+ private templates: Record<string, PromptTemplate>;
52
+
53
+ constructor(context: AIContextManager, logger: Logger) {
54
+ this.context = context;
55
+ this.logger = logger;
56
+ this.templates = { ...DEFAULT_TEMPLATES };
57
+ }
58
+
59
+ /**
60
+ * @method listTemplates
61
+ * @description Lists the names of all registered prompt templates.
62
+ * @returns {string[]} Template names.
63
+ * @example
64
+ * const names = prompts.listTemplates();
65
+ * // ['analyze-bug', 'generate-command', 'explain-code', 'refactor-code']
66
+ */
67
+ public listTemplates(): string[] {
68
+ return Object.keys(this.templates);
69
+ }
70
+
71
+ /**
72
+ * @method registerTemplate
73
+ * @description Registers a new prompt template, or overrides an existing one.
74
+ * @param {string} name - Unique template name.
75
+ * @param {PromptTemplate} template - The system role instructions and the user template string.
76
+ * @example
77
+ * prompts.registerTemplate('write-tests', {
78
+ * system: 'You are a senior QA engineer...',
79
+ * user: 'Code:\n```\n{{code}}\n```',
80
+ * });
81
+ */
82
+ public registerTemplate(name: string, template: PromptTemplate): void {
83
+ this.templates[name] = template;
84
+ }
85
+
86
+ private fill(template: string, vars: Record<string, string>): string {
87
+ return template.replace(/{{\s*(\w+)\s*}}/g, (_match, key: string) => {
88
+ if (!(key in vars)) {
89
+ throw new TyrError(
90
+ `Missing placeholder value: '${key}'`,
91
+ null,
92
+ `Provide a value for '${key}' when building this prompt.`
93
+ );
94
+ }
95
+ return vars[key];
96
+ });
97
+ }
98
+
99
+ /**
100
+ * @method build
101
+ * @description Fills a named template with the given variables and prepends the project's
102
+ * context (see AIContextManager), producing the full list of messages ready to send to
103
+ * AIVendorManager.
104
+ * @param {string} templateName - Name of a registered template (see listTemplates()).
105
+ * @param {Record<string,string>} vars - Values for the template's {{placeholders}}.
106
+ * @param {string} projectDir - Absolute path to the project root, used to load its context.
107
+ * @returns {Promise<AIMessage[]>} Ordered messages: [system, ...context, user].
108
+ * @example
109
+ * const messages = await prompts.build('analyze-bug', {
110
+ * description: 'Login fails with a 500 error',
111
+ * code: fileContent,
112
+ * }, process.cwd());
113
+ * const result = await ai.complete(messages);
114
+ */
115
+ public async build(templateName: string, vars: Record<string, string>, projectDir: string): Promise<AIMessage[]> {
116
+ const template = this.templates[templateName];
117
+ if (!template) {
118
+ throw new TyrError(
119
+ `Unknown prompt template: '${templateName}'`,
120
+ null,
121
+ `Available templates: ${this.listTemplates().join(', ')}`
122
+ );
123
+ }
124
+
125
+ const systemMessage: AIMessage = { role: 'system', content: template.system };
126
+ const userMessage: AIMessage = { role: 'user', content: this.fill(template.user, vars) };
127
+ const contextMessages = await this.context.getContext(projectDir);
128
+
129
+ return [systemMessage, ...contextMessages, userMessage];
130
+ }
131
+ }
132
+
133
+ export const PromptTemplateManagerTests = {
134
+ listTemplates: {},
135
+ };
@@ -1,5 +1,6 @@
1
1
  import path from 'path';
2
2
  import sql, { config as SQLConfig } from 'mssql';
3
+ import { getEnvString } from '../core/util/getenv.js';
3
4
 
4
5
  /**
5
6
  * @class SQLManager
@@ -16,10 +17,10 @@ export class SQLManager {
16
17
  if (!this.connected) {
17
18
 
18
19
  const db_config: SQLConfig = {
19
- user: process.env.MSSQL_USER,
20
- password: process.env.MSSQL_PASSWORD,
21
- server: process.env.MSSQL_SERVER || '',
22
- database: process.env.MSSQL_DATABASE,
20
+ user: getEnvString('MSSQL_USER'),
21
+ password: getEnvString('MSSQL_PASSWORD'),
22
+ server: getEnvString('MSSQL_SERVER') || '',
23
+ database: getEnvString('MSSQL_DATABASE'),
23
24
  options: {
24
25
  encrypt: false,
25
26
  trustServerCertificate: true
@@ -0,0 +1,169 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ import { AIMessage } from './AIVendorManager.js';
6
+ import { Logger } from '../core/Logger.js';
7
+ import { TyrError } from '../core/TyrError.js';
8
+ import { getEnvInt } from '../core/util/getenv.js';
9
+
10
+ const DEFAULT_MAX_INPUT_TOKENS = getEnvInt('DEFAULT_MAX_INPUT_TOKENS', 100000);
11
+ const CHARS_PER_TOKEN = 4;
12
+ const PER_MESSAGE_OVERHEAD_TOKENS = 4;
13
+
14
+ interface UsageEntry {
15
+ timestamp: string;
16
+ vendor: string;
17
+ model: string;
18
+ promptTokens: number;
19
+ completionTokens: number;
20
+ }
21
+
22
+ export interface UsageStats {
23
+ totalCalls: number;
24
+ totalPromptTokens: number;
25
+ totalCompletionTokens: number;
26
+ byModel: Record<string, { calls: number; promptTokens: number; completionTokens: number }>;
27
+ }
28
+
29
+ /**
30
+ * @class TokenManager
31
+ * @description Controls AI token consumption and limits. Estimates the token cost of a prompt
32
+ * before it is sent (to avoid oversized requests or runaway costs), records real usage once a
33
+ * request completes, and reports aggregated statistics. The estimate is a fast heuristic
34
+ * (~4 characters per token), not an exact vendor tokenizer, so it should be treated as an
35
+ * upper-bound guard rather than a billing-accurate figure.
36
+ */
37
+ export class TokenManager {
38
+ private logger: Logger;
39
+ private usageFile: string;
40
+
41
+ constructor(logger: Logger) {
42
+ this.logger = logger;
43
+ this.usageFile = path.join(homedir(), '.tyr', 'logs', 'ai-usage.jsonl');
44
+ }
45
+
46
+ /**
47
+ * @method estimateTokens
48
+ * @description Estimates the number of tokens a piece of text will consume.
49
+ * @param {string} text - The text to estimate.
50
+ * @returns {number} Estimated token count.
51
+ * @example
52
+ * const tokens = tokens_.estimateTokens(fileContent);
53
+ */
54
+ public estimateTokens(text: string): number {
55
+ if (!text) return 0;
56
+ return Math.ceil(text.length / CHARS_PER_TOKEN);
57
+ }
58
+
59
+ /**
60
+ * @method estimateMessagesTokens
61
+ * @description Estimates the total token cost of a full list of prompt messages.
62
+ * @param {AIMessage[]} messages - Messages that would be sent to AIVendorManager.
63
+ * @returns {number} Estimated total token count, including a small per-message overhead.
64
+ * @example
65
+ * const total = tokens_.estimateMessagesTokens(messages);
66
+ */
67
+ public estimateMessagesTokens(messages: AIMessage[]): number {
68
+ return messages.reduce(
69
+ (total, m) => total + this.estimateTokens(m.content) + PER_MESSAGE_OVERHEAD_TOKENS,
70
+ 0
71
+ );
72
+ }
73
+
74
+ /**
75
+ * @method assertWithinLimit
76
+ * @description Stops execution with an error if the estimated prompt size exceeds the given
77
+ * limit (or the default of 100,000 tokens). Call this before sending a prompt to AIVendorManager.
78
+ * @param {AIMessage[]} messages - Messages that would be sent to AIVendorManager.
79
+ * @param {number} limit - Maximum allowed estimated tokens (default: 100000).
80
+ * @example
81
+ * tokens_.assertWithinLimit(messages);
82
+ * const result = await ai.complete(messages);
83
+ */
84
+ public assertWithinLimit(messages: AIMessage[], limit: number = DEFAULT_MAX_INPUT_TOKENS): void {
85
+ const estimated = this.estimateMessagesTokens(messages);
86
+ if (estimated > limit) {
87
+ throw new TyrError(
88
+ `Estimated prompt size (${estimated} tokens) exceeds the limit (${limit} tokens).`,
89
+ null,
90
+ 'Provide a smaller context (fewer or shorter files) or split the task into smaller steps.'
91
+ );
92
+ }
93
+ }
94
+
95
+ /**
96
+ * @method recordUsage
97
+ * @description Appends a usage record for a completed AI request, for later reporting.
98
+ * @param {string} vendor - Vendor name used (e.g. 'anthropic').
99
+ * @param {string} model - Model name used.
100
+ * @param {number} promptTokens - Prompt tokens consumed (from the vendor's response, if available).
101
+ * @param {number} completionTokens - Completion tokens consumed (from the vendor's response, if available).
102
+ * @example
103
+ * const result = await ai.complete(messages);
104
+ * tokens_.recordUsage(result.vendor, result.model, result.promptTokens ?? 0, result.completionTokens ?? 0);
105
+ */
106
+ public recordUsage(vendor: string, model: string, promptTokens: number, completionTokens: number): void {
107
+ const entry: UsageEntry = {
108
+ timestamp: new Date().toISOString(),
109
+ vendor,
110
+ model,
111
+ promptTokens,
112
+ completionTokens,
113
+ };
114
+ try {
115
+ fs.mkdirSync(path.dirname(this.usageFile), { recursive: true });
116
+ fs.appendFileSync(this.usageFile, JSON.stringify(entry) + '\n', 'utf-8');
117
+ } catch (e) {
118
+ this.logger.warn(`Could not record AI token usage: ${(e as Error).message}`);
119
+ }
120
+ }
121
+
122
+ /**
123
+ * @method getUsageStats
124
+ * @description Reads the recorded usage log and returns aggregated statistics, overall and
125
+ * broken down by vendor/model.
126
+ * @returns {Promise<UsageStats>} Aggregated usage statistics.
127
+ * @example
128
+ * const stats = await tokens_.getUsageStats();
129
+ * logger.info(`Total tokens used: ${stats.totalPromptTokens + stats.totalCompletionTokens}`);
130
+ */
131
+ public async getUsageStats(): Promise<UsageStats> {
132
+ const stats: UsageStats = {
133
+ totalCalls: 0,
134
+ totalPromptTokens: 0,
135
+ totalCompletionTokens: 0,
136
+ byModel: {},
137
+ };
138
+
139
+ if (!fs.existsSync(this.usageFile)) return stats;
140
+
141
+ const lines = fs.readFileSync(this.usageFile, 'utf-8').split('\n').filter(Boolean);
142
+ for (const line of lines) {
143
+ try {
144
+ const entry: UsageEntry = JSON.parse(line);
145
+ stats.totalCalls += 1;
146
+ stats.totalPromptTokens += entry.promptTokens;
147
+ stats.totalCompletionTokens += entry.completionTokens;
148
+
149
+ const key = `${entry.vendor}/${entry.model}`;
150
+ if (!stats.byModel[key]) {
151
+ stats.byModel[key] = { calls: 0, promptTokens: 0, completionTokens: 0 };
152
+ }
153
+ stats.byModel[key].calls += 1;
154
+ stats.byModel[key].promptTokens += entry.promptTokens;
155
+ stats.byModel[key].completionTokens += entry.completionTokens;
156
+ } catch {
157
+ // Skip malformed lines rather than failing the whole report.
158
+ }
159
+ }
160
+
161
+ return stats;
162
+ }
163
+ }
164
+
165
+ export const TokenManagerTests = {
166
+ estimateTokens: { text: 'Hello world, this is a test string for token estimation.' },
167
+ estimateMessagesTokens: { messages: [{ role: 'user', content: 'Hello world' }] },
168
+ assertWithinLimit: { messages: [{ role: 'user', content: 'Hello world' }] },
169
+ };