@zhangferry-dev/tokendash 1.1.3 → 1.2.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.
@@ -0,0 +1,314 @@
1
+ import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ const MODEL_PRICING = {
5
+ // Claude 4.6
6
+ 'claude-opus-4-6': { inputPer1M: 15, cacheReadPer1M: 1.50, outputPer1M: 75 },
7
+ 'claude-sonnet-4-6': { inputPer1M: 3, cacheReadPer1M: 0.30, outputPer1M: 15 },
8
+ // Claude 4.5
9
+ 'claude-sonnet-4-5-20250514': { inputPer1M: 3, cacheReadPer1M: 0.30, outputPer1M: 15 },
10
+ 'claude-haiku-4-5-20251001': { inputPer1M: 0.80, cacheReadPer1M: 0.08, outputPer1M: 4 },
11
+ // Older Claude models
12
+ 'claude-3-5-sonnet-20241022': { inputPer1M: 3, cacheReadPer1M: 0.30, outputPer1M: 15 },
13
+ 'claude-3-5-haiku-20241022': { inputPer1M: 0.80, cacheReadPer1M: 0.08, outputPer1M: 4 },
14
+ 'claude-3-opus-20240229': { inputPer1M: 15, cacheReadPer1M: 1.50, outputPer1M: 75 },
15
+ 'claude-3-haiku-20240307': { inputPer1M: 0.25, cacheReadPer1M: 0.03, outputPer1M: 1.25 },
16
+ };
17
+ const DEFAULT_PRICING = { inputPer1M: 3, cacheReadPer1M: 0.30, outputPer1M: 15 };
18
+ function getPricing(model) {
19
+ // Try exact match first, then prefix match
20
+ if (MODEL_PRICING[model])
21
+ return MODEL_PRICING[model];
22
+ const lower = model.toLowerCase();
23
+ for (const key of Object.keys(MODEL_PRICING)) {
24
+ if (lower.startsWith(key) || lower.includes(key))
25
+ return MODEL_PRICING[key];
26
+ }
27
+ return DEFAULT_PRICING;
28
+ }
29
+ export function calculateCost(inputTokens, cacheReadTokens, outputTokens, model) {
30
+ const p = getPricing(model);
31
+ const nonCachedInput = Math.max(inputTokens - cacheReadTokens, 0);
32
+ return (nonCachedInput / 1_000_000) * p.inputPer1M
33
+ + (cacheReadTokens / 1_000_000) * p.cacheReadPer1M
34
+ + (outputTokens / 1_000_000) * p.outputPer1M;
35
+ }
36
+ // ---------------------------------------------------------------------------
37
+ // JSONL parsing with mtime cache
38
+ // ---------------------------------------------------------------------------
39
+ const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
40
+ const fileCache = new Map();
41
+ function extractProjectName(dirName) {
42
+ const parts = dirName.replace(/^-/, '').split('-');
43
+ return parts[parts.length - 1] || dirName;
44
+ }
45
+ function matchesProject(dirName, filter) {
46
+ return extractProjectName(dirName) === extractProjectName(filter);
47
+ }
48
+ function findJsonlFiles(dir) {
49
+ const results = [];
50
+ try {
51
+ const entries = readdirSync(dir, { withFileTypes: true });
52
+ for (const entry of entries) {
53
+ if (entry.isDirectory()) {
54
+ results.push(...findJsonlFiles(join(dir, entry.name)));
55
+ }
56
+ else if (entry.name.endsWith('.jsonl')) {
57
+ results.push(join(dir, entry.name));
58
+ }
59
+ }
60
+ }
61
+ catch { /* skip unreadable dirs */ }
62
+ return results;
63
+ }
64
+ function parseAllSessions(project) {
65
+ if (!existsSync(CLAUDE_PROJECTS_DIR))
66
+ return [];
67
+ const results = [];
68
+ const projectDirs = readdirSync(CLAUDE_PROJECTS_DIR, { withFileTypes: true })
69
+ .filter(d => d.isDirectory())
70
+ .map(d => d.name);
71
+ for (const dirName of projectDirs) {
72
+ if (project && !matchesProject(dirName, project))
73
+ continue;
74
+ const dirPath = join(CLAUDE_PROJECTS_DIR, dirName);
75
+ const files = findJsonlFiles(dirPath);
76
+ for (const filePath of files) {
77
+ let mtime = 0;
78
+ try {
79
+ mtime = statSync(filePath).mtimeMs;
80
+ }
81
+ catch { /* ok */ }
82
+ const cached = fileCache.get(filePath);
83
+ if (cached && cached.mtime === mtime) {
84
+ results.push(...cached.entries);
85
+ continue;
86
+ }
87
+ const entries = [];
88
+ let content;
89
+ try {
90
+ content = readFileSync(filePath, 'utf-8');
91
+ }
92
+ catch {
93
+ continue;
94
+ }
95
+ for (const line of content.split('\n')) {
96
+ const trimmed = line.trim();
97
+ if (!trimmed)
98
+ continue;
99
+ let obj;
100
+ try {
101
+ obj = JSON.parse(trimmed);
102
+ }
103
+ catch {
104
+ continue;
105
+ }
106
+ if (obj.type !== 'assistant' || !obj.message)
107
+ continue;
108
+ const msg = obj.message;
109
+ const usage = msg.usage || {};
110
+ const inputTokens = usage.input_tokens || 0;
111
+ const outputTokens = usage.output_tokens || 0;
112
+ const cacheCreationTokens = usage.cache_creation_input_tokens || 0;
113
+ const cacheReadTokens = usage.cache_read_input_tokens || 0;
114
+ const totalTokens = inputTokens + outputTokens + cacheReadTokens;
115
+ if (totalTokens === 0)
116
+ continue;
117
+ entries.push({
118
+ timestamp: obj.timestamp,
119
+ model: msg.model || 'unknown',
120
+ inputTokens,
121
+ outputTokens,
122
+ cacheCreationTokens,
123
+ cacheReadTokens,
124
+ projectDir: dirName,
125
+ });
126
+ }
127
+ fileCache.set(filePath, { mtime, entries });
128
+ results.push(...entries);
129
+ }
130
+ }
131
+ return results;
132
+ }
133
+ // ---------------------------------------------------------------------------
134
+ // Timezone helpers
135
+ // ---------------------------------------------------------------------------
136
+ const TZ_OFFSETS = {
137
+ 'Asia/Shanghai': 8,
138
+ 'Asia/Tokyo': 9,
139
+ 'America/New_York': -5,
140
+ 'America/Los_Angeles': -8,
141
+ 'Europe/London': 0,
142
+ 'UTC': 0,
143
+ };
144
+ export function getDateKey(timestamp, tz) {
145
+ const offset = (TZ_OFFSETS[tz] ?? 8) * 3_600_000;
146
+ const d = new Date(new Date(timestamp).getTime() + offset);
147
+ // Use UTC methods since we manually applied the timezone offset
148
+ return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`;
149
+ }
150
+ export function getHourKey(timestamp, tz) {
151
+ const offset = (TZ_OFFSETS[tz] ?? 8) * 3_600_000;
152
+ const d = new Date(new Date(timestamp).getTime() + offset);
153
+ // Use UTC methods since we manually applied the timezone offset
154
+ const yyyy = d.getUTCFullYear();
155
+ const mm = String(d.getUTCMonth() + 1).padStart(2, '0');
156
+ const dd = String(d.getUTCDate()).padStart(2, '0');
157
+ const hh = String(d.getUTCHours()).padStart(2, '0');
158
+ return `${yyyy}-${mm}-${dd}T${hh}`;
159
+ }
160
+ function toDailyEntry(agg) {
161
+ const modelBreakdowns = [...agg.models.entries()].map(([modelName, m]) => ({
162
+ modelName,
163
+ inputTokens: m.input,
164
+ outputTokens: m.output,
165
+ cacheCreationTokens: m.cacheCreation,
166
+ cacheReadTokens: m.cacheRead,
167
+ cost: m.cost,
168
+ }));
169
+ return {
170
+ date: agg.date,
171
+ inputTokens: agg.inputTokens,
172
+ outputTokens: agg.outputTokens,
173
+ cacheCreationTokens: agg.cacheCreationTokens,
174
+ cacheReadTokens: agg.cacheReadTokens,
175
+ totalTokens: agg.totalTokens,
176
+ totalCost: Math.round(agg.totalCost * 10000) / 10000,
177
+ modelsUsed: [...agg.models.keys()],
178
+ modelBreakdowns,
179
+ };
180
+ }
181
+ // ---------------------------------------------------------------------------
182
+ // Public API
183
+ // ---------------------------------------------------------------------------
184
+ const DEFAULT_TZ = 'Asia/Shanghai';
185
+ export function getDailyResponse(project, tz = DEFAULT_TZ) {
186
+ const entries = parseAllSessions(project);
187
+ const dayMap = new Map();
188
+ for (const e of entries) {
189
+ const date = getDateKey(e.timestamp, tz);
190
+ if (!dayMap.has(date)) {
191
+ dayMap.set(date, {
192
+ date, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0,
193
+ cacheReadTokens: 0, totalTokens: 0, totalCost: 0,
194
+ models: new Map(),
195
+ });
196
+ }
197
+ const agg = dayMap.get(date);
198
+ agg.inputTokens += e.inputTokens;
199
+ agg.outputTokens += e.outputTokens;
200
+ agg.cacheCreationTokens += e.cacheCreationTokens;
201
+ agg.cacheReadTokens += e.cacheReadTokens;
202
+ agg.totalTokens += e.inputTokens + e.outputTokens + e.cacheReadTokens;
203
+ const cost = calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model);
204
+ agg.totalCost += cost;
205
+ if (!agg.models.has(e.model)) {
206
+ agg.models.set(e.model, { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, cost: 0 });
207
+ }
208
+ const m = agg.models.get(e.model);
209
+ m.input += e.inputTokens;
210
+ m.output += e.outputTokens;
211
+ m.cacheCreation += e.cacheCreationTokens;
212
+ m.cacheRead += e.cacheReadTokens;
213
+ m.cost += cost;
214
+ }
215
+ const daily = [...dayMap.values()].sort((a, b) => a.date.localeCompare(b.date)).map(toDailyEntry);
216
+ const totals = daily.reduce((acc, d) => ({
217
+ inputTokens: acc.inputTokens + d.inputTokens,
218
+ outputTokens: acc.outputTokens + d.outputTokens,
219
+ cacheCreationTokens: acc.cacheCreationTokens + d.cacheCreationTokens,
220
+ cacheReadTokens: acc.cacheReadTokens + d.cacheReadTokens,
221
+ totalTokens: acc.totalTokens + d.totalTokens,
222
+ totalCost: acc.totalCost + d.totalCost,
223
+ }), { inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0, cacheReadTokens: 0, totalTokens: 0, totalCost: 0 });
224
+ return { daily, totals };
225
+ }
226
+ export function getProjectsResponse(tz = DEFAULT_TZ) {
227
+ const entries = parseAllSessions();
228
+ const projectMap = new Map();
229
+ for (const e of entries) {
230
+ const date = getDateKey(e.timestamp, tz);
231
+ const projectName = e.projectDir;
232
+ if (!projectMap.has(projectName)) {
233
+ projectMap.set(projectName, new Map());
234
+ }
235
+ const dayMap = projectMap.get(projectName);
236
+ if (!dayMap.has(date)) {
237
+ dayMap.set(date, {
238
+ date, inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0,
239
+ cacheReadTokens: 0, totalTokens: 0, totalCost: 0,
240
+ models: new Map(),
241
+ });
242
+ }
243
+ const agg = dayMap.get(date);
244
+ agg.inputTokens += e.inputTokens;
245
+ agg.outputTokens += e.outputTokens;
246
+ agg.cacheCreationTokens += e.cacheCreationTokens;
247
+ agg.cacheReadTokens += e.cacheReadTokens;
248
+ agg.totalTokens += e.inputTokens + e.outputTokens + e.cacheReadTokens;
249
+ const cost = calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model);
250
+ agg.totalCost += cost;
251
+ if (!agg.models.has(e.model)) {
252
+ agg.models.set(e.model, { input: 0, output: 0, cacheCreation: 0, cacheRead: 0, cost: 0 });
253
+ }
254
+ const m = agg.models.get(e.model);
255
+ m.input += e.inputTokens;
256
+ m.output += e.outputTokens;
257
+ m.cacheCreation += e.cacheCreationTokens;
258
+ m.cacheRead += e.cacheReadTokens;
259
+ m.cost += cost;
260
+ }
261
+ const projects = {};
262
+ for (const [projectName, dayMap] of projectMap) {
263
+ projects[projectName] = [...dayMap.values()]
264
+ .sort((a, b) => a.date.localeCompare(b.date))
265
+ .map(toDailyEntry);
266
+ }
267
+ return { projects };
268
+ }
269
+ export function getBlocksResponse(project, tz = DEFAULT_TZ) {
270
+ const entries = parseAllSessions(project);
271
+ const hourMap = new Map();
272
+ for (const e of entries) {
273
+ const hourKey = getHourKey(e.timestamp, tz);
274
+ if (!hourMap.has(hourKey)) {
275
+ hourMap.set(hourKey, {
276
+ inputTokens: 0, outputTokens: 0, cacheCreationTokens: 0,
277
+ cacheReadTokens: 0, costUSD: 0, models: new Set(),
278
+ });
279
+ }
280
+ const bucket = hourMap.get(hourKey);
281
+ bucket.inputTokens += e.inputTokens;
282
+ bucket.outputTokens += e.outputTokens;
283
+ bucket.cacheCreationTokens += e.cacheCreationTokens;
284
+ bucket.cacheReadTokens += e.cacheReadTokens;
285
+ bucket.costUSD += calculateCost(e.inputTokens, e.cacheReadTokens, e.outputTokens, e.model);
286
+ bucket.models.add(e.model);
287
+ }
288
+ const blocks = [];
289
+ let idx = 0;
290
+ for (const [hourKey, bucket] of hourMap) {
291
+ const totalTokens = bucket.inputTokens + bucket.outputTokens + bucket.cacheReadTokens;
292
+ blocks.push({
293
+ id: `claude-${idx}`,
294
+ startTime: `${hourKey}:00:00`,
295
+ endTime: `${hourKey}:59:59`,
296
+ actualEndTime: null,
297
+ isActive: false,
298
+ isGap: false,
299
+ entries: totalTokens > 0 ? 1 : 0,
300
+ tokenCounts: {
301
+ inputTokens: bucket.inputTokens,
302
+ outputTokens: bucket.outputTokens,
303
+ cacheCreationInputTokens: bucket.cacheCreationTokens,
304
+ cacheReadInputTokens: bucket.cacheReadTokens,
305
+ },
306
+ totalTokens,
307
+ costUSD: Math.round(bucket.costUSD * 10000) / 10000,
308
+ models: [...bucket.models],
309
+ });
310
+ idx++;
311
+ }
312
+ blocks.sort((a, b) => a.startTime.localeCompare(b.startTime));
313
+ return { blocks };
314
+ }
@@ -1,7 +1,7 @@
1
1
  import express from 'express';
2
2
  import { readFileSync } from 'node:fs';
3
3
  import { registerApiRoutes } from './routes/api.js';
4
- import { detectAvailableAgents } from './ccusage.js';
4
+ import { detectAvailableAgents } from './agentDetection.js';
5
5
  import open from 'open';
6
6
  const CLI_USAGE = [
7
7
  'Usage:',
@@ -52,10 +52,10 @@ function parseCliArgs() {
52
52
  }
53
53
  async function ensureUsageSupportAvailable() {
54
54
  try {
55
- const agents = await detectAvailableAgents();
55
+ const agents = detectAvailableAgents();
56
56
  if (!agents.claude && !agents.codex) {
57
57
  console.error('Error: No AI coding assistant data found.');
58
- console.error('\nDetails: Could not find Claude Code (ccusage CLI) or Codex (~/.codex/sessions/) data.');
58
+ console.error('\nDetails: Could not find Claude Code (~/.claude/projects/) or Codex (~/.codex/sessions/) data.');
59
59
  console.error('Please install at least one of: Claude Code or Codex CLI.');
60
60
  return false;
61
61
  }
@@ -40,6 +40,7 @@ export function scanOpenClawSessions() {
40
40
  }
41
41
  for (const agentEntry of agentEntries) {
42
42
  const sessionsDir = join(agentsDir, agentEntry, 'sessions');
43
+ const indexedPaths = new Set();
43
44
  // Try sessions.json index first
44
45
  const indexPath = join(sessionsDir, 'sessions.json');
45
46
  try {
@@ -50,44 +51,66 @@ export function scanOpenClawSessions() {
50
51
  continue;
51
52
  let sessionPath;
52
53
  if (entry.sessionFile) {
53
- sessionPath = entry.sessionFile.startsWith('/')
54
- ? entry.sessionFile
55
- : join(sessionsDir, entry.sessionFile);
54
+ const filePath = entry.sessionFile;
55
+ if (filePath.startsWith('/')) {
56
+ // Validate absolute path stays within an OpenClaw directory
57
+ if (!getOpenClawDirs().some(dir => filePath.startsWith(dir)))
58
+ continue;
59
+ sessionPath = filePath;
60
+ }
61
+ else {
62
+ sessionPath = join(sessionsDir, filePath);
63
+ }
56
64
  }
57
65
  else {
58
66
  sessionPath = join(sessionsDir, `${entry.sessionId}.jsonl`);
59
67
  }
68
+ indexedPaths.add(sessionPath);
60
69
  refs.push({ sessionId: entry.sessionId, sessionFile: sessionPath, agentId: agentEntry });
61
70
  }
62
71
  }
63
72
  catch {
64
- // No sessions.json — fall back to scanning .jsonl files directly
65
- let files;
66
- try {
67
- files = readdirSync(sessionsDir);
68
- }
69
- catch {
73
+ // No sessions.json — will scan .jsonl files below
74
+ }
75
+ // Scan for .jsonl files not already covered by the index
76
+ let files;
77
+ try {
78
+ files = readdirSync(sessionsDir);
79
+ }
80
+ catch {
81
+ continue;
82
+ }
83
+ for (const f of files) {
84
+ if (!f.endsWith('.jsonl'))
70
85
  continue;
71
- }
72
- for (const f of files) {
73
- if (!f.endsWith('.jsonl'))
74
- continue;
75
- const sessionId = f.replace(/\.jsonl.*$/, ''); // strip .jsonl and any suffixes
76
- refs.push({
77
- sessionId,
78
- sessionFile: join(sessionsDir, f),
79
- agentId: agentEntry,
80
- });
81
- }
86
+ const fullPath = join(sessionsDir, f);
87
+ if (indexedPaths.has(fullPath))
88
+ continue;
89
+ const sessionId = f.replace(/\.jsonl.*$/, '');
90
+ refs.push({ sessionId, sessionFile: fullPath, agentId: agentEntry });
82
91
  }
83
92
  }
84
93
  }
85
94
  return refs;
86
95
  }
87
96
  // ---------------------------------------------------------------------------
97
+ // Session-level cache (mtime-based invalidation)
98
+ // ---------------------------------------------------------------------------
99
+ const sessionCache = new Map();
100
+ // ---------------------------------------------------------------------------
88
101
  // JSONL parser
89
102
  // ---------------------------------------------------------------------------
90
103
  export function parseOpenClawSession(ref) {
104
+ let fileMtimeMs = 0;
105
+ try {
106
+ fileMtimeMs = statSync(ref.sessionFile).mtimeMs;
107
+ }
108
+ catch { /* ok */ }
109
+ // Return cached result if file hasn't changed
110
+ const cached = sessionCache.get(ref.sessionFile);
111
+ if (cached && cached.mtime === fileMtimeMs) {
112
+ return cached.result;
113
+ }
91
114
  let content;
92
115
  try {
93
116
  content = readFileSync(ref.sessionFile, 'utf-8');
@@ -95,12 +118,6 @@ export function parseOpenClawSession(ref) {
95
118
  catch {
96
119
  return null;
97
120
  }
98
- // Fallback timestamp: file mtime
99
- let fileMtimeMs = 0;
100
- try {
101
- fileMtimeMs = statSync(ref.sessionFile).mtimeMs;
102
- }
103
- catch { /* ok */ }
104
121
  const tokenEvents = [];
105
122
  let currentModel = '';
106
123
  let currentProvider = '';
@@ -161,15 +178,19 @@ export function parseOpenClawSession(ref) {
161
178
  outputTokens: Math.max(0, output),
162
179
  cacheReadTokens: Math.max(0, cacheRead),
163
180
  cacheWriteTokens: Math.max(0, cacheWrite),
164
- totalTokens: Math.max(0, input + output),
181
+ totalTokens: Math.max(0, input + output + cacheRead),
165
182
  cost: Math.max(0, cost),
166
183
  model: `${provider}/${model}`,
167
184
  });
168
185
  }
169
186
  }
170
- if (tokenEvents.length === 0)
187
+ if (tokenEvents.length === 0) {
188
+ sessionCache.set(ref.sessionFile, { mtime: fileMtimeMs, result: null });
171
189
  return null;
172
- return { id: ref.sessionId, agentId: ref.agentId, tokenEvents };
190
+ }
191
+ const result = { id: ref.sessionId, agentId: ref.agentId, tokenEvents };
192
+ sessionCache.set(ref.sessionFile, { mtime: fileMtimeMs, result });
193
+ return result;
173
194
  }
174
195
  export function parseAllOpenClawSessions() {
175
196
  return scanOpenClawSessions()
@@ -251,7 +272,6 @@ export function getDailyResponse(options) {
251
272
  const sessions = parseAllOpenClawSessions();
252
273
  const tz = options?.timezone || 'Asia/Shanghai';
253
274
  const grouped = new Map();
254
- const allModels = new Set();
255
275
  const totalsAcc = emptyAcc();
256
276
  for (const session of sessions) {
257
277
  if (options?.project && session.agentId !== options.project)
@@ -267,7 +287,6 @@ export function getDailyResponse(options) {
267
287
  const entry = grouped.get(key);
268
288
  addEvent(entry.acc, ev);
269
289
  entry.models.add(ev.model);
270
- allModels.add(ev.model);
271
290
  }
272
291
  }
273
292
  const daily = [];
@@ -0,0 +1,2 @@
1
+ import { type Request, type Response } from 'express';
2
+ export declare function getAnalytics(req: Request, res: Response): Promise<void>;
@@ -0,0 +1,40 @@
1
+ import { cache } from '../cache.js';
2
+ import { validateAnalytics } from '../../shared/schemas.js';
3
+ import { extractClaudeToolCalls, extractOpenClawToolCalls, computeAnalytics } from '../analyticsParser.js';
4
+ const EMPTY_ANALYTICS = {
5
+ codeChangeTrend: [],
6
+ toolUsageDistribution: [],
7
+ productivityKPIs: { avgLinesPerEdit: 0, filesModifiedPerDay: 0, addDeleteRatio: 0, totalEdits: 0, totalFilesModified: 0, activeDaysWithEdits: 0 },
8
+ toolCallTrend: [],
9
+ };
10
+ export async function getAnalytics(req, res) {
11
+ const agent = req.query.agent || 'claude';
12
+ const project = req.query.project || undefined;
13
+ if (agent === 'codex') {
14
+ res.json(EMPTY_ANALYTICS);
15
+ return;
16
+ }
17
+ try {
18
+ const cacheKey = `analytics:${agent}:${project || 'all'}`;
19
+ const cached = cache.get(cacheKey);
20
+ if (cached) {
21
+ res.json(cached);
22
+ return;
23
+ }
24
+ const toolCalls = agent === 'openclaw'
25
+ ? extractOpenClawToolCalls(project || null)
26
+ : extractClaudeToolCalls(project || null);
27
+ const data = computeAnalytics(toolCalls);
28
+ const validated = validateAnalytics(data);
29
+ cache.set(cacheKey, validated);
30
+ res.json(validated);
31
+ }
32
+ catch (error) {
33
+ const message = error instanceof Error ? error.message : 'Unknown error';
34
+ console.error('Error fetching analytics:', error);
35
+ res.status(502).json({
36
+ error: `Failed to fetch analytics from ${agent}`,
37
+ hint: message,
38
+ });
39
+ }
40
+ }
@@ -3,15 +3,19 @@ import { getMonthly } from './monthly.js';
3
3
  import { getSession } from './session.js';
4
4
  import { getProjects } from './projects.js';
5
5
  import { getBlocks } from './blocks.js';
6
- import { detectAvailableAgents } from '../ccusage.js';
7
- async function getAgents(_req, res) {
6
+ import { getAnalytics } from './analytics.js';
7
+ import { detectAvailableAgents } from '../agentDetection.js';
8
+ import { isOpenClawAccessible } from '../openclawParser.js';
9
+ function getAgents(_req, res) {
8
10
  try {
9
- const agents = await detectAvailableAgents();
11
+ const agents = detectAvailableAgents();
10
12
  const available = [];
11
13
  if (agents.claude)
12
14
  available.push('claude');
13
15
  if (agents.codex)
14
16
  available.push('codex');
17
+ if (isOpenClawAccessible())
18
+ available.push('openclaw');
15
19
  res.json({ available, default: available[0] || null });
16
20
  }
17
21
  catch (error) {
@@ -26,4 +30,5 @@ export function registerApiRoutes(router) {
26
30
  router.get('/session', getSession);
27
31
  router.get('/projects', getProjects);
28
32
  router.get('/blocks', getBlocks);
33
+ router.get('/analytics', getAnalytics);
29
34
  }
@@ -1,57 +1,52 @@
1
- import { runCcusage } from '../ccusage.js';
2
1
  import { cache } from '../cache.js';
3
2
  import { validateBlocks } from '../../shared/schemas.js';
4
- import { getBlocksResponse } from '../codexParser.js';
5
- import { getClaudeBlocksByProject } from '../claudeBlocksParser.js';
3
+ import { getBlocksResponse as getCodexBlocksResponse } from '../codexParser.js';
4
+ import { getBlocksResponse as getOpenClawBlocksResponse } from '../openclawParser.js';
5
+ import { getBlocksResponse as getClaudeBlocksResponse } from '../claudeJsonlParser.js';
6
6
  export async function getBlocks(req, res) {
7
7
  const agent = req.query.agent || 'claude';
8
8
  const project = req.query.project || undefined;
9
9
  try {
10
- if (agent === 'codex') {
11
- const projectCacheKey = `blocks:${agent}:${project || 'all'}`;
12
- const cached = cache.get(projectCacheKey);
13
- if (cached) {
14
- res.json(cached);
15
- return;
16
- }
17
- const data = getBlocksResponse({ project: project || null });
18
- cache.set(projectCacheKey, data);
19
- res.json(data);
20
- return;
21
- }
22
- // Claude Code with project filter: use custom JSONL parser
23
- if (project) {
24
- const projectCacheKey = `blocks:claude:${project}`;
25
- const cached = cache.get(projectCacheKey);
26
- if (cached) {
27
- res.json(cached);
28
- return;
29
- }
30
- const blocks = getClaudeBlocksByProject(project);
31
- const data = { blocks };
32
- cache.set(projectCacheKey, data);
33
- res.json(data);
34
- return;
35
- }
36
- // Claude Code without project filter: use ccusage blocks
37
- const cacheKey = `blocks:${agent}`;
10
+ const cacheKey = `blocks:${agent}:${project || 'all'}`;
38
11
  const cached = cache.get(cacheKey);
39
12
  if (cached) {
40
13
  res.json(cached);
41
14
  return;
42
15
  }
43
- const stdout = await runCcusage(['blocks']);
44
- const data = JSON.parse(stdout);
45
- const validated = validateBlocks(data);
46
- cache.set(cacheKey, validated);
47
- res.json(validated);
16
+ // Stale-while-revalidate
17
+ const stale = cache.getStale(cacheKey);
18
+ if (stale) {
19
+ refreshBlocksCache(agent, project, cacheKey);
20
+ res.json(stale);
21
+ return;
22
+ }
23
+ const data = fetchBlocksData(agent, project);
24
+ cache.set(cacheKey, data);
25
+ res.json(data);
48
26
  }
49
27
  catch (error) {
50
28
  const message = error instanceof Error ? error.message : 'Unknown error';
51
29
  console.error('Error fetching blocks data:', error);
52
30
  res.status(502).json({
53
- error: 'Failed to fetch blocks data from ccusage',
31
+ error: 'Failed to fetch blocks data',
54
32
  hint: message,
55
33
  });
56
34
  }
57
35
  }
36
+ function fetchBlocksData(agent, project) {
37
+ if (agent === 'openclaw') {
38
+ return validateBlocks(getOpenClawBlocksResponse({ project: project || null }));
39
+ }
40
+ else if (agent === 'codex') {
41
+ return validateBlocks(getCodexBlocksResponse({ project: project || null }));
42
+ }
43
+ else {
44
+ // Claude Code: parse JSONL directly (fast, no CLI)
45
+ return validateBlocks(getClaudeBlocksResponse(project || null));
46
+ }
47
+ }
48
+ function refreshBlocksCache(agent, project, cacheKey) {
49
+ Promise.resolve()
50
+ .then(() => { const data = fetchBlocksData(agent, project); cache.set(cacheKey, data); })
51
+ .catch(err => console.error('Background refresh failed (blocks):', err));
52
+ }