@zhangferry-dev/tokendash 1.4.2 → 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.
@@ -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
- const parts = dirName.replace(/^-/, '').split('-');
53
- return parts[parts.length - 1] || dirName;
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
- /** Extract project display name from encoded directory path.
28
- * -Users-zhangferry-AI-Ideas Ideas
29
- * -Users-zhangferry-Desktop-Develop-DailyNewsReport → DailyNewsReport
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
- const parts = dirName.replace(/^-/, '').split('-');
33
- return parts[parts.length - 1] || dirName;
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
- function extractProjectName(dirName) {
42
- const parts = dirName.replace(/^-/, '').split('-');
43
- return parts[parts.length - 1] || dirName;
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;
@@ -37,10 +37,14 @@ export declare function scanCodexSessions(): string[];
37
37
  export declare function parseCodexSession(filepath: string): ParsedSession | null;
38
38
  /** Parse all Codex sessions. */
39
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
+ };
40
45
  /** Aggregate and return DailyResponse format (for /daily?agent=codex) */
41
46
  export declare function getDailyResponse(options?: Partial<AggregateOptions>): DailyResponse;
42
47
  /** Aggregate and return ProjectsResponse format (for /projects?agent=codex) */
43
48
  export declare function getProjectsResponse(options?: Partial<AggregateOptions>): ProjectsResponse;
44
49
  /** Aggregate and return BlocksResponse format (hourly, for /blocks?agent=codex) */
45
50
  export declare function getBlocksResponse(options?: Partial<AggregateOptions>): BlocksResponse;
46
- export {};
@@ -219,8 +219,18 @@ function mergeAcc(a, b) {
219
219
  a.reasoningOutputTokens += b.reasoningOutputTokens;
220
220
  a.totalTokens += b.totalTokens;
221
221
  }
222
- function accToEntry(date, acc, models) {
223
- const cost = calculateCost(acc, models);
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);
224
234
  return {
225
235
  date,
226
236
  inputTokens: acc.inputTokens,
@@ -228,23 +238,19 @@ function accToEntry(date, acc, models) {
228
238
  cacheCreationTokens: 0,
229
239
  cacheReadTokens: acc.cachedInputTokens,
230
240
  totalTokens: acc.totalTokens,
231
- totalCost: cost,
232
- modelsUsed: [...models],
233
- modelBreakdowns: buildModelBreakdowns(acc, models, cost),
241
+ totalCost,
242
+ modelsUsed: modelNames,
243
+ modelBreakdowns,
234
244
  };
235
245
  }
236
- function buildModelBreakdowns(acc, models, totalCost) {
237
- const modelList = [...models];
238
- if (modelList.length === 0)
239
- return [];
240
- const costPerModel = totalCost / modelList.length;
241
- return modelList.map(name => ({
242
- modelName: name,
246
+ function buildModelBreakdowns(modelAccs) {
247
+ return [...modelAccs.entries()].map(([modelName, acc]) => ({
248
+ modelName,
243
249
  inputTokens: acc.inputTokens,
244
250
  outputTokens: acc.outputTokens,
245
251
  cacheCreationTokens: 0,
246
252
  cacheReadTokens: acc.cachedInputTokens,
247
- cost: costPerModel,
253
+ cost: calculateCost(acc, new Set([modelName])),
248
254
  }));
249
255
  }
250
256
  function groupSessions(sessions, options) {
@@ -278,12 +284,9 @@ function groupSessions(sessions, options) {
278
284
  break;
279
285
  }
280
286
  if (!grouped.has(key)) {
281
- grouped.set(key, { acc: emptyAcc(), models: new Set() });
287
+ grouped.set(key, { acc: emptyAcc(), models: new Map() });
282
288
  }
283
- const entry = grouped.get(key);
284
- addAcc(entry.acc, ev);
285
- if (session.model)
286
- entry.models.add(session.model);
289
+ addAccToBucket(grouped.get(key), ev, session.model);
287
290
  }
288
291
  }
289
292
  return grouped;
@@ -291,23 +294,29 @@ function groupSessions(sessions, options) {
291
294
  // ---------------------------------------------------------------------------
292
295
  // Public API — response builders for route handlers
293
296
  // ---------------------------------------------------------------------------
294
- /** Aggregate and return DailyResponse format (for /daily?agent=codex) */
295
- export function getDailyResponse(options) {
296
- const sessions = parseAllSessions();
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) {
297
305
  const grouped = groupSessions(sessions, { groupBy: 'day', ...options });
298
306
  const daily = [];
299
307
  const totalsAcc = emptyAcc();
308
+ const totalModels = new Map();
300
309
  for (const [date, { acc, models }] of grouped) {
301
310
  daily.push(accToEntry(date, acc, models));
302
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
+ }
303
317
  }
304
- // Sort by date ascending
305
318
  daily.sort((a, b) => a.date.localeCompare(b.date));
306
- const models = new Set();
307
- for (const s of sessions)
308
- if (s.model)
309
- models.add(s.model);
310
- const totalCost = calculateCost(totalsAcc, models);
319
+ const totalCost = buildModelBreakdowns(totalModels).reduce((sum, model) => sum + model.cost, 0);
311
320
  return {
312
321
  daily,
313
322
  totals: {
@@ -320,15 +329,16 @@ export function getDailyResponse(options) {
320
329
  },
321
330
  };
322
331
  }
323
- /** Aggregate and return ProjectsResponse format (for /projects?agent=codex) */
324
- export function getProjectsResponse(options) {
325
- const sessions = parseAllSessions();
332
+ function buildProjectsResponse(sessions, options) {
326
333
  const tz = options?.timezone || 'Asia/Shanghai';
327
- const projects = {};
334
+ const projectGroups = new Map();
328
335
  for (const session of sessions) {
329
336
  const projectName = extractProjectName(session.cwd);
330
- // Per-project daily grouping
331
- const dailyMap = new Map();
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);
332
342
  for (const ev of session.tokenEvents) {
333
343
  const evDate = new Date(ev.timestamp);
334
344
  if (options?.since && evDate < options.since)
@@ -337,32 +347,25 @@ export function getProjectsResponse(options) {
337
347
  continue;
338
348
  const dayKey = getDateKey(ev.timestamp, tz);
339
349
  if (!dailyMap.has(dayKey)) {
340
- dailyMap.set(dayKey, { acc: emptyAcc(), models: new Set() });
350
+ dailyMap.set(dayKey, { acc: emptyAcc(), models: new Map() });
341
351
  }
342
- addAcc(dailyMap.get(dayKey).acc, ev);
343
- if (session.model)
344
- dailyMap.get(dayKey).models.add(session.model);
345
- }
346
- if (!projects[projectName])
347
- projects[projectName] = [];
348
- for (const [date, { acc, models }] of dailyMap) {
349
- projects[projectName].push(accToEntry(date, acc, models));
352
+ addAccToBucket(dailyMap.get(dayKey), ev, session.model);
350
353
  }
351
354
  }
352
- // Sort each project's daily entries
353
- for (const key of Object.keys(projects)) {
354
- projects[key].sort((a, b) => a.date.localeCompare(b.date));
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));
355
360
  }
356
361
  return { projects };
357
362
  }
358
- /** Aggregate and return BlocksResponse format (hourly, for /blocks?agent=codex) */
359
- export function getBlocksResponse(options) {
360
- const sessions = parseAllSessions();
363
+ function buildBlocksResponse(sessions, options) {
361
364
  const grouped = groupSessions(sessions, { groupBy: 'hour', ...options });
362
365
  const blocks = [];
363
366
  let idx = 0;
364
367
  for (const [hourKey, { acc, models }] of grouped) {
365
- const cost = calculateCost(acc, models);
368
+ const cost = buildModelBreakdowns(models).reduce((sum, model) => sum + model.cost, 0);
366
369
  const [datePart, timePart] = hourKey.split(' ');
367
370
  const hour = timePart.split(':')[0];
368
371
  blocks.push({
@@ -381,11 +384,22 @@ export function getBlocksResponse(options) {
381
384
  },
382
385
  totalTokens: acc.totalTokens,
383
386
  costUSD: cost,
384
- models: [...models],
387
+ models: [...models.keys()],
385
388
  });
386
389
  idx++;
387
390
  }
388
- // Sort by startTime
389
391
  blocks.sort((a, b) => a.startTime.localeCompare(b.startTime));
390
392
  return { blocks };
391
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
+ }
@@ -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 };
@@ -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
- // Resolve paths: use provided baseDir or compute from import.meta.url
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
- res.sendFile(popoverPath);
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhangferry-dev/tokendash",
3
- "version": "1.4.2",
3
+ "version": "1.5.0",
4
4
  "type": "module",
5
5
  "description": "Token Usage Analytics Dashboard",
6
6
  "publishConfig": {