tlc-claude-code 1.2.26 → 1.2.28

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.
Files changed (177) hide show
  1. package/dashboard/dist/components/ActivityFeed.d.ts +17 -0
  2. package/dashboard/dist/components/ActivityFeed.js +42 -0
  3. package/dashboard/dist/components/ActivityFeed.test.d.ts +1 -0
  4. package/dashboard/dist/components/ActivityFeed.test.js +162 -0
  5. package/dashboard/dist/components/BranchSelector.d.ts +16 -0
  6. package/dashboard/dist/components/BranchSelector.js +49 -0
  7. package/dashboard/dist/components/BranchSelector.test.d.ts +1 -0
  8. package/dashboard/dist/components/BranchSelector.test.js +166 -0
  9. package/dashboard/dist/components/CommandPalette.d.ts +17 -0
  10. package/dashboard/dist/components/CommandPalette.js +118 -0
  11. package/dashboard/dist/components/CommandPalette.test.d.ts +1 -0
  12. package/dashboard/dist/components/CommandPalette.test.js +181 -0
  13. package/dashboard/dist/components/ConnectionStatus.d.ts +16 -0
  14. package/dashboard/dist/components/ConnectionStatus.js +27 -0
  15. package/dashboard/dist/components/ConnectionStatus.test.d.ts +1 -0
  16. package/dashboard/dist/components/ConnectionStatus.test.js +121 -0
  17. package/dashboard/dist/components/DeviceFrame.d.ts +19 -0
  18. package/dashboard/dist/components/DeviceFrame.js +52 -0
  19. package/dashboard/dist/components/DeviceFrame.test.d.ts +1 -0
  20. package/dashboard/dist/components/DeviceFrame.test.js +118 -0
  21. package/dashboard/dist/components/EnvironmentBadge.d.ts +11 -0
  22. package/dashboard/dist/components/EnvironmentBadge.js +16 -0
  23. package/dashboard/dist/components/EnvironmentBadge.test.d.ts +1 -0
  24. package/dashboard/dist/components/EnvironmentBadge.test.js +102 -0
  25. package/dashboard/dist/components/FocusIndicator.d.ts +19 -0
  26. package/dashboard/dist/components/FocusIndicator.js +47 -0
  27. package/dashboard/dist/components/FocusIndicator.test.d.ts +1 -0
  28. package/dashboard/dist/components/FocusIndicator.test.js +117 -0
  29. package/dashboard/dist/components/KeyboardHelp.d.ts +15 -0
  30. package/dashboard/dist/components/KeyboardHelp.js +61 -0
  31. package/dashboard/dist/components/KeyboardHelp.test.d.ts +1 -0
  32. package/dashboard/dist/components/KeyboardHelp.test.js +131 -0
  33. package/dashboard/dist/components/LogSearch.d.ts +13 -0
  34. package/dashboard/dist/components/LogSearch.js +43 -0
  35. package/dashboard/dist/components/LogSearch.test.d.ts +1 -0
  36. package/dashboard/dist/components/LogSearch.test.js +100 -0
  37. package/dashboard/dist/components/LogStream.d.ts +21 -0
  38. package/dashboard/dist/components/LogStream.js +123 -0
  39. package/dashboard/dist/components/LogStream.test.d.ts +1 -0
  40. package/dashboard/dist/components/LogStream.test.js +159 -0
  41. package/dashboard/dist/components/PlanView.d.ts +7 -0
  42. package/dashboard/dist/components/PlanView.js +74 -2
  43. package/dashboard/dist/components/PlanView.test.js +70 -1
  44. package/dashboard/dist/components/PreviewPanel.d.ts +18 -0
  45. package/dashboard/dist/components/PreviewPanel.js +73 -0
  46. package/dashboard/dist/components/PreviewPanel.test.d.ts +1 -0
  47. package/dashboard/dist/components/PreviewPanel.test.js +124 -0
  48. package/dashboard/dist/components/ProjectCard.d.ts +18 -0
  49. package/dashboard/dist/components/ProjectCard.js +19 -0
  50. package/dashboard/dist/components/ProjectCard.test.d.ts +1 -0
  51. package/dashboard/dist/components/ProjectCard.test.js +53 -0
  52. package/dashboard/dist/components/ProjectDetail.d.ts +44 -0
  53. package/dashboard/dist/components/ProjectDetail.js +65 -0
  54. package/dashboard/dist/components/ProjectDetail.test.d.ts +1 -0
  55. package/dashboard/dist/components/ProjectDetail.test.js +196 -0
  56. package/dashboard/dist/components/ProjectList.d.ts +11 -0
  57. package/dashboard/dist/components/ProjectList.js +62 -0
  58. package/dashboard/dist/components/ProjectList.test.d.ts +1 -0
  59. package/dashboard/dist/components/ProjectList.test.js +93 -0
  60. package/dashboard/dist/components/SettingsPanel.d.ts +32 -0
  61. package/dashboard/dist/components/SettingsPanel.js +154 -0
  62. package/dashboard/dist/components/SettingsPanel.test.d.ts +1 -0
  63. package/dashboard/dist/components/SettingsPanel.test.js +196 -0
  64. package/dashboard/dist/components/StatusBar.d.ts +16 -0
  65. package/dashboard/dist/components/StatusBar.js +47 -0
  66. package/dashboard/dist/components/StatusBar.test.d.ts +1 -0
  67. package/dashboard/dist/components/StatusBar.test.js +123 -0
  68. package/dashboard/dist/components/TaskBoard.d.ts +22 -0
  69. package/dashboard/dist/components/TaskBoard.js +102 -0
  70. package/dashboard/dist/components/TaskBoard.test.d.ts +1 -0
  71. package/dashboard/dist/components/TaskBoard.test.js +113 -0
  72. package/dashboard/dist/components/TaskCard.d.ts +17 -0
  73. package/dashboard/dist/components/TaskCard.js +29 -0
  74. package/dashboard/dist/components/TaskCard.test.d.ts +1 -0
  75. package/dashboard/dist/components/TaskCard.test.js +109 -0
  76. package/dashboard/dist/components/TaskDetail.d.ts +36 -0
  77. package/dashboard/dist/components/TaskDetail.js +41 -0
  78. package/dashboard/dist/components/TaskDetail.test.d.ts +1 -0
  79. package/dashboard/dist/components/TaskDetail.test.js +164 -0
  80. package/dashboard/dist/components/TaskFilter.d.ts +12 -0
  81. package/dashboard/dist/components/TaskFilter.js +138 -0
  82. package/dashboard/dist/components/TaskFilter.test.d.ts +1 -0
  83. package/dashboard/dist/components/TaskFilter.test.js +109 -0
  84. package/dashboard/dist/components/TeamPanel.d.ts +15 -0
  85. package/dashboard/dist/components/TeamPanel.js +24 -0
  86. package/dashboard/dist/components/TeamPanel.test.d.ts +1 -0
  87. package/dashboard/dist/components/TeamPanel.test.js +109 -0
  88. package/dashboard/dist/components/TeamPresence.d.ts +14 -0
  89. package/dashboard/dist/components/TeamPresence.js +31 -0
  90. package/dashboard/dist/components/TeamPresence.test.d.ts +1 -0
  91. package/dashboard/dist/components/TeamPresence.test.js +144 -0
  92. package/dashboard/dist/components/layout/Header.d.ts +9 -0
  93. package/dashboard/dist/components/layout/Header.js +11 -0
  94. package/dashboard/dist/components/layout/Header.test.d.ts +1 -0
  95. package/dashboard/dist/components/layout/Header.test.js +35 -0
  96. package/dashboard/dist/components/layout/Shell.d.ts +10 -0
  97. package/dashboard/dist/components/layout/Shell.js +5 -0
  98. package/dashboard/dist/components/layout/Shell.test.d.ts +1 -0
  99. package/dashboard/dist/components/layout/Shell.test.js +34 -0
  100. package/dashboard/dist/components/layout/Sidebar.d.ts +14 -0
  101. package/dashboard/dist/components/layout/Sidebar.js +8 -0
  102. package/dashboard/dist/components/layout/Sidebar.test.d.ts +1 -0
  103. package/dashboard/dist/components/layout/Sidebar.test.js +40 -0
  104. package/dashboard/dist/components/ui/Badge.d.ts +9 -0
  105. package/dashboard/dist/components/ui/Badge.js +13 -0
  106. package/dashboard/dist/components/ui/Badge.test.d.ts +1 -0
  107. package/dashboard/dist/components/ui/Badge.test.js +69 -0
  108. package/dashboard/dist/components/ui/Button.d.ts +12 -0
  109. package/dashboard/dist/components/ui/Button.js +14 -0
  110. package/dashboard/dist/components/ui/Button.test.d.ts +1 -0
  111. package/dashboard/dist/components/ui/Button.test.js +81 -0
  112. package/dashboard/dist/components/ui/Card.d.ts +21 -0
  113. package/dashboard/dist/components/ui/Card.js +20 -0
  114. package/dashboard/dist/components/ui/Card.test.d.ts +1 -0
  115. package/dashboard/dist/components/ui/Card.test.js +82 -0
  116. package/dashboard/dist/components/ui/Input.d.ts +13 -0
  117. package/dashboard/dist/components/ui/Input.js +8 -0
  118. package/dashboard/dist/components/ui/Input.test.d.ts +1 -0
  119. package/dashboard/dist/components/ui/Input.test.js +68 -0
  120. package/dashboard/dist/styles/tokens.d.ts +150 -0
  121. package/dashboard/dist/styles/tokens.js +184 -0
  122. package/dashboard/dist/styles/tokens.test.d.ts +1 -0
  123. package/dashboard/dist/styles/tokens.test.js +95 -0
  124. package/dashboard/dist/test/setup.d.ts +1 -0
  125. package/dashboard/dist/test/setup.js +1 -0
  126. package/dashboard/package.json +3 -0
  127. package/package.json +1 -1
  128. package/server/dashboard/index.html +157 -2
  129. package/server/index.js +38 -21
  130. package/server/lib/adapters/base-adapter.js +114 -0
  131. package/server/lib/adapters/base-adapter.test.js +90 -0
  132. package/server/lib/adapters/claude-adapter.js +141 -0
  133. package/server/lib/adapters/claude-adapter.test.js +180 -0
  134. package/server/lib/adapters/deepseek-adapter.js +153 -0
  135. package/server/lib/adapters/deepseek-adapter.test.js +193 -0
  136. package/server/lib/adapters/openai-adapter.js +190 -0
  137. package/server/lib/adapters/openai-adapter.test.js +231 -0
  138. package/server/lib/budget-tracker.js +169 -0
  139. package/server/lib/budget-tracker.test.js +165 -0
  140. package/server/lib/claude-injector.js +85 -0
  141. package/server/lib/claude-injector.test.js +161 -0
  142. package/server/lib/consensus-engine.js +135 -0
  143. package/server/lib/consensus-engine.test.js +152 -0
  144. package/server/lib/context-builder.js +112 -0
  145. package/server/lib/context-builder.test.js +120 -0
  146. package/server/lib/file-collector.js +322 -0
  147. package/server/lib/file-collector.test.js +307 -0
  148. package/server/lib/memory-classifier.js +175 -0
  149. package/server/lib/memory-classifier.test.js +169 -0
  150. package/server/lib/memory-committer.js +138 -0
  151. package/server/lib/memory-committer.test.js +136 -0
  152. package/server/lib/memory-hooks.js +127 -0
  153. package/server/lib/memory-hooks.test.js +136 -0
  154. package/server/lib/memory-init.js +104 -0
  155. package/server/lib/memory-init.test.js +119 -0
  156. package/server/lib/memory-observer.js +149 -0
  157. package/server/lib/memory-observer.test.js +158 -0
  158. package/server/lib/memory-reader.js +243 -0
  159. package/server/lib/memory-reader.test.js +216 -0
  160. package/server/lib/memory-storage.js +120 -0
  161. package/server/lib/memory-storage.test.js +136 -0
  162. package/server/lib/memory-writer.js +176 -0
  163. package/server/lib/memory-writer.test.js +231 -0
  164. package/server/lib/overdrive-command.js +30 -6
  165. package/server/lib/overdrive-command.test.js +8 -1
  166. package/server/lib/pattern-detector.js +216 -0
  167. package/server/lib/pattern-detector.test.js +241 -0
  168. package/server/lib/relevance-scorer.js +175 -0
  169. package/server/lib/relevance-scorer.test.js +107 -0
  170. package/server/lib/review-command.js +238 -0
  171. package/server/lib/review-command.test.js +245 -0
  172. package/server/lib/review-orchestrator.js +273 -0
  173. package/server/lib/review-orchestrator.test.js +300 -0
  174. package/server/lib/review-reporter.js +288 -0
  175. package/server/lib/review-reporter.test.js +240 -0
  176. package/server/lib/session-summary.js +90 -0
  177. package/server/lib/session-summary.test.js +156 -0
@@ -0,0 +1,231 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { OpenAIAdapter, OPENAI_PRICING, OPENAI_MODEL, DEFAULT_RATE_LIMITS } from './openai-adapter.js';
3
+ import { BaseAdapter } from './base-adapter.js';
4
+
5
+ describe('OpenAIAdapter', () => {
6
+ describe('constructor', () => {
7
+ it('sets name to openai by default', () => {
8
+ const adapter = new OpenAIAdapter();
9
+ expect(adapter.name).toBe('openai');
10
+ });
11
+
12
+ it('initializes rate limit counters', () => {
13
+ const adapter = new OpenAIAdapter();
14
+ expect(adapter.requestsThisMinute).toBe(0);
15
+ expect(adapter.tokensThisMinute).toBe(0);
16
+ });
17
+
18
+ it('accepts custom rate limits', () => {
19
+ const adapter = new OpenAIAdapter({
20
+ rateLimits: { requestsPerMinute: 100, tokensPerMinute: 50000 },
21
+ });
22
+ expect(adapter.rateLimits.requestsPerMinute).toBe(100);
23
+ expect(adapter.rateLimits.tokensPerMinute).toBe(50000);
24
+ });
25
+ });
26
+
27
+ describe('review', () => {
28
+ it('returns empty response for empty code', async () => {
29
+ const adapter = new OpenAIAdapter();
30
+ const result = await adapter.review('');
31
+
32
+ expect(result.issues).toEqual([]);
33
+ expect(result.model).toBe('openai');
34
+ });
35
+
36
+ it('returns standardized response format', async () => {
37
+ const adapter = new OpenAIAdapter();
38
+ const result = await adapter.review('const x = 1;');
39
+
40
+ expect(BaseAdapter.validateResponse(result)).toBe(true);
41
+ expect(result.model).toBe('openai');
42
+ });
43
+
44
+ it('throws when budget exceeded', async () => {
45
+ const mockTracker = {
46
+ canSpend: vi.fn(() => false),
47
+ record: vi.fn(),
48
+ getUsage: vi.fn(() => ({ daily: 10, monthly: 100, requests: 50 })),
49
+ };
50
+ const adapter = new OpenAIAdapter({ budgetTracker: mockTracker });
51
+
52
+ await expect(adapter.review('const x = 1;')).rejects.toThrow('Budget exceeded');
53
+ });
54
+
55
+ it('throws when rate limit exceeded', async () => {
56
+ const adapter = new OpenAIAdapter({
57
+ rateLimits: { requestsPerMinute: 1, tokensPerMinute: 10 },
58
+ });
59
+
60
+ // First request succeeds
61
+ await adapter.review('const x = 1;');
62
+
63
+ // Second request exceeds rate limit
64
+ await expect(adapter.review('const y = 2;')).rejects.toThrow('Rate limit exceeded');
65
+ });
66
+
67
+ it('records cost after successful review', async () => {
68
+ const mockTracker = {
69
+ canSpend: vi.fn(() => true),
70
+ record: vi.fn(),
71
+ getUsage: vi.fn(() => ({ daily: 0, monthly: 0, requests: 0 })),
72
+ };
73
+ const adapter = new OpenAIAdapter({ budgetTracker: mockTracker });
74
+
75
+ await adapter.review('const x = 1;');
76
+
77
+ expect(mockTracker.record).toHaveBeenCalledWith('openai', expect.any(Number));
78
+ });
79
+
80
+ it('increments rate limit counters', async () => {
81
+ const adapter = new OpenAIAdapter();
82
+
83
+ await adapter.review('const x = 1;');
84
+
85
+ expect(adapter.requestsThisMinute).toBe(1);
86
+ expect(adapter.tokensThisMinute).toBeGreaterThan(0);
87
+ });
88
+
89
+ it('handles API errors gracefully', async () => {
90
+ const adapter = new OpenAIAdapter();
91
+ adapter.callAPI = vi.fn().mockRejectedValue(new Error('API error'));
92
+
93
+ const result = await adapter.review('const x = 1;');
94
+
95
+ expect(result.error).toBe('API error');
96
+ expect(result.issues).toEqual([]);
97
+ });
98
+ });
99
+
100
+ describe('rate limiting', () => {
101
+ it('resets counters after minute window', () => {
102
+ const adapter = new OpenAIAdapter();
103
+ adapter.requestsThisMinute = 100;
104
+ adapter.tokensThisMinute = 50000;
105
+ adapter.lastMinuteReset = Date.now() - 61000; // 61 seconds ago
106
+
107
+ adapter.checkRateLimitReset();
108
+
109
+ expect(adapter.requestsThisMinute).toBe(0);
110
+ expect(adapter.tokensThisMinute).toBe(0);
111
+ });
112
+
113
+ it('does not reset within minute window', () => {
114
+ const adapter = new OpenAIAdapter();
115
+ adapter.requestsThisMinute = 100;
116
+ adapter.lastMinuteReset = Date.now() - 30000; // 30 seconds ago
117
+
118
+ adapter.checkRateLimitReset();
119
+
120
+ expect(adapter.requestsThisMinute).toBe(100);
121
+ });
122
+
123
+ it('withinRateLimits checks both requests and tokens', () => {
124
+ const adapter = new OpenAIAdapter({
125
+ rateLimits: { requestsPerMinute: 10, tokensPerMinute: 1000 },
126
+ });
127
+
128
+ // Within limits
129
+ expect(adapter.withinRateLimits(500)).toBe(true);
130
+
131
+ // Exceed token limit
132
+ expect(adapter.withinRateLimits(1500)).toBe(false);
133
+
134
+ // Exceed request limit
135
+ adapter.requestsThisMinute = 10;
136
+ expect(adapter.withinRateLimits(100)).toBe(false);
137
+ });
138
+ });
139
+
140
+ describe('estimateCost', () => {
141
+ it('calculates cost based on token count', () => {
142
+ const adapter = new OpenAIAdapter();
143
+ const cost = adapter.estimateCost(1000);
144
+
145
+ // At default pricing (o3): (500 * 10 + 500 * 40) / 1M = 0.025
146
+ expect(cost).toBeCloseTo(0.025, 4);
147
+ });
148
+
149
+ it('uses custom pricing if provided', () => {
150
+ const adapter = new OpenAIAdapter({
151
+ pricing: { inputPerMillion: 5.00, outputPerMillion: 15.00 },
152
+ });
153
+ const cost = adapter.estimateCost(1000);
154
+
155
+ // (500 * 5 + 500 * 15) / 1M = 0.01
156
+ expect(cost).toBeCloseTo(0.01, 4);
157
+ });
158
+ });
159
+
160
+ describe('canAfford', () => {
161
+ it('returns true without budget tracker', () => {
162
+ const adapter = new OpenAIAdapter();
163
+ expect(adapter.canAfford(100)).toBe(true);
164
+ });
165
+
166
+ it('checks budget tracker when available', () => {
167
+ const mockTracker = {
168
+ canSpend: vi.fn(() => true),
169
+ };
170
+ const adapter = new OpenAIAdapter({
171
+ budgetTracker: mockTracker,
172
+ budget: { budgetDaily: 10, budgetMonthly: 100 },
173
+ });
174
+
175
+ adapter.canAfford(0.5);
176
+
177
+ expect(mockTracker.canSpend).toHaveBeenCalledWith('openai', 0.5, expect.any(Object));
178
+ });
179
+ });
180
+
181
+ describe('getUsage', () => {
182
+ it('returns zero without budget tracker', () => {
183
+ const adapter = new OpenAIAdapter();
184
+ expect(adapter.getUsage()).toEqual({ daily: 0, monthly: 0, requests: 0 });
185
+ });
186
+
187
+ it('returns tracker usage when available', () => {
188
+ const mockTracker = {
189
+ getUsage: vi.fn(() => ({ daily: 5.00, monthly: 50.00, requests: 25 })),
190
+ };
191
+ const adapter = new OpenAIAdapter({ budgetTracker: mockTracker });
192
+
193
+ const usage = adapter.getUsage();
194
+
195
+ expect(usage.daily).toBe(5.00);
196
+ expect(usage.requests).toBe(25);
197
+ });
198
+ });
199
+
200
+ describe('getRateLimitStatus', () => {
201
+ it('returns current rate limit status', () => {
202
+ const adapter = new OpenAIAdapter();
203
+ adapter.requestsThisMinute = 5;
204
+ adapter.tokensThisMinute = 1000;
205
+
206
+ const status = adapter.getRateLimitStatus();
207
+
208
+ expect(status.requestsUsed).toBe(5);
209
+ expect(status.requestsLimit).toBe(DEFAULT_RATE_LIMITS.requestsPerMinute);
210
+ expect(status.tokensUsed).toBe(1000);
211
+ expect(status.tokensLimit).toBe(DEFAULT_RATE_LIMITS.tokensPerMinute);
212
+ expect(typeof status.resetsIn).toBe('number');
213
+ });
214
+ });
215
+
216
+ describe('exports', () => {
217
+ it('exports OPENAI_PRICING for o3', () => {
218
+ expect(OPENAI_PRICING.inputPerMillion).toBe(10.00);
219
+ expect(OPENAI_PRICING.outputPerMillion).toBe(40.00);
220
+ });
221
+
222
+ it('exports OPENAI_MODEL', () => {
223
+ expect(OPENAI_MODEL).toBe('o3');
224
+ });
225
+
226
+ it('exports DEFAULT_RATE_LIMITS', () => {
227
+ expect(DEFAULT_RATE_LIMITS.requestsPerMinute).toBe(500);
228
+ expect(DEFAULT_RATE_LIMITS.tokensPerMinute).toBe(150000);
229
+ });
230
+ });
231
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Budget Tracker - Track and limit API spending across models
3
+ */
4
+
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+
8
+ class BudgetTracker {
9
+ constructor(configPath = '.tlc/usage.json') {
10
+ this.configPath = configPath;
11
+ this.usage = {};
12
+ this.load();
13
+ }
14
+
15
+ /**
16
+ * Load usage from file
17
+ */
18
+ load() {
19
+ try {
20
+ if (fs.existsSync(this.configPath)) {
21
+ const data = fs.readFileSync(this.configPath, 'utf-8');
22
+ this.usage = JSON.parse(data);
23
+ }
24
+ } catch (err) {
25
+ console.error('Failed to load usage:', err.message);
26
+ this.usage = {};
27
+ }
28
+
29
+ // Initialize missing models
30
+ this.initializeModel('openai');
31
+ this.initializeModel('deepseek');
32
+
33
+ this.checkResets();
34
+ this.save();
35
+ }
36
+
37
+ /**
38
+ * Initialize model with default values
39
+ */
40
+ initializeModel(model) {
41
+ if (!this.usage[model]) {
42
+ this.usage[model] = {
43
+ daily: 0,
44
+ monthly: 0,
45
+ requests: 0,
46
+ lastDailyReset: new Date().toISOString(),
47
+ lastMonthlyReset: new Date().toISOString(),
48
+ };
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Save usage to file
54
+ */
55
+ save() {
56
+ try {
57
+ const dir = path.dirname(this.configPath);
58
+ if (!fs.existsSync(dir)) {
59
+ fs.mkdirSync(dir, { recursive: true });
60
+ }
61
+ fs.writeFileSync(this.configPath, JSON.stringify(this.usage, null, 2), 'utf-8');
62
+ } catch (err) {
63
+ console.error('Failed to save usage:', err.message);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Check and perform resets if needed
69
+ */
70
+ checkResets() {
71
+ const now = new Date();
72
+
73
+ for (const model of Object.keys(this.usage)) {
74
+ const usage = this.usage[model];
75
+
76
+ // Daily reset
77
+ const lastDaily = new Date(usage.lastDailyReset);
78
+ if (lastDaily.toDateString() !== now.toDateString()) {
79
+ usage.daily = 0;
80
+ usage.lastDailyReset = now.toISOString();
81
+ }
82
+
83
+ // Monthly reset
84
+ const lastMonthly = new Date(usage.lastMonthlyReset);
85
+ if (lastMonthly.getMonth() !== now.getMonth() || lastMonthly.getFullYear() !== now.getFullYear()) {
86
+ usage.monthly = 0;
87
+ usage.lastMonthlyReset = now.toISOString();
88
+ }
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Check if can spend amount
94
+ * @param {string} model - Model name
95
+ * @param {number} amount - Amount in USD
96
+ * @param {Object} config - Budget config { budgetDaily, budgetMonthly }
97
+ * @returns {boolean}
98
+ */
99
+ canSpend(model, amount, config) {
100
+ this.initializeModel(model);
101
+ const usage = this.usage[model];
102
+
103
+ const withinDaily = (usage.daily + amount) <= config.budgetDaily;
104
+ const withinMonthly = (usage.monthly + amount) <= config.budgetMonthly;
105
+
106
+ return withinDaily && withinMonthly;
107
+ }
108
+
109
+ /**
110
+ * Record spending
111
+ * @param {string} model - Model name
112
+ * @param {number} amount - Amount in USD
113
+ */
114
+ record(model, amount) {
115
+ this.initializeModel(model);
116
+ this.usage[model].daily += amount;
117
+ this.usage[model].monthly += amount;
118
+ this.usage[model].requests = (this.usage[model].requests || 0) + 1;
119
+ this.save();
120
+ }
121
+
122
+ /**
123
+ * Check if should alert about budget
124
+ * @param {string} model - Model name
125
+ * @param {Object} config - Budget config { budgetDaily, alertThreshold }
126
+ * @returns {boolean}
127
+ */
128
+ shouldAlert(model, config) {
129
+ this.initializeModel(model);
130
+ const usage = this.usage[model];
131
+ const percentUsed = usage.daily / config.budgetDaily;
132
+ return percentUsed >= (config.alertThreshold || 0.8);
133
+ }
134
+
135
+ /**
136
+ * Get usage for model
137
+ * @param {string} model - Model name
138
+ * @returns {Object} Usage { daily, monthly, requests }
139
+ */
140
+ getUsage(model) {
141
+ if (!this.usage[model]) {
142
+ return { daily: 0, monthly: 0, requests: 0 };
143
+ }
144
+ return {
145
+ daily: this.usage[model].daily || 0,
146
+ monthly: this.usage[model].monthly || 0,
147
+ requests: this.usage[model].requests || 0,
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Reset usage for model (admin)
153
+ * @param {string} model - Model name
154
+ */
155
+ reset(model) {
156
+ this.usage[model] = {
157
+ daily: 0,
158
+ monthly: 0,
159
+ requests: 0,
160
+ lastDailyReset: new Date().toISOString(),
161
+ lastMonthlyReset: new Date().toISOString(),
162
+ };
163
+ this.save();
164
+ }
165
+ }
166
+
167
+ module.exports = {
168
+ BudgetTracker,
169
+ };
@@ -0,0 +1,165 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+ import { BudgetTracker } from './budget-tracker.js';
6
+
7
+ describe('BudgetTracker', () => {
8
+ let testDir;
9
+ let tracker;
10
+
11
+ beforeEach(() => {
12
+ testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tlc-budget-test-'));
13
+ tracker = new BudgetTracker(path.join(testDir, '.tlc', 'usage.json'));
14
+ });
15
+
16
+ afterEach(() => {
17
+ fs.rmSync(testDir, { recursive: true, force: true });
18
+ });
19
+
20
+ describe('initialization', () => {
21
+ it('creates usage file if not exists', () => {
22
+ expect(fs.existsSync(tracker.configPath)).toBe(true);
23
+ });
24
+
25
+ it('loads existing usage', () => {
26
+ const initialData = {
27
+ openai: { daily: 2.50, monthly: 25.00, lastDailyReset: new Date().toISOString() },
28
+ };
29
+ fs.mkdirSync(path.dirname(tracker.configPath), { recursive: true });
30
+ fs.writeFileSync(tracker.configPath, JSON.stringify(initialData));
31
+
32
+ const newTracker = new BudgetTracker(tracker.configPath);
33
+ expect(newTracker.usage.openai.daily).toBe(2.50);
34
+ });
35
+ });
36
+
37
+ describe('canSpend', () => {
38
+ it('allows spending within daily budget', () => {
39
+ const config = { budgetDaily: 5.00, budgetMonthly: 50.00 };
40
+ tracker.usage.openai = { daily: 2.00, monthly: 10.00 };
41
+
42
+ expect(tracker.canSpend('openai', 1.00, config)).toBe(true);
43
+ });
44
+
45
+ it('blocks spending over daily budget', () => {
46
+ const config = { budgetDaily: 5.00, budgetMonthly: 50.00 };
47
+ tracker.usage.openai = { daily: 4.50, monthly: 10.00 };
48
+
49
+ expect(tracker.canSpend('openai', 1.00, config)).toBe(false);
50
+ });
51
+
52
+ it('blocks spending over monthly budget', () => {
53
+ const config = { budgetDaily: 5.00, budgetMonthly: 50.00 };
54
+ tracker.usage.openai = { daily: 0.00, monthly: 49.50 };
55
+
56
+ expect(tracker.canSpend('openai', 1.00, config)).toBe(false);
57
+ });
58
+ });
59
+
60
+ describe('record', () => {
61
+ it('records spending', () => {
62
+ tracker.record('openai', 1.50);
63
+
64
+ expect(tracker.usage.openai.daily).toBe(1.50);
65
+ expect(tracker.usage.openai.monthly).toBe(1.50);
66
+ });
67
+
68
+ it('accumulates spending', () => {
69
+ tracker.record('openai', 1.00);
70
+ tracker.record('openai', 0.50);
71
+
72
+ expect(tracker.usage.openai.daily).toBe(1.50);
73
+ });
74
+
75
+ it('persists to file', () => {
76
+ tracker.record('openai', 2.00);
77
+
78
+ const loaded = JSON.parse(fs.readFileSync(tracker.configPath, 'utf-8'));
79
+ expect(loaded.openai.daily).toBe(2.00);
80
+ });
81
+ });
82
+
83
+ describe('daily reset', () => {
84
+ it('resets daily at midnight', () => {
85
+ tracker.usage.openai = {
86
+ daily: 5.00,
87
+ monthly: 20.00,
88
+ lastDailyReset: new Date('2026-01-30').toISOString(),
89
+ lastMonthlyReset: new Date('2026-01-01').toISOString(), // Same month
90
+ };
91
+
92
+ // Simulate new day (same month)
93
+ vi.setSystemTime(new Date('2026-01-31T10:00:00'));
94
+ tracker.checkResets();
95
+
96
+ expect(tracker.usage.openai.daily).toBe(0);
97
+ expect(tracker.usage.openai.monthly).toBe(20.00); // Monthly preserved
98
+ });
99
+
100
+ it('does not reset if same day', () => {
101
+ const now = new Date('2026-01-31T10:00:00');
102
+ vi.setSystemTime(now);
103
+
104
+ tracker.usage.openai = {
105
+ daily: 3.00,
106
+ monthly: 15.00,
107
+ lastDailyReset: now.toISOString(),
108
+ };
109
+
110
+ tracker.checkResets();
111
+
112
+ expect(tracker.usage.openai.daily).toBe(3.00);
113
+ });
114
+ });
115
+
116
+ describe('monthly reset', () => {
117
+ it('resets monthly at month start', () => {
118
+ tracker.usage.openai = {
119
+ daily: 5.00,
120
+ monthly: 45.00,
121
+ lastMonthlyReset: new Date('2025-12-15').toISOString(),
122
+ };
123
+
124
+ // Simulate new month
125
+ vi.setSystemTime(new Date('2026-01-15'));
126
+ tracker.checkResets();
127
+
128
+ expect(tracker.usage.openai.monthly).toBe(0);
129
+ });
130
+ });
131
+
132
+ describe('shouldAlert', () => {
133
+ it('returns true at threshold', () => {
134
+ tracker.usage.openai = { daily: 4.00, monthly: 0 };
135
+ const config = { budgetDaily: 5.00, alertThreshold: 0.8 };
136
+
137
+ expect(tracker.shouldAlert('openai', config)).toBe(true);
138
+ });
139
+
140
+ it('returns false below threshold', () => {
141
+ tracker.usage.openai = { daily: 2.00, monthly: 0 };
142
+ const config = { budgetDaily: 5.00, alertThreshold: 0.8 };
143
+
144
+ expect(tracker.shouldAlert('openai', config)).toBe(false);
145
+ });
146
+ });
147
+
148
+ describe('getUsage', () => {
149
+ it('returns usage for model', () => {
150
+ tracker.usage.openai = { daily: 2.50, monthly: 25.00, requests: 10 };
151
+
152
+ const usage = tracker.getUsage('openai');
153
+
154
+ expect(usage.daily).toBe(2.50);
155
+ expect(usage.monthly).toBe(25.00);
156
+ });
157
+
158
+ it('returns zero for unknown model', () => {
159
+ const usage = tracker.getUsage('unknown');
160
+
161
+ expect(usage.daily).toBe(0);
162
+ expect(usage.monthly).toBe(0);
163
+ });
164
+ });
165
+ });
@@ -0,0 +1,85 @@
1
+ /**
2
+ * CLAUDE.md Injector - Inject memory context into CLAUDE.md
3
+ */
4
+
5
+ const fs = require('fs').promises;
6
+ const path = require('path');
7
+
8
+ /**
9
+ * Markers for the auto-generated memory section
10
+ */
11
+ const MEMORY_SECTION_MARKERS = {
12
+ START: '<!-- TLC-MEMORY-START -->',
13
+ END: '<!-- TLC-MEMORY-END -->',
14
+ };
15
+
16
+ /**
17
+ * Extract memory section content from CLAUDE.md
18
+ * @param {string} content - CLAUDE.md content
19
+ * @returns {string|null} Extracted memory content or null if not found
20
+ */
21
+ function extractMemorySection(content) {
22
+ if (!content) return null;
23
+
24
+ const startIdx = content.indexOf(MEMORY_SECTION_MARKERS.START);
25
+ const endIdx = content.indexOf(MEMORY_SECTION_MARKERS.END);
26
+
27
+ if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
28
+ return null;
29
+ }
30
+
31
+ const start = startIdx + MEMORY_SECTION_MARKERS.START.length;
32
+ return content.slice(start, endIdx).trim();
33
+ }
34
+
35
+ /**
36
+ * Inject memory context into CLAUDE.md
37
+ * @param {string} projectRoot - Project root directory
38
+ * @param {string} memoryContext - Memory context to inject
39
+ * @returns {Promise<void>}
40
+ */
41
+ async function injectMemoryContext(projectRoot, memoryContext) {
42
+ const claudeMdPath = path.join(projectRoot, 'CLAUDE.md');
43
+
44
+ let existingContent = '';
45
+ try {
46
+ existingContent = await fs.readFile(claudeMdPath, 'utf-8');
47
+ } catch (err) {
48
+ if (err.code !== 'ENOENT') throw err;
49
+ // File doesn't exist, will create new
50
+ }
51
+
52
+ // Build the new memory section
53
+ const memorySection = `${MEMORY_SECTION_MARKERS.START}
54
+ ${memoryContext}
55
+ ${MEMORY_SECTION_MARKERS.END}`;
56
+
57
+ let newContent;
58
+
59
+ // Check if memory section already exists
60
+ const startIdx = existingContent.indexOf(MEMORY_SECTION_MARKERS.START);
61
+ const endIdx = existingContent.indexOf(MEMORY_SECTION_MARKERS.END);
62
+
63
+ if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
64
+ // Replace existing section
65
+ const before = existingContent.slice(0, startIdx);
66
+ const after = existingContent.slice(endIdx + MEMORY_SECTION_MARKERS.END.length);
67
+ newContent = before + memorySection + after;
68
+ } else {
69
+ // Append new section
70
+ const trimmed = existingContent.trim();
71
+ if (trimmed) {
72
+ newContent = trimmed + '\n\n' + memorySection + '\n';
73
+ } else {
74
+ newContent = memorySection + '\n';
75
+ }
76
+ }
77
+
78
+ await fs.writeFile(claudeMdPath, newContent, 'utf-8');
79
+ }
80
+
81
+ module.exports = {
82
+ injectMemoryContext,
83
+ extractMemorySection,
84
+ MEMORY_SECTION_MARKERS,
85
+ };