@orxataguy/tyr 1.0.29 → 1.0.31

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.
@@ -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,142 @@
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
+ 'generate-code': {
27
+ system:
28
+ 'You are a senior engineer. Follow the ' +
29
+ 'existing conventions exactly: KISS, SOLID, DRY. Only make simple comments on the functions to give context if is necessary ' +
30
+ 'Output only the code for the file, no commentary.',
31
+ user: 'Command name: {{name}}\nRequested behaviour:\n{{description}}',
32
+ },
33
+ 'explain-code': {
34
+ system:
35
+ 'You are a senior software engineer. Explain what the given code does clearly and ' +
36
+ 'concisely, for a developer unfamiliar with it.',
37
+ user: 'Code:\n```\n{{code}}\n```',
38
+ },
39
+ 'refactor-code': {
40
+ system:
41
+ 'You are a senior software engineer. Refactor the given code for clarity and ' +
42
+ 'maintainability without changing its behaviour. Explain the key changes briefly, then ' +
43
+ 'provide the full refactored code.',
44
+ user: 'Code:\n```\n{{code}}\n```\n\nGoal:\n{{goal}}',
45
+ },
46
+ };
47
+
48
+ /**
49
+ * @class PromptTemplateManager
50
+ * @description Manages the system's prompt templates. Exposes a base template for common tasks
51
+ * (analysing a bug, generating a command, explaining or refactoring code) and fills in the
52
+ * placeholders with the user's code and the project context, so the AI always receives its role
53
+ * instructions in a uniform way.
54
+ */
55
+ export class PromptTemplateManager {
56
+ private context: AIContextManager;
57
+ private logger: Logger;
58
+ private templates: Record<string, PromptTemplate>;
59
+
60
+ constructor(context: AIContextManager, logger: Logger) {
61
+ this.context = context;
62
+ this.logger = logger;
63
+ this.templates = { ...DEFAULT_TEMPLATES };
64
+ }
65
+
66
+ /**
67
+ * @method listTemplates
68
+ * @description Lists the names of all registered prompt templates.
69
+ * @returns {string[]} Template names.
70
+ * @example
71
+ * const names = prompts.listTemplates();
72
+ * // ['analyze-bug', 'generate-command', 'explain-code', 'refactor-code']
73
+ */
74
+ public listTemplates(): string[] {
75
+ return Object.keys(this.templates);
76
+ }
77
+
78
+ /**
79
+ * @method registerTemplate
80
+ * @description Registers a new prompt template, or overrides an existing one.
81
+ * @param {string} name - Unique template name.
82
+ * @param {PromptTemplate} template - The system role instructions and the user template string.
83
+ * @example
84
+ * prompts.registerTemplate('write-tests', {
85
+ * system: 'You are a senior QA engineer...',
86
+ * user: 'Code:\n```\n{{code}}\n```',
87
+ * });
88
+ */
89
+ public registerTemplate(name: string, template: PromptTemplate): void {
90
+ this.templates[name] = template;
91
+ }
92
+
93
+ private fill(template: string, vars: Record<string, string>): string {
94
+ return template.replace(/{{\s*(\w+)\s*}}/g, (_match, key: string) => {
95
+ if (!(key in vars)) {
96
+ throw new TyrError(
97
+ `Missing placeholder value: '${key}'`,
98
+ null,
99
+ `Provide a value for '${key}' when building this prompt.`
100
+ );
101
+ }
102
+ return vars[key];
103
+ });
104
+ }
105
+
106
+ /**
107
+ * @method build
108
+ * @description Fills a named template with the given variables and prepends the project's
109
+ * context (see AIContextManager), producing the full list of messages ready to send to
110
+ * AIVendorManager.
111
+ * @param {string} templateName - Name of a registered template (see listTemplates()).
112
+ * @param {Record<string,string>} vars - Values for the template's {{placeholders}}.
113
+ * @param {string} projectDir - Absolute path to the project root, used to load its context.
114
+ * @returns {Promise<AIMessage[]>} Ordered messages: [system, ...context, user].
115
+ * @example
116
+ * const messages = await prompts.build('analyze-bug', {
117
+ * description: 'Login fails with a 500 error',
118
+ * code: fileContent,
119
+ * }, process.cwd());
120
+ * const result = await ai.complete(messages);
121
+ */
122
+ public async build(templateName: string, vars: Record<string, string>, projectDir: string): Promise<AIMessage[]> {
123
+ const template = this.templates[templateName];
124
+ if (!template) {
125
+ throw new TyrError(
126
+ `Unknown prompt template: '${templateName}'`,
127
+ null,
128
+ `Available templates: ${this.listTemplates().join(', ')}`
129
+ );
130
+ }
131
+
132
+ const systemMessage: AIMessage = { role: 'system', content: template.system };
133
+ const userMessage: AIMessage = { role: 'user', content: this.fill(template.user, vars) };
134
+ const contextMessages = await this.context.getContext(projectDir);
135
+
136
+ return [systemMessage, ...contextMessages, userMessage];
137
+ }
138
+ }
139
+
140
+ export const PromptTemplateManagerTests = {
141
+ listTemplates: {},
142
+ };
@@ -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
+ };