token-guard 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/display.ts ADDED
@@ -0,0 +1,160 @@
1
+ import type { UsageStats, TokenGuardOptions } from './types.js';
2
+ import { formatTokens } from './tokenizer.js';
3
+
4
+ const COLORS = {
5
+ reset: '\x1b[0m',
6
+ bold: '\x1b[1m',
7
+ dim: '\x1b[2m',
8
+ red: '\x1b[31m',
9
+ green: '\x1b[32m',
10
+ yellow: '\x1b[33m',
11
+ blue: '\x1b[34m',
12
+ magenta: '\x1b[35m',
13
+ cyan: '\x1b[36m',
14
+ white: '\x1b[37m',
15
+ bgRed: '\x1b[41m',
16
+ bgYellow: '\x1b[43m',
17
+ };
18
+
19
+ export function clearLine(): void {
20
+ process.stderr.write('\x1b[2K\x1b[G');
21
+ }
22
+
23
+ export function moveCursorUp(lines: number): void {
24
+ process.stderr.write(`\x1b[${lines}A`);
25
+ }
26
+
27
+ export function progressBar(current: number, max: number, width: number = 20): string {
28
+ const percent = Math.min(current / max, 1);
29
+ const filled = Math.round(width * percent);
30
+ const empty = width - filled;
31
+
32
+ let color = COLORS.green;
33
+ if (percent >= 0.9) color = COLORS.red;
34
+ else if (percent >= 0.8) color = COLORS.yellow;
35
+
36
+ return `${color}${'█'.repeat(filled)}${COLORS.dim}${'░'.repeat(empty)}${COLORS.reset}`;
37
+ }
38
+
39
+ export function formatCost(cost: number): string {
40
+ return `$${cost.toFixed(4)}`;
41
+ }
42
+
43
+ export function renderStatus(
44
+ stats: UsageStats,
45
+ options: TokenGuardOptions,
46
+ inline: boolean = true
47
+ ): string {
48
+ const { budget, costLimit, warnPercent } = options;
49
+ const { inputTokens, outputTokens, totalTokens, estimatedCost, model } = stats;
50
+
51
+ if (options.quiet) {
52
+ return '';
53
+ }
54
+
55
+ const lines: string[] = [];
56
+
57
+ if (inline) {
58
+ // Compact inline display
59
+ let status = `${COLORS.cyan}◆${COLORS.reset} `;
60
+ status += `${COLORS.bold}${formatTokens(totalTokens)}${COLORS.reset} tokens `;
61
+ status += `${COLORS.dim}(in:${formatTokens(inputTokens)} out:${formatTokens(outputTokens)})${COLORS.reset} `;
62
+ status += `${COLORS.green}${formatCost(estimatedCost)}${COLORS.reset} `;
63
+
64
+ if (budget) {
65
+ const percent = (totalTokens / budget) * 100;
66
+ if (percent >= 100) {
67
+ status += `${COLORS.bgRed}${COLORS.white} BUDGET EXCEEDED ${COLORS.reset}`;
68
+ } else if (percent >= warnPercent) {
69
+ status += `${COLORS.yellow}⚠ ${percent.toFixed(0)}% of budget${COLORS.reset}`;
70
+ }
71
+ }
72
+
73
+ if (costLimit) {
74
+ const percent = (estimatedCost / costLimit) * 100;
75
+ if (percent >= 100) {
76
+ status += `${COLORS.bgRed}${COLORS.white} COST LIMIT EXCEEDED ${COLORS.reset}`;
77
+ } else if (percent >= warnPercent) {
78
+ status += `${COLORS.yellow}⚠ ${percent.toFixed(0)}% of cost limit${COLORS.reset}`;
79
+ }
80
+ }
81
+
82
+ return status;
83
+ }
84
+
85
+ // Box display
86
+ const width = 47;
87
+ const border = '─'.repeat(width - 2);
88
+
89
+ lines.push(`${COLORS.dim}┌${border}┐${COLORS.reset}`);
90
+ lines.push(`${COLORS.dim}│${COLORS.reset} ${COLORS.bold}${COLORS.cyan}token-guard${COLORS.reset} v0.1.0${' '.repeat(width - 24)}${COLORS.dim}│${COLORS.reset}`);
91
+
92
+ if (budget) {
93
+ const budgetLine = ` Budget: ${formatTokens(budget)} tokens | Warn: ${warnPercent}%`;
94
+ lines.push(`${COLORS.dim}│${COLORS.reset}${budgetLine}${' '.repeat(width - budgetLine.length - 2)}${COLORS.dim}│${COLORS.reset}`);
95
+ }
96
+
97
+ lines.push(`${COLORS.dim}├${border}┤${COLORS.reset}`);
98
+
99
+ // Progress bar
100
+ if (budget) {
101
+ const bar = progressBar(totalTokens, budget, 20);
102
+ const counts = `${formatTokens(totalTokens)} / ${formatTokens(budget)}`;
103
+ lines.push(`${COLORS.dim}│${COLORS.reset} ${bar} ${counts}${' '.repeat(width - 30 - counts.length)}${COLORS.dim}│${COLORS.reset}`);
104
+ }
105
+
106
+ // Token breakdown
107
+ const breakdown = ` Input: ${formatTokens(inputTokens)} | Output: ${formatTokens(outputTokens)}`;
108
+ lines.push(`${COLORS.dim}│${COLORS.reset}${breakdown}${' '.repeat(width - breakdown.length - 2)}${COLORS.dim}│${COLORS.reset}`);
109
+
110
+ // Cost
111
+ const costLine = ` Est. Cost: ${formatCost(estimatedCost)} (${model})`;
112
+ lines.push(`${COLORS.dim}│${COLORS.reset}${costLine}${' '.repeat(width - costLine.length - 2)}${COLORS.dim}│${COLORS.reset}`);
113
+
114
+ lines.push(`${COLORS.dim}└${border}┘${COLORS.reset}`);
115
+
116
+ return lines.join('\n');
117
+ }
118
+
119
+ export function renderFinalReport(stats: UsageStats, options: TokenGuardOptions): string {
120
+ const lines: string[] = [];
121
+ const duration = stats.endTime
122
+ ? ((stats.endTime.getTime() - stats.startTime.getTime()) / 1000).toFixed(1)
123
+ : '?';
124
+
125
+ lines.push('');
126
+ lines.push(`${COLORS.bold}${COLORS.cyan}═══ token-guard Report ═══${COLORS.reset}`);
127
+ lines.push('');
128
+ lines.push(`${COLORS.bold}Command:${COLORS.reset} ${stats.command}`);
129
+ lines.push(`${COLORS.bold}Model:${COLORS.reset} ${stats.model}`);
130
+ lines.push(`${COLORS.bold}Duration:${COLORS.reset} ${duration}s`);
131
+ lines.push('');
132
+ lines.push(`${COLORS.bold}Tokens:${COLORS.reset}`);
133
+ lines.push(` Input: ${formatTokens(stats.inputTokens).padStart(10)}`);
134
+ lines.push(` Output: ${formatTokens(stats.outputTokens).padStart(10)}`);
135
+ lines.push(` Total: ${formatTokens(stats.totalTokens).padStart(10)}`);
136
+ lines.push('');
137
+ lines.push(`${COLORS.bold}Estimated Cost:${COLORS.reset} ${COLORS.green}${formatCost(stats.estimatedCost)}${COLORS.reset}`);
138
+
139
+ if (stats.budgetExceeded) {
140
+ lines.push('');
141
+ lines.push(`${COLORS.bgRed}${COLORS.white} BUDGET EXCEEDED - Process terminated ${COLORS.reset}`);
142
+ }
143
+
144
+ if (stats.costExceeded) {
145
+ lines.push('');
146
+ lines.push(`${COLORS.bgRed}${COLORS.white} COST LIMIT EXCEEDED - Process terminated ${COLORS.reset}`);
147
+ }
148
+
149
+ lines.push('');
150
+
151
+ return lines.join('\n');
152
+ }
153
+
154
+ export function warn(message: string): void {
155
+ console.error(`${COLORS.yellow}⚠ token-guard:${COLORS.reset} ${message}`);
156
+ }
157
+
158
+ export function error(message: string): void {
159
+ console.error(`${COLORS.red}✖ token-guard:${COLORS.reset} ${message}`);
160
+ }
package/src/index.ts ADDED
@@ -0,0 +1,7 @@
1
+ // token-guard - Monitor and limit token usage for AI coding assistants
2
+
3
+ export { TokenMonitor } from './monitor.js';
4
+ export { countTokens, formatTokens } from './tokenizer.js';
5
+ export { calculateCost, getPricing, detectModel, MODEL_PRICING } from './pricing.js';
6
+ export { loadConfig, getConfigPath } from './config.js';
7
+ export type { TokenGuardOptions, UsageStats, ModelPricing, Config } from './types.js';
package/src/monitor.ts ADDED
@@ -0,0 +1,189 @@
1
+ import { spawn, type ChildProcess } from 'node:child_process';
2
+ import { writeFileSync } from 'node:fs';
3
+ import type { TokenGuardOptions, UsageStats } from './types.js';
4
+ import { countTokens } from './tokenizer.js';
5
+ import { calculateCost, detectModel } from './pricing.js';
6
+ import { renderStatus, renderFinalReport, clearLine, warn, error } from './display.js';
7
+
8
+ export class TokenMonitor {
9
+ private stats: UsageStats;
10
+ private options: TokenGuardOptions;
11
+ private process: ChildProcess | null = null;
12
+ private updateInterval: NodeJS.Timeout | null = null;
13
+ private lastStatusLength = 0;
14
+
15
+ constructor(command: string, options: TokenGuardOptions) {
16
+ const model = options.model || detectModel(command);
17
+
18
+ this.options = options;
19
+ this.stats = {
20
+ inputTokens: 0,
21
+ outputTokens: 0,
22
+ totalTokens: 0,
23
+ estimatedCost: 0,
24
+ model,
25
+ startTime: new Date(),
26
+ command,
27
+ budgetExceeded: false,
28
+ costExceeded: false,
29
+ };
30
+ }
31
+
32
+ async run(args: string[]): Promise<number> {
33
+ return new Promise((resolve) => {
34
+ const [cmd, ...cmdArgs] = args;
35
+
36
+ this.process = spawn(cmd, cmdArgs, {
37
+ stdio: ['inherit', 'pipe', 'pipe'],
38
+ shell: true,
39
+ });
40
+
41
+ // Track input (what we send to the process)
42
+ // For now, we count the command itself as input
43
+ this.addInputTokens(args.join(' '));
44
+
45
+ // Track stdout
46
+ this.process.stdout?.on('data', (data: Buffer) => {
47
+ const text = data.toString();
48
+ this.addOutputTokens(text);
49
+ process.stdout.write(data);
50
+ this.checkLimits();
51
+ });
52
+
53
+ // Track stderr
54
+ this.process.stderr?.on('data', (data: Buffer) => {
55
+ const text = data.toString();
56
+ this.addOutputTokens(text);
57
+ process.stderr.write(data);
58
+ this.checkLimits();
59
+ });
60
+
61
+ // Periodic status update
62
+ if (!this.options.quiet) {
63
+ this.updateInterval = setInterval(() => {
64
+ this.showStatus();
65
+ }, 1000);
66
+ }
67
+
68
+ this.process.on('close', (code) => {
69
+ this.cleanup();
70
+ this.stats.endTime = new Date();
71
+
72
+ if (!this.options.quiet) {
73
+ console.error(renderFinalReport(this.stats, this.options));
74
+ }
75
+
76
+ if (this.options.output) {
77
+ this.saveReport();
78
+ }
79
+
80
+ resolve(code ?? 0);
81
+ });
82
+
83
+ this.process.on('error', (err) => {
84
+ this.cleanup();
85
+ error(`Failed to start command: ${err.message}`);
86
+ resolve(1);
87
+ });
88
+ });
89
+ }
90
+
91
+ private addInputTokens(text: string): void {
92
+ const tokens = countTokens(text);
93
+ this.stats.inputTokens += tokens;
94
+ this.updateTotals();
95
+ }
96
+
97
+ private addOutputTokens(text: string): void {
98
+ const tokens = countTokens(text);
99
+ this.stats.outputTokens += tokens;
100
+ this.updateTotals();
101
+ }
102
+
103
+ private updateTotals(): void {
104
+ this.stats.totalTokens = this.stats.inputTokens + this.stats.outputTokens;
105
+ this.stats.estimatedCost = calculateCost(
106
+ this.stats.inputTokens,
107
+ this.stats.outputTokens,
108
+ this.stats.model
109
+ );
110
+ }
111
+
112
+ private checkLimits(): void {
113
+ const { budget, costLimit, warnPercent } = this.options;
114
+
115
+ // Check token budget
116
+ if (budget) {
117
+ const percent = (this.stats.totalTokens / budget) * 100;
118
+
119
+ if (percent >= 100) {
120
+ this.stats.budgetExceeded = true;
121
+ this.terminate('Token budget exceeded');
122
+ return;
123
+ }
124
+
125
+ if (percent >= warnPercent && percent < warnPercent + 5) {
126
+ warn(`${percent.toFixed(0)}% of token budget used`);
127
+ }
128
+ }
129
+
130
+ // Check cost limit
131
+ if (costLimit) {
132
+ const percent = (this.stats.estimatedCost / costLimit) * 100;
133
+
134
+ if (percent >= 100) {
135
+ this.stats.costExceeded = true;
136
+ this.terminate('Cost limit exceeded');
137
+ return;
138
+ }
139
+
140
+ if (percent >= warnPercent && percent < warnPercent + 5) {
141
+ warn(`${percent.toFixed(0)}% of cost limit used`);
142
+ }
143
+ }
144
+ }
145
+
146
+ private terminate(reason: string): void {
147
+ error(reason);
148
+ if (this.process) {
149
+ this.process.kill('SIGTERM');
150
+ }
151
+ }
152
+
153
+ private showStatus(): void {
154
+ if (this.options.quiet) return;
155
+
156
+ const status = renderStatus(this.stats, this.options, true);
157
+ clearLine();
158
+ process.stderr.write(status);
159
+ }
160
+
161
+ private cleanup(): void {
162
+ if (this.updateInterval) {
163
+ clearInterval(this.updateInterval);
164
+ this.updateInterval = null;
165
+ }
166
+ clearLine();
167
+ }
168
+
169
+ private saveReport(): void {
170
+ if (!this.options.output) return;
171
+
172
+ const report = {
173
+ ...this.stats,
174
+ startTime: this.stats.startTime.toISOString(),
175
+ endTime: this.stats.endTime?.toISOString(),
176
+ options: this.options,
177
+ };
178
+
179
+ try {
180
+ writeFileSync(this.options.output, JSON.stringify(report, null, 2));
181
+ } catch (err) {
182
+ error(`Failed to save report: ${err}`);
183
+ }
184
+ }
185
+
186
+ getStats(): UsageStats {
187
+ return { ...this.stats };
188
+ }
189
+ }
package/src/pricing.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { ModelPricing } from './types.js';
2
+
3
+ // Pricing per 1M tokens (as of Jan 2026)
4
+ export const MODEL_PRICING: Record<string, ModelPricing> = {
5
+ // Claude models
6
+ 'claude-opus-4-5': { input: 15.00, output: 75.00 },
7
+ 'claude-opus-4': { input: 15.00, output: 75.00 },
8
+ 'claude-sonnet-4': { input: 3.00, output: 15.00 },
9
+ 'claude-3-opus': { input: 15.00, output: 75.00 },
10
+ 'claude-3-sonnet': { input: 3.00, output: 15.00 },
11
+ 'claude-3-haiku': { input: 0.25, output: 1.25 },
12
+
13
+ // OpenAI models
14
+ 'gpt-4o': { input: 2.50, output: 10.00 },
15
+ 'gpt-4-turbo': { input: 10.00, output: 30.00 },
16
+ 'gpt-4': { input: 30.00, output: 60.00 },
17
+ 'gpt-3.5-turbo': { input: 0.50, output: 1.50 },
18
+ 'o1': { input: 15.00, output: 60.00 },
19
+ 'o1-mini': { input: 3.00, output: 12.00 },
20
+
21
+ // Default fallback (sonnet-level pricing)
22
+ 'default': { input: 3.00, output: 15.00 },
23
+ };
24
+
25
+ export function getPricing(model: string): ModelPricing {
26
+ const normalizedModel = model.toLowerCase().replace(/[_-]/g, '-');
27
+
28
+ for (const [key, pricing] of Object.entries(MODEL_PRICING)) {
29
+ if (normalizedModel.includes(key.replace(/[_-]/g, '-'))) {
30
+ return pricing;
31
+ }
32
+ }
33
+
34
+ return MODEL_PRICING['default'];
35
+ }
36
+
37
+ export function calculateCost(
38
+ inputTokens: number,
39
+ outputTokens: number,
40
+ model: string
41
+ ): number {
42
+ const pricing = getPricing(model);
43
+ const inputCost = (inputTokens / 1_000_000) * pricing.input;
44
+ const outputCost = (outputTokens / 1_000_000) * pricing.output;
45
+ return inputCost + outputCost;
46
+ }
47
+
48
+ export function detectModel(command: string): string {
49
+ const lowerCommand = command.toLowerCase();
50
+
51
+ if (lowerCommand.includes('claude')) {
52
+ if (lowerCommand.includes('opus')) return 'claude-opus-4';
53
+ if (lowerCommand.includes('haiku')) return 'claude-3-haiku';
54
+ return 'claude-sonnet-4'; // default for claude commands
55
+ }
56
+
57
+ if (lowerCommand.includes('aider')) {
58
+ // Aider defaults vary, assume sonnet
59
+ return 'claude-sonnet-4';
60
+ }
61
+
62
+ if (lowerCommand.includes('gpt-4')) return 'gpt-4o';
63
+ if (lowerCommand.includes('openai')) return 'gpt-4o';
64
+
65
+ return 'default';
66
+ }
@@ -0,0 +1,57 @@
1
+ // Simple token estimation without external dependencies
2
+ // Uses GPT-4 style tokenization rules (~4 chars per token)
3
+
4
+ const CHARS_PER_TOKEN = 4;
5
+
6
+ export function countTokens(text: string): number {
7
+ if (!text) return 0;
8
+
9
+ // More accurate estimation:
10
+ // - Split on whitespace and punctuation
11
+ // - Count special tokens for code
12
+
13
+ // Count words
14
+ const words = text.split(/\s+/).filter(w => w.length > 0);
15
+
16
+ // Estimate tokens (rough approximation)
17
+ // - Short words (1-4 chars) = 1 token
18
+ // - Medium words (5-8 chars) = 1-2 tokens
19
+ // - Long words (9+ chars) = 2+ tokens
20
+ // - Code symbols often get their own tokens
21
+
22
+ let tokens = 0;
23
+
24
+ for (const word of words) {
25
+ if (word.length <= 4) {
26
+ tokens += 1;
27
+ } else if (word.length <= 8) {
28
+ tokens += Math.ceil(word.length / 4);
29
+ } else {
30
+ tokens += Math.ceil(word.length / 3);
31
+ }
32
+ }
33
+
34
+ // Add tokens for punctuation and special characters
35
+ const specialChars = text.match(/[{}()\[\]<>:;,."'`~!@#$%^&*+=|\\/?-]/g);
36
+ if (specialChars) {
37
+ tokens += Math.ceil(specialChars.length * 0.5);
38
+ }
39
+
40
+ // Add tokens for newlines (often separate tokens)
41
+ const newlines = text.match(/\n/g);
42
+ if (newlines) {
43
+ tokens += newlines.length;
44
+ }
45
+
46
+ return Math.max(1, Math.round(tokens));
47
+ }
48
+
49
+ export function formatTokens(count: number): string {
50
+ if (count >= 1_000_000) {
51
+ return `${(count / 1_000_000).toFixed(2)}M`;
52
+ }
53
+ if (count >= 1_000) {
54
+ return `${(count / 1_000).toFixed(1)}k`;
55
+ }
56
+ return count.toString();
57
+ }
package/src/types.ts ADDED
@@ -0,0 +1,33 @@
1
+ export interface TokenGuardOptions {
2
+ budget?: number;
3
+ costLimit?: number;
4
+ model?: string;
5
+ warnPercent: number;
6
+ quiet: boolean;
7
+ output?: string;
8
+ }
9
+
10
+ export interface ModelPricing {
11
+ input: number; // per 1M tokens
12
+ output: number; // per 1M tokens
13
+ }
14
+
15
+ export interface UsageStats {
16
+ inputTokens: number;
17
+ outputTokens: number;
18
+ totalTokens: number;
19
+ estimatedCost: number;
20
+ model: string;
21
+ startTime: Date;
22
+ endTime?: Date;
23
+ command: string;
24
+ budgetExceeded: boolean;
25
+ costExceeded: boolean;
26
+ }
27
+
28
+ export interface Config {
29
+ defaultBudget?: number;
30
+ defaultCostLimit?: number;
31
+ warnPercent: number;
32
+ models: Record<string, ModelPricing>;
33
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true
15
+ },
16
+ "include": ["src/**/*"],
17
+ "exclude": ["node_modules", "dist"]
18
+ }