@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.
- package/README.md +3 -3
- package/dist/client/assets/index-92lvfG3S.css +1 -0
- package/dist/client/assets/index-DJOsmILU.js +121 -0
- package/dist/client/index.html +2 -2
- package/dist/server/agentDetection.d.ts +6 -0
- package/dist/server/agentDetection.js +25 -0
- package/dist/server/analyticsParser.d.ts +13 -0
- package/dist/server/analyticsParser.js +277 -0
- package/dist/server/cache.d.ts +5 -0
- package/dist/server/cache.js +54 -10
- package/dist/server/claudeJsonlParser.d.ts +9 -0
- package/dist/server/claudeJsonlParser.js +314 -0
- package/dist/server/index.js +3 -3
- package/dist/server/openclawParser.js +50 -31
- package/dist/server/routes/analytics.d.ts +2 -0
- package/dist/server/routes/analytics.js +40 -0
- package/dist/server/routes/api.js +8 -3
- package/dist/server/routes/blocks.js +32 -37
- package/dist/server/routes/daily.js +29 -13
- package/dist/server/routes/monthly.d.ts +1 -1
- package/dist/server/routes/monthly.js +26 -9
- package/dist/server/routes/projects.js +29 -13
- package/dist/server/routes/session.d.ts +1 -1
- package/dist/server/routes/session.js +26 -9
- package/dist/shared/schemas.d.ts +53 -31
- package/dist/shared/schemas.js +29 -0
- package/dist/shared/types.d.ts +29 -0
- package/package.json +8 -3
- package/dist/client/assets/index-C3o5PaD5.js +0 -121
- package/dist/client/assets/index-CaxfZubD.css +0 -1
|
@@ -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
|
+
}
|
package/dist/server/index.js
CHANGED
|
@@ -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 './
|
|
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 =
|
|
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 (
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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 —
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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,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 {
|
|
7
|
-
|
|
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 =
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
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
|
+
}
|