@ian2018cs/agenthub 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.
Files changed (136) hide show
  1. package/LICENSE +675 -0
  2. package/README.md +330 -0
  3. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/dist/assets/index-B4ru3EJb.css +32 -0
  63. package/dist/assets/index-DDFuyrpY.js +154 -0
  64. package/dist/assets/vendor-codemirror-C_VWDoZS.js +39 -0
  65. package/dist/assets/vendor-icons-CJV4dnDL.js +326 -0
  66. package/dist/assets/vendor-katex-DK8hFnhL.js +261 -0
  67. package/dist/assets/vendor-markdown-VwNYkg_0.js +35 -0
  68. package/dist/assets/vendor-react-BeVl62c0.js +59 -0
  69. package/dist/assets/vendor-syntax-CdGaPJRS.js +16 -0
  70. package/dist/assets/vendor-utils-00TdZexr.js +1 -0
  71. package/dist/assets/vendor-xterm-CvdiG4-n.js +66 -0
  72. package/dist/clear-cache.html +85 -0
  73. package/dist/convert-icons.md +53 -0
  74. package/dist/favicon.png +0 -0
  75. package/dist/favicon.svg +9 -0
  76. package/dist/generate-icons.js +49 -0
  77. package/dist/icons/claude-ai-icon.svg +1 -0
  78. package/dist/icons/codex-white.svg +3 -0
  79. package/dist/icons/codex.svg +3 -0
  80. package/dist/icons/cursor-white.svg +12 -0
  81. package/dist/icons/cursor.svg +1 -0
  82. package/dist/icons/generate-icons.md +19 -0
  83. package/dist/icons/icon-128x128.png +0 -0
  84. package/dist/icons/icon-128x128.svg +12 -0
  85. package/dist/icons/icon-144x144.png +0 -0
  86. package/dist/icons/icon-144x144.svg +12 -0
  87. package/dist/icons/icon-152x152.png +0 -0
  88. package/dist/icons/icon-152x152.svg +12 -0
  89. package/dist/icons/icon-192x192.png +0 -0
  90. package/dist/icons/icon-192x192.svg +12 -0
  91. package/dist/icons/icon-384x384.png +0 -0
  92. package/dist/icons/icon-384x384.svg +12 -0
  93. package/dist/icons/icon-512x512.png +0 -0
  94. package/dist/icons/icon-512x512.svg +12 -0
  95. package/dist/icons/icon-72x72.png +0 -0
  96. package/dist/icons/icon-72x72.svg +12 -0
  97. package/dist/icons/icon-96x96.png +0 -0
  98. package/dist/icons/icon-96x96.svg +12 -0
  99. package/dist/icons/icon-template.svg +12 -0
  100. package/dist/index.html +57 -0
  101. package/dist/logo-128.png +0 -0
  102. package/dist/logo-256.png +0 -0
  103. package/dist/logo-32.png +0 -0
  104. package/dist/logo-512.png +0 -0
  105. package/dist/logo-64.png +0 -0
  106. package/dist/logo.svg +17 -0
  107. package/dist/manifest.json +61 -0
  108. package/dist/screenshots/cli-selection.png +0 -0
  109. package/dist/screenshots/desktop-main.png +0 -0
  110. package/dist/screenshots/mobile-chat.png +0 -0
  111. package/dist/screenshots/tools-modal.png +0 -0
  112. package/dist/sw.js +49 -0
  113. package/package.json +113 -0
  114. package/server/claude-sdk.js +791 -0
  115. package/server/cli.js +330 -0
  116. package/server/database/auth.db +0 -0
  117. package/server/database/db.js +523 -0
  118. package/server/database/init.sql +23 -0
  119. package/server/index.js +1678 -0
  120. package/server/load-env.js +27 -0
  121. package/server/middleware/auth.js +118 -0
  122. package/server/projects.js +899 -0
  123. package/server/routes/admin.js +89 -0
  124. package/server/routes/auth.js +144 -0
  125. package/server/routes/commands.js +570 -0
  126. package/server/routes/mcp-utils.js +37 -0
  127. package/server/routes/mcp.js +593 -0
  128. package/server/routes/projects.js +216 -0
  129. package/server/routes/skills.js +891 -0
  130. package/server/routes/usage.js +206 -0
  131. package/server/services/pricing.js +196 -0
  132. package/server/services/usage-scanner.js +283 -0
  133. package/server/services/user-directories.js +123 -0
  134. package/server/utils/commandParser.js +303 -0
  135. package/server/utils/mcp-detector.js +73 -0
  136. package/shared/modelConstants.js +23 -0
@@ -0,0 +1,206 @@
1
+ import express from 'express';
2
+ import { userDb, usageDb } from '../database/db.js';
3
+ import { authenticateToken } from '../middleware/auth.js';
4
+ import { triggerManualScan } from '../services/usage-scanner.js';
5
+
6
+ const router = express.Router();
7
+
8
+ // Admin middleware
9
+ const requireAdmin = (req, res, next) => {
10
+ if (req.user.role !== 'admin') {
11
+ return res.status(403).json({ error: 'Admin access required' });
12
+ }
13
+ next();
14
+ };
15
+
16
+ // Apply auth and admin middleware to all routes
17
+ router.use(authenticateToken);
18
+ router.use(requireAdmin);
19
+
20
+ /**
21
+ * Get usage summary for all users
22
+ * Used in the user list page to show cost per user
23
+ */
24
+ router.get('/summary', (req, res) => {
25
+ try {
26
+ const usageSummary = usageDb.getAllUsersSummary();
27
+ const users = userDb.getAllUsers();
28
+
29
+ // Create a map of uuid to user info
30
+ const userMap = {};
31
+ for (const user of users) {
32
+ userMap[user.uuid] = {
33
+ id: user.id,
34
+ username: user.username,
35
+ role: user.role,
36
+ status: user.status
37
+ };
38
+ }
39
+
40
+ // Merge usage data with user info
41
+ const result = usageSummary.map(usage => ({
42
+ ...usage,
43
+ ...userMap[usage.user_uuid]
44
+ }));
45
+
46
+ // Add users with no usage
47
+ for (const user of users) {
48
+ if (!usageSummary.find(u => u.user_uuid === user.uuid)) {
49
+ result.push({
50
+ user_uuid: user.uuid,
51
+ id: user.id,
52
+ username: user.username,
53
+ role: user.role,
54
+ status: user.status,
55
+ total_cost: 0,
56
+ total_requests: 0,
57
+ total_sessions: 0,
58
+ last_active: null
59
+ });
60
+ }
61
+ }
62
+
63
+ res.json({ users: result });
64
+ } catch (error) {
65
+ console.error('Error fetching usage summary:', error);
66
+ res.status(500).json({ error: 'Failed to fetch usage summary' });
67
+ }
68
+ });
69
+
70
+ /**
71
+ * Get detailed usage for a specific user
72
+ */
73
+ router.get('/users/:uuid', (req, res) => {
74
+ try {
75
+ const { uuid } = req.params;
76
+ const { period = 'week' } = req.query;
77
+
78
+ // Validate user exists
79
+ const user = userDb.getUserByUuid(uuid);
80
+ if (!user) {
81
+ return res.status(404).json({ error: 'User not found' });
82
+ }
83
+
84
+ // Calculate date range
85
+ const endDate = new Date().toISOString().split('T')[0];
86
+ let startDate;
87
+
88
+ switch (period) {
89
+ case 'week':
90
+ startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
91
+ break;
92
+ case 'month':
93
+ startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
94
+ break;
95
+ case 'all':
96
+ startDate = '2020-01-01';
97
+ break;
98
+ default:
99
+ startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
100
+ }
101
+
102
+ // Get usage data
103
+ const dailyUsage = usageDb.getUserUsageByPeriod(uuid, startDate, endDate);
104
+ const totalUsage = usageDb.getUserTotalUsage(uuid);
105
+ const modelDistribution = usageDb.getUserModelDistribution(uuid, startDate, endDate);
106
+
107
+ // Aggregate daily data
108
+ const dailyMap = {};
109
+ for (const record of dailyUsage) {
110
+ if (!dailyMap[record.date]) {
111
+ dailyMap[record.date] = { date: record.date, cost: 0, requests: 0 };
112
+ }
113
+ dailyMap[record.date].cost += record.total_cost_usd;
114
+ dailyMap[record.date].requests += record.request_count;
115
+ }
116
+ const dailyTrend = Object.values(dailyMap).sort((a, b) => a.date.localeCompare(b.date));
117
+
118
+ res.json({
119
+ user: {
120
+ uuid: user.uuid,
121
+ username: user.username,
122
+ role: user.role
123
+ },
124
+ period,
125
+ totalCost: totalUsage?.total_cost || 0,
126
+ totalRequests: totalUsage?.total_requests || 0,
127
+ totalSessions: totalUsage?.total_sessions || 0,
128
+ dailyTrend,
129
+ modelDistribution
130
+ });
131
+ } catch (error) {
132
+ console.error('Error fetching user usage:', error);
133
+ res.status(500).json({ error: 'Failed to fetch user usage' });
134
+ }
135
+ });
136
+
137
+ /**
138
+ * Get global dashboard statistics
139
+ */
140
+ router.get('/dashboard', (req, res) => {
141
+ try {
142
+ const { period = 'week' } = req.query;
143
+
144
+ // Calculate date range
145
+ const endDate = new Date().toISOString().split('T')[0];
146
+ let startDate;
147
+
148
+ switch (period) {
149
+ case 'week':
150
+ startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
151
+ break;
152
+ case 'month':
153
+ startDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
154
+ break;
155
+ default:
156
+ startDate = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
157
+ }
158
+
159
+ const stats = usageDb.getDashboardStats(startDate, endDate);
160
+ const users = userDb.getAllUsers();
161
+
162
+ // Create username map
163
+ const usernameMap = {};
164
+ for (const user of users) {
165
+ usernameMap[user.uuid] = user.username;
166
+ }
167
+
168
+ // Enrich top users with usernames
169
+ const topUsers = stats.topUsers.map(user => ({
170
+ ...user,
171
+ username: usernameMap[user.user_uuid] || 'Unknown'
172
+ }));
173
+
174
+ res.json({
175
+ period,
176
+ totals: {
177
+ totalCost: stats.totals?.total_cost || 0,
178
+ totalRequests: stats.totals?.total_requests || 0,
179
+ totalSessions: stats.totals?.total_sessions || 0,
180
+ activeUsers: stats.totals?.active_users || 0,
181
+ totalUsers: users.length
182
+ },
183
+ dailyTrend: stats.dailyTrend,
184
+ modelDistribution: stats.modelDistribution,
185
+ topUsers
186
+ });
187
+ } catch (error) {
188
+ console.error('Error fetching dashboard stats:', error);
189
+ res.status(500).json({ error: 'Failed to fetch dashboard statistics' });
190
+ }
191
+ });
192
+
193
+ /**
194
+ * Trigger a manual usage scan
195
+ */
196
+ router.post('/scan', async (req, res) => {
197
+ try {
198
+ const result = await triggerManualScan();
199
+ res.json(result);
200
+ } catch (error) {
201
+ console.error('Error triggering manual scan:', error);
202
+ res.status(500).json({ error: 'Failed to trigger scan' });
203
+ }
204
+ });
205
+
206
+ export default router;
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Claude Model Pricing Table
3
+ *
4
+ * Prices are in USD per token.
5
+ * Reference: https://www.anthropic.com/pricing
6
+ */
7
+
8
+ // Pricing per million tokens
9
+ // Reference: https://platform.claude.com/docs/en/about-claude/pricing
10
+ // Cache pricing: read = 0.1x base, write (5min) = 1.25x base, write (1h) = 2x base
11
+ const PRICING_PER_MILLION = {
12
+ // ============ Latest Models ============
13
+ // Claude Opus 4.5 (latest flagship)
14
+ 'claude-opus-4-5-20251101': {
15
+ input: 5.00,
16
+ output: 25.00,
17
+ cacheRead: 0.50,
18
+ cacheCreate: 6.25
19
+ },
20
+ // Claude Sonnet 4.5
21
+ 'claude-sonnet-4-5-20250929': {
22
+ input: 3.00,
23
+ output: 15.00,
24
+ cacheRead: 0.30,
25
+ cacheCreate: 3.75
26
+ },
27
+ // Claude Haiku 4.5
28
+ 'claude-haiku-4-5-20251001': {
29
+ input: 1.00,
30
+ output: 5.00,
31
+ cacheRead: 0.10,
32
+ cacheCreate: 1.25
33
+ },
34
+ // ============ Legacy Models ============
35
+ // Claude Opus 4.1
36
+ 'claude-opus-4-1-20250805': {
37
+ input: 15.00,
38
+ output: 75.00,
39
+ cacheRead: 1.50,
40
+ cacheCreate: 18.75
41
+ },
42
+ // Claude Opus 4
43
+ 'claude-opus-4-20250514': {
44
+ input: 15.00,
45
+ output: 75.00,
46
+ cacheRead: 1.50,
47
+ cacheCreate: 18.75
48
+ },
49
+ // Claude Sonnet 4
50
+ 'claude-sonnet-4-20250514': {
51
+ input: 3.00,
52
+ output: 15.00,
53
+ cacheRead: 0.30,
54
+ cacheCreate: 3.75
55
+ },
56
+ // Claude Sonnet 3.7
57
+ 'claude-3-7-sonnet-20250219': {
58
+ input: 3.00,
59
+ output: 15.00,
60
+ cacheRead: 0.30,
61
+ cacheCreate: 3.75
62
+ },
63
+ // Claude Haiku 3.5
64
+ 'claude-3-5-haiku-20241022': {
65
+ input: 0.80,
66
+ output: 4.00,
67
+ cacheRead: 0.08,
68
+ cacheCreate: 1.00
69
+ },
70
+ // Claude Haiku 3
71
+ 'claude-3-haiku-20240307': {
72
+ input: 0.25,
73
+ output: 1.25,
74
+ cacheRead: 0.03,
75
+ cacheCreate: 0.30
76
+ },
77
+ // ============ Aliases ============
78
+ // Aliases for simplified model names (pointing to latest versions)
79
+ 'sonnet': {
80
+ input: 3.00,
81
+ output: 15.00,
82
+ cacheRead: 0.30,
83
+ cacheCreate: 3.75
84
+ },
85
+ 'opus': {
86
+ input: 5.00,
87
+ output: 25.00,
88
+ cacheRead: 0.50,
89
+ cacheCreate: 6.25
90
+ },
91
+ 'haiku': {
92
+ input: 1.00,
93
+ output: 5.00,
94
+ cacheRead: 0.10,
95
+ cacheCreate: 1.25
96
+ }
97
+ };
98
+
99
+ // Convert to price per token
100
+ const PRICING = {};
101
+ for (const [model, prices] of Object.entries(PRICING_PER_MILLION)) {
102
+ PRICING[model] = {
103
+ input: prices.input / 1_000_000,
104
+ output: prices.output / 1_000_000,
105
+ cacheRead: prices.cacheRead / 1_000_000,
106
+ cacheCreate: prices.cacheCreate / 1_000_000
107
+ };
108
+ }
109
+
110
+ /**
111
+ * Normalize model name to a standard format
112
+ * @param {string} model - Raw model name
113
+ * @returns {string} Normalized model name
114
+ */
115
+ function normalizeModelName(model) {
116
+ if (!model) return 'sonnet';
117
+
118
+ const modelLower = model.toLowerCase();
119
+
120
+ // Check for exact match
121
+ if (PRICING[model]) {
122
+ return model;
123
+ }
124
+
125
+ // Check for partial matches
126
+ if (modelLower.includes('opus')) {
127
+ return 'opus';
128
+ }
129
+ if (modelLower.includes('haiku')) {
130
+ return 'haiku';
131
+ }
132
+ if (modelLower.includes('sonnet')) {
133
+ return 'sonnet';
134
+ }
135
+
136
+ // Default to sonnet
137
+ return 'sonnet';
138
+ }
139
+
140
+ /**
141
+ * Calculate cost for token usage
142
+ * @param {Object} usage - Token usage object
143
+ * @param {string} usage.model - Model name
144
+ * @param {number} usage.inputTokens - Input tokens
145
+ * @param {number} usage.outputTokens - Output tokens
146
+ * @param {number} usage.cacheReadTokens - Cache read tokens
147
+ * @param {number} usage.cacheCreationTokens - Cache creation tokens
148
+ * @returns {number} Cost in USD
149
+ */
150
+ function calculateCost(usage) {
151
+ const model = normalizeModelName(usage.model);
152
+ const prices = PRICING[model];
153
+
154
+ if (!prices) {
155
+ console.warn(`Unknown model: ${usage.model}, using sonnet pricing`);
156
+ return calculateCost({ ...usage, model: 'sonnet' });
157
+ }
158
+
159
+ const inputCost = (usage.inputTokens || 0) * prices.input;
160
+ const outputCost = (usage.outputTokens || 0) * prices.output;
161
+ const cacheReadCost = (usage.cacheReadTokens || 0) * prices.cacheRead;
162
+ const cacheCreateCost = (usage.cacheCreationTokens || 0) * prices.cacheCreate;
163
+
164
+ return inputCost + outputCost + cacheReadCost + cacheCreateCost;
165
+ }
166
+
167
+ /**
168
+ * Get pricing for a model
169
+ * @param {string} model - Model name
170
+ * @returns {Object} Pricing object
171
+ */
172
+ function getModelPricing(model) {
173
+ const normalizedModel = normalizeModelName(model);
174
+ return PRICING[normalizedModel] || PRICING['sonnet'];
175
+ }
176
+
177
+ /**
178
+ * Format cost for display
179
+ * @param {number} cost - Cost in USD
180
+ * @returns {string} Formatted cost string
181
+ */
182
+ function formatCost(cost) {
183
+ if (cost < 0.01) {
184
+ return `$${cost.toFixed(4)}`;
185
+ }
186
+ return `$${cost.toFixed(2)}`;
187
+ }
188
+
189
+ export {
190
+ PRICING,
191
+ PRICING_PER_MILLION,
192
+ normalizeModelName,
193
+ calculateCost,
194
+ getModelPricing,
195
+ formatCost
196
+ };
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Usage Scanner Service
3
+ *
4
+ * Scans Claude session files (JSONL) for token usage data and records it to the database.
5
+ * This handles CLI mode usage tracking and serves as a backup for SDK mode.
6
+ *
7
+ * Features:
8
+ * - Scans all users' Claude session directories
9
+ * - Tracks scan position to avoid re-processing
10
+ * - Updates daily summaries
11
+ * - Cleans up old records (30 days)
12
+ */
13
+
14
+ import { promises as fs } from 'fs';
15
+ import path from 'path';
16
+ import { usageDb, userDb } from '../database/db.js';
17
+ import { calculateCost, normalizeModelName } from './pricing.js';
18
+ import { DATA_DIR, getUserPaths } from './user-directories.js';
19
+
20
+ // Scan interval: 5 minutes
21
+ const SCAN_INTERVAL_MS = 5 * 60 * 1000;
22
+
23
+ // Retention period: 30 days
24
+ const RETENTION_DAYS = 30;
25
+
26
+ // Scanner state
27
+ let scannerInterval = null;
28
+ let isScanning = false;
29
+
30
+ /**
31
+ * Start the usage scanner
32
+ */
33
+ export function startUsageScanner() {
34
+ console.log('[UsageScanner] Starting usage scanner service');
35
+
36
+ // Run initial scan after a short delay
37
+ setTimeout(() => {
38
+ runScan();
39
+ }, 10000);
40
+
41
+ // Schedule periodic scans
42
+ scannerInterval = setInterval(() => {
43
+ runScan();
44
+ }, SCAN_INTERVAL_MS);
45
+
46
+ console.log(`[UsageScanner] Scheduled to run every ${SCAN_INTERVAL_MS / 1000 / 60} minutes`);
47
+ }
48
+
49
+ /**
50
+ * Stop the usage scanner
51
+ */
52
+ export function stopUsageScanner() {
53
+ if (scannerInterval) {
54
+ clearInterval(scannerInterval);
55
+ scannerInterval = null;
56
+ console.log('[UsageScanner] Stopped usage scanner service');
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Run a scan cycle
62
+ */
63
+ async function runScan() {
64
+ if (isScanning) {
65
+ console.log('[UsageScanner] Scan already in progress, skipping');
66
+ return;
67
+ }
68
+
69
+ isScanning = true;
70
+ console.log('[UsageScanner] Starting scan cycle');
71
+
72
+ try {
73
+ // Get all users
74
+ const users = userDb.getAllUsers();
75
+
76
+ for (const user of users) {
77
+ if (!user.uuid) continue;
78
+
79
+ try {
80
+ await scanUserSessions(user.uuid);
81
+ } catch (error) {
82
+ console.error(`[UsageScanner] Error scanning user ${user.uuid}:`, error.message);
83
+ }
84
+ }
85
+
86
+ // Cleanup old records
87
+ const deletedCount = usageDb.cleanupOldRecords(RETENTION_DAYS);
88
+ if (deletedCount > 0) {
89
+ console.log(`[UsageScanner] Cleaned up ${deletedCount} old usage records`);
90
+ }
91
+
92
+ console.log('[UsageScanner] Scan cycle completed');
93
+ } catch (error) {
94
+ console.error('[UsageScanner] Error during scan cycle:', error);
95
+ } finally {
96
+ isScanning = false;
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Scan a user's session files
102
+ */
103
+ async function scanUserSessions(userUuid) {
104
+ const userPaths = getUserPaths(userUuid);
105
+ const projectsDir = path.join(userPaths.claudeDir, 'projects');
106
+ const scanStatePath = path.join(userPaths.claudeDir, '.usage-scan-state.json');
107
+
108
+ // Check if projects directory exists
109
+ try {
110
+ await fs.access(projectsDir);
111
+ } catch {
112
+ // No projects directory yet
113
+ return;
114
+ }
115
+
116
+ // Load scan state
117
+ let scanState = { lastScanTime: null, scannedSessions: {} };
118
+ try {
119
+ const stateContent = await fs.readFile(scanStatePath, 'utf8');
120
+ scanState = JSON.parse(stateContent);
121
+ } catch {
122
+ // File doesn't exist or is invalid, start fresh
123
+ }
124
+
125
+ // Get all project directories
126
+ const projectDirs = await fs.readdir(projectsDir, { withFileTypes: true });
127
+
128
+ let newRecordsCount = 0;
129
+
130
+ for (const projectDir of projectDirs) {
131
+ if (!projectDir.isDirectory()) continue;
132
+
133
+ const projectPath = path.join(projectsDir, projectDir.name);
134
+ const sessionFiles = await fs.readdir(projectPath);
135
+
136
+ for (const sessionFile of sessionFiles) {
137
+ if (!sessionFile.endsWith('.jsonl')) continue;
138
+
139
+ const sessionId = sessionFile.replace('.jsonl', '');
140
+ const sessionPath = path.join(projectPath, sessionFile);
141
+
142
+ // Get file stats
143
+ const stats = await fs.stat(sessionPath);
144
+ const lastModified = stats.mtime.toISOString();
145
+
146
+ // Check if we need to scan this session
147
+ const lastScanned = scanState.scannedSessions[sessionId];
148
+ if (lastScanned && lastScanned.lastModified === lastModified) {
149
+ // Already scanned and file hasn't changed
150
+ continue;
151
+ }
152
+
153
+ // Scan the session file
154
+ try {
155
+ const recordsAdded = await scanSessionFile(
156
+ userUuid,
157
+ sessionId,
158
+ sessionPath,
159
+ lastScanned?.lastLine || 0
160
+ );
161
+ newRecordsCount += recordsAdded;
162
+
163
+ // Update scan state
164
+ scanState.scannedSessions[sessionId] = {
165
+ lastModified,
166
+ lastLine: lastScanned?.lastLine || 0,
167
+ lastScan: new Date().toISOString()
168
+ };
169
+ } catch (error) {
170
+ console.error(`[UsageScanner] Error scanning session ${sessionId}:`, error.message);
171
+ }
172
+ }
173
+ }
174
+
175
+ // Save scan state
176
+ scanState.lastScanTime = new Date().toISOString();
177
+ await fs.writeFile(scanStatePath, JSON.stringify(scanState, null, 2));
178
+
179
+ if (newRecordsCount > 0) {
180
+ console.log(`[UsageScanner] User ${userUuid}: added ${newRecordsCount} new records`);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Scan a session JSONL file for usage data
186
+ */
187
+ async function scanSessionFile(userUuid, sessionId, filePath, startLine) {
188
+ const content = await fs.readFile(filePath, 'utf8');
189
+ const lines = content.trim().split('\n');
190
+
191
+ let recordsAdded = 0;
192
+ const sessionDates = new Set();
193
+
194
+ for (let i = startLine; i < lines.length; i++) {
195
+ const line = lines[i];
196
+ if (!line.trim()) continue;
197
+
198
+ try {
199
+ const entry = JSON.parse(line);
200
+
201
+ // Only process assistant messages with usage data
202
+ if (entry.type !== 'assistant' || !entry.message?.usage) {
203
+ continue;
204
+ }
205
+
206
+ const usage = entry.message.usage;
207
+ const model = normalizeModelName(entry.message?.model || 'sonnet');
208
+
209
+ const inputTokens = usage.input_tokens || 0;
210
+ const outputTokens = usage.output_tokens || 0;
211
+ const cacheReadTokens = usage.cache_read_input_tokens || 0;
212
+ const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
213
+
214
+ const cost = calculateCost({
215
+ model,
216
+ inputTokens,
217
+ outputTokens,
218
+ cacheReadTokens,
219
+ cacheCreationTokens
220
+ });
221
+
222
+ // Determine the date from the entry timestamp or use current date
223
+ const entryDate = entry.timestamp
224
+ ? new Date(entry.timestamp).toISOString().split('T')[0]
225
+ : new Date().toISOString().split('T')[0];
226
+
227
+ // Insert usage record (source: cli)
228
+ usageDb.insertRecord({
229
+ user_uuid: userUuid,
230
+ session_id: sessionId,
231
+ model,
232
+ input_tokens: inputTokens,
233
+ output_tokens: outputTokens,
234
+ cache_read_tokens: cacheReadTokens,
235
+ cache_creation_tokens: cacheCreationTokens,
236
+ cost_usd: cost,
237
+ source: 'cli',
238
+ created_at: entry.timestamp || new Date().toISOString()
239
+ });
240
+
241
+ // Update daily summary
242
+ usageDb.upsertDailySummary({
243
+ user_uuid: userUuid,
244
+ date: entryDate,
245
+ model,
246
+ total_input_tokens: inputTokens,
247
+ total_output_tokens: outputTokens,
248
+ total_cost_usd: cost,
249
+ session_count: 0,
250
+ request_count: 1
251
+ });
252
+
253
+ sessionDates.add(entryDate);
254
+ recordsAdded++;
255
+ } catch (parseError) {
256
+ // Skip lines that can't be parsed
257
+ continue;
258
+ }
259
+ }
260
+
261
+ // Update session count for each date (once per scan, not per record)
262
+ for (const date of sessionDates) {
263
+ try {
264
+ // We count this as a session activity for today
265
+ // Note: This is approximate since we're scanning multiple messages at once
266
+ } catch (error) {
267
+ // Ignore session count errors
268
+ }
269
+ }
270
+
271
+ return recordsAdded;
272
+ }
273
+
274
+ /**
275
+ * Manually trigger a scan (for testing or admin use)
276
+ */
277
+ export async function triggerManualScan() {
278
+ console.log('[UsageScanner] Manual scan triggered');
279
+ await runScan();
280
+ return { success: true, message: 'Scan completed' };
281
+ }
282
+
283
+ export { SCAN_INTERVAL_MS, RETENTION_DAYS };