@vibe-cafe/vibe-usage 0.9.16 → 0.10.1
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/README.md +5 -3
- package/package.json +1 -1
- package/src/claude-roots.js +81 -0
- package/src/daemon-service.js +23 -4
- package/src/parsers/claude-code.js +250 -188
- package/src/parsers/codex-cache.js +138 -0
- package/src/parsers/codex.js +601 -144
- package/src/sync.js +43 -10
- package/src/tools.js +1 -14
package/README.md
CHANGED
|
@@ -50,8 +50,8 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
50
50
|
|
|
51
51
|
| Tool | Data Location |
|
|
52
52
|
|------|---------------|
|
|
53
|
-
| Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only); also scans `$CLAUDE_CONFIG_DIR`
|
|
54
|
-
| Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`);
|
|
53
|
+
| Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only); also scans `$CLAUDE_CONFIG_DIR` and data-bearing `~/.claude-*` profiles, selects the most complete duplicate session, and streams logs so large active sessions are not silently omitted. Cache creation tokens are included in input usage. |
|
|
54
|
+
| Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`); a versioned local index avoids re-reading unchanged rollouts and reads only safe append tails for ordinary sessions, while fork/sub-agent replay matching, duplicate suppression, and live/archive deduplication retain their existing semantics |
|
|
55
55
|
| Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`); token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; honors `GROK_HOME` |
|
|
56
56
|
| GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
|
|
57
57
|
| Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`); cloud data is stamped with a fixed `cursor-cloud` hostname so multi-machine setups don't double-count |
|
|
@@ -77,7 +77,9 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
77
77
|
- Aggregates token usage into 30-minute buckets
|
|
78
78
|
- Extracts session metadata from all parsers: active time (AI generation time, excluding queue/TTFT wait), total duration, message counts
|
|
79
79
|
- Uploads buckets + sessions to your vibecafe.ai dashboard (always gzip-compressed, ~94% smaller)
|
|
80
|
-
- Incremental:
|
|
80
|
+
- Incremental upload: every parser emits a complete local snapshot, then only buckets/sessions that are new or changed since the last successful upload are sent — a quiet machine uploads nothing. Upload state remains in `~/.vibe-usage/state.json`; failed or still-indexing parsers retain their prior state, while deleted local logs are pruned. Deleting the state file triggers a one-time full re-upload, and `reset` clears it automatically after deleting cloud data
|
|
81
|
+
- Incremental Codex parsing: a versioned, disposable cache under `~/.vibe-usage/cache/codex/` stores per-rollout aggregate results and parser continuation state. Unchanged rollouts require no raw-log reads; an ordinary append reads only the new tail; forks, sub-agents, replacements, truncations, and failed safety checks fall back to the full correctness path. A bounded rolling audit occasionally re-reads one historical file. Very large first-time indexes checkpoint before the Mac app timeout and resume on the next sync instead of restarting
|
|
82
|
+
- The Codex parser cache contains derived aggregates and replay metadata, not raw prompt or response text. It is independent of upload state and can be deleted safely (the next sync rebuilds it). `reset` intentionally keeps it so the required full re-upload does not also require a full disk rescan. Set `VIBE_USAGE_CODEX_CACHE=0` to disable the optimization for diagnosis
|
|
81
83
|
- SQLite-backed tools (Cursor, OpenCode, Kiro, Hermes) are read via Node's built-in `node:sqlite` on Node ≥ 22.5 — no `sqlite3` binary needed (works on Windows out of the box); on older Node it falls back to the system `sqlite3` CLI
|
|
82
84
|
- For continuous syncing, use `npx @vibe-cafe/vibe-usage daemon` or the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app)
|
|
83
85
|
|
package/package.json
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { delimiter, join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
function expandHome(value) {
|
|
6
|
+
const trimmed = value.trim().replace(/[/\\]+$/, '');
|
|
7
|
+
if (trimmed === '~') return homedir();
|
|
8
|
+
if (trimmed.startsWith('~/') || trimmed.startsWith('~\\')) {
|
|
9
|
+
return join(homedir(), trimmed.slice(2));
|
|
10
|
+
}
|
|
11
|
+
return trimmed;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function hasClaudeData(root) {
|
|
15
|
+
return existsSync(join(root, 'projects')) || existsSync(join(root, 'transcripts'));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Return every Claude Code state root visible from this process.
|
|
20
|
+
*
|
|
21
|
+
* In addition to the default and CLAUDE_CONFIG_DIR, discover the documented
|
|
22
|
+
* multi-profile convention (~/.claude-work, ~/.claude-personal, ...). This is
|
|
23
|
+
* important for launchd/systemd and GUI processes, which commonly do not
|
|
24
|
+
* inherit the shell environment used to launch Claude Code.
|
|
25
|
+
*
|
|
26
|
+
* VIBE_USAGE_CLAUDE_DIRS is a test/diagnostic override. It replaces discovery
|
|
27
|
+
* with a path.delimiter-separated root list.
|
|
28
|
+
*/
|
|
29
|
+
export function getClaudeRoots() {
|
|
30
|
+
const override = process.env.VIBE_USAGE_CLAUDE_DIRS?.trim();
|
|
31
|
+
const roots = override
|
|
32
|
+
? override.split(delimiter).map(expandHome).filter(Boolean)
|
|
33
|
+
: [join(homedir(), '.claude')];
|
|
34
|
+
|
|
35
|
+
if (!override) {
|
|
36
|
+
const configured = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
37
|
+
if (configured) roots.push(expandHome(configured));
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
for (const entry of readdirSync(homedir(), { withFileTypes: true })) {
|
|
41
|
+
// Profiles are sometimes symlinked, so let hasClaudeData() follow the
|
|
42
|
+
// entry instead of requiring Dirent.isDirectory() here.
|
|
43
|
+
if (!/^\.claude-.+/.test(entry.name)) continue;
|
|
44
|
+
const candidate = join(homedir(), entry.name);
|
|
45
|
+
if (hasClaudeData(candidate)) roots.push(candidate);
|
|
46
|
+
}
|
|
47
|
+
} catch {
|
|
48
|
+
// The default/configured roots remain usable if home discovery fails.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
const unique = [];
|
|
54
|
+
for (const root of roots) {
|
|
55
|
+
let canonical = root;
|
|
56
|
+
try {
|
|
57
|
+
canonical = realpathSync(root);
|
|
58
|
+
} catch {
|
|
59
|
+
// Keep a missing explicit/default root so callers can report it normally.
|
|
60
|
+
}
|
|
61
|
+
if (seen.has(canonical)) continue;
|
|
62
|
+
seen.add(canonical);
|
|
63
|
+
unique.push(root);
|
|
64
|
+
}
|
|
65
|
+
return unique;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function findClaudeCodeDataDirs() {
|
|
69
|
+
const dirs = [];
|
|
70
|
+
for (const root of getClaudeRoots()) {
|
|
71
|
+
for (const name of ['projects', 'transcripts']) {
|
|
72
|
+
const candidate = join(root, name);
|
|
73
|
+
try {
|
|
74
|
+
if (statSync(candidate).isDirectory()) dirs.push(candidate);
|
|
75
|
+
} catch {
|
|
76
|
+
// Missing or unreadable roots are handled by the parser.
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return dirs;
|
|
81
|
+
}
|
package/src/daemon-service.js
CHANGED
|
@@ -43,7 +43,23 @@ function getServicePaths(plat) {
|
|
|
43
43
|
return null;
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
function
|
|
46
|
+
function escapeSystemdEnvironment(value) {
|
|
47
|
+
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function escapeXml(value) {
|
|
51
|
+
return value
|
|
52
|
+
.replace(/&/g, '&')
|
|
53
|
+
.replace(/</g, '<')
|
|
54
|
+
.replace(/>/g, '>')
|
|
55
|
+
.replace(/"/g, '"')
|
|
56
|
+
.replace(/'/g, ''');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function generateSystemdUnit(nodePath, binPath, claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim()) {
|
|
60
|
+
const claudeEnvironment = claudeConfigDir
|
|
61
|
+
? `Environment="CLAUDE_CONFIG_DIR=${escapeSystemdEnvironment(claudeConfigDir)}"\n`
|
|
62
|
+
: '';
|
|
47
63
|
return `[Unit]
|
|
48
64
|
Description=VibeCafe Usage Tracker
|
|
49
65
|
After=network.target
|
|
@@ -54,15 +70,18 @@ ExecStart=${nodePath} ${binPath} daemon
|
|
|
54
70
|
Restart=on-failure
|
|
55
71
|
RestartSec=10
|
|
56
72
|
Environment=NODE_ENV=production
|
|
57
|
-
WorkingDirectory=${homedir()}
|
|
73
|
+
${claudeEnvironment}WorkingDirectory=${homedir()}
|
|
58
74
|
|
|
59
75
|
[Install]
|
|
60
76
|
WantedBy=default.target
|
|
61
77
|
`;
|
|
62
78
|
}
|
|
63
79
|
|
|
64
|
-
function generateLaunchdPlist(nodePath, binPath) {
|
|
80
|
+
export function generateLaunchdPlist(nodePath, binPath, claudeConfigDir = process.env.CLAUDE_CONFIG_DIR?.trim()) {
|
|
65
81
|
const logDir = join(homedir(), '.vibe-usage');
|
|
82
|
+
const claudeEnvironment = claudeConfigDir
|
|
83
|
+
? ` <key>CLAUDE_CONFIG_DIR</key>\n <string>${escapeXml(claudeConfigDir)}</string>\n`
|
|
84
|
+
: '';
|
|
66
85
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
67
86
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
68
87
|
<plist version="1.0">
|
|
@@ -89,7 +108,7 @@ function generateLaunchdPlist(nodePath, binPath) {
|
|
|
89
108
|
<dict>
|
|
90
109
|
<key>NODE_ENV</key>
|
|
91
110
|
<string>production</string>
|
|
92
|
-
</dict>
|
|
111
|
+
${claudeEnvironment} </dict>
|
|
93
112
|
</dict>
|
|
94
113
|
</plist>
|
|
95
114
|
`;
|
|
@@ -1,243 +1,305 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { createReadStream, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
2
3
|
import { join, basename, sep } from 'node:path';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
4
|
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
|
+
import { getClaudeRoots } from '../claude-roots.js';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
* Stateless Claude Code parser.
|
|
8
|
-
* Reads ALL *.jsonl files under <root>/projects/ and extracts per-message
|
|
9
|
-
* token usage from assistant messages. No state file needed — every sync
|
|
10
|
-
* computes the full bucket totals from raw data, making server-side
|
|
11
|
-
* ON CONFLICT ... DO UPDATE SET idempotent.
|
|
12
|
-
*
|
|
13
|
-
* Roots: always ~/.claude, plus $CLAUDE_CONFIG_DIR when set to a different
|
|
14
|
-
* path. Claude Code itself relocates its whole tree (incl. projects/) to
|
|
15
|
-
* $CLAUDE_CONFIG_DIR and uses only that dir — but a GUI launched from the
|
|
16
|
-
* Dock may not inherit the shell's env, so usage can be split across both
|
|
17
|
-
* roots. We scan both and dedup so neither source is missed or double-counted.
|
|
18
|
-
*/
|
|
7
|
+
const MAX_WARNINGS = 20;
|
|
19
8
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
*/
|
|
25
|
-
function getClaudeRoots() {
|
|
26
|
-
const roots = [join(homedir(), '.claude')];
|
|
27
|
-
|
|
28
|
-
const cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
29
|
-
if (cfg) {
|
|
30
|
-
let custom = cfg;
|
|
31
|
-
if (custom.startsWith('~')) custom = join(homedir(), custom.slice(1));
|
|
32
|
-
custom = custom.replace(/[/\\]+$/, '') || custom;
|
|
33
|
-
roots.push(custom);
|
|
34
|
-
}
|
|
9
|
+
function addWarning(ctx, message) {
|
|
10
|
+
ctx.incomplete = true;
|
|
11
|
+
if (ctx.warnings.length < MAX_WARNINGS) ctx.warnings.push(message);
|
|
12
|
+
}
|
|
35
13
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
// dir may not exist yet — fall back to the literal path
|
|
14
|
+
/** Recursively collect JSONL files without making one unreadable branch fatal. */
|
|
15
|
+
function findJsonlFiles(dir, ctx) {
|
|
16
|
+
let entries;
|
|
17
|
+
try {
|
|
18
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
19
|
+
} catch (err) {
|
|
20
|
+
if (err?.code !== 'ENOENT') {
|
|
21
|
+
addWarning(ctx, `Claude Code: cannot read directory ${dir}: ${err.message}`);
|
|
45
22
|
}
|
|
46
|
-
|
|
47
|
-
seen.add(key);
|
|
48
|
-
unique.push(r);
|
|
23
|
+
return [];
|
|
49
24
|
}
|
|
50
|
-
return unique;
|
|
51
|
-
}
|
|
52
25
|
|
|
53
|
-
/**
|
|
54
|
-
* Recursively find all .jsonl files under a directory.
|
|
55
|
-
* Claude Code stores sessions in two layouts:
|
|
56
|
-
* 2-layer: projects/{projectPath}/{sessionId}.jsonl
|
|
57
|
-
* 3-layer: projects/{projectPath}/{sessionId}/subagents/agent-*.jsonl
|
|
58
|
-
*/
|
|
59
|
-
function findJsonlFiles(dir) {
|
|
60
26
|
const results = [];
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
} else if (entry.name.endsWith('.jsonl')) {
|
|
68
|
-
results.push(fullPath);
|
|
69
|
-
}
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
const fullPath = join(dir, entry.name);
|
|
29
|
+
if (entry.isDirectory()) {
|
|
30
|
+
results.push(...findJsonlFiles(fullPath, ctx));
|
|
31
|
+
} else if (entry.name.endsWith('.jsonl')) {
|
|
32
|
+
results.push(fullPath);
|
|
70
33
|
}
|
|
71
|
-
} catch {
|
|
72
|
-
// ignore unreadable directories
|
|
73
34
|
}
|
|
74
35
|
return results;
|
|
75
36
|
}
|
|
76
37
|
|
|
77
|
-
/**
|
|
78
|
-
* Path of a project file relative to its root's projects/ dir, e.g.
|
|
79
|
-
* "<root>/projects/-Users-foo-app/abc.jsonl" → "-Users-foo-app/abc.jsonl".
|
|
80
|
-
* Used both for project-name extraction and cross-root dedup.
|
|
81
|
-
*/
|
|
82
38
|
function projectRelativePath(filePath, projectsDir) {
|
|
83
39
|
const prefix = projectsDir + sep;
|
|
84
40
|
return filePath.startsWith(prefix) ? filePath.slice(prefix.length) : null;
|
|
85
41
|
}
|
|
86
42
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
* The first segment is the dash-encoded project path (e.g. -Users-foo-myproject);
|
|
90
|
-
* we take its last component as the project name.
|
|
91
|
-
*/
|
|
92
|
-
function extractProject(relative) {
|
|
43
|
+
/** Best-effort fallback for old records without cwd. */
|
|
44
|
+
function projectFromRelative(relative) {
|
|
93
45
|
if (!relative) return 'unknown';
|
|
94
|
-
const
|
|
95
|
-
if (!
|
|
96
|
-
const parts =
|
|
97
|
-
return parts.
|
|
46
|
+
const firstSegment = relative.split(sep)[0];
|
|
47
|
+
if (!firstSegment) return 'unknown';
|
|
48
|
+
const parts = firstSegment.split('-').filter(Boolean);
|
|
49
|
+
return parts.at(-1) || 'unknown';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Works for Unix and Windows cwd values regardless of the current OS. */
|
|
53
|
+
function projectFromCwd(cwd, fallback) {
|
|
54
|
+
if (typeof cwd !== 'string') return fallback;
|
|
55
|
+
const trimmed = cwd.trim().replace(/[\\/]+$/, '');
|
|
56
|
+
if (!trimmed) return fallback;
|
|
57
|
+
return trimmed.split(/[\\/]/).filter(Boolean).at(-1) || fallback;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toCount(value) {
|
|
61
|
+
const n = Number(value);
|
|
62
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function cacheCreationTokens(usage) {
|
|
66
|
+
const direct = toCount(usage.cache_creation_input_tokens);
|
|
67
|
+
const breakdown = usage.cache_creation || {};
|
|
68
|
+
const split =
|
|
69
|
+
toCount(breakdown.ephemeral_5m_input_tokens) +
|
|
70
|
+
toCount(breakdown.ephemeral_1h_input_tokens);
|
|
71
|
+
// Current Claude logs carry both the total and its TTL breakdown. max()
|
|
72
|
+
// avoids double-counting while remaining tolerant of partially populated logs.
|
|
73
|
+
return Math.max(direct, split);
|
|
98
74
|
}
|
|
99
75
|
|
|
100
|
-
function
|
|
101
|
-
|
|
76
|
+
function candidateIsBetter(next, current) {
|
|
77
|
+
if (!current) return true;
|
|
78
|
+
if (next.size !== current.size) return next.size > current.size;
|
|
79
|
+
if (next.mtimeMs !== current.mtimeMs) return next.mtimeMs > current.mtimeMs;
|
|
80
|
+
return next.filePath.localeCompare(current.filePath) < 0;
|
|
102
81
|
}
|
|
103
82
|
|
|
104
83
|
/**
|
|
105
|
-
*
|
|
84
|
+
* Group physical files by logical session id and keep candidates ordered by
|
|
85
|
+
* completeness. A session copied between ~/.claude and CLAUDE_CONFIG_DIR must
|
|
86
|
+
* use its largest/newest copy, not whichever root happened to be scanned first.
|
|
106
87
|
*/
|
|
107
|
-
function
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
88
|
+
function collectCandidates(roots, directoryName, ctx) {
|
|
89
|
+
const groups = new Map();
|
|
90
|
+
for (const root of roots) {
|
|
91
|
+
const baseDir = join(root, directoryName);
|
|
92
|
+
for (const filePath of findJsonlFiles(baseDir, ctx)) {
|
|
93
|
+
let stat;
|
|
94
|
+
try {
|
|
95
|
+
stat = statSync(filePath);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
addWarning(ctx, `Claude Code: cannot stat ${filePath}: ${err.message}`);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const sessionId = basename(filePath, '.jsonl');
|
|
101
|
+
const relative = projectRelativePath(filePath, baseDir);
|
|
102
|
+
const candidate = {
|
|
103
|
+
filePath,
|
|
104
|
+
sessionId,
|
|
105
|
+
size: stat.size,
|
|
106
|
+
mtimeMs: stat.mtimeMs,
|
|
107
|
+
fallbackProject: directoryName === 'projects'
|
|
108
|
+
? projectFromRelative(relative)
|
|
109
|
+
: 'unknown',
|
|
110
|
+
};
|
|
111
|
+
const group = groups.get(sessionId) || [];
|
|
112
|
+
group.push(candidate);
|
|
113
|
+
groups.set(sessionId, group);
|
|
124
114
|
}
|
|
115
|
+
}
|
|
116
|
+
for (const group of groups.values()) {
|
|
117
|
+
group.sort((a, b) => candidateIsBetter(a, b) ? -1 : candidateIsBetter(b, a) ? 1 : 0);
|
|
118
|
+
}
|
|
119
|
+
return groups;
|
|
120
|
+
}
|
|
125
121
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
122
|
+
/** Read only the file size captured during discovery, so live appends wait. */
|
|
123
|
+
async function readJsonl(candidate, onObject) {
|
|
124
|
+
if (candidate.size === 0) return;
|
|
125
|
+
const stream = createReadStream(candidate.filePath, {
|
|
126
|
+
encoding: 'utf8',
|
|
127
|
+
start: 0,
|
|
128
|
+
end: candidate.size - 1,
|
|
129
|
+
});
|
|
130
|
+
let streamError = null;
|
|
131
|
+
stream.on('error', (err) => { streamError = err; });
|
|
132
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
129
133
|
|
|
130
|
-
|
|
134
|
+
try {
|
|
135
|
+
for await (const line of lines) {
|
|
131
136
|
if (!line.trim()) continue;
|
|
132
137
|
try {
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const timestamp = obj.timestamp;
|
|
136
|
-
if (!timestamp) continue;
|
|
137
|
-
const ts = new Date(timestamp);
|
|
138
|
-
if (isNaN(ts.getTime())) continue;
|
|
139
|
-
|
|
140
|
-
if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
|
|
141
|
-
ctx.sessionEvents.push({
|
|
142
|
-
sessionId,
|
|
143
|
-
source: 'claude-code',
|
|
144
|
-
project,
|
|
145
|
-
timestamp: ts,
|
|
146
|
-
role: obj.type === 'user' ? 'user' : 'assistant',
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
if (obj.type !== 'assistant') continue;
|
|
151
|
-
const msg = obj.message;
|
|
152
|
-
if (!msg || !msg.usage) continue;
|
|
153
|
-
|
|
154
|
-
const usage = msg.usage;
|
|
155
|
-
if (usage.input_tokens == null && usage.output_tokens == null) continue;
|
|
156
|
-
|
|
157
|
-
const uuid = obj.uuid;
|
|
158
|
-
if (uuid) {
|
|
159
|
-
if (ctx.seenUuids.has(uuid)) continue;
|
|
160
|
-
ctx.seenUuids.add(uuid);
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
ctx.entries.push({
|
|
164
|
-
source: 'claude-code',
|
|
165
|
-
model: msg.model || 'unknown',
|
|
166
|
-
project,
|
|
167
|
-
timestamp: ts,
|
|
168
|
-
inputTokens: usage.input_tokens || 0,
|
|
169
|
-
outputTokens: usage.output_tokens || 0,
|
|
170
|
-
cachedInputTokens: usage.cache_read_input_tokens || 0,
|
|
171
|
-
reasoningOutputTokens: 0,
|
|
172
|
-
});
|
|
138
|
+
onObject(JSON.parse(line));
|
|
173
139
|
} catch {
|
|
174
|
-
|
|
140
|
+
// Claude may be appending the final JSONL record while we snapshot it.
|
|
141
|
+
// A later sync will see the complete line; malformed historical lines
|
|
142
|
+
// are isolated instead of taking the whole parser down.
|
|
175
143
|
}
|
|
176
144
|
}
|
|
145
|
+
if (streamError) throw streamError;
|
|
146
|
+
} finally {
|
|
147
|
+
lines.close();
|
|
148
|
+
stream.destroy();
|
|
177
149
|
}
|
|
178
150
|
}
|
|
179
151
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
152
|
+
function timingEvent(obj, sessionId, project) {
|
|
153
|
+
if (
|
|
154
|
+
obj.type !== 'user' &&
|
|
155
|
+
obj.type !== 'assistant' &&
|
|
156
|
+
obj.type !== 'tool_use' &&
|
|
157
|
+
obj.type !== 'tool_result'
|
|
158
|
+
) return null;
|
|
159
|
+
if (!obj.timestamp) return null;
|
|
160
|
+
const timestamp = new Date(obj.timestamp);
|
|
161
|
+
if (Number.isNaN(timestamp.getTime())) return null;
|
|
162
|
+
return {
|
|
163
|
+
sessionId,
|
|
164
|
+
source: 'claude-code',
|
|
165
|
+
project,
|
|
166
|
+
timestamp,
|
|
167
|
+
role: obj.type === 'user' ? 'user' : 'assistant',
|
|
168
|
+
};
|
|
169
|
+
}
|
|
189
170
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
171
|
+
async function scanProjectCandidate(candidate) {
|
|
172
|
+
const entries = [];
|
|
173
|
+
const events = [];
|
|
174
|
+
let lastModel = null;
|
|
175
|
+
let sessionProject = candidate.fallbackProject;
|
|
176
|
+
let foundSessionCwd = false;
|
|
177
|
+
|
|
178
|
+
await readJsonl(candidate, (obj) => {
|
|
179
|
+
// cwd can change after Claude runs `cd`; project attribution should remain
|
|
180
|
+
// the directory where this session started, not fragment into subfolders.
|
|
181
|
+
if (!foundSessionCwd && typeof obj.cwd === 'string' && obj.cwd.trim()) {
|
|
182
|
+
sessionProject = projectFromCwd(obj.cwd, candidate.fallbackProject);
|
|
183
|
+
foundSessionCwd = true;
|
|
195
184
|
}
|
|
185
|
+
const event = timingEvent(obj, candidate.sessionId, sessionProject);
|
|
186
|
+
if (event) events.push(event);
|
|
196
187
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
188
|
+
if (obj.type !== 'assistant' || !obj.message?.usage || !obj.timestamp) return;
|
|
189
|
+
const timestamp = new Date(obj.timestamp);
|
|
190
|
+
if (Number.isNaN(timestamp.getTime())) return;
|
|
191
|
+
|
|
192
|
+
const usage = obj.message.usage;
|
|
193
|
+
const rawModel = typeof obj.message.model === 'string'
|
|
194
|
+
? obj.message.model.trim()
|
|
195
|
+
: '';
|
|
196
|
+
if (rawModel && rawModel !== '<synthetic>') lastModel = rawModel;
|
|
197
|
+
const model = rawModel && rawModel !== '<synthetic>'
|
|
198
|
+
? rawModel
|
|
199
|
+
: lastModel || 'claude-unknown';
|
|
200
|
+
const inputTokens = toCount(usage.input_tokens) + cacheCreationTokens(usage);
|
|
201
|
+
const outputTokens = toCount(usage.output_tokens);
|
|
202
|
+
const cachedInputTokens = toCount(usage.cache_read_input_tokens);
|
|
203
|
+
const usageScore = inputTokens + outputTokens + cachedInputTokens;
|
|
204
|
+
|
|
205
|
+
// Synthetic bookkeeping messages are common and carry zero usage. Do not
|
|
206
|
+
// inflate the CLI's bucket count with rows the server will discard anyway.
|
|
207
|
+
if (usageScore === 0) return;
|
|
208
|
+
|
|
209
|
+
entries.push({
|
|
210
|
+
uuid: typeof obj.uuid === 'string' && obj.uuid ? obj.uuid : null,
|
|
211
|
+
usageScore,
|
|
212
|
+
source: 'claude-code',
|
|
213
|
+
model,
|
|
214
|
+
project: sessionProject,
|
|
215
|
+
timestamp,
|
|
216
|
+
inputTokens,
|
|
217
|
+
outputTokens,
|
|
218
|
+
cachedInputTokens,
|
|
219
|
+
reasoningOutputTokens: 0,
|
|
220
|
+
});
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// A cwd can appear after initial metadata/messages. Normalize the completed
|
|
224
|
+
// session in one place so early records receive the same project label.
|
|
225
|
+
for (const entry of entries) entry.project = sessionProject;
|
|
226
|
+
for (const event of events) event.project = sessionProject;
|
|
227
|
+
return { entries, events };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function scanTranscriptCandidate(candidate) {
|
|
231
|
+
const events = [];
|
|
232
|
+
await readJsonl(candidate, (obj) => {
|
|
233
|
+
const event = timingEvent(
|
|
234
|
+
obj,
|
|
235
|
+
candidate.sessionId,
|
|
236
|
+
projectFromCwd(obj.cwd, 'unknown'),
|
|
237
|
+
);
|
|
238
|
+
if (event) events.push(event);
|
|
239
|
+
});
|
|
240
|
+
return { entries: [], events };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
async function scanBestCandidate(candidates, scanner, ctx) {
|
|
244
|
+
for (const candidate of candidates) {
|
|
245
|
+
try {
|
|
246
|
+
return await scanner(candidate);
|
|
247
|
+
} catch (err) {
|
|
248
|
+
addWarning(ctx, `Claude Code: cannot read ${candidate.filePath}: ${err.message}`);
|
|
219
249
|
}
|
|
220
250
|
}
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function mergeUsageEntry(ctx, entry) {
|
|
255
|
+
if (!entry.uuid) {
|
|
256
|
+
ctx.anonymousEntries.push(entry);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
const current = ctx.entriesByUuid.get(entry.uuid);
|
|
260
|
+
// Claude sometimes copies the same UUID into another session with zeroed
|
|
261
|
+
// usage. Keep the most complete payload, independent of directory order.
|
|
262
|
+
if (!current || entry.usageScore > current.usageScore) {
|
|
263
|
+
ctx.entriesByUuid.set(entry.uuid, entry);
|
|
264
|
+
}
|
|
221
265
|
}
|
|
222
266
|
|
|
223
267
|
export async function parse() {
|
|
224
268
|
const ctx = {
|
|
225
|
-
|
|
269
|
+
entriesByUuid: new Map(),
|
|
270
|
+
anonymousEntries: [],
|
|
226
271
|
sessionEvents: [],
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
seenProjectFiles: new Set(), // projects-relative path → dedup same session across roots
|
|
272
|
+
warnings: [],
|
|
273
|
+
incomplete: false,
|
|
230
274
|
};
|
|
231
|
-
|
|
232
275
|
const roots = getClaudeRoots();
|
|
276
|
+
const projectGroups = collectCandidates(roots, 'projects', ctx);
|
|
277
|
+
const projectSessionIds = new Set();
|
|
278
|
+
|
|
279
|
+
for (const [sessionId, candidates] of projectGroups) {
|
|
280
|
+
const parsed = await scanBestCandidate(candidates, scanProjectCandidate, ctx);
|
|
281
|
+
if (!parsed) continue;
|
|
282
|
+
projectSessionIds.add(sessionId);
|
|
283
|
+
ctx.sessionEvents.push(...parsed.events);
|
|
284
|
+
for (const entry of parsed.entries) mergeUsageEntry(ctx, entry);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const transcriptGroups = collectCandidates(roots, 'transcripts', ctx);
|
|
288
|
+
for (const [sessionId, candidates] of transcriptGroups) {
|
|
289
|
+
if (projectSessionIds.has(sessionId)) continue;
|
|
290
|
+
const parsed = await scanBestCandidate(candidates, scanTranscriptCandidate, ctx);
|
|
291
|
+
if (parsed) ctx.sessionEvents.push(...parsed.events);
|
|
292
|
+
}
|
|
233
293
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
294
|
+
const entries = [
|
|
295
|
+
...ctx.anonymousEntries,
|
|
296
|
+
...ctx.entriesByUuid.values(),
|
|
297
|
+
].map(({ uuid: _uuid, usageScore: _usageScore, ...entry }) => entry);
|
|
238
298
|
|
|
239
299
|
return {
|
|
240
|
-
buckets: aggregateToBuckets(
|
|
300
|
+
buckets: aggregateToBuckets(entries),
|
|
241
301
|
sessions: extractSessions(ctx.sessionEvents),
|
|
302
|
+
...(ctx.incomplete ? { skipped: true } : {}),
|
|
303
|
+
...(ctx.warnings.length > 0 ? { warnings: ctx.warnings } : {}),
|
|
242
304
|
};
|
|
243
305
|
}
|