@chrysb/alphaclaw 0.3.2 → 0.3.4-beta.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 (54) hide show
  1. package/bin/alphaclaw.js +47 -2
  2. package/lib/cli/git-sync.js +25 -0
  3. package/lib/plugin/usage-tracker/index.js +308 -0
  4. package/lib/plugin/usage-tracker/openclaw.plugin.json +8 -0
  5. package/lib/public/css/explorer.css +1033 -0
  6. package/lib/public/css/shell.css +50 -4
  7. package/lib/public/css/theme.css +41 -1
  8. package/lib/public/icons/folder-line.svg +1 -0
  9. package/lib/public/icons/hashtag.svg +3 -0
  10. package/lib/public/icons/home-5-line.svg +1 -0
  11. package/lib/public/icons/save-fill.svg +3 -0
  12. package/lib/public/js/app.js +310 -160
  13. package/lib/public/js/components/action-button.js +12 -1
  14. package/lib/public/js/components/file-tree.js +497 -0
  15. package/lib/public/js/components/file-viewer.js +714 -0
  16. package/lib/public/js/components/icons.js +182 -0
  17. package/lib/public/js/components/segmented-control.js +33 -0
  18. package/lib/public/js/components/sidebar-git-panel.js +149 -0
  19. package/lib/public/js/components/sidebar.js +254 -0
  20. package/lib/public/js/components/telegram-workspace/index.js +353 -0
  21. package/lib/public/js/components/telegram-workspace/manage.js +397 -0
  22. package/lib/public/js/components/telegram-workspace/onboarding.js +616 -0
  23. package/lib/public/js/components/usage-tab.js +528 -0
  24. package/lib/public/js/components/watchdog-tab.js +1 -1
  25. package/lib/public/js/lib/api.js +51 -1
  26. package/lib/public/js/lib/browse-draft-state.js +109 -0
  27. package/lib/public/js/lib/file-highlighting.js +6 -0
  28. package/lib/public/js/lib/file-tree-utils.js +12 -0
  29. package/lib/public/js/lib/syntax-highlighters/css.js +124 -0
  30. package/lib/public/js/lib/syntax-highlighters/frontmatter.js +49 -0
  31. package/lib/public/js/lib/syntax-highlighters/html.js +209 -0
  32. package/lib/public/js/lib/syntax-highlighters/index.js +28 -0
  33. package/lib/public/js/lib/syntax-highlighters/javascript.js +134 -0
  34. package/lib/public/js/lib/syntax-highlighters/json.js +61 -0
  35. package/lib/public/js/lib/syntax-highlighters/markdown.js +37 -0
  36. package/lib/public/js/lib/syntax-highlighters/utils.js +13 -0
  37. package/lib/public/js/lib/telegram-api.js +78 -0
  38. package/lib/public/js/lib/ui-settings.js +38 -0
  39. package/lib/public/setup.html +34 -29
  40. package/lib/server/alphaclaw-version.js +3 -3
  41. package/lib/server/constants.js +2 -0
  42. package/lib/server/onboarding/openclaw.js +15 -0
  43. package/lib/server/onboarding/workspace.js +3 -2
  44. package/lib/server/routes/auth.js +5 -1
  45. package/lib/server/routes/browse.js +295 -0
  46. package/lib/server/routes/telegram.js +185 -60
  47. package/lib/server/routes/usage.js +133 -0
  48. package/lib/server/usage-db.js +570 -0
  49. package/lib/server.js +45 -4
  50. package/lib/setup/core-prompts/AGENTS.md +0 -101
  51. package/lib/setup/core-prompts/TOOLS.md +3 -1
  52. package/lib/setup/skills/control-ui/SKILL.md +12 -20
  53. package/package.json +1 -1
  54. package/lib/public/js/components/telegram-workspace.js +0 -1365
@@ -0,0 +1,133 @@
1
+ const topicRegistry = require("../topic-registry");
2
+
3
+ const kSummaryCacheTtlMs = 60 * 1000;
4
+
5
+ const parsePositiveInt = (value, fallbackValue) => {
6
+ const parsed = Number.parseInt(String(value ?? ""), 10);
7
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallbackValue;
8
+ };
9
+
10
+ const createSummaryCache = () => new Map();
11
+
12
+ // Parse "agent:main:telegram:group:-123:topic:42" into structured labels.
13
+ const parseSessionLabels = (sessionKey) => {
14
+ const raw = String(sessionKey || "").trim();
15
+ if (!raw) return null;
16
+ const parts = raw.split(":");
17
+ const labels = [];
18
+
19
+ if (parts[0] === "agent" && parts[1]) {
20
+ labels.push({
21
+ label: parts[1].charAt(0).toUpperCase() + parts[1].slice(1),
22
+ tone: "cyan",
23
+ });
24
+ }
25
+
26
+ const channelIndex = parts.indexOf("telegram");
27
+ if (channelIndex !== -1 && parts[channelIndex + 1]) {
28
+ const channelType = parts[channelIndex + 1];
29
+ if (channelType === "direct") {
30
+ labels.push({ label: "Telegram Direct", tone: "blue" });
31
+ } else if (channelType === "group") {
32
+ const groupId = parts[channelIndex + 2] || "";
33
+ let groupName = null;
34
+ let groupEntry = null;
35
+ try {
36
+ groupEntry = topicRegistry.getGroup(groupId);
37
+ groupName = groupEntry?.name || null;
38
+ } catch {}
39
+ labels.push({
40
+ label: groupName || `Group ${groupId}`,
41
+ tone: "purple",
42
+ });
43
+ const topicIndex = parts.indexOf("topic", channelIndex);
44
+ if (topicIndex !== -1 && parts[topicIndex + 1]) {
45
+ const topicId = parts[topicIndex + 1];
46
+ const topicName = groupEntry?.topics?.[topicId]?.name || null;
47
+ labels.push({
48
+ label: topicName || `Topic ${topicId}`,
49
+ tone: "gray",
50
+ });
51
+ }
52
+ } else {
53
+ labels.push({
54
+ label: `Telegram ${channelType.charAt(0).toUpperCase() + channelType.slice(1)}`,
55
+ tone: "blue",
56
+ });
57
+ }
58
+ }
59
+
60
+ return labels.length > 0 ? labels : null;
61
+ };
62
+
63
+ const enrichSessionLabels = (session) => ({
64
+ ...session,
65
+ labels: parseSessionLabels(session.sessionKey || session.sessionId),
66
+ });
67
+
68
+ const registerUsageRoutes = ({
69
+ app,
70
+ requireAuth,
71
+ getDailySummary,
72
+ getSessionsList,
73
+ getSessionDetail,
74
+ getSessionTimeSeries,
75
+ }) => {
76
+ const summaryCache = createSummaryCache();
77
+
78
+ app.get("/api/usage/summary", requireAuth, (req, res) => {
79
+ try {
80
+ const days = parsePositiveInt(req.query.days, 30);
81
+ const cacheKey = String(days);
82
+ const cached = summaryCache.get(cacheKey);
83
+ const now = Date.now();
84
+ if (cached && now - cached.cachedAt <= kSummaryCacheTtlMs) {
85
+ res.json({ ok: true, ...cached.payload, cached: true });
86
+ return;
87
+ }
88
+ const summary = getDailySummary({ days });
89
+ const payload = { summary };
90
+ summaryCache.set(cacheKey, { payload, cachedAt: now });
91
+ res.json({ ok: true, ...payload, cached: false });
92
+ } catch (err) {
93
+ res.status(500).json({ ok: false, error: err.message });
94
+ }
95
+ });
96
+
97
+ app.get("/api/usage/sessions", requireAuth, (req, res) => {
98
+ try {
99
+ const limit = parsePositiveInt(req.query.limit, 50);
100
+ const sessions = getSessionsList({ limit }).map(enrichSessionLabels);
101
+ res.json({ ok: true, sessions });
102
+ } catch (err) {
103
+ res.status(500).json({ ok: false, error: err.message });
104
+ }
105
+ });
106
+
107
+ app.get("/api/usage/sessions/:id", requireAuth, (req, res) => {
108
+ try {
109
+ const sessionId = String(req.params.id || "").trim();
110
+ const detail = getSessionDetail({ sessionId });
111
+ if (!detail) {
112
+ res.status(404).json({ ok: false, error: "Session not found" });
113
+ return;
114
+ }
115
+ res.json({ ok: true, detail: enrichSessionLabels(detail) });
116
+ } catch (err) {
117
+ res.status(500).json({ ok: false, error: err.message });
118
+ }
119
+ });
120
+
121
+ app.get("/api/usage/sessions/:id/timeseries", requireAuth, (req, res) => {
122
+ try {
123
+ const sessionId = String(req.params.id || "").trim();
124
+ const maxPoints = parsePositiveInt(req.query.maxPoints, 100);
125
+ const series = getSessionTimeSeries({ sessionId, maxPoints });
126
+ res.json({ ok: true, series });
127
+ } catch (err) {
128
+ res.status(500).json({ ok: false, error: err.message });
129
+ }
130
+ });
131
+ };
132
+
133
+ module.exports = { registerUsageRoutes };
@@ -0,0 +1,570 @@
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { DatabaseSync } = require("node:sqlite");
4
+
5
+ const kDefaultSessionLimit = 50;
6
+ const kMaxSessionLimit = 200;
7
+ const kDefaultDays = 30;
8
+ const kDefaultMaxPoints = 100;
9
+ const kMaxMaxPoints = 1000;
10
+ const kTokensPerMillion = 1_000_000;
11
+ const kGlobalModelPricing = {
12
+ "claude-opus-4-6": { input: 15.0, output: 75.0 },
13
+ "claude-sonnet-4-6": { input: 3.0, output: 15.0 },
14
+ "claude-haiku-4-6": { input: 0.8, output: 4.0 },
15
+ "gpt-5.1-codex": { input: 2.5, output: 10.0 },
16
+ "gpt-5.3-codex": { input: 2.5, output: 10.0 },
17
+ "gpt-4o": { input: 2.5, output: 10.0 },
18
+ "gemini-3-pro-preview": { input: 1.25, output: 5.0 },
19
+ "gemini-3-flash-preview": { input: 0.1, output: 0.4 },
20
+ "gemini-2.0-flash": { input: 0.1, output: 0.4 },
21
+ };
22
+
23
+ let db = null;
24
+ let usageDbPath = "";
25
+
26
+ const coerceInt = (value, fallbackValue = 0) => {
27
+ const parsed = Number.parseInt(String(value ?? ""), 10);
28
+ return Number.isFinite(parsed) ? parsed : fallbackValue;
29
+ };
30
+
31
+ const clampInt = (value, minValue, maxValue, fallbackValue) =>
32
+ Math.min(maxValue, Math.max(minValue, coerceInt(value, fallbackValue)));
33
+
34
+ const resolvePricing = (model) => {
35
+ const normalized = String(model || "").toLowerCase();
36
+ if (!normalized) return null;
37
+ const exact = kGlobalModelPricing[normalized];
38
+ if (exact) return exact;
39
+ const matchKey = Object.keys(kGlobalModelPricing).find((key) =>
40
+ normalized.includes(key),
41
+ );
42
+ return matchKey ? kGlobalModelPricing[matchKey] : null;
43
+ };
44
+
45
+ const deriveCostBreakdown = ({
46
+ inputTokens = 0,
47
+ outputTokens = 0,
48
+ cacheReadTokens = 0,
49
+ cacheWriteTokens = 0,
50
+ model = "",
51
+ }) => {
52
+ const pricing = resolvePricing(model);
53
+ if (!pricing) {
54
+ return {
55
+ inputCost: 0,
56
+ outputCost: 0,
57
+ cacheReadCost: 0,
58
+ cacheWriteCost: 0,
59
+ totalCost: 0,
60
+ pricingFound: false,
61
+ };
62
+ }
63
+ const inputCost = (inputTokens / kTokensPerMillion) * pricing.input;
64
+ const outputCost = (outputTokens / kTokensPerMillion) * pricing.output;
65
+ const cacheReadCost = 0;
66
+ const cacheWriteCost = (cacheWriteTokens / kTokensPerMillion) * pricing.input;
67
+ return {
68
+ inputCost,
69
+ outputCost,
70
+ cacheReadCost,
71
+ cacheWriteCost,
72
+ totalCost: inputCost + outputCost + cacheReadCost + cacheWriteCost,
73
+ pricingFound: true,
74
+ };
75
+ };
76
+
77
+ const ensureDb = () => {
78
+ if (!db) throw new Error("Usage DB not initialized");
79
+ return db;
80
+ };
81
+
82
+ const safeAlterTable = (database, sql) => {
83
+ try {
84
+ database.exec(sql);
85
+ } catch (err) {
86
+ const message = String(err?.message || "").toLowerCase();
87
+ if (!message.includes("duplicate column name")) throw err;
88
+ }
89
+ };
90
+
91
+ const ensureSchema = (database) => {
92
+ database.exec("PRAGMA journal_mode=WAL;");
93
+ database.exec("PRAGMA synchronous=NORMAL;");
94
+ database.exec("PRAGMA busy_timeout=5000;");
95
+ database.exec(`
96
+ CREATE TABLE IF NOT EXISTS usage_events (
97
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
98
+ timestamp INTEGER NOT NULL,
99
+ session_id TEXT,
100
+ session_key TEXT,
101
+ run_id TEXT,
102
+ provider TEXT NOT NULL,
103
+ model TEXT NOT NULL,
104
+ input_tokens INTEGER NOT NULL DEFAULT 0,
105
+ output_tokens INTEGER NOT NULL DEFAULT 0,
106
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
107
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
108
+ total_tokens INTEGER NOT NULL DEFAULT 0
109
+ );
110
+ `);
111
+ database.exec(`
112
+ CREATE INDEX IF NOT EXISTS idx_usage_events_ts
113
+ ON usage_events(timestamp DESC);
114
+ `);
115
+ database.exec(`
116
+ CREATE INDEX IF NOT EXISTS idx_usage_events_session
117
+ ON usage_events(session_id);
118
+ `);
119
+ safeAlterTable(
120
+ database,
121
+ "ALTER TABLE usage_events ADD COLUMN session_key TEXT;",
122
+ );
123
+ database.exec(`
124
+ CREATE INDEX IF NOT EXISTS idx_usage_events_session_key
125
+ ON usage_events(session_key);
126
+ `);
127
+ database.exec(`
128
+ CREATE TABLE IF NOT EXISTS usage_daily (
129
+ date TEXT NOT NULL,
130
+ model TEXT NOT NULL,
131
+ provider TEXT,
132
+ input_tokens INTEGER NOT NULL DEFAULT 0,
133
+ output_tokens INTEGER NOT NULL DEFAULT 0,
134
+ cache_read_tokens INTEGER NOT NULL DEFAULT 0,
135
+ cache_write_tokens INTEGER NOT NULL DEFAULT 0,
136
+ total_tokens INTEGER NOT NULL DEFAULT 0,
137
+ turn_count INTEGER NOT NULL DEFAULT 0,
138
+ PRIMARY KEY (date, model)
139
+ );
140
+ `);
141
+ database.exec(`
142
+ CREATE TABLE IF NOT EXISTS tool_events (
143
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
144
+ timestamp INTEGER NOT NULL,
145
+ session_id TEXT,
146
+ session_key TEXT,
147
+ tool_name TEXT NOT NULL,
148
+ success INTEGER NOT NULL DEFAULT 1,
149
+ duration_ms INTEGER
150
+ );
151
+ `);
152
+ database.exec(`
153
+ CREATE INDEX IF NOT EXISTS idx_tool_events_session
154
+ ON tool_events(session_id);
155
+ `);
156
+ safeAlterTable(
157
+ database,
158
+ "ALTER TABLE tool_events ADD COLUMN session_key TEXT;",
159
+ );
160
+ database.exec(`
161
+ CREATE INDEX IF NOT EXISTS idx_tool_events_session_key
162
+ ON tool_events(session_key);
163
+ `);
164
+ };
165
+
166
+ const initUsageDb = ({ rootDir }) => {
167
+ const dbDir = path.join(rootDir, "db");
168
+ fs.mkdirSync(dbDir, { recursive: true });
169
+ usageDbPath = path.join(dbDir, "usage.db");
170
+ db = new DatabaseSync(usageDbPath);
171
+ ensureSchema(db);
172
+ return { path: usageDbPath };
173
+ };
174
+
175
+ const toDayKey = (timestampMs) => new Date(timestampMs).toISOString().slice(0, 10);
176
+
177
+ const getPeriodRange = (days) => {
178
+ const now = Date.now();
179
+ const safeDays = clampInt(days, 1, 3650, kDefaultDays);
180
+ const startMs = now - safeDays * 24 * 60 * 60 * 1000;
181
+ return { now, safeDays, startDay: toDayKey(startMs) };
182
+ };
183
+
184
+ const appendCostToRows = (rows) =>
185
+ rows.map((row) => {
186
+ const inputTokens = coerceInt(row.input_tokens);
187
+ const outputTokens = coerceInt(row.output_tokens);
188
+ const cacheReadTokens = coerceInt(row.cache_read_tokens);
189
+ const cacheWriteTokens = coerceInt(row.cache_write_tokens);
190
+ const totalTokens =
191
+ coerceInt(row.total_tokens) ||
192
+ inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
193
+ const cost = deriveCostBreakdown({
194
+ inputTokens,
195
+ outputTokens,
196
+ cacheReadTokens,
197
+ cacheWriteTokens,
198
+ model: row.model,
199
+ });
200
+ return {
201
+ ...row,
202
+ inputTokens,
203
+ outputTokens,
204
+ cacheReadTokens,
205
+ cacheWriteTokens,
206
+ totalTokens,
207
+ ...cost,
208
+ };
209
+ });
210
+
211
+ const getDailySummary = ({ days = kDefaultDays } = {}) => {
212
+ const database = ensureDb();
213
+ const { safeDays, startDay } = getPeriodRange(days);
214
+ const rows = database
215
+ .prepare(`
216
+ SELECT
217
+ date,
218
+ model,
219
+ provider,
220
+ input_tokens,
221
+ output_tokens,
222
+ cache_read_tokens,
223
+ cache_write_tokens,
224
+ total_tokens,
225
+ turn_count
226
+ FROM usage_daily
227
+ WHERE date >= $startDay
228
+ ORDER BY date ASC, total_tokens DESC
229
+ `)
230
+ .all({ $startDay: startDay });
231
+ const enriched = appendCostToRows(rows);
232
+ const byDate = new Map();
233
+ for (const row of enriched) {
234
+ if (!byDate.has(row.date)) byDate.set(row.date, []);
235
+ byDate.get(row.date).push({
236
+ model: row.model,
237
+ provider: row.provider,
238
+ inputTokens: row.inputTokens,
239
+ outputTokens: row.outputTokens,
240
+ cacheReadTokens: row.cacheReadTokens,
241
+ cacheWriteTokens: row.cacheWriteTokens,
242
+ totalTokens: row.totalTokens,
243
+ turnCount: coerceInt(row.turn_count),
244
+ totalCost: row.totalCost,
245
+ inputCost: row.inputCost,
246
+ outputCost: row.outputCost,
247
+ cacheReadCost: row.cacheReadCost,
248
+ cacheWriteCost: row.cacheWriteCost,
249
+ pricingFound: row.pricingFound,
250
+ });
251
+ }
252
+ const daily = [];
253
+ const totals = {
254
+ inputTokens: 0,
255
+ outputTokens: 0,
256
+ cacheReadTokens: 0,
257
+ cacheWriteTokens: 0,
258
+ totalTokens: 0,
259
+ totalCost: 0,
260
+ turnCount: 0,
261
+ modelCount: 0,
262
+ };
263
+ for (const [date, modelRows] of byDate.entries()) {
264
+ const aggregate = modelRows.reduce(
265
+ (acc, row) => ({
266
+ inputTokens: acc.inputTokens + row.inputTokens,
267
+ outputTokens: acc.outputTokens + row.outputTokens,
268
+ cacheReadTokens: acc.cacheReadTokens + row.cacheReadTokens,
269
+ cacheWriteTokens: acc.cacheWriteTokens + row.cacheWriteTokens,
270
+ totalTokens: acc.totalTokens + row.totalTokens,
271
+ totalCost: acc.totalCost + row.totalCost,
272
+ turnCount: acc.turnCount + row.turnCount,
273
+ }),
274
+ {
275
+ inputTokens: 0,
276
+ outputTokens: 0,
277
+ cacheReadTokens: 0,
278
+ cacheWriteTokens: 0,
279
+ totalTokens: 0,
280
+ totalCost: 0,
281
+ turnCount: 0,
282
+ },
283
+ );
284
+ daily.push({ date, ...aggregate, models: modelRows });
285
+ totals.inputTokens += aggregate.inputTokens;
286
+ totals.outputTokens += aggregate.outputTokens;
287
+ totals.cacheReadTokens += aggregate.cacheReadTokens;
288
+ totals.cacheWriteTokens += aggregate.cacheWriteTokens;
289
+ totals.totalTokens += aggregate.totalTokens;
290
+ totals.totalCost += aggregate.totalCost;
291
+ totals.turnCount += aggregate.turnCount;
292
+ totals.modelCount += modelRows.length;
293
+ }
294
+ return {
295
+ updatedAt: Date.now(),
296
+ days: safeDays,
297
+ daily,
298
+ totals,
299
+ };
300
+ };
301
+
302
+ const getSessionsList = ({ limit = kDefaultSessionLimit } = {}) => {
303
+ const database = ensureDb();
304
+ const safeLimit = clampInt(limit, 1, kMaxSessionLimit, kDefaultSessionLimit);
305
+ const rows = database
306
+ .prepare(`
307
+ SELECT
308
+ COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) AS session_ref,
309
+ MAX(session_key) AS session_key,
310
+ MAX(session_id) AS session_id,
311
+ MIN(timestamp) AS first_activity_ms,
312
+ MAX(timestamp) AS last_activity_ms,
313
+ COUNT(*) AS turn_count,
314
+ SUM(input_tokens) AS input_tokens,
315
+ SUM(output_tokens) AS output_tokens,
316
+ SUM(cache_read_tokens) AS cache_read_tokens,
317
+ SUM(cache_write_tokens) AS cache_write_tokens,
318
+ SUM(total_tokens) AS total_tokens
319
+ FROM usage_events
320
+ WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) IS NOT NULL
321
+ GROUP BY session_ref
322
+ ORDER BY last_activity_ms DESC
323
+ LIMIT $limit
324
+ `)
325
+ .all({ $limit: safeLimit });
326
+ return rows.map((row) => {
327
+ const modelRows = appendCostToRows(
328
+ database
329
+ .prepare(`
330
+ SELECT
331
+ model,
332
+ SUM(input_tokens) AS input_tokens,
333
+ SUM(output_tokens) AS output_tokens,
334
+ SUM(cache_read_tokens) AS cache_read_tokens,
335
+ SUM(cache_write_tokens) AS cache_write_tokens,
336
+ SUM(total_tokens) AS total_tokens
337
+ FROM usage_events
338
+ WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
339
+ GROUP BY model
340
+ ORDER BY total_tokens DESC
341
+ `)
342
+ .all({ $sessionRef: row.session_ref }),
343
+ );
344
+ const dominantModel = String(modelRows[0]?.model || "");
345
+ const totalCost = modelRows.reduce(
346
+ (sum, modelRow) => sum + Number(modelRow.totalCost || 0),
347
+ 0,
348
+ );
349
+ return {
350
+ sessionId: row.session_ref,
351
+ sessionKey: String(row.session_key || ""),
352
+ rawSessionId: String(row.session_id || ""),
353
+ firstActivityMs: coerceInt(row.first_activity_ms),
354
+ lastActivityMs: coerceInt(row.last_activity_ms),
355
+ durationMs: Math.max(
356
+ 0,
357
+ coerceInt(row.last_activity_ms) - coerceInt(row.first_activity_ms),
358
+ ),
359
+ turnCount: coerceInt(row.turn_count),
360
+ inputTokens: coerceInt(row.input_tokens),
361
+ outputTokens: coerceInt(row.output_tokens),
362
+ cacheReadTokens: coerceInt(row.cache_read_tokens),
363
+ cacheWriteTokens: coerceInt(row.cache_write_tokens),
364
+ totalTokens: coerceInt(row.total_tokens),
365
+ totalCost,
366
+ dominantModel,
367
+ };
368
+ });
369
+ };
370
+
371
+ const getSessionDetail = ({ sessionId }) => {
372
+ const safeSessionRef = String(sessionId || "").trim();
373
+ if (!safeSessionRef) return null;
374
+ const database = ensureDb();
375
+ const summaryRow = database
376
+ .prepare(`
377
+ SELECT
378
+ MAX(session_key) AS session_key,
379
+ MAX(session_id) AS session_id,
380
+ MIN(timestamp) AS first_activity_ms,
381
+ MAX(timestamp) AS last_activity_ms,
382
+ COUNT(*) AS turn_count,
383
+ SUM(input_tokens) AS input_tokens,
384
+ SUM(output_tokens) AS output_tokens,
385
+ SUM(cache_read_tokens) AS cache_read_tokens,
386
+ SUM(cache_write_tokens) AS cache_write_tokens,
387
+ SUM(total_tokens) AS total_tokens
388
+ FROM usage_events
389
+ WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
390
+ `)
391
+ .get({ $sessionRef: safeSessionRef });
392
+ if (!summaryRow || !coerceInt(summaryRow.turn_count)) return null;
393
+
394
+ const modelRows = appendCostToRows(
395
+ database
396
+ .prepare(`
397
+ SELECT
398
+ provider,
399
+ model,
400
+ COUNT(*) AS turn_count,
401
+ SUM(input_tokens) AS input_tokens,
402
+ SUM(output_tokens) AS output_tokens,
403
+ SUM(cache_read_tokens) AS cache_read_tokens,
404
+ SUM(cache_write_tokens) AS cache_write_tokens,
405
+ SUM(total_tokens) AS total_tokens
406
+ FROM usage_events
407
+ WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
408
+ GROUP BY provider, model
409
+ ORDER BY total_tokens DESC
410
+ `)
411
+ .all({ $sessionRef: safeSessionRef }),
412
+ ).map((row) => ({
413
+ provider: row.provider,
414
+ model: row.model,
415
+ turnCount: coerceInt(row.turn_count),
416
+ inputTokens: row.inputTokens,
417
+ outputTokens: row.outputTokens,
418
+ cacheReadTokens: row.cacheReadTokens,
419
+ cacheWriteTokens: row.cacheWriteTokens,
420
+ totalTokens: row.totalTokens,
421
+ totalCost: row.totalCost,
422
+ inputCost: row.inputCost,
423
+ outputCost: row.outputCost,
424
+ cacheReadCost: row.cacheReadCost,
425
+ cacheWriteCost: row.cacheWriteCost,
426
+ pricingFound: row.pricingFound,
427
+ }));
428
+
429
+ const toolRows = database
430
+ .prepare(`
431
+ SELECT
432
+ tool_name,
433
+ COUNT(*) AS call_count,
434
+ SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) AS success_count,
435
+ SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) AS error_count,
436
+ AVG(duration_ms) AS avg_duration_ms,
437
+ MIN(duration_ms) AS min_duration_ms,
438
+ MAX(duration_ms) AS max_duration_ms
439
+ FROM tool_events
440
+ WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
441
+ GROUP BY tool_name
442
+ ORDER BY call_count DESC
443
+ `)
444
+ .all({ $sessionRef: safeSessionRef })
445
+ .map((row) => {
446
+ const callCount = coerceInt(row.call_count);
447
+ const successCount = coerceInt(row.success_count);
448
+ const errorCount = coerceInt(row.error_count);
449
+ return {
450
+ toolName: row.tool_name,
451
+ callCount,
452
+ successCount,
453
+ errorCount,
454
+ errorRate: callCount > 0 ? errorCount / callCount : 0,
455
+ avgDurationMs: Number(row.avg_duration_ms || 0),
456
+ minDurationMs: coerceInt(row.min_duration_ms),
457
+ maxDurationMs: coerceInt(row.max_duration_ms),
458
+ };
459
+ });
460
+
461
+ const firstActivityMs = coerceInt(summaryRow.first_activity_ms);
462
+ const lastActivityMs = coerceInt(summaryRow.last_activity_ms);
463
+ const totalCost = modelRows.reduce(
464
+ (sum, modelRow) => sum + Number(modelRow.totalCost || 0),
465
+ 0,
466
+ );
467
+
468
+ return {
469
+ sessionId: safeSessionRef,
470
+ sessionKey: String(summaryRow.session_key || ""),
471
+ rawSessionId: String(summaryRow.session_id || ""),
472
+ firstActivityMs,
473
+ lastActivityMs,
474
+ durationMs: Math.max(0, lastActivityMs - firstActivityMs),
475
+ turnCount: coerceInt(summaryRow.turn_count),
476
+ inputTokens: coerceInt(summaryRow.input_tokens),
477
+ outputTokens: coerceInt(summaryRow.output_tokens),
478
+ cacheReadTokens: coerceInt(summaryRow.cache_read_tokens),
479
+ cacheWriteTokens: coerceInt(summaryRow.cache_write_tokens),
480
+ totalTokens: coerceInt(summaryRow.total_tokens),
481
+ totalCost,
482
+ modelBreakdown: modelRows,
483
+ toolUsage: toolRows,
484
+ };
485
+ };
486
+
487
+ const downsamplePoints = (points, maxPoints) => {
488
+ if (points.length <= maxPoints) return points;
489
+ const stride = Math.ceil(points.length / maxPoints);
490
+ const sampled = [];
491
+ for (let index = 0; index < points.length; index += stride) {
492
+ sampled.push(points[index]);
493
+ }
494
+ const lastPoint = points[points.length - 1];
495
+ if (sampled[sampled.length - 1]?.timestamp !== lastPoint.timestamp) {
496
+ sampled.push(lastPoint);
497
+ }
498
+ return sampled;
499
+ };
500
+
501
+ const getSessionTimeSeries = ({ sessionId, maxPoints = kDefaultMaxPoints }) => {
502
+ const safeSessionRef = String(sessionId || "").trim();
503
+ if (!safeSessionRef) return { sessionId: safeSessionRef, points: [] };
504
+ const database = ensureDb();
505
+ const rows = database
506
+ .prepare(`
507
+ SELECT
508
+ timestamp,
509
+ session_key,
510
+ session_id,
511
+ model,
512
+ input_tokens,
513
+ output_tokens,
514
+ cache_read_tokens,
515
+ cache_write_tokens,
516
+ total_tokens
517
+ FROM usage_events
518
+ WHERE COALESCE(NULLIF(session_key, ''), NULLIF(session_id, '')) = $sessionRef
519
+ ORDER BY timestamp ASC
520
+ `)
521
+ .all({ $sessionRef: safeSessionRef });
522
+ let cumulativeTokens = 0;
523
+ let cumulativeCost = 0;
524
+ const points = rows.map((row) => {
525
+ const inputTokens = coerceInt(row.input_tokens);
526
+ const outputTokens = coerceInt(row.output_tokens);
527
+ const cacheReadTokens = coerceInt(row.cache_read_tokens);
528
+ const cacheWriteTokens = coerceInt(row.cache_write_tokens);
529
+ const totalTokens =
530
+ coerceInt(row.total_tokens) ||
531
+ inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
532
+ const cost = deriveCostBreakdown({
533
+ inputTokens,
534
+ outputTokens,
535
+ cacheReadTokens,
536
+ cacheWriteTokens,
537
+ model: row.model,
538
+ });
539
+ cumulativeTokens += totalTokens;
540
+ cumulativeCost += cost.totalCost;
541
+ return {
542
+ timestamp: coerceInt(row.timestamp),
543
+ sessionKey: String(row.session_key || ""),
544
+ rawSessionId: String(row.session_id || ""),
545
+ model: String(row.model || ""),
546
+ inputTokens,
547
+ outputTokens,
548
+ cacheReadTokens,
549
+ cacheWriteTokens,
550
+ totalTokens,
551
+ cost: cost.totalCost,
552
+ cumulativeTokens,
553
+ cumulativeCost,
554
+ };
555
+ });
556
+ const safeMaxPoints = clampInt(maxPoints, 10, kMaxMaxPoints, kDefaultMaxPoints);
557
+ return {
558
+ sessionId: safeSessionRef,
559
+ points: downsamplePoints(points, safeMaxPoints),
560
+ };
561
+ };
562
+
563
+ module.exports = {
564
+ initUsageDb,
565
+ getDailySummary,
566
+ getSessionsList,
567
+ getSessionDetail,
568
+ getSessionTimeSeries,
569
+ kGlobalModelPricing,
570
+ };