@vibe-cafe/vibe-usage 0.4.2 → 0.5.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 +2 -2
- package/package.json +2 -4
- package/src/parsers/claude-code.js +98 -100
- package/src/parsers/codex.js +8 -4
- package/src/parsers/gemini-cli.js +14 -10
- package/src/parsers/index.js +2 -3
- package/src/reset.js +2 -11
- package/src/sync.js +1 -11
package/README.md
CHANGED
|
@@ -40,12 +40,12 @@ npx vibe-usage status # Show config & detected tools
|
|
|
40
40
|
- Parses local session logs from each AI coding tool
|
|
41
41
|
- Aggregates token usage into 30-minute buckets
|
|
42
42
|
- Uploads to your vibecafe.ai dashboard
|
|
43
|
-
-
|
|
43
|
+
- Stateless: computes full totals from local logs each sync (idempotent, no state files)
|
|
44
44
|
- For continuous syncing, use `npx vibe-usage daemon` or the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app)
|
|
45
45
|
|
|
46
46
|
## Config
|
|
47
47
|
|
|
48
|
-
Config stored at `~/.vibe-usage/config.json`. Contains your API key and
|
|
48
|
+
Config stored at `~/.vibe-usage/config.json`. Contains your API key and server URL.
|
|
49
49
|
|
|
50
50
|
## Daemon Mode
|
|
51
51
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibe-cafe/vibe-usage",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "Track your AI coding tool token usage and sync to vibecafe.ai",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,9 +22,7 @@
|
|
|
22
22
|
"codex",
|
|
23
23
|
"gemini"
|
|
24
24
|
],
|
|
25
|
-
"dependencies": {
|
|
26
|
-
"ccusage": "18.0.5"
|
|
27
|
-
},
|
|
25
|
+
"dependencies": {},
|
|
28
26
|
"repository": {
|
|
29
27
|
"type": "git",
|
|
30
28
|
"url": "git+https://github.com/vibe-cafe/vibe-usage.git"
|
|
@@ -1,123 +1,121 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, basename, sep } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import {
|
|
5
|
-
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
4
|
+
import { aggregateToBuckets } from './index.js';
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Stateless Claude Code parser.
|
|
8
|
+
* Reads ALL *.jsonl files under ~/.claude/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
|
+
*/
|
|
8
13
|
|
|
9
|
-
|
|
10
|
-
let _pendingState = null;
|
|
14
|
+
const CLAUDE_DIR = join(homedir(), '.claude', 'projects');
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
/**
|
|
17
|
+
* Recursively find all .jsonl files under a directory.
|
|
18
|
+
* Claude Code stores sessions in two layouts:
|
|
19
|
+
* 2-layer: projects/{projectPath}/{sessionId}.jsonl
|
|
20
|
+
* 3-layer: projects/{projectPath}/{sessionId}/subagents/agent-*.jsonl
|
|
21
|
+
*/
|
|
22
|
+
function findJsonlFiles(dir) {
|
|
23
|
+
const results = [];
|
|
24
|
+
if (!existsSync(dir)) return results;
|
|
13
25
|
try {
|
|
14
|
-
|
|
26
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
27
|
+
const fullPath = join(dir, entry.name);
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
results.push(...findJsonlFiles(fullPath));
|
|
30
|
+
} else if (entry.name.endsWith('.jsonl')) {
|
|
31
|
+
results.push(fullPath);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
15
34
|
} catch {
|
|
16
|
-
|
|
35
|
+
// ignore unreadable directories
|
|
17
36
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
function saveState(state) {
|
|
21
|
-
const dir = join(homedir(), '.vibe-usage');
|
|
22
|
-
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
23
|
-
writeFileSync(STATE_FILE, JSON.stringify(state), 'utf-8');
|
|
37
|
+
return results;
|
|
24
38
|
}
|
|
25
39
|
|
|
26
40
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
41
|
+
* Extract project name from file path.
|
|
42
|
+
* Path format: ~/.claude/projects/{encodedProjectPath}/{sessionId}.jsonl
|
|
43
|
+
* The encodedProjectPath uses dashes for separators (e.g. -Users-foo-myproject).
|
|
44
|
+
* We extract the last path segment as the project name.
|
|
30
45
|
*/
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
46
|
+
function extractProject(filePath) {
|
|
47
|
+
// Get relative path from the projects dir
|
|
48
|
+
const projectsPrefix = CLAUDE_DIR + sep;
|
|
49
|
+
if (!filePath.startsWith(projectsPrefix)) return 'unknown';
|
|
50
|
+
const relative = filePath.slice(projectsPrefix.length);
|
|
51
|
+
// First segment is the encoded project path
|
|
52
|
+
const firstSeg = relative.split(sep)[0];
|
|
53
|
+
if (!firstSeg) return 'unknown';
|
|
54
|
+
// The encoded path uses dashes: -Users-kalasoo-Projects-myproject
|
|
55
|
+
// Take the last segment after splitting by dash
|
|
56
|
+
const parts = firstSeg.split('-').filter(Boolean);
|
|
57
|
+
return parts.length > 0 ? parts[parts.length - 1] : 'unknown';
|
|
36
58
|
}
|
|
37
59
|
|
|
38
60
|
export async function parse() {
|
|
39
|
-
|
|
40
|
-
try {
|
|
41
|
-
sessions = await loadSessionData({ mode: 'display' });
|
|
42
|
-
} catch {
|
|
43
|
-
return [];
|
|
44
|
-
}
|
|
61
|
+
if (!existsSync(CLAUDE_DIR)) return [];
|
|
45
62
|
|
|
46
|
-
|
|
63
|
+
const files = findJsonlFiles(CLAUDE_DIR);
|
|
64
|
+
if (files.length === 0) return [];
|
|
47
65
|
|
|
48
|
-
const state = loadState();
|
|
49
|
-
const nextState = { ...state };
|
|
50
66
|
const entries = [];
|
|
67
|
+
const seenUuids = new Set();
|
|
68
|
+
|
|
69
|
+
for (const filePath of files) {
|
|
70
|
+
let content;
|
|
71
|
+
try {
|
|
72
|
+
content = readFileSync(filePath, 'utf-8');
|
|
73
|
+
} catch {
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
51
76
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
77
|
+
const project = extractProject(filePath);
|
|
78
|
+
|
|
79
|
+
for (const line of content.split('\n')) {
|
|
80
|
+
if (!line.trim()) continue;
|
|
81
|
+
try {
|
|
82
|
+
const obj = JSON.parse(line);
|
|
83
|
+
|
|
84
|
+
// Only process assistant messages with usage data
|
|
85
|
+
if (obj.type !== 'assistant') continue;
|
|
86
|
+
const msg = obj.message;
|
|
87
|
+
if (!msg || !msg.usage) continue;
|
|
88
|
+
|
|
89
|
+
const usage = msg.usage;
|
|
90
|
+
if (usage.input_tokens == null && usage.output_tokens == null) continue;
|
|
91
|
+
|
|
92
|
+
// Deduplicate by UUID across all files
|
|
93
|
+
const uuid = obj.uuid;
|
|
94
|
+
if (uuid) {
|
|
95
|
+
if (seenUuids.has(uuid)) continue;
|
|
96
|
+
seenUuids.add(uuid);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const timestamp = obj.timestamp;
|
|
100
|
+
if (!timestamp) continue;
|
|
101
|
+
const ts = new Date(timestamp);
|
|
102
|
+
if (isNaN(ts.getTime())) continue;
|
|
103
|
+
|
|
104
|
+
entries.push({
|
|
105
|
+
source: 'claude-code',
|
|
106
|
+
model: msg.model || 'unknown',
|
|
107
|
+
project,
|
|
108
|
+
timestamp: ts,
|
|
109
|
+
inputTokens: usage.input_tokens || 0,
|
|
110
|
+
outputTokens: usage.output_tokens || 0,
|
|
111
|
+
cachedInputTokens: usage.cache_read_input_tokens || 0,
|
|
112
|
+
reasoningOutputTokens: 0,
|
|
113
|
+
});
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
87
117
|
}
|
|
88
118
|
}
|
|
89
119
|
|
|
90
|
-
// Stage state — only persisted to disk after successful upload
|
|
91
|
-
_pendingState = nextState;
|
|
92
|
-
|
|
93
120
|
return aggregateToBuckets(entries);
|
|
94
121
|
}
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Resolve project name from ccusage session data.
|
|
98
|
-
*
|
|
99
|
-
* ccusage v18 assumes 3-layer: projects/{projectPath}/{sessionId}/{file}.jsonl
|
|
100
|
-
* but Claude Code main sessions are 2-layer: projects/{projectPath}/{sessionId}.jsonl
|
|
101
|
-
*
|
|
102
|
-
* For 2-layer files ccusage incorrectly puts the project dir name into sessionId
|
|
103
|
-
* and sets projectPath to "Unknown Project". We detect and correct this.
|
|
104
|
-
*/
|
|
105
|
-
function resolveProject(session) {
|
|
106
|
-
if (session.projectPath === 'Unknown Project') {
|
|
107
|
-
// 2-layer: sessionId actually holds the project directory name
|
|
108
|
-
return cleanProjectDir(session.sessionId);
|
|
109
|
-
}
|
|
110
|
-
// 3-layer: projectPath is correct, strip any session UUID suffix
|
|
111
|
-
return cleanProjectDir(session.projectPath);
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Clean a raw project directory name from ccusage.
|
|
116
|
-
* Strips session UUID suffix for subagent paths like '-Users-foo-project/77e854f9-...'.
|
|
117
|
-
*/
|
|
118
|
-
function cleanProjectDir(raw) {
|
|
119
|
-
if (!raw || raw === 'unknown' || raw === 'Unknown Project') return 'unknown';
|
|
120
|
-
const slashIdx = raw.indexOf('/');
|
|
121
|
-
if (slashIdx !== -1) raw = raw.slice(0, slashIdx);
|
|
122
|
-
return raw;
|
|
123
|
-
}
|
package/src/parsers/codex.js
CHANGED
|
@@ -115,15 +115,19 @@ export async function parse() {
|
|
|
115
115
|
|
|
116
116
|
const model = info.model || payload.model || turnContextModel || sessionModel;
|
|
117
117
|
|
|
118
|
+
// OpenAI API: input_tokens INCLUDES cached, output_tokens INCLUDES reasoning.
|
|
119
|
+
// Normalize to Anthropic-style semantics where each field is non-overlapping.
|
|
120
|
+
const cachedInput = usage.cached_input_tokens || usage.cache_read_input_tokens || 0;
|
|
121
|
+
const reasoningOutput = usage.reasoning_output_tokens || 0;
|
|
118
122
|
entries.push({
|
|
119
123
|
source: 'codex',
|
|
120
124
|
model,
|
|
121
125
|
project: sessionProject,
|
|
122
126
|
timestamp,
|
|
123
|
-
inputTokens: usage.input_tokens || 0,
|
|
124
|
-
outputTokens: usage.output_tokens || 0,
|
|
125
|
-
cachedInputTokens:
|
|
126
|
-
reasoningOutputTokens:
|
|
127
|
+
inputTokens: (usage.input_tokens || 0) - cachedInput,
|
|
128
|
+
outputTokens: (usage.output_tokens || 0) - reasoningOutput,
|
|
129
|
+
cachedInputTokens: cachedInput,
|
|
130
|
+
reasoningOutputTokens: reasoningOutput,
|
|
127
131
|
});
|
|
128
132
|
} catch {
|
|
129
133
|
continue;
|
|
@@ -59,28 +59,32 @@ export async function parse() {
|
|
|
59
59
|
if (isNaN(ts.getTime())) continue;
|
|
60
60
|
|
|
61
61
|
if (tokens) {
|
|
62
|
-
//
|
|
62
|
+
// Gemini API: input INCLUDES cached, output INCLUDES thoughts. Normalize to non-overlapping.
|
|
63
|
+
const cached = tokens.cached || 0;
|
|
64
|
+
const thoughts = tokens.thoughts || 0;
|
|
63
65
|
entries.push({
|
|
64
66
|
source: 'gemini-cli',
|
|
65
67
|
model: msg.model || data.model || 'unknown',
|
|
66
68
|
project: 'unknown',
|
|
67
69
|
timestamp: ts,
|
|
68
|
-
inputTokens: tokens.input || 0,
|
|
69
|
-
outputTokens: tokens.output || 0,
|
|
70
|
-
cachedInputTokens:
|
|
71
|
-
reasoningOutputTokens:
|
|
70
|
+
inputTokens: (tokens.input || 0) - cached,
|
|
71
|
+
outputTokens: (tokens.output || 0) - thoughts,
|
|
72
|
+
cachedInputTokens: cached,
|
|
73
|
+
reasoningOutputTokens: thoughts,
|
|
72
74
|
});
|
|
73
75
|
} else {
|
|
74
|
-
//
|
|
76
|
+
// Gemini API: promptTokenCount INCLUDES cachedContentTokenCount. Normalize to non-overlapping.
|
|
77
|
+
const cached = usage.cachedContentTokenCount || 0;
|
|
78
|
+
const thoughts = usage.thoughtsTokenCount || 0;
|
|
75
79
|
entries.push({
|
|
76
80
|
source: 'gemini-cli',
|
|
77
81
|
model: msg.model || data.model || 'unknown',
|
|
78
82
|
project: 'unknown',
|
|
79
83
|
timestamp: ts,
|
|
80
|
-
inputTokens: usage.promptTokenCount || usage.input_tokens || 0,
|
|
81
|
-
outputTokens: usage.candidatesTokenCount || usage.output_tokens || 0,
|
|
82
|
-
cachedInputTokens:
|
|
83
|
-
reasoningOutputTokens:
|
|
84
|
+
inputTokens: (usage.promptTokenCount || usage.input_tokens || 0) - cached,
|
|
85
|
+
outputTokens: (usage.candidatesTokenCount || usage.output_tokens || 0) - thoughts,
|
|
86
|
+
cachedInputTokens: cached,
|
|
87
|
+
reasoningOutputTokens: thoughts,
|
|
84
88
|
});
|
|
85
89
|
}
|
|
86
90
|
}
|
package/src/parsers/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { parse as parseClaudeCode
|
|
1
|
+
import { parse as parseClaudeCode } from './claude-code.js';
|
|
2
2
|
import { parse as parseCodex } from './codex.js';
|
|
3
3
|
import { parse as parseGeminiCli } from './gemini-cli.js';
|
|
4
4
|
import { parse as parseOpencode } from './opencode.js';
|
|
@@ -12,7 +12,6 @@ export const parsers = {
|
|
|
12
12
|
'openclaw': parseOpenclaw,
|
|
13
13
|
};
|
|
14
14
|
|
|
15
|
-
export const postSyncHooks = [commitClaudeCodeState];
|
|
16
15
|
|
|
17
16
|
export function roundToHalfHour(date) {
|
|
18
17
|
const d = new Date(date);
|
|
@@ -46,7 +45,7 @@ export function aggregateToBuckets(entries) {
|
|
|
46
45
|
b.outputTokens += e.outputTokens || 0;
|
|
47
46
|
b.cachedInputTokens += e.cachedInputTokens || 0;
|
|
48
47
|
b.reasoningOutputTokens += e.reasoningOutputTokens || 0;
|
|
49
|
-
b.totalTokens += (e.inputTokens || 0) + (e.outputTokens || 0);
|
|
48
|
+
b.totalTokens += (e.inputTokens || 0) + (e.outputTokens || 0) + (e.reasoningOutputTokens || 0);
|
|
50
49
|
}
|
|
51
50
|
|
|
52
51
|
return Array.from(map.values());
|
package/src/reset.js
CHANGED
|
@@ -1,14 +1,11 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
|
-
import { existsSync
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { homedir, hostname as getHostname } from 'node:os';
|
|
5
5
|
import { loadConfig, saveConfig } from './config.js';
|
|
6
6
|
import { deleteAllData } from './api.js';
|
|
7
7
|
import { runSync } from './sync.js';
|
|
8
8
|
|
|
9
|
-
const STATE_FILES = [
|
|
10
|
-
join(homedir(), '.vibe-usage', 'claude-code-state.json'),
|
|
11
|
-
];
|
|
12
9
|
|
|
13
10
|
function prompt(question) {
|
|
14
11
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -73,15 +70,9 @@ export async function runReset(args = []) {
|
|
|
73
70
|
}
|
|
74
71
|
}
|
|
75
72
|
|
|
76
|
-
// 2. Clear local state
|
|
73
|
+
// 2. Clear local state (legacy — no state files needed for current parsers)
|
|
77
74
|
config.lastSync = null;
|
|
78
75
|
saveConfig(config);
|
|
79
|
-
|
|
80
|
-
for (const stateFile of STATE_FILES) {
|
|
81
|
-
if (existsSync(stateFile)) {
|
|
82
|
-
unlinkSync(stateFile);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
76
|
console.log('Cleared local sync state.');
|
|
86
77
|
|
|
87
78
|
// 3. Re-upload everything
|
package/src/sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { hostname as osHostname } from 'node:os';
|
|
2
2
|
import { loadConfig, saveConfig } from './config.js';
|
|
3
3
|
import { ingest, fetchSettings } from './api.js';
|
|
4
|
-
import { parsers
|
|
4
|
+
import { parsers } from './parsers/index.js';
|
|
5
5
|
|
|
6
6
|
const BATCH_SIZE = 100;
|
|
7
7
|
|
|
@@ -81,16 +81,6 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
81
81
|
},
|
|
82
82
|
});
|
|
83
83
|
totalIngested += result.ingested ?? batch.length;
|
|
84
|
-
|
|
85
|
-
// State commit happens after ALL batches complete (see postSyncHooks below)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
// Commit parser state now that all data has been uploaded successfully.
|
|
90
|
-
// State is staged during parse() but only persisted here to prevent
|
|
91
|
-
// data loss if uploads fail (deltas would be re-computed on retry).
|
|
92
|
-
for (const hook of postSyncHooks) {
|
|
93
|
-
try { hook(); } catch { /* best effort */ }
|
|
94
84
|
}
|
|
95
85
|
|
|
96
86
|
if (totalBatches > 1 || allBuckets.length > 0) {
|