@vibescope/mcp-server 0.2.0 → 0.2.2

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 (104) hide show
  1. package/README.md +60 -7
  2. package/dist/api-client.d.ts +251 -1
  3. package/dist/api-client.js +82 -3
  4. package/dist/handlers/blockers.js +9 -8
  5. package/dist/handlers/bodies-of-work.js +96 -63
  6. package/dist/handlers/connectors.d.ts +45 -0
  7. package/dist/handlers/connectors.js +183 -0
  8. package/dist/handlers/cost.d.ts +10 -0
  9. package/dist/handlers/cost.js +112 -50
  10. package/dist/handlers/decisions.js +32 -19
  11. package/dist/handlers/deployment.js +144 -122
  12. package/dist/handlers/discovery.d.ts +7 -0
  13. package/dist/handlers/discovery.js +96 -7
  14. package/dist/handlers/fallback.js +29 -23
  15. package/dist/handlers/file-checkouts.d.ts +20 -0
  16. package/dist/handlers/file-checkouts.js +133 -0
  17. package/dist/handlers/findings.d.ts +6 -0
  18. package/dist/handlers/findings.js +96 -40
  19. package/dist/handlers/git-issues.js +40 -36
  20. package/dist/handlers/ideas.js +49 -31
  21. package/dist/handlers/index.d.ts +3 -0
  22. package/dist/handlers/index.js +9 -0
  23. package/dist/handlers/milestones.js +39 -32
  24. package/dist/handlers/organizations.js +99 -91
  25. package/dist/handlers/progress.js +24 -13
  26. package/dist/handlers/project.js +68 -28
  27. package/dist/handlers/requests.js +18 -14
  28. package/dist/handlers/roles.d.ts +18 -0
  29. package/dist/handlers/roles.js +130 -0
  30. package/dist/handlers/session.js +58 -17
  31. package/dist/handlers/sprints.js +93 -81
  32. package/dist/handlers/tasks.d.ts +2 -0
  33. package/dist/handlers/tasks.js +189 -91
  34. package/dist/handlers/types.d.ts +64 -2
  35. package/dist/handlers/types.js +48 -1
  36. package/dist/handlers/validation.js +21 -17
  37. package/dist/index.js +7 -2716
  38. package/dist/token-tracking.d.ts +74 -0
  39. package/dist/token-tracking.js +122 -0
  40. package/dist/tools.js +685 -9
  41. package/dist/utils.d.ts +5 -0
  42. package/dist/utils.js +17 -0
  43. package/docs/TOOLS.md +2053 -0
  44. package/package.json +4 -1
  45. package/scripts/generate-docs.ts +212 -0
  46. package/src/api-client.test.ts +718 -0
  47. package/src/api-client.ts +320 -6
  48. package/src/handlers/__test-setup__.ts +16 -0
  49. package/src/handlers/blockers.test.ts +31 -19
  50. package/src/handlers/blockers.ts +9 -8
  51. package/src/handlers/bodies-of-work.test.ts +55 -32
  52. package/src/handlers/bodies-of-work.ts +115 -115
  53. package/src/handlers/connectors.test.ts +834 -0
  54. package/src/handlers/connectors.ts +229 -0
  55. package/src/handlers/cost.test.ts +34 -44
  56. package/src/handlers/cost.ts +136 -85
  57. package/src/handlers/decisions.test.ts +37 -27
  58. package/src/handlers/decisions.ts +35 -30
  59. package/src/handlers/deployment.ts +180 -208
  60. package/src/handlers/discovery.test.ts +4 -5
  61. package/src/handlers/discovery.ts +98 -8
  62. package/src/handlers/fallback.test.ts +26 -22
  63. package/src/handlers/fallback.ts +36 -33
  64. package/src/handlers/file-checkouts.test.ts +670 -0
  65. package/src/handlers/file-checkouts.ts +165 -0
  66. package/src/handlers/findings.test.ts +178 -19
  67. package/src/handlers/findings.ts +112 -74
  68. package/src/handlers/git-issues.test.ts +51 -43
  69. package/src/handlers/git-issues.ts +44 -84
  70. package/src/handlers/ideas.test.ts +28 -23
  71. package/src/handlers/ideas.ts +61 -59
  72. package/src/handlers/index.ts +9 -0
  73. package/src/handlers/milestones.test.ts +33 -28
  74. package/src/handlers/milestones.ts +52 -50
  75. package/src/handlers/organizations.test.ts +104 -83
  76. package/src/handlers/organizations.ts +117 -142
  77. package/src/handlers/progress.test.ts +20 -14
  78. package/src/handlers/progress.ts +26 -24
  79. package/src/handlers/project.test.ts +34 -27
  80. package/src/handlers/project.ts +95 -63
  81. package/src/handlers/requests.test.ts +27 -18
  82. package/src/handlers/requests.ts +21 -17
  83. package/src/handlers/roles.test.ts +303 -0
  84. package/src/handlers/roles.ts +208 -0
  85. package/src/handlers/session.test.ts +47 -0
  86. package/src/handlers/session.ts +71 -26
  87. package/src/handlers/sprints.test.ts +71 -50
  88. package/src/handlers/sprints.ts +113 -146
  89. package/src/handlers/tasks.test.ts +77 -15
  90. package/src/handlers/tasks.ts +231 -156
  91. package/src/handlers/tool-categories.test.ts +66 -0
  92. package/src/handlers/types.ts +81 -2
  93. package/src/handlers/validation.test.ts +78 -45
  94. package/src/handlers/validation.ts +23 -25
  95. package/src/index.ts +12 -2732
  96. package/src/token-tracking.test.ts +453 -0
  97. package/src/token-tracking.ts +164 -0
  98. package/src/tools.ts +685 -9
  99. package/src/utils.test.ts +2 -2
  100. package/src/utils.ts +17 -0
  101. package/dist/config/tool-categories.d.ts +0 -31
  102. package/dist/config/tool-categories.js +0 -253
  103. package/dist/knowledge.d.ts +0 -6
  104. package/dist/knowledge.js +0 -218
@@ -0,0 +1,453 @@
1
+ import { describe, it, expect, beforeEach } from 'vitest';
2
+ import {
3
+ estimateTokens,
4
+ createTokenUsage,
5
+ trackTokenUsage,
6
+ setCurrentModel,
7
+ resetTokenUsage,
8
+ getTokenUsageSummary,
9
+ type TokenUsage,
10
+ } from './token-tracking.js';
11
+
12
+ // ============================================================================
13
+ // estimateTokens Tests
14
+ // ============================================================================
15
+
16
+ describe('estimateTokens', () => {
17
+ it('should return 1 for empty object', () => {
18
+ // "{}" is 2 chars, ceil(2/4) = 1
19
+ expect(estimateTokens({})).toBe(1);
20
+ });
21
+
22
+ it('should return 1 for empty array', () => {
23
+ // "[]" is 2 chars, ceil(2/4) = 1
24
+ expect(estimateTokens([])).toBe(1);
25
+ });
26
+
27
+ it('should return 1 for empty string', () => {
28
+ // '""' is 2 chars, ceil(2/4) = 1
29
+ expect(estimateTokens('')).toBe(1);
30
+ });
31
+
32
+ it('should return 1 for null', () => {
33
+ // "null" is 4 chars, ceil(4/4) = 1
34
+ expect(estimateTokens(null)).toBe(1);
35
+ });
36
+
37
+ it('should return 1 for boolean', () => {
38
+ // "true" is 4 chars, ceil(4/4) = 1
39
+ expect(estimateTokens(true)).toBe(1);
40
+ // "false" is 5 chars, ceil(5/4) = 2
41
+ expect(estimateTokens(false)).toBe(2);
42
+ });
43
+
44
+ it('should estimate tokens for simple object', () => {
45
+ const obj = { name: 'test' };
46
+ // {"name":"test"} is 15 chars, ceil(15/4) = 4
47
+ expect(estimateTokens(obj)).toBe(4);
48
+ });
49
+
50
+ it('should estimate tokens for array of strings', () => {
51
+ const arr = ['one', 'two', 'three'];
52
+ // ["one","two","three"] is 21 chars, ceil(21/4) = 6
53
+ expect(estimateTokens(arr)).toBe(6);
54
+ });
55
+
56
+ it('should estimate tokens for nested object', () => {
57
+ const obj = {
58
+ user: {
59
+ name: 'John',
60
+ age: 30,
61
+ },
62
+ active: true,
63
+ };
64
+ // Complex object - just verify it returns a reasonable positive number
65
+ const tokens = estimateTokens(obj);
66
+ expect(tokens).toBeGreaterThan(0);
67
+ expect(tokens).toBeLessThan(100); // Sanity check
68
+ });
69
+
70
+ it('should estimate tokens for large object', () => {
71
+ const obj = {
72
+ tasks: Array(100)
73
+ .fill(null)
74
+ .map((_, i) => ({
75
+ id: `task-${i}`,
76
+ title: `Task number ${i}`,
77
+ status: 'pending',
78
+ })),
79
+ };
80
+ const tokens = estimateTokens(obj);
81
+ // Should be a large number for 100 tasks
82
+ expect(tokens).toBeGreaterThan(500);
83
+ });
84
+
85
+ it('should handle numbers', () => {
86
+ // "12345" is 5 chars, ceil(5/4) = 2
87
+ expect(estimateTokens(12345)).toBe(2);
88
+ // "3.14159" is 7 chars, ceil(7/4) = 2
89
+ expect(estimateTokens(3.14159)).toBe(2);
90
+ });
91
+
92
+ it('should handle undefined by treating as null', () => {
93
+ // JSON.stringify(undefined) returns undefined, not a string
94
+ // Our function handles this gracefully
95
+ const tokens = estimateTokens(undefined);
96
+ expect(tokens).toBeGreaterThanOrEqual(1);
97
+ });
98
+
99
+ it('should handle circular reference gracefully', () => {
100
+ const obj: Record<string, unknown> = { name: 'test' };
101
+ obj.self = obj; // Create circular reference
102
+
103
+ // Should not throw, should return minimal estimate
104
+ const tokens = estimateTokens(obj);
105
+ expect(tokens).toBe(1);
106
+ });
107
+
108
+ it('should handle objects with toJSON method', () => {
109
+ const obj = {
110
+ data: 'test',
111
+ toJSON() {
112
+ return { serialized: true };
113
+ },
114
+ };
115
+ // toJSON returns {"serialized":true} which is 18 chars, ceil(18/4) = 5
116
+ expect(estimateTokens(obj)).toBe(5);
117
+ });
118
+ });
119
+
120
+ // ============================================================================
121
+ // createTokenUsage Tests
122
+ // ============================================================================
123
+
124
+ describe('createTokenUsage', () => {
125
+ it('should create fresh token usage object', () => {
126
+ const usage = createTokenUsage();
127
+
128
+ expect(usage.callCount).toBe(0);
129
+ expect(usage.totalTokens).toBe(0);
130
+ expect(usage.byTool).toEqual({});
131
+ expect(usage.byModel).toEqual({});
132
+ expect(usage.currentModel).toBeNull();
133
+ });
134
+
135
+ it('should create independent instances', () => {
136
+ const usage1 = createTokenUsage();
137
+ const usage2 = createTokenUsage();
138
+
139
+ usage1.callCount = 5;
140
+ usage1.byTool['test'] = { calls: 1, tokens: 10 };
141
+
142
+ expect(usage2.callCount).toBe(0);
143
+ expect(usage2.byTool).toEqual({});
144
+ });
145
+ });
146
+
147
+ // ============================================================================
148
+ // trackTokenUsage Tests
149
+ // ============================================================================
150
+
151
+ describe('trackTokenUsage', () => {
152
+ let usage: TokenUsage;
153
+
154
+ beforeEach(() => {
155
+ usage = createTokenUsage();
156
+ });
157
+
158
+ it('should increment call count', () => {
159
+ trackTokenUsage(usage, 'test_tool', {}, {});
160
+ expect(usage.callCount).toBe(1);
161
+
162
+ trackTokenUsage(usage, 'test_tool', {}, {});
163
+ expect(usage.callCount).toBe(2);
164
+ });
165
+
166
+ it('should accumulate total tokens', () => {
167
+ trackTokenUsage(usage, 'tool1', { key: 'value' }, { result: 'ok' });
168
+ const firstTotal = usage.totalTokens;
169
+ expect(firstTotal).toBeGreaterThan(0);
170
+
171
+ trackTokenUsage(usage, 'tool2', { key: 'value' }, { result: 'ok' });
172
+ expect(usage.totalTokens).toBeGreaterThan(firstTotal);
173
+ });
174
+
175
+ it('should track by tool name', () => {
176
+ trackTokenUsage(usage, 'add_task', { title: 'Test' }, { success: true });
177
+ trackTokenUsage(usage, 'add_task', { title: 'Test 2' }, { success: true });
178
+ trackTokenUsage(usage, 'complete_task', { id: '123' }, { success: true });
179
+
180
+ expect(usage.byTool['add_task'].calls).toBe(2);
181
+ expect(usage.byTool['complete_task'].calls).toBe(1);
182
+ expect(usage.byTool['add_task'].tokens).toBeGreaterThan(0);
183
+ });
184
+
185
+ it('should track by model when set', () => {
186
+ setCurrentModel(usage, 'opus');
187
+ trackTokenUsage(usage, 'tool1', { data: 'input' }, { data: 'output' });
188
+
189
+ expect(usage.byModel['opus']).toBeDefined();
190
+ expect(usage.byModel['opus'].input).toBeGreaterThan(0);
191
+ expect(usage.byModel['opus'].output).toBeGreaterThan(0);
192
+ });
193
+
194
+ it('should not track by model when not set', () => {
195
+ trackTokenUsage(usage, 'tool1', { data: 'input' }, { data: 'output' });
196
+
197
+ expect(Object.keys(usage.byModel)).toHaveLength(0);
198
+ });
199
+
200
+ it('should track multiple models separately', () => {
201
+ setCurrentModel(usage, 'opus');
202
+ trackTokenUsage(usage, 'tool1', { x: 1 }, { y: 2 });
203
+
204
+ setCurrentModel(usage, 'sonnet');
205
+ trackTokenUsage(usage, 'tool2', { x: 1 }, { y: 2 });
206
+ trackTokenUsage(usage, 'tool3', { x: 1 }, { y: 2 });
207
+
208
+ expect(Object.keys(usage.byModel)).toHaveLength(2);
209
+ expect(usage.byModel['opus']).toBeDefined();
210
+ expect(usage.byModel['sonnet']).toBeDefined();
211
+ });
212
+
213
+ it('should handle empty args and response', () => {
214
+ trackTokenUsage(usage, 'empty_tool', {}, {});
215
+
216
+ expect(usage.callCount).toBe(1);
217
+ expect(usage.totalTokens).toBeGreaterThanOrEqual(2); // At least 2 for {} + {}
218
+ expect(usage.byTool['empty_tool'].calls).toBe(1);
219
+ });
220
+
221
+ it('should handle large args and response', () => {
222
+ const largeArgs = { items: Array(1000).fill('item') };
223
+ const largeResponse = { results: Array(1000).fill({ ok: true }) };
224
+
225
+ trackTokenUsage(usage, 'large_tool', largeArgs, largeResponse);
226
+
227
+ expect(usage.totalTokens).toBeGreaterThan(1000);
228
+ });
229
+ });
230
+
231
+ // ============================================================================
232
+ // setCurrentModel Tests
233
+ // ============================================================================
234
+
235
+ describe('setCurrentModel', () => {
236
+ it('should set the current model', () => {
237
+ const usage = createTokenUsage();
238
+
239
+ setCurrentModel(usage, 'opus');
240
+ expect(usage.currentModel).toBe('opus');
241
+
242
+ setCurrentModel(usage, 'sonnet');
243
+ expect(usage.currentModel).toBe('sonnet');
244
+ });
245
+
246
+ it('should allow clearing the model with null', () => {
247
+ const usage = createTokenUsage();
248
+
249
+ setCurrentModel(usage, 'opus');
250
+ expect(usage.currentModel).toBe('opus');
251
+
252
+ setCurrentModel(usage, null);
253
+ expect(usage.currentModel).toBeNull();
254
+ });
255
+ });
256
+
257
+ // ============================================================================
258
+ // resetTokenUsage Tests
259
+ // ============================================================================
260
+
261
+ describe('resetTokenUsage', () => {
262
+ it('should reset all tracking data', () => {
263
+ const usage = createTokenUsage();
264
+
265
+ // Add some data
266
+ setCurrentModel(usage, 'opus');
267
+ trackTokenUsage(usage, 'tool1', { a: 1 }, { b: 2 });
268
+ trackTokenUsage(usage, 'tool2', { c: 3 }, { d: 4 });
269
+
270
+ expect(usage.callCount).toBe(2);
271
+ expect(usage.totalTokens).toBeGreaterThan(0);
272
+ expect(Object.keys(usage.byTool)).toHaveLength(2);
273
+
274
+ // Reset
275
+ resetTokenUsage(usage);
276
+
277
+ expect(usage.callCount).toBe(0);
278
+ expect(usage.totalTokens).toBe(0);
279
+ expect(usage.byTool).toEqual({});
280
+ expect(usage.byModel).toEqual({});
281
+ expect(usage.currentModel).toBeNull();
282
+ });
283
+
284
+ it('should allow tracking after reset', () => {
285
+ const usage = createTokenUsage();
286
+
287
+ trackTokenUsage(usage, 'tool1', {}, {});
288
+ resetTokenUsage(usage);
289
+
290
+ trackTokenUsage(usage, 'tool2', { x: 1 }, { y: 2 });
291
+
292
+ expect(usage.callCount).toBe(1);
293
+ expect(usage.byTool['tool1']).toBeUndefined();
294
+ expect(usage.byTool['tool2']).toBeDefined();
295
+ });
296
+ });
297
+
298
+ // ============================================================================
299
+ // getTokenUsageSummary Tests
300
+ // ============================================================================
301
+
302
+ describe('getTokenUsageSummary', () => {
303
+ it('should return empty summary for new usage', () => {
304
+ const usage = createTokenUsage();
305
+ const summary = getTokenUsageSummary(usage);
306
+
307
+ expect(summary.total_calls).toBe(0);
308
+ expect(summary.total_tokens).toBe(0);
309
+ expect(summary.average_tokens_per_call).toBe(0);
310
+ expect(summary.by_tool).toEqual({});
311
+ expect(summary.by_model).toEqual({});
312
+ expect(summary.current_model).toBeNull();
313
+ });
314
+
315
+ it('should calculate average tokens per call', () => {
316
+ const usage = createTokenUsage();
317
+
318
+ // Track 3 calls with roughly similar sizes
319
+ trackTokenUsage(usage, 'tool1', { a: 1 }, { b: 2 });
320
+ trackTokenUsage(usage, 'tool1', { a: 1 }, { b: 2 });
321
+ trackTokenUsage(usage, 'tool1', { a: 1 }, { b: 2 });
322
+
323
+ const summary = getTokenUsageSummary(usage);
324
+
325
+ expect(summary.total_calls).toBe(3);
326
+ expect(summary.average_tokens_per_call).toBeGreaterThan(0);
327
+ expect(summary.average_tokens_per_call).toBe(
328
+ Math.round(summary.total_tokens / summary.total_calls)
329
+ );
330
+ });
331
+
332
+ it('should include per-tool averages', () => {
333
+ const usage = createTokenUsage();
334
+
335
+ trackTokenUsage(usage, 'small_tool', { x: 1 }, { y: 2 });
336
+ trackTokenUsage(usage, 'small_tool', { x: 1 }, { y: 2 });
337
+
338
+ const summary = getTokenUsageSummary(usage);
339
+
340
+ expect(summary.by_tool['small_tool'].calls).toBe(2);
341
+ expect(summary.by_tool['small_tool'].avg).toBeGreaterThan(0);
342
+ expect(summary.by_tool['small_tool'].avg).toBe(
343
+ Math.round(summary.by_tool['small_tool'].tokens / summary.by_tool['small_tool'].calls)
344
+ );
345
+ });
346
+
347
+ it('should include model breakdown', () => {
348
+ const usage = createTokenUsage();
349
+
350
+ setCurrentModel(usage, 'opus');
351
+ trackTokenUsage(usage, 'tool1', { a: 1 }, { b: 2 });
352
+
353
+ setCurrentModel(usage, 'haiku');
354
+ trackTokenUsage(usage, 'tool2', { c: 3 }, { d: 4 });
355
+
356
+ const summary = getTokenUsageSummary(usage);
357
+
358
+ expect(summary.by_model['opus']).toBeDefined();
359
+ expect(summary.by_model['haiku']).toBeDefined();
360
+ expect(summary.current_model).toBe('haiku');
361
+ });
362
+
363
+ it('should return a copy of byModel to prevent mutation', () => {
364
+ const usage = createTokenUsage();
365
+
366
+ setCurrentModel(usage, 'opus');
367
+ trackTokenUsage(usage, 'tool1', { a: 1 }, { b: 2 });
368
+
369
+ const summary = getTokenUsageSummary(usage);
370
+
371
+ // Modify the summary
372
+ summary.by_model['opus'].input = 999;
373
+
374
+ // Original should be unchanged
375
+ expect(usage.byModel['opus'].input).not.toBe(999);
376
+ });
377
+ });
378
+
379
+ // ============================================================================
380
+ // Integration Tests
381
+ // ============================================================================
382
+
383
+ describe('Token Tracking Integration', () => {
384
+ it('should track a realistic session workflow', () => {
385
+ const usage = createTokenUsage();
386
+
387
+ // Agent starts with opus model
388
+ setCurrentModel(usage, 'opus');
389
+
390
+ // Start session
391
+ trackTokenUsage(
392
+ usage,
393
+ 'start_work_session',
394
+ { git_url: 'https://github.com/org/repo', model: 'opus' },
395
+ {
396
+ session_id: '123',
397
+ persona: 'Atlas',
398
+ next_task: { id: 'task-1', title: 'Fix bug' },
399
+ }
400
+ );
401
+
402
+ // Update task
403
+ trackTokenUsage(
404
+ usage,
405
+ 'update_task',
406
+ { task_id: 'task-1', status: 'in_progress', progress_percentage: 25 },
407
+ { success: true }
408
+ );
409
+
410
+ // Complete task
411
+ trackTokenUsage(
412
+ usage,
413
+ 'complete_task',
414
+ { task_id: 'task-1', summary: 'Fixed the bug' },
415
+ { success: true, next_task: null }
416
+ );
417
+
418
+ const summary = getTokenUsageSummary(usage);
419
+
420
+ expect(summary.total_calls).toBe(3);
421
+ expect(summary.by_tool['start_work_session'].calls).toBe(1);
422
+ expect(summary.by_tool['update_task'].calls).toBe(1);
423
+ expect(summary.by_tool['complete_task'].calls).toBe(1);
424
+ expect(summary.by_model['opus']).toBeDefined();
425
+ expect(summary.by_model['opus'].input).toBeGreaterThan(0);
426
+ expect(summary.by_model['opus'].output).toBeGreaterThan(0);
427
+ });
428
+
429
+ it('should handle session reset', () => {
430
+ const usage = createTokenUsage();
431
+
432
+ // First session
433
+ setCurrentModel(usage, 'sonnet');
434
+ trackTokenUsage(usage, 'tool1', {}, {});
435
+ trackTokenUsage(usage, 'tool2', {}, {});
436
+
437
+ const firstSummary = getTokenUsageSummary(usage);
438
+ expect(firstSummary.total_calls).toBe(2);
439
+
440
+ // Reset for new session
441
+ resetTokenUsage(usage);
442
+
443
+ // Second session
444
+ setCurrentModel(usage, 'haiku');
445
+ trackTokenUsage(usage, 'tool3', {}, {});
446
+
447
+ const secondSummary = getTokenUsageSummary(usage);
448
+ expect(secondSummary.total_calls).toBe(1);
449
+ expect(secondSummary.by_tool['tool1']).toBeUndefined();
450
+ expect(secondSummary.by_tool['tool3']).toBeDefined();
451
+ expect(secondSummary.current_model).toBe('haiku');
452
+ });
453
+ });
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Token Tracking Utilities
3
+ *
4
+ * Functions for estimating and tracking token usage across MCP tool calls.
5
+ * Extracted from index.ts to enable unit testing.
6
+ */
7
+
8
+ // ============================================================================
9
+ // Types
10
+ // ============================================================================
11
+
12
+ export interface ModelTokens {
13
+ input: number;
14
+ output: number;
15
+ }
16
+
17
+ export interface TokenUsage {
18
+ callCount: number;
19
+ totalTokens: number;
20
+ byTool: Record<string, { calls: number; tokens: number }>;
21
+ byModel: Record<string, ModelTokens>;
22
+ currentModel: string | null;
23
+ }
24
+
25
+ // ============================================================================
26
+ // Token Estimation
27
+ // ============================================================================
28
+
29
+ /**
30
+ * Estimate tokens from a JSON-serializable object.
31
+ * Uses a rough heuristic of ~4 characters per token.
32
+ *
33
+ * @param obj - Any JSON-serializable object
34
+ * @returns Estimated token count (always >= 1)
35
+ */
36
+ export function estimateTokens(obj: unknown): number {
37
+ try {
38
+ const json = JSON.stringify(obj);
39
+ return Math.max(1, Math.ceil(json.length / 4));
40
+ } catch {
41
+ // If JSON serialization fails, return a minimal estimate
42
+ return 1;
43
+ }
44
+ }
45
+
46
+ // ============================================================================
47
+ // Token Usage Tracking
48
+ // ============================================================================
49
+
50
+ /**
51
+ * Create a fresh token usage tracker.
52
+ */
53
+ export function createTokenUsage(): TokenUsage {
54
+ return {
55
+ callCount: 0,
56
+ totalTokens: 0,
57
+ byTool: {},
58
+ byModel: {},
59
+ currentModel: null,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Track token usage for a tool call.
65
+ * Updates the usage object in-place with input/output token estimates.
66
+ *
67
+ * @param usage - The token usage object to update
68
+ * @param toolName - Name of the tool being called
69
+ * @param args - Input arguments to the tool
70
+ * @param response - Response from the tool
71
+ */
72
+ export function trackTokenUsage(
73
+ usage: TokenUsage,
74
+ toolName: string,
75
+ args: unknown,
76
+ response: unknown
77
+ ): void {
78
+ const inputTokens = estimateTokens(args);
79
+ const outputTokens = estimateTokens(response);
80
+ const totalTokens = inputTokens + outputTokens;
81
+
82
+ usage.callCount++;
83
+ usage.totalTokens += totalTokens;
84
+
85
+ if (!usage.byTool[toolName]) {
86
+ usage.byTool[toolName] = { calls: 0, tokens: 0 };
87
+ }
88
+ usage.byTool[toolName].calls++;
89
+ usage.byTool[toolName].tokens += totalTokens;
90
+
91
+ // Track by model if a model is set
92
+ const model = usage.currentModel;
93
+ if (model) {
94
+ if (!usage.byModel[model]) {
95
+ usage.byModel[model] = { input: 0, output: 0 };
96
+ }
97
+ usage.byModel[model].input += inputTokens;
98
+ usage.byModel[model].output += outputTokens;
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Set the current model for token tracking.
104
+ * Subsequent calls to trackTokenUsage will be attributed to this model.
105
+ *
106
+ * @param usage - The token usage object to update
107
+ * @param model - Model name (e.g., "opus", "sonnet", "haiku")
108
+ */
109
+ export function setCurrentModel(usage: TokenUsage, model: string | null): void {
110
+ usage.currentModel = model;
111
+ }
112
+
113
+ /**
114
+ * Reset token usage tracking to initial state.
115
+ *
116
+ * @param usage - The token usage object to reset
117
+ */
118
+ export function resetTokenUsage(usage: TokenUsage): void {
119
+ usage.callCount = 0;
120
+ usage.totalTokens = 0;
121
+ usage.byTool = {};
122
+ usage.byModel = {};
123
+ usage.currentModel = null;
124
+ }
125
+
126
+ /**
127
+ * Get a summary of token usage for reporting.
128
+ *
129
+ * @param usage - The token usage object
130
+ * @returns Summary object with stats
131
+ */
132
+ export function getTokenUsageSummary(usage: TokenUsage): {
133
+ total_calls: number;
134
+ total_tokens: number;
135
+ average_tokens_per_call: number;
136
+ by_tool: Record<string, { calls: number; tokens: number; avg: number }>;
137
+ by_model: Record<string, ModelTokens>;
138
+ current_model: string | null;
139
+ } {
140
+ const byTool: Record<string, { calls: number; tokens: number; avg: number }> = {};
141
+
142
+ for (const [tool, stats] of Object.entries(usage.byTool)) {
143
+ byTool[tool] = {
144
+ calls: stats.calls,
145
+ tokens: stats.tokens,
146
+ avg: stats.calls > 0 ? Math.round(stats.tokens / stats.calls) : 0,
147
+ };
148
+ }
149
+
150
+ // Deep copy byModel to prevent mutation
151
+ const byModel: Record<string, ModelTokens> = {};
152
+ for (const [model, tokens] of Object.entries(usage.byModel)) {
153
+ byModel[model] = { input: tokens.input, output: tokens.output };
154
+ }
155
+
156
+ return {
157
+ total_calls: usage.callCount,
158
+ total_tokens: usage.totalTokens,
159
+ average_tokens_per_call: usage.callCount > 0 ? Math.round(usage.totalTokens / usage.callCount) : 0,
160
+ by_tool: byTool,
161
+ by_model: byModel,
162
+ current_model: usage.currentModel,
163
+ };
164
+ }