cchubber 0.1.0 → 0.3.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,43 +35,27 @@ 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 (one per session)
40
+ const jsonlFiles = readdirSync(projectDir).filter(f => f.endsWith('.jsonl'));
40
41
  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
42
+ readJsonlFile(join(projectDir, file), basename(file, '.jsonl'), hash, entries);
43
+ }
44
+
45
+ // Read subagent JSONL files (for Haiku/Sonnet model attribution)
46
+ // Dedup by message ID prevents double-counting
47
+ const subdirs = readdirSync(projectDir).filter(f => {
48
+ try { return statSync(join(projectDir, f)).isDirectory(); } catch { return false; }
49
+ });
50
+ for (const subdir of subdirs) {
51
+ const subagentDir = join(projectDir, subdir, 'subagents');
52
+ if (existsSync(subagentDir)) {
53
+ try {
54
+ const subFiles = readdirSync(subagentDir).filter(f => f.endsWith('.jsonl'));
55
+ for (const file of subFiles) {
56
+ readJsonlFile(join(subagentDir, file), basename(file, '.jsonl'), hash, entries);
71
57
  }
72
- }
73
- } catch {
74
- // Skip unreadable files
58
+ } catch { /* skip */ }
75
59
  }
76
60
  }
77
61
  }
@@ -80,6 +64,51 @@ function readProjectsDir(dir, entries) {
80
64
  }
81
65
  }
82
66
 
67
+ // Track seen message IDs to deduplicate (JSONL files contain dupes from session resume)
68
+ const seenMessageIds = new Set();
69
+
70
+ function readJsonlFile(filePath, sessionId, projectHash, entries) {
71
+ try {
72
+ const raw = readFileSync(filePath, 'utf-8');
73
+ const lines = raw.split('\n').filter(l => l.trim());
74
+
75
+ for (const line of lines) {
76
+ try {
77
+ const record = JSON.parse(line);
78
+
79
+ // Only assistant messages have token usage
80
+ if (record.type !== 'assistant') continue;
81
+
82
+ const usage = record.message?.usage;
83
+ if (!usage) continue;
84
+
85
+ // Deduplicate by message ID — JSONL files contain duplicates from session resume
86
+ const msgId = record.message?.id;
87
+ if (msgId) {
88
+ if (seenMessageIds.has(msgId)) continue;
89
+ seenMessageIds.add(msgId);
90
+ }
91
+
92
+ entries.push({
93
+ sessionId,
94
+ projectHash,
95
+ timestamp: record.timestamp || '',
96
+ model: record.message?.model || 'unknown',
97
+ inputTokens: usage.input_tokens || 0,
98
+ outputTokens: usage.output_tokens || 0,
99
+ cacheCreationTokens: usage.cache_creation_input_tokens || 0,
100
+ cacheReadTokens: usage.cache_read_input_tokens || 0,
101
+ costUSD: record.costUSD || 0,
102
+ });
103
+ } catch {
104
+ // Skip malformed lines
105
+ }
106
+ }
107
+ } catch {
108
+ // Skip unreadable files
109
+ }
110
+ }
111
+
83
112
  /**
84
113
  * Aggregate JSONL entries into daily summaries.
85
114
  */
@@ -213,26 +242,17 @@ export function aggregateByProject(entries, claudeDir) {
213
242
  if (entry.timestamp < p.firstSeen) p.firstSeen = entry.timestamp;
214
243
  }
215
244
 
216
- // Resolve project paths from .claude/projects/<hash>/.project_path if available
217
- const projectsDir = join(claudeDir, 'projects');
245
+ // Decode project paths from directory names
246
+ // Claude Code encodes paths as: C--Users-asmir-Documents-Project-Name
247
+ // Decode: replace leading drive letter pattern, split on -, take last meaningful segments
218
248
  for (const proj of Object.values(byProject)) {
219
249
  proj.sessionCount = proj.sessions.size;
220
250
  delete proj.sessions;
221
251
 
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
- }
252
+ // Decode the hash (which is the encoded path)
253
+ const decoded = decodeProjectHash(proj.hash);
254
+ proj.path = decoded.path;
255
+ proj.name = decoded.name;
236
256
  }
237
257
 
238
258
  return Object.values(byProject)
@@ -245,3 +265,50 @@ function cleanModelName(name) {
245
265
  .replace(/-\d{8}$/, '')
246
266
  .replace(/-20\d{6}$/, '');
247
267
  }
268
+
269
+ /**
270
+ * Decode Claude Code's encoded project directory name into a readable path and name.
271
+ * Format: C--Users-asmir-Documents-Obsidian-Architect-OS-01-Projects-My-Project
272
+ * Becomes: C:/Users/asmir/Documents/.../My-Project → name: "My-Project"
273
+ */
274
+ function decodeProjectHash(hash) {
275
+ if (!hash || hash === 'unknown') return { path: null, name: 'Unknown' };
276
+
277
+ // Replace the drive letter pattern: C-- → C:/
278
+ let decoded = hash.replace(/^([A-Z])--/, '$1:/');
279
+
280
+ // The rest uses - as separator, but some folder names have dashes too.
281
+ // Best heuristic: split on known path separators
282
+ // Common patterns: Users, Documents, Desktop, Projects, etc.
283
+ const pathSegments = decoded.split('-');
284
+
285
+ // Reconstruct a readable path
286
+ // The encoded format replaces / with - so we need to figure out boundaries
287
+ // Simple approach: reconstruct full path and extract last meaningful project name
288
+ const fullPath = decoded;
289
+
290
+ // Extract project name: take the last meaningful segments
291
+ // Skip common prefixes to find the project-specific part
292
+ const skipPrefixes = ['C:', 'Users', 'Documents', 'Desktop', 'Downloads', 'Obsidian', 'repos', 'projects', 'code', 'dev', 'src', 'home'];
293
+
294
+ let segments = hash.replace(/^[A-Z]--/, '').split('-');
295
+
296
+ // Find where the "interesting" name starts (after common path prefixes)
297
+ let nameStart = 0;
298
+ for (let i = 0; i < segments.length; i++) {
299
+ if (skipPrefixes.some(p => p.toLowerCase() === segments[i].toLowerCase())) {
300
+ nameStart = i + 1;
301
+ } else {
302
+ break;
303
+ }
304
+ }
305
+
306
+ // Take the last 2-3 meaningful segments as the project name
307
+ const nameSegments = segments.slice(Math.max(nameStart, segments.length - 3));
308
+ const name = nameSegments.join(' ') || hash.slice(0, 12);
309
+
310
+ // Reconstruct a shortened display path
311
+ const path = hash.replace(/^([A-Z])--/, '$1:/').replace(/-/g, '/');
312
+
313
+ return { path, name };
314
+ }