@zhangferry-dev/tokendash 1.4.0 → 1.5.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 +32 -40
- package/dist/client/assets/{index-B4YgU_cb.js → index-BPWY9q0y.js} +45 -45
- package/dist/client/index.html +1 -1
- package/dist/client/popover.html +44 -17
- package/dist/electron-server.cjs +180 -53
- package/dist/electron-server.cjs.map +3 -3
- package/dist/server/analyticsParser.js +66 -2
- package/dist/server/claudeBlocksParser.js +48 -5
- package/dist/server/claudeJsonlParser.d.ts +6 -0
- package/dist/server/claudeJsonlParser.js +53 -4
- package/dist/server/codexParser.d.ts +10 -7
- package/dist/server/codexParser.js +83 -57
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +25 -9
- package/electron/main.cjs +67 -12
- package/electron/preload.cjs +3 -0
- package/electron/trayBadge.cjs +3 -1
- package/electron/trayHelper +0 -0
- package/electron/trayHelper.swift +38 -16
- package/electron-builder.yml +4 -1
- package/package.json +1 -1
- package/resources/icon.icns +0 -0
- package/resources/product_menu.png +0 -0
- package/resources/cache_diagram.html +0 -456
- package/resources/cache_diagram.png +0 -0
- package/resources/pr1_preview.png +0 -0
- package/resources/test_single_agent.png +0 -0
|
@@ -48,9 +48,56 @@ function countLines(text) {
|
|
|
48
48
|
// Claude Code session scanning & tool extraction
|
|
49
49
|
// ---------------------------------------------------------------------------
|
|
50
50
|
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
51
|
+
const projectNameCache = new Map();
|
|
52
|
+
/** Decode Claude's encoded project directory name.
|
|
53
|
+
* Claude encodes paths: /Users/foo/bar → -Users-foo-bar
|
|
54
|
+
* Since '-' replaces '/' and project names can contain '-',
|
|
55
|
+
* we use filesystem checks to find the correct last segment.
|
|
56
|
+
*/
|
|
51
57
|
function extractProjectName(dirName) {
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
if (!dirName.startsWith('-'))
|
|
59
|
+
return dirName;
|
|
60
|
+
const cached = projectNameCache.get(dirName);
|
|
61
|
+
if (cached)
|
|
62
|
+
return cached;
|
|
63
|
+
const segments = dirName.replace(/^-/, '').split('-').filter(Boolean);
|
|
64
|
+
if (segments.length === 0) {
|
|
65
|
+
projectNameCache.set(dirName, dirName);
|
|
66
|
+
return dirName;
|
|
67
|
+
}
|
|
68
|
+
if (segments.length === 1) {
|
|
69
|
+
projectNameCache.set(dirName, segments[0]);
|
|
70
|
+
return segments[0];
|
|
71
|
+
}
|
|
72
|
+
let bestName = segments[segments.length - 1];
|
|
73
|
+
for (let splitAt = segments.length - 1; splitAt >= 1; splitAt--) {
|
|
74
|
+
const parentSegments = segments.slice(0, splitAt);
|
|
75
|
+
const candidateName = segments.slice(splitAt).join('-');
|
|
76
|
+
let parentPath = '/';
|
|
77
|
+
let valid = true;
|
|
78
|
+
for (const seg of parentSegments) {
|
|
79
|
+
const regular = join(parentPath, seg);
|
|
80
|
+
const hidden = join(parentPath, '.' + seg);
|
|
81
|
+
if (existsSync(regular)) {
|
|
82
|
+
parentPath = regular;
|
|
83
|
+
}
|
|
84
|
+
else if (existsSync(hidden)) {
|
|
85
|
+
parentPath = hidden;
|
|
86
|
+
}
|
|
87
|
+
else {
|
|
88
|
+
valid = false;
|
|
89
|
+
break;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (!valid)
|
|
93
|
+
continue;
|
|
94
|
+
if (existsSync(join(parentPath, candidateName)) || existsSync(join(parentPath, '.' + candidateName))) {
|
|
95
|
+
bestName = candidateName;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
projectNameCache.set(dirName, bestName);
|
|
100
|
+
return bestName;
|
|
54
101
|
}
|
|
55
102
|
function matchesProject(dirName, filter) {
|
|
56
103
|
return extractProjectName(dirName) === extractProjectName(filter);
|
|
@@ -273,5 +320,22 @@ export function computeAnalytics(toolCalls, timezone = 'Asia/Shanghai') {
|
|
|
273
320
|
toolCallTrend.push(entry);
|
|
274
321
|
}
|
|
275
322
|
toolCallTrend.sort((a, b) => a.date.localeCompare(b.date));
|
|
323
|
+
// Fill missing tool values with 0 so chart lines don't break
|
|
324
|
+
if (toolCallTrend.length > 0) {
|
|
325
|
+
const allTools = new Set();
|
|
326
|
+
for (const entry of toolCallTrend) {
|
|
327
|
+
for (const key of Object.keys(entry)) {
|
|
328
|
+
if (key !== 'date')
|
|
329
|
+
allTools.add(key);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const entry of toolCallTrend) {
|
|
333
|
+
for (const tool of allTools) {
|
|
334
|
+
if (entry[tool] === undefined) {
|
|
335
|
+
entry[tool] = 0;
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
276
340
|
return { codeChangeTrend, toolUsageDistribution, productivityKPIs, toolCallTrend };
|
|
277
341
|
}
|
|
@@ -24,13 +24,56 @@ const ClaudeEventSchema = z.object({
|
|
|
24
24
|
// Helpers
|
|
25
25
|
// ---------------------------------------------------------------------------
|
|
26
26
|
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
* -Users-
|
|
27
|
+
const projectNameCache = new Map();
|
|
28
|
+
/** Decode Claude's encoded project directory name.
|
|
29
|
+
* Claude encodes paths: /Users/foo/bar → -Users-foo-bar
|
|
30
|
+
* Since '-' replaces '/' and project names can contain '-',
|
|
31
|
+
* we use filesystem checks to find the correct last segment.
|
|
30
32
|
*/
|
|
31
33
|
function extractProjectName(dirName) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
if (!dirName.startsWith('-'))
|
|
35
|
+
return dirName;
|
|
36
|
+
const cached = projectNameCache.get(dirName);
|
|
37
|
+
if (cached)
|
|
38
|
+
return cached;
|
|
39
|
+
const segments = dirName.replace(/^-/, '').split('-').filter(Boolean);
|
|
40
|
+
if (segments.length === 0) {
|
|
41
|
+
projectNameCache.set(dirName, dirName);
|
|
42
|
+
return dirName;
|
|
43
|
+
}
|
|
44
|
+
if (segments.length === 1) {
|
|
45
|
+
projectNameCache.set(dirName, segments[0]);
|
|
46
|
+
return segments[0];
|
|
47
|
+
}
|
|
48
|
+
let bestName = segments[segments.length - 1];
|
|
49
|
+
for (let splitAt = segments.length - 1; splitAt >= 1; splitAt--) {
|
|
50
|
+
const parentSegments = segments.slice(0, splitAt);
|
|
51
|
+
const candidateName = segments.slice(splitAt).join('-');
|
|
52
|
+
let parentPath = '/';
|
|
53
|
+
let valid = true;
|
|
54
|
+
for (const seg of parentSegments) {
|
|
55
|
+
const regular = join(parentPath, seg);
|
|
56
|
+
const hidden = join(parentPath, '.' + seg);
|
|
57
|
+
if (existsSync(regular)) {
|
|
58
|
+
parentPath = regular;
|
|
59
|
+
}
|
|
60
|
+
else if (existsSync(hidden)) {
|
|
61
|
+
parentPath = hidden;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
valid = false;
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (!valid)
|
|
69
|
+
continue;
|
|
70
|
+
if (existsSync(join(parentPath, candidateName)) || existsSync(join(parentPath, '.' + candidateName))) {
|
|
71
|
+
bestName = candidateName;
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
projectNameCache.set(dirName, bestName);
|
|
76
|
+
return bestName;
|
|
34
77
|
}
|
|
35
78
|
/** Match project display name against a filter (also normalizes the filter) */
|
|
36
79
|
function matchesProject(dirName, filter) {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import type { DailyResponse, ProjectsResponse, BlockEntry } from '../shared/types.js';
|
|
2
2
|
export declare function calculateCost(inputTokens: number, cacheReadTokens: number, outputTokens: number, model: string): number;
|
|
3
|
+
/** Decode Claude's encoded project directory name.
|
|
4
|
+
* Claude encodes paths: /Users/foo/bar → -Users-foo-bar
|
|
5
|
+
* Since '-' replaces '/' and project names can contain '-',
|
|
6
|
+
* we use filesystem checks to find the correct last segment.
|
|
7
|
+
*/
|
|
8
|
+
export declare function extractProjectName(dirName: string): string;
|
|
3
9
|
export declare function getDateKey(timestamp: string, tz: string): string;
|
|
4
10
|
export declare function getHourKey(timestamp: string, tz: string): string;
|
|
5
11
|
export declare function getDailyResponse(project?: string | null, tz?: string): DailyResponse;
|
|
@@ -38,9 +38,58 @@ export function calculateCost(inputTokens, cacheReadTokens, outputTokens, model)
|
|
|
38
38
|
// ---------------------------------------------------------------------------
|
|
39
39
|
const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
|
|
40
40
|
const fileCache = new Map();
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
const projectNameCache = new Map();
|
|
42
|
+
/** Decode Claude's encoded project directory name.
|
|
43
|
+
* Claude encodes paths: /Users/foo/bar → -Users-foo-bar
|
|
44
|
+
* Since '-' replaces '/' and project names can contain '-',
|
|
45
|
+
* we use filesystem checks to find the correct last segment.
|
|
46
|
+
*/
|
|
47
|
+
export function extractProjectName(dirName) {
|
|
48
|
+
if (!dirName.startsWith('-'))
|
|
49
|
+
return dirName;
|
|
50
|
+
const cached = projectNameCache.get(dirName);
|
|
51
|
+
if (cached)
|
|
52
|
+
return cached;
|
|
53
|
+
const segments = dirName.replace(/^-/, '').split('-').filter(Boolean);
|
|
54
|
+
if (segments.length === 0) {
|
|
55
|
+
projectNameCache.set(dirName, dirName);
|
|
56
|
+
return dirName;
|
|
57
|
+
}
|
|
58
|
+
if (segments.length === 1) {
|
|
59
|
+
projectNameCache.set(dirName, segments[0]);
|
|
60
|
+
return segments[0];
|
|
61
|
+
}
|
|
62
|
+
let bestName = segments[segments.length - 1];
|
|
63
|
+
// Try from right: find the longest last segment that forms a valid path
|
|
64
|
+
for (let splitAt = segments.length - 1; splitAt >= 1; splitAt--) {
|
|
65
|
+
const parentSegments = segments.slice(0, splitAt);
|
|
66
|
+
const candidateName = segments.slice(splitAt).join('-');
|
|
67
|
+
// Build parent path, handling hidden directories (dot prefix)
|
|
68
|
+
let parentPath = '/';
|
|
69
|
+
let valid = true;
|
|
70
|
+
for (const seg of parentSegments) {
|
|
71
|
+
const regular = join(parentPath, seg);
|
|
72
|
+
const hidden = join(parentPath, '.' + seg);
|
|
73
|
+
if (existsSync(regular)) {
|
|
74
|
+
parentPath = regular;
|
|
75
|
+
}
|
|
76
|
+
else if (existsSync(hidden)) {
|
|
77
|
+
parentPath = hidden;
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
valid = false;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!valid)
|
|
85
|
+
continue;
|
|
86
|
+
if (existsSync(join(parentPath, candidateName)) || existsSync(join(parentPath, '.' + candidateName))) {
|
|
87
|
+
bestName = candidateName;
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
projectNameCache.set(dirName, bestName);
|
|
92
|
+
return bestName;
|
|
44
93
|
}
|
|
45
94
|
function matchesProject(dirName, filter) {
|
|
46
95
|
return extractProjectName(dirName) === extractProjectName(filter);
|
|
@@ -228,7 +277,7 @@ export function getProjectsResponse(tz = DEFAULT_TZ) {
|
|
|
228
277
|
const projectMap = new Map();
|
|
229
278
|
for (const e of entries) {
|
|
230
279
|
const date = getDateKey(e.timestamp, tz);
|
|
231
|
-
const projectName = e.projectDir;
|
|
280
|
+
const projectName = extractProjectName(e.projectDir);
|
|
232
281
|
if (!projectMap.has(projectName)) {
|
|
233
282
|
projectMap.set(projectName, new Map());
|
|
234
283
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { DailyResponse, ProjectsResponse, BlocksResponse } from '../shared/types.js';
|
|
2
|
-
interface ParsedTokenEvent {
|
|
2
|
+
export interface ParsedTokenEvent {
|
|
3
3
|
timestamp: string;
|
|
4
4
|
inputTokens: number;
|
|
5
5
|
cachedInputTokens: number;
|
|
@@ -29,19 +29,22 @@ export declare function scanCodexSessions(): string[];
|
|
|
29
29
|
/**
|
|
30
30
|
* Parse a single Codex session JSONL file.
|
|
31
31
|
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* total (4.7M instead of the correct 9.2M).
|
|
32
|
+
* Codex can emit duplicate token_count events for the same turn, with identical
|
|
33
|
+
* total_token_usage and last_token_usage snapshots a few seconds apart. These
|
|
34
|
+
* are repeated status updates, not separate billable usage records, so only the
|
|
35
|
+
* first occurrence of each cumulative total_token_usage snapshot should count.
|
|
37
36
|
*/
|
|
38
37
|
export declare function parseCodexSession(filepath: string): ParsedSession | null;
|
|
39
38
|
/** Parse all Codex sessions. */
|
|
40
39
|
export declare function parseAllSessions(): ParsedSession[];
|
|
40
|
+
export declare function buildCodexResponsesFromSessions(sessions: ParsedSession[], options?: Partial<AggregateOptions>): {
|
|
41
|
+
daily: DailyResponse;
|
|
42
|
+
projects: ProjectsResponse;
|
|
43
|
+
blocks: BlocksResponse;
|
|
44
|
+
};
|
|
41
45
|
/** Aggregate and return DailyResponse format (for /daily?agent=codex) */
|
|
42
46
|
export declare function getDailyResponse(options?: Partial<AggregateOptions>): DailyResponse;
|
|
43
47
|
/** Aggregate and return ProjectsResponse format (for /projects?agent=codex) */
|
|
44
48
|
export declare function getProjectsResponse(options?: Partial<AggregateOptions>): ProjectsResponse;
|
|
45
49
|
/** Aggregate and return BlocksResponse format (hourly, for /blocks?agent=codex) */
|
|
46
50
|
export declare function getBlocksResponse(options?: Partial<AggregateOptions>): BlocksResponse;
|
|
47
|
-
export {};
|
|
@@ -21,6 +21,15 @@ const TokenCountPayloadSchema = z.object({
|
|
|
21
21
|
type: z.literal('token_count'),
|
|
22
22
|
info: TokenCountInfoSchema,
|
|
23
23
|
});
|
|
24
|
+
function tokenUsageKey(usage) {
|
|
25
|
+
return [
|
|
26
|
+
usage.input_tokens,
|
|
27
|
+
usage.cached_input_tokens,
|
|
28
|
+
usage.output_tokens,
|
|
29
|
+
usage.reasoning_output_tokens,
|
|
30
|
+
usage.total_tokens,
|
|
31
|
+
].join(':');
|
|
32
|
+
}
|
|
24
33
|
// ---------------------------------------------------------------------------
|
|
25
34
|
// Helpers
|
|
26
35
|
// ---------------------------------------------------------------------------
|
|
@@ -73,11 +82,10 @@ export function scanCodexSessions() {
|
|
|
73
82
|
/**
|
|
74
83
|
* Parse a single Codex session JSONL file.
|
|
75
84
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* total (4.7M instead of the correct 9.2M).
|
|
85
|
+
* Codex can emit duplicate token_count events for the same turn, with identical
|
|
86
|
+
* total_token_usage and last_token_usage snapshots a few seconds apart. These
|
|
87
|
+
* are repeated status updates, not separate billable usage records, so only the
|
|
88
|
+
* first occurrence of each cumulative total_token_usage snapshot should count.
|
|
81
89
|
*/
|
|
82
90
|
export function parseCodexSession(filepath) {
|
|
83
91
|
let content;
|
|
@@ -93,6 +101,7 @@ export function parseCodexSession(filepath) {
|
|
|
93
101
|
let model = '';
|
|
94
102
|
let createdAt = '';
|
|
95
103
|
const tokenEvents = [];
|
|
104
|
+
const seenTotalUsageSnapshots = new Set();
|
|
96
105
|
for (const line of lines) {
|
|
97
106
|
const trimmed = line.trim();
|
|
98
107
|
if (!trimmed)
|
|
@@ -118,7 +127,6 @@ export function parseCodexSession(filepath) {
|
|
|
118
127
|
}
|
|
119
128
|
}
|
|
120
129
|
// Extract token counts from event_msg with nested token_count payload.
|
|
121
|
-
// NEVER deduplicate — see invariant comment above.
|
|
122
130
|
if (type === 'event_msg') {
|
|
123
131
|
const payload = obj.payload || {};
|
|
124
132
|
if (payload.type === 'token_count') {
|
|
@@ -131,6 +139,10 @@ export function parseCodexSession(filepath) {
|
|
|
131
139
|
const info = parseResult.data.info;
|
|
132
140
|
if (!info)
|
|
133
141
|
continue;
|
|
142
|
+
const totalUsageKey = tokenUsageKey(info.total_token_usage);
|
|
143
|
+
if (seenTotalUsageSnapshots.has(totalUsageKey))
|
|
144
|
+
continue;
|
|
145
|
+
seenTotalUsageSnapshots.add(totalUsageKey);
|
|
134
146
|
const last = info.last_token_usage;
|
|
135
147
|
tokenEvents.push({
|
|
136
148
|
timestamp,
|
|
@@ -207,8 +219,18 @@ function mergeAcc(a, b) {
|
|
|
207
219
|
a.reasoningOutputTokens += b.reasoningOutputTokens;
|
|
208
220
|
a.totalTokens += b.totalTokens;
|
|
209
221
|
}
|
|
210
|
-
function
|
|
211
|
-
|
|
222
|
+
function addAccToBucket(bucket, ev, model) {
|
|
223
|
+
addAcc(bucket.acc, ev);
|
|
224
|
+
if (!model)
|
|
225
|
+
return;
|
|
226
|
+
if (!bucket.models.has(model))
|
|
227
|
+
bucket.models.set(model, emptyAcc());
|
|
228
|
+
addAcc(bucket.models.get(model), ev);
|
|
229
|
+
}
|
|
230
|
+
function accToEntry(date, acc, modelAccs) {
|
|
231
|
+
const modelNames = [...modelAccs.keys()];
|
|
232
|
+
const modelBreakdowns = buildModelBreakdowns(modelAccs);
|
|
233
|
+
const totalCost = modelBreakdowns.reduce((sum, model) => sum + model.cost, 0);
|
|
212
234
|
return {
|
|
213
235
|
date,
|
|
214
236
|
inputTokens: acc.inputTokens,
|
|
@@ -216,23 +238,19 @@ function accToEntry(date, acc, models) {
|
|
|
216
238
|
cacheCreationTokens: 0,
|
|
217
239
|
cacheReadTokens: acc.cachedInputTokens,
|
|
218
240
|
totalTokens: acc.totalTokens,
|
|
219
|
-
totalCost
|
|
220
|
-
modelsUsed:
|
|
221
|
-
modelBreakdowns
|
|
241
|
+
totalCost,
|
|
242
|
+
modelsUsed: modelNames,
|
|
243
|
+
modelBreakdowns,
|
|
222
244
|
};
|
|
223
245
|
}
|
|
224
|
-
function buildModelBreakdowns(
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return [];
|
|
228
|
-
const costPerModel = totalCost / modelList.length;
|
|
229
|
-
return modelList.map(name => ({
|
|
230
|
-
modelName: name,
|
|
246
|
+
function buildModelBreakdowns(modelAccs) {
|
|
247
|
+
return [...modelAccs.entries()].map(([modelName, acc]) => ({
|
|
248
|
+
modelName,
|
|
231
249
|
inputTokens: acc.inputTokens,
|
|
232
250
|
outputTokens: acc.outputTokens,
|
|
233
251
|
cacheCreationTokens: 0,
|
|
234
252
|
cacheReadTokens: acc.cachedInputTokens,
|
|
235
|
-
cost:
|
|
253
|
+
cost: calculateCost(acc, new Set([modelName])),
|
|
236
254
|
}));
|
|
237
255
|
}
|
|
238
256
|
function groupSessions(sessions, options) {
|
|
@@ -266,12 +284,9 @@ function groupSessions(sessions, options) {
|
|
|
266
284
|
break;
|
|
267
285
|
}
|
|
268
286
|
if (!grouped.has(key)) {
|
|
269
|
-
grouped.set(key, { acc: emptyAcc(), models: new
|
|
287
|
+
grouped.set(key, { acc: emptyAcc(), models: new Map() });
|
|
270
288
|
}
|
|
271
|
-
|
|
272
|
-
addAcc(entry.acc, ev);
|
|
273
|
-
if (session.model)
|
|
274
|
-
entry.models.add(session.model);
|
|
289
|
+
addAccToBucket(grouped.get(key), ev, session.model);
|
|
275
290
|
}
|
|
276
291
|
}
|
|
277
292
|
return grouped;
|
|
@@ -279,23 +294,29 @@ function groupSessions(sessions, options) {
|
|
|
279
294
|
// ---------------------------------------------------------------------------
|
|
280
295
|
// Public API — response builders for route handlers
|
|
281
296
|
// ---------------------------------------------------------------------------
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
297
|
+
export function buildCodexResponsesFromSessions(sessions, options) {
|
|
298
|
+
return {
|
|
299
|
+
daily: buildDailyResponse(sessions, options),
|
|
300
|
+
projects: buildProjectsResponse(sessions, options),
|
|
301
|
+
blocks: buildBlocksResponse(sessions, options),
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
function buildDailyResponse(sessions, options) {
|
|
285
305
|
const grouped = groupSessions(sessions, { groupBy: 'day', ...options });
|
|
286
306
|
const daily = [];
|
|
287
307
|
const totalsAcc = emptyAcc();
|
|
308
|
+
const totalModels = new Map();
|
|
288
309
|
for (const [date, { acc, models }] of grouped) {
|
|
289
310
|
daily.push(accToEntry(date, acc, models));
|
|
290
311
|
mergeAcc(totalsAcc, acc);
|
|
312
|
+
for (const [model, modelAcc] of models) {
|
|
313
|
+
if (!totalModels.has(model))
|
|
314
|
+
totalModels.set(model, emptyAcc());
|
|
315
|
+
mergeAcc(totalModels.get(model), modelAcc);
|
|
316
|
+
}
|
|
291
317
|
}
|
|
292
|
-
// Sort by date ascending
|
|
293
318
|
daily.sort((a, b) => a.date.localeCompare(b.date));
|
|
294
|
-
const
|
|
295
|
-
for (const s of sessions)
|
|
296
|
-
if (s.model)
|
|
297
|
-
models.add(s.model);
|
|
298
|
-
const totalCost = calculateCost(totalsAcc, models);
|
|
319
|
+
const totalCost = buildModelBreakdowns(totalModels).reduce((sum, model) => sum + model.cost, 0);
|
|
299
320
|
return {
|
|
300
321
|
daily,
|
|
301
322
|
totals: {
|
|
@@ -308,15 +329,16 @@ export function getDailyResponse(options) {
|
|
|
308
329
|
},
|
|
309
330
|
};
|
|
310
331
|
}
|
|
311
|
-
|
|
312
|
-
export function getProjectsResponse(options) {
|
|
313
|
-
const sessions = parseAllSessions();
|
|
332
|
+
function buildProjectsResponse(sessions, options) {
|
|
314
333
|
const tz = options?.timezone || 'Asia/Shanghai';
|
|
315
|
-
const
|
|
334
|
+
const projectGroups = new Map();
|
|
316
335
|
for (const session of sessions) {
|
|
317
336
|
const projectName = extractProjectName(session.cwd);
|
|
318
|
-
|
|
319
|
-
|
|
337
|
+
if (options?.project && projectName !== options.project)
|
|
338
|
+
continue;
|
|
339
|
+
if (!projectGroups.has(projectName))
|
|
340
|
+
projectGroups.set(projectName, new Map());
|
|
341
|
+
const dailyMap = projectGroups.get(projectName);
|
|
320
342
|
for (const ev of session.tokenEvents) {
|
|
321
343
|
const evDate = new Date(ev.timestamp);
|
|
322
344
|
if (options?.since && evDate < options.since)
|
|
@@ -325,32 +347,25 @@ export function getProjectsResponse(options) {
|
|
|
325
347
|
continue;
|
|
326
348
|
const dayKey = getDateKey(ev.timestamp, tz);
|
|
327
349
|
if (!dailyMap.has(dayKey)) {
|
|
328
|
-
dailyMap.set(dayKey, { acc: emptyAcc(), models: new
|
|
350
|
+
dailyMap.set(dayKey, { acc: emptyAcc(), models: new Map() });
|
|
329
351
|
}
|
|
330
|
-
|
|
331
|
-
if (session.model)
|
|
332
|
-
dailyMap.get(dayKey).models.add(session.model);
|
|
333
|
-
}
|
|
334
|
-
if (!projects[projectName])
|
|
335
|
-
projects[projectName] = [];
|
|
336
|
-
for (const [date, { acc, models }] of dailyMap) {
|
|
337
|
-
projects[projectName].push(accToEntry(date, acc, models));
|
|
352
|
+
addAccToBucket(dailyMap.get(dayKey), ev, session.model);
|
|
338
353
|
}
|
|
339
354
|
}
|
|
340
|
-
|
|
341
|
-
for (const
|
|
342
|
-
projects[
|
|
355
|
+
const projects = {};
|
|
356
|
+
for (const [projectName, dailyMap] of projectGroups) {
|
|
357
|
+
projects[projectName] = [...dailyMap.entries()]
|
|
358
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
359
|
+
.map(([date, { acc, models }]) => accToEntry(date, acc, models));
|
|
343
360
|
}
|
|
344
361
|
return { projects };
|
|
345
362
|
}
|
|
346
|
-
|
|
347
|
-
export function getBlocksResponse(options) {
|
|
348
|
-
const sessions = parseAllSessions();
|
|
363
|
+
function buildBlocksResponse(sessions, options) {
|
|
349
364
|
const grouped = groupSessions(sessions, { groupBy: 'hour', ...options });
|
|
350
365
|
const blocks = [];
|
|
351
366
|
let idx = 0;
|
|
352
367
|
for (const [hourKey, { acc, models }] of grouped) {
|
|
353
|
-
const cost =
|
|
368
|
+
const cost = buildModelBreakdowns(models).reduce((sum, model) => sum + model.cost, 0);
|
|
354
369
|
const [datePart, timePart] = hourKey.split(' ');
|
|
355
370
|
const hour = timePart.split(':')[0];
|
|
356
371
|
blocks.push({
|
|
@@ -369,11 +384,22 @@ export function getBlocksResponse(options) {
|
|
|
369
384
|
},
|
|
370
385
|
totalTokens: acc.totalTokens,
|
|
371
386
|
costUSD: cost,
|
|
372
|
-
models: [...models],
|
|
387
|
+
models: [...models.keys()],
|
|
373
388
|
});
|
|
374
389
|
idx++;
|
|
375
390
|
}
|
|
376
|
-
// Sort by startTime
|
|
377
391
|
blocks.sort((a, b) => a.startTime.localeCompare(b.startTime));
|
|
378
392
|
return { blocks };
|
|
379
393
|
}
|
|
394
|
+
/** Aggregate and return DailyResponse format (for /daily?agent=codex) */
|
|
395
|
+
export function getDailyResponse(options) {
|
|
396
|
+
return buildDailyResponse(parseAllSessions(), options);
|
|
397
|
+
}
|
|
398
|
+
/** Aggregate and return ProjectsResponse format (for /projects?agent=codex) */
|
|
399
|
+
export function getProjectsResponse(options) {
|
|
400
|
+
return buildProjectsResponse(parseAllSessions(), options);
|
|
401
|
+
}
|
|
402
|
+
/** Aggregate and return BlocksResponse format (hourly, for /blocks?agent=codex) */
|
|
403
|
+
export function getBlocksResponse(options) {
|
|
404
|
+
return buildBlocksResponse(parseAllSessions(), options);
|
|
405
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
1
|
import type { Express } from 'express';
|
|
2
|
+
export declare function resolveStaticAssetBaseDir(moduleUrl?: string, baseDir?: string): {
|
|
3
|
+
baseDir: string;
|
|
4
|
+
isProduction: boolean;
|
|
5
|
+
};
|
|
2
6
|
export declare function createApp(_port: number, baseDir?: string): Express;
|
|
3
7
|
declare function main(): Promise<void>;
|
|
4
8
|
export { main };
|
package/dist/server/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { fileURLToPath } from 'node:url';
|
|
4
|
-
import { dirname, join } from 'node:path';
|
|
4
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { registerApiRoutes } from './routes/api.js';
|
|
6
6
|
import { detectAvailableAgents } from './agentDetection.js';
|
|
7
7
|
import open from 'open';
|
|
@@ -117,22 +117,38 @@ async function listenWithPortFallback(app, preferredPort) {
|
|
|
117
117
|
}
|
|
118
118
|
throw new Error(`Could not find an available port starting from ${preferredPort}`);
|
|
119
119
|
}
|
|
120
|
+
export function resolveStaticAssetBaseDir(moduleUrl = import.meta.url, baseDir) {
|
|
121
|
+
if (baseDir)
|
|
122
|
+
return { baseDir: resolve(baseDir), isProduction: true };
|
|
123
|
+
const moduleDir = dirname(fileURLToPath(moduleUrl));
|
|
124
|
+
const isProduction = moduleUrl.includes('/dist/');
|
|
125
|
+
if (!isProduction)
|
|
126
|
+
return { baseDir: resolve(moduleDir), isProduction: false };
|
|
127
|
+
// The CLI entrypoint runs from dist/server/index.js while the Vite assets are
|
|
128
|
+
// emitted to dist/client. Resolve the production asset base to dist instead
|
|
129
|
+
// of dist/server so / resolves to dist/client/index.html in installed npm
|
|
130
|
+
// packages. Electron passes dist explicitly and is unaffected by this branch.
|
|
131
|
+
if (basename(moduleDir) === 'server') {
|
|
132
|
+
return { baseDir: resolve(dirname(moduleDir)), isProduction: true };
|
|
133
|
+
}
|
|
134
|
+
return { baseDir: resolve(moduleDir), isProduction: true };
|
|
135
|
+
}
|
|
120
136
|
export function createApp(_port, baseDir) {
|
|
121
137
|
const app = express();
|
|
122
138
|
const router = express.Router();
|
|
123
139
|
// Register API routes
|
|
124
140
|
registerApiRoutes(router);
|
|
125
141
|
app.use('/api', router);
|
|
126
|
-
|
|
127
|
-
const _baseDir = baseDir ?? dirname(fileURLToPath(import.meta.url));
|
|
128
|
-
const isProduction = baseDir
|
|
129
|
-
? true
|
|
130
|
-
: import.meta.url.includes('dist/');
|
|
142
|
+
const { baseDir: _baseDir, isProduction } = resolveStaticAssetBaseDir(import.meta.url, baseDir);
|
|
131
143
|
const popoverPath = isProduction
|
|
132
144
|
? join(_baseDir, 'client', 'popover.html')
|
|
133
145
|
: join(_baseDir, '..', '..', 'public', 'popover.html');
|
|
134
|
-
app.get('/popover.html', (_req, res) => {
|
|
135
|
-
|
|
146
|
+
app.get('/popover.html', (_req, res, next) => {
|
|
147
|
+
if (!existsSync(popoverPath)) {
|
|
148
|
+
next();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
res.type('html').send(readFileSync(popoverPath, 'utf8'));
|
|
136
152
|
});
|
|
137
153
|
// Check if running from dist (production build)
|
|
138
154
|
if (isProduction) {
|