@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 +1 -1
- package/src/core/Container.ts +14 -0
- package/src/core/sys/config.ts +22 -15
- package/src/core/util/getenv.ts +57 -0
- package/src/lib/AIContextManager.ts +302 -0
- package/src/lib/AIVendorManager.ts +388 -0
- package/src/lib/FileSystemManager.ts +22 -2
- package/src/lib/JiraManager.ts +5 -2
- package/src/lib/MongoManager.ts +3 -2
- package/src/lib/PromptTemplateManager.ts +135 -0
- package/src/lib/SQLManager.ts +5 -4
- package/src/lib/TokenManager.ts +169 -0
package/package.json
CHANGED
package/src/core/Container.ts
CHANGED
|
@@ -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
|
|
package/src/core/sys/config.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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(`\
|
|
80
|
-
logger.warn('
|
|
81
|
-
logger.info('
|
|
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('
|
|
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
|
|
180
|
-
logger.info(`
|
|
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
|
|
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 (
|
|
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(`
|
|
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(`
|
|
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('\
|
|
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(
|
|
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.
|
package/src/lib/JiraManager.ts
CHANGED
|
@@ -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
|
|
35
|
+
return getEnvString('JIRA_URL');
|
|
33
36
|
}
|
|
34
37
|
|
|
35
38
|
private get jiraToken(): string | undefined {
|
|
36
|
-
return
|
|
39
|
+
return getEnvString('JIRA_TOKEN');
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
private async fetchMyIssues(): Promise<JiraIssue[]> {
|
package/src/lib/MongoManager.ts
CHANGED
|
@@ -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 =
|
|
17
|
-
const dbName =
|
|
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
|
+
};
|
package/src/lib/SQLManager.ts
CHANGED
|
@@ -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:
|
|
20
|
-
password:
|
|
21
|
-
server:
|
|
22
|
-
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
|
+
};
|