@zhangferry-dev/tokendash 1.1.4 → 1.2.1

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
  }
@@ -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,11 +3,12 @@ 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';
6
+ import { getAnalytics } from './analytics.js';
7
+ import { detectAvailableAgents } from '../agentDetection.js';
7
8
  import { isOpenClawAccessible } from '../openclawParser.js';
8
- async function getAgents(_req, res) {
9
+ function getAgents(_req, res) {
9
10
  try {
10
- const agents = await detectAvailableAgents();
11
+ const agents = detectAvailableAgents();
11
12
  const available = [];
12
13
  if (agents.claude)
13
14
  available.push('claude');
@@ -29,4 +30,5 @@ export function registerApiRoutes(router) {
29
30
  router.get('/session', getSession);
30
31
  router.get('/projects', getProjects);
31
32
  router.get('/blocks', getBlocks);
33
+ router.get('/analytics', getAnalytics);
32
34
  }
@@ -1,72 +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';
3
+ import { getBlocksResponse as getCodexBlocksResponse } from '../codexParser.js';
5
4
  import { getBlocksResponse as getOpenClawBlocksResponse } from '../openclawParser.js';
6
- import { getClaudeBlocksByProject } from '../claudeBlocksParser.js';
5
+ import { getBlocksResponse as getClaudeBlocksResponse } from '../claudeJsonlParser.js';
7
6
  export async function getBlocks(req, res) {
8
7
  const agent = req.query.agent || 'claude';
9
8
  const project = req.query.project || undefined;
10
9
  try {
11
- if (agent === 'openclaw') {
12
- const projectCacheKey = `blocks:${agent}:${project || 'all'}`;
13
- const cached = cache.get(projectCacheKey);
14
- if (cached) {
15
- res.json(cached);
16
- return;
17
- }
18
- const data = getOpenClawBlocksResponse({ project: project || null });
19
- const validated = validateBlocks(data);
20
- cache.set(projectCacheKey, validated);
21
- res.json(validated);
22
- return;
23
- }
24
- if (agent === 'codex') {
25
- const projectCacheKey = `blocks:${agent}:${project || 'all'}`;
26
- const cached = cache.get(projectCacheKey);
27
- if (cached) {
28
- res.json(cached);
29
- return;
30
- }
31
- const data = getBlocksResponse({ project: project || null });
32
- const validated = validateBlocks(data);
33
- cache.set(projectCacheKey, validated);
34
- res.json(validated);
35
- return;
36
- }
37
- // Claude Code with project filter: use custom JSONL parser
38
- if (project) {
39
- const projectCacheKey = `blocks:claude:${project}`;
40
- const cached = cache.get(projectCacheKey);
41
- if (cached) {
42
- res.json(cached);
43
- return;
44
- }
45
- const blocks = getClaudeBlocksByProject(project);
46
- const data = { blocks };
47
- cache.set(projectCacheKey, data);
48
- res.json(data);
49
- return;
50
- }
51
- // Claude Code without project filter: use ccusage blocks
52
- const cacheKey = `blocks:${agent}`;
10
+ const cacheKey = `blocks:${agent}:${project || 'all'}`;
53
11
  const cached = cache.get(cacheKey);
54
12
  if (cached) {
55
13
  res.json(cached);
56
14
  return;
57
15
  }
58
- const stdout = await runCcusage(['blocks']);
59
- const data = JSON.parse(stdout);
60
- const validated = validateBlocks(data);
61
- cache.set(cacheKey, validated);
62
- 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);
63
26
  }
64
27
  catch (error) {
65
28
  const message = error instanceof Error ? error.message : 'Unknown error';
66
29
  console.error('Error fetching blocks data:', error);
67
30
  res.status(502).json({
68
- error: 'Failed to fetch blocks data from ccusage',
31
+ error: 'Failed to fetch blocks data',
69
32
  hint: message,
70
33
  });
71
34
  }
72
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
+ }
@@ -1,8 +1,8 @@
1
- import { runCcusage } from '../ccusage.js';
2
1
  import { cache } from '../cache.js';
3
2
  import { validateDaily } from '../../shared/schemas.js';
4
- import { getDailyResponse } from '../codexParser.js';
3
+ import { getDailyResponse as getCodexDailyResponse } from '../codexParser.js';
5
4
  import { getDailyResponse as getOpenClawDailyResponse } from '../openclawParser.js';
5
+ import { getDailyResponse as getClaudeDailyResponse } from '../claudeJsonlParser.js';
6
6
  export async function getDaily(req, res) {
7
7
  const agent = req.query.agent || 'claude';
8
8
  const cacheKey = `daily:${agent}`;
@@ -12,24 +12,16 @@ export async function getDaily(req, res) {
12
12
  res.json(cached);
13
13
  return;
14
14
  }
15
- if (agent === 'codex') {
16
- const data = getDailyResponse();
17
- cache.set(cacheKey, data);
18
- res.json(data);
19
- }
20
- else if (agent === 'openclaw') {
21
- const data = getOpenClawDailyResponse();
22
- const validated = validateDaily(data);
23
- cache.set(cacheKey, validated);
24
- res.json(validated);
25
- }
26
- else {
27
- const stdout = await runCcusage(['daily', '--breakdown']);
28
- const data = JSON.parse(stdout);
29
- const validated = validateDaily(data);
30
- cache.set(cacheKey, validated);
31
- res.json(validated);
15
+ // Stale-while-revalidate: return stale data, refresh in background
16
+ const stale = cache.getStale(cacheKey);
17
+ if (stale) {
18
+ refreshDailyCache(agent, cacheKey);
19
+ res.json(stale);
20
+ return;
32
21
  }
22
+ const data = await fetchDailyData(agent);
23
+ cache.set(cacheKey, data);
24
+ res.json(data);
33
25
  }
34
26
  catch (error) {
35
27
  const message = error instanceof Error ? error.message : 'Unknown error';
@@ -40,3 +32,20 @@ export async function getDaily(req, res) {
40
32
  });
41
33
  }
42
34
  }
35
+ function fetchDailyData(agent) {
36
+ if (agent === 'codex') {
37
+ return Promise.resolve(getCodexDailyResponse());
38
+ }
39
+ else if (agent === 'openclaw') {
40
+ return Promise.resolve(validateDaily(getOpenClawDailyResponse()));
41
+ }
42
+ else {
43
+ // Claude Code: parse JSONL directly (fast, no CLI)
44
+ return Promise.resolve(validateDaily(getClaudeDailyResponse()));
45
+ }
46
+ }
47
+ function refreshDailyCache(agent, cacheKey) {
48
+ fetchDailyData(agent)
49
+ .then(data => cache.set(cacheKey, data))
50
+ .catch(err => console.error('Background refresh failed (daily):', err));
51
+ }
@@ -1,2 +1,2 @@
1
1
  import { type Request, type Response } from 'express';
2
- export declare function getMonthly(_req: Request, res: Response): Promise<void>;
2
+ export declare function getMonthly(req: Request, res: Response): Promise<void>;
@@ -1,24 +1,41 @@
1
- import { runCcusage } from '../ccusage.js';
2
1
  import { cache } from '../cache.js';
3
2
  import { validateDaily } from '../../shared/schemas.js';
4
- export async function getMonthly(_req, res) {
3
+ import { getDailyResponse as getClaudeDailyResponse } from '../claudeJsonlParser.js';
4
+ import { getDailyResponse as getCodexDailyResponse } from '../codexParser.js';
5
+ import { getDailyResponse as getOpenClawDailyResponse } from '../openclawParser.js';
6
+ export async function getMonthly(req, res) {
7
+ const agent = req.query.agent || 'claude';
8
+ const cacheKey = `monthly:${agent}`;
5
9
  try {
6
- const cached = cache.get('monthly');
10
+ const cached = cache.get(cacheKey);
7
11
  if (cached) {
8
12
  res.json(cached);
9
13
  return;
10
14
  }
11
- const stdout = await runCcusage(['monthly', '--breakdown']);
12
- const data = JSON.parse(stdout);
13
- const validated = validateDaily(data);
14
- cache.set('monthly', validated);
15
- res.json(validated);
15
+ const stale = cache.getStale(cacheKey);
16
+ if (stale) {
17
+ res.json(stale);
18
+ return;
19
+ }
20
+ // Monthly is same as daily for our parser (aggregated by date already)
21
+ let data;
22
+ if (agent === 'codex') {
23
+ data = validateDaily(getCodexDailyResponse());
24
+ }
25
+ else if (agent === 'openclaw') {
26
+ data = validateDaily(getOpenClawDailyResponse());
27
+ }
28
+ else {
29
+ data = validateDaily(getClaudeDailyResponse());
30
+ }
31
+ cache.set(cacheKey, data);
32
+ res.json(data);
16
33
  }
17
34
  catch (error) {
18
35
  const message = error instanceof Error ? error.message : 'Unknown error';
19
36
  console.error('Error fetching monthly data:', error);
20
37
  res.status(502).json({
21
- error: 'Failed to fetch monthly data from ccusage',
38
+ error: 'Failed to fetch monthly data',
22
39
  hint: message,
23
40
  });
24
41
  }