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.
- package/package.json +1 -1
- package/src/analyzers/cache-health.js +11 -4
- package/src/analyzers/inflection-detector.js +38 -26
- package/src/analyzers/model-routing.js +72 -0
- package/src/analyzers/recommendations.js +106 -72
- package/src/analyzers/session-intelligence.js +114 -0
- package/src/cli/index.js +11 -6
- package/src/readers/claude-md.js +27 -1
- package/src/readers/jsonl-reader.js +117 -50
- package/src/renderers/html-report.js +1045 -1375
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
//
|
|
217
|
-
|
|
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
|
-
//
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
+
}
|