cchubber 0.1.0 → 0.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.
@@ -35,44 +35,14 @@ function readProjectsDir(dir, entries) {
35
35
 
36
36
  for (const hash of projectHashes) {
37
37
  const projectDir = join(dir, hash);
38
- const jsonlFiles = readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
39
38
 
39
+ // Read top-level JSONL files only (one per session).
40
+ // Subagent files in <session>/subagents/ are NOT read for cost —
41
+ // parent session JSONL already includes subagent token billing.
42
+ // Reading both would double-count (confirmed: $5.7K → $10.8K).
43
+ const jsonlFiles = readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
40
44
  for (const file of jsonlFiles) {
41
- const sessionId = basename(file, '.jsonl');
42
- const filePath = join(projectDir, file);
43
-
44
- try {
45
- const raw = readFileSync(filePath, 'utf-8');
46
- const lines = raw.split('\n').filter(l => l.trim());
47
-
48
- for (const line of lines) {
49
- try {
50
- const record = JSON.parse(line);
51
-
52
- // Only assistant messages have token usage
53
- if (record.type !== 'assistant') continue;
54
-
55
- const usage = record.message?.usage;
56
- if (!usage) continue;
57
-
58
- entries.push({
59
- sessionId,
60
- projectHash: hash,
61
- timestamp: record.timestamp || '',
62
- model: record.message?.model || 'unknown',
63
- inputTokens: usage.input_tokens || 0,
64
- outputTokens: usage.output_tokens || 0,
65
- cacheCreationTokens: usage.cache_creation_input_tokens || 0,
66
- cacheReadTokens: usage.cache_read_input_tokens || 0,
67
- costUSD: record.costUSD || 0, // Pre-calculated by Claude Code
68
- });
69
- } catch {
70
- // Skip malformed lines
71
- }
72
- }
73
- } catch {
74
- // Skip unreadable files
75
- }
45
+ readJsonlFile(join(projectDir, file), basename(file, '.jsonl'), hash, entries);
76
46
  }
77
47
  }
78
48
  } catch {
@@ -80,6 +50,41 @@ function readProjectsDir(dir, entries) {
80
50
  }
81
51
  }
82
52
 
53
+ function readJsonlFile(filePath, sessionId, projectHash, entries) {
54
+ try {
55
+ const raw = readFileSync(filePath, 'utf-8');
56
+ const lines = raw.split('\n').filter(l => l.trim());
57
+
58
+ for (const line of lines) {
59
+ try {
60
+ const record = JSON.parse(line);
61
+
62
+ // Only assistant messages have token usage
63
+ if (record.type !== 'assistant') continue;
64
+
65
+ const usage = record.message?.usage;
66
+ if (!usage) continue;
67
+
68
+ entries.push({
69
+ sessionId,
70
+ projectHash,
71
+ timestamp: record.timestamp || '',
72
+ model: record.message?.model || 'unknown',
73
+ inputTokens: usage.input_tokens || 0,
74
+ outputTokens: usage.output_tokens || 0,
75
+ cacheCreationTokens: usage.cache_creation_input_tokens || 0,
76
+ cacheReadTokens: usage.cache_read_input_tokens || 0,
77
+ costUSD: record.costUSD || 0,
78
+ });
79
+ } catch {
80
+ // Skip malformed lines
81
+ }
82
+ }
83
+ } catch {
84
+ // Skip unreadable files
85
+ }
86
+ }
87
+
83
88
  /**
84
89
  * Aggregate JSONL entries into daily summaries.
85
90
  */
@@ -213,26 +218,17 @@ export function aggregateByProject(entries, claudeDir) {
213
218
  if (entry.timestamp < p.firstSeen) p.firstSeen = entry.timestamp;
214
219
  }
215
220
 
216
- // Resolve project paths from .claude/projects/<hash>/.project_path if available
217
- const projectsDir = join(claudeDir, 'projects');
221
+ // Decode project paths from directory names
222
+ // Claude Code encodes paths as: C--Users-asmir-Documents-Project-Name
223
+ // Decode: replace leading drive letter pattern, split on -, take last meaningful segments
218
224
  for (const proj of Object.values(byProject)) {
219
225
  proj.sessionCount = proj.sessions.size;
220
226
  delete proj.sessions;
221
227
 
222
- // Try to read the project path file
223
- try {
224
- const pathFile = join(projectsDir, proj.hash, '.project_path');
225
- if (existsSync(pathFile)) {
226
- const raw = readFileSync(pathFile, 'utf-8').trim();
227
- proj.path = raw;
228
- // Use last directory segment as display name
229
- proj.name = raw.split(/[/\\]/).filter(Boolean).pop() || proj.hash.slice(0, 8);
230
- } else {
231
- proj.name = proj.hash.slice(0, 8);
232
- }
233
- } catch {
234
- proj.name = proj.hash.slice(0, 8);
235
- }
228
+ // Decode the hash (which is the encoded path)
229
+ const decoded = decodeProjectHash(proj.hash);
230
+ proj.path = decoded.path;
231
+ proj.name = decoded.name;
236
232
  }
237
233
 
238
234
  return Object.values(byProject)
@@ -245,3 +241,50 @@ function cleanModelName(name) {
245
241
  .replace(/-\d{8}$/, '')
246
242
  .replace(/-20\d{6}$/, '');
247
243
  }
244
+
245
+ /**
246
+ * Decode Claude Code's encoded project directory name into a readable path and name.
247
+ * Format: C--Users-asmir-Documents-Obsidian-Architect-OS-01-Projects-My-Project
248
+ * Becomes: C:/Users/asmir/Documents/.../My-Project → name: "My-Project"
249
+ */
250
+ function decodeProjectHash(hash) {
251
+ if (!hash || hash === 'unknown') return { path: null, name: 'Unknown' };
252
+
253
+ // Replace the drive letter pattern: C-- → C:/
254
+ let decoded = hash.replace(/^([A-Z])--/, '$1:/');
255
+
256
+ // The rest uses - as separator, but some folder names have dashes too.
257
+ // Best heuristic: split on known path separators
258
+ // Common patterns: Users, Documents, Desktop, Projects, etc.
259
+ const pathSegments = decoded.split('-');
260
+
261
+ // Reconstruct a readable path
262
+ // The encoded format replaces / with - so we need to figure out boundaries
263
+ // Simple approach: reconstruct full path and extract last meaningful project name
264
+ const fullPath = decoded;
265
+
266
+ // Extract project name: take the last meaningful segments
267
+ // Skip common prefixes to find the project-specific part
268
+ const skipPrefixes = ['C:', 'Users', 'Documents', 'Desktop', 'Downloads', 'Obsidian', 'repos', 'projects', 'code', 'dev', 'src', 'home'];
269
+
270
+ let segments = hash.replace(/^[A-Z]--/, '').split('-');
271
+
272
+ // Find where the "interesting" name starts (after common path prefixes)
273
+ let nameStart = 0;
274
+ for (let i = 0; i < segments.length; i++) {
275
+ if (skipPrefixes.some(p => p.toLowerCase() === segments[i].toLowerCase())) {
276
+ nameStart = i + 1;
277
+ } else {
278
+ break;
279
+ }
280
+ }
281
+
282
+ // Take the last 2-3 meaningful segments as the project name
283
+ const nameSegments = segments.slice(Math.max(nameStart, segments.length - 3));
284
+ const name = nameSegments.join(' ') || hash.slice(0, 12);
285
+
286
+ // Reconstruct a shortened display path
287
+ const path = hash.replace(/^([A-Z])--/, '$1:/').replace(/-/g, '/');
288
+
289
+ return { path, name };
290
+ }