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.
- package/package.json +1 -1
- package/src/analyzers/model-routing.js +72 -0
- package/src/analyzers/recommendations.js +83 -70
- package/src/analyzers/session-intelligence.js +114 -0
- package/src/cli/index.js +11 -6
- package/src/readers/claude-md.js +28 -1
- package/src/readers/jsonl-reader.js +95 -52
- package/src/renderers/html-report.js +631 -1239
|
@@ -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
|
-
|
|
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
|
-
//
|
|
217
|
-
|
|
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
|
-
//
|
|
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
|
-
}
|
|
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
|
+
}
|