@vibe-cafe/vibe-usage 0.1.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/README.md ADDED
@@ -0,0 +1,49 @@
1
+ # vibe-usage
2
+
3
+ Track your AI coding tool token usage and sync to [vibecafe.ai](https://vibecafe.ai).
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ npx vibe-usage
9
+ ```
10
+
11
+ This will:
12
+ 1. Ask for your API key (get one at https://vibecafe.ai/usage/setup)
13
+ 2. Detect installed AI coding tools
14
+ 3. Install session-end hooks for automatic syncing
15
+ 4. Run an initial sync of your usage data
16
+
17
+ ## Commands
18
+
19
+ ```bash
20
+ npx vibe-usage # Init (first run) or sync (subsequent runs)
21
+ npx vibe-usage init # Re-run setup
22
+ npx vibe-usage sync # Manual sync
23
+ npx vibe-usage status # Show config & detected tools
24
+ ```
25
+
26
+ ## Supported Tools
27
+
28
+ | Tool | Auto-sync | Data Location |
29
+ |------|-----------|---------------|
30
+ | Claude Code | Yes (session hook) | `~/.claude/projects/` |
31
+ | Codex CLI | Yes (notify hook) | `~/.codex/sessions/` |
32
+ | Gemini CLI | Yes (session hook) | `~/.gemini/tmp/` |
33
+ | OpenCode | Manual only | `~/.local/share/opencode/` |
34
+ | OpenClaw | Manual only | `~/.openclaw/agents/` |
35
+
36
+ ## How It Works
37
+
38
+ - Parses local session logs from each AI coding tool
39
+ - Aggregates token usage into 30-minute buckets
40
+ - Uploads to your vibecafe.ai dashboard
41
+ - Only syncs new data since last sync (incremental)
42
+
43
+ ## Config
44
+
45
+ Config stored at `~/.vibe-usage/config.json`. Contains your API key and last sync timestamp.
46
+
47
+ ## License
48
+
49
+ MIT
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * vibe-usage CLI entry point.
5
+ * Routes to the appropriate command handler.
6
+ */
7
+
8
+ import { run } from '../src/index.js';
9
+
10
+ run(process.argv.slice(2));
package/package.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "@vibe-cafe/vibe-usage",
3
+ "version": "0.1.0",
4
+ "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
+ "type": "module",
6
+ "bin": {
7
+ "vibe-usage": "./bin/vibe-usage.js"
8
+ },
9
+ "files": ["bin/", "src/"],
10
+ "engines": { "node": ">=20" },
11
+ "keywords": ["ai", "coding", "usage", "tokens", "claude", "codex", "gemini"],
12
+ "dependencies": {
13
+ "ccusage": "18.0.5"
14
+ },
15
+ "license": "MIT"
16
+ }
package/src/api.js ADDED
@@ -0,0 +1,50 @@
1
+ import https from 'node:https';
2
+ import http from 'node:http';
3
+ import { URL } from 'node:url';
4
+
5
+ /**
6
+ * POST buckets to the vibecafe ingest API.
7
+ * Uses native http/https — zero dependencies.
8
+ * @param {string} apiUrl - Base URL (e.g. "https://vibecafe.ai")
9
+ * @param {string} apiKey - Bearer token (vbu_xxx)
10
+ * @param {Array} buckets - Array of usage bucket objects
11
+ * @returns {Promise<{ingested: number}>}
12
+ */
13
+ export function ingest(apiUrl, apiKey, buckets) {
14
+ return new Promise((resolve, reject) => {
15
+ const url = new URL('/api/usage/ingest', apiUrl);
16
+ const body = JSON.stringify({ buckets });
17
+ const mod = url.protocol === 'https:' ? https : http;
18
+
19
+ const req = mod.request(url, {
20
+ method: 'POST',
21
+ headers: {
22
+ 'Content-Type': 'application/json',
23
+ 'Authorization': `Bearer ${apiKey}`,
24
+ 'Content-Length': Buffer.byteLength(body),
25
+ },
26
+ }, (res) => {
27
+ let data = '';
28
+ res.on('data', (chunk) => { data += chunk; });
29
+ res.on('end', () => {
30
+ if (res.statusCode === 401) {
31
+ reject(new Error('UNAUTHORIZED'));
32
+ return;
33
+ }
34
+ if (res.statusCode < 200 || res.statusCode >= 300) {
35
+ reject(new Error(`HTTP ${res.statusCode}: ${data}`));
36
+ return;
37
+ }
38
+ try {
39
+ resolve(JSON.parse(data));
40
+ } catch {
41
+ reject(new Error(`Invalid JSON response: ${data}`));
42
+ }
43
+ });
44
+ });
45
+
46
+ req.on('error', (err) => reject(err));
47
+ req.write(body);
48
+ req.end();
49
+ });
50
+ }
package/src/config.js ADDED
@@ -0,0 +1,24 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ const CONFIG_DIR = join(homedir(), '.vibe-usage');
6
+ const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
7
+
8
+ export function getConfigPath() {
9
+ return CONFIG_FILE;
10
+ }
11
+
12
+ export function loadConfig() {
13
+ if (!existsSync(CONFIG_FILE)) return null;
14
+ try {
15
+ return JSON.parse(readFileSync(CONFIG_FILE, 'utf-8'));
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ export function saveConfig(config) {
22
+ mkdirSync(CONFIG_DIR, { recursive: true });
23
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', 'utf-8');
24
+ }
package/src/hooks.js ADDED
@@ -0,0 +1,116 @@
1
+ import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
2
+ import { join, dirname } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ const SYNC_CMD = 'npx vibe-usage sync 2>/dev/null &';
6
+
7
+ function hasVibeUsageHook(hooks) {
8
+ if (!Array.isArray(hooks)) return false;
9
+ return hooks.some(h => h.command && h.command.includes('vibe-usage'));
10
+ }
11
+
12
+ export function injectClaudeCode() {
13
+ const settingsPath = join(homedir(), '.claude', 'settings.json');
14
+ let settings = {};
15
+ if (existsSync(settingsPath)) {
16
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { settings = {}; }
17
+ } else {
18
+ mkdirSync(dirname(settingsPath), { recursive: true });
19
+ }
20
+
21
+ if (!settings.hooks) settings.hooks = {};
22
+ if (!settings.hooks.SessionEnd) settings.hooks.SessionEnd = [];
23
+
24
+ if (hasVibeUsageHook(settings.hooks.SessionEnd)) {
25
+ return { injected: false, reason: 'already installed' };
26
+ }
27
+
28
+ settings.hooks.SessionEnd.push({ type: 'command', command: SYNC_CMD });
29
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
30
+ return { injected: true };
31
+ }
32
+
33
+ export function injectCodex() {
34
+ const configPath = join(homedir(), '.codex', 'config.toml');
35
+ let content = '';
36
+ if (existsSync(configPath)) {
37
+ content = readFileSync(configPath, 'utf-8');
38
+ } else {
39
+ mkdirSync(dirname(configPath), { recursive: true });
40
+ }
41
+
42
+ if (content.includes('vibe-usage')) {
43
+ return { injected: false, reason: 'already installed' };
44
+ }
45
+
46
+ const notifySection = `\n[notify]\ncommand = "${SYNC_CMD}"\n`;
47
+ const notifyIdx = content.indexOf('[notify]');
48
+ if (notifyIdx !== -1) {
49
+ const nextSection = content.indexOf('\n[', notifyIdx + 1);
50
+ const sectionEnd = nextSection === -1 ? content.length : nextSection;
51
+ content = content.slice(0, notifyIdx) + `[notify]\ncommand = "${SYNC_CMD}"` + content.slice(sectionEnd);
52
+ } else {
53
+ content += notifySection;
54
+ }
55
+
56
+ writeFileSync(configPath, content, 'utf-8');
57
+ return { injected: true };
58
+ }
59
+
60
+ export function injectGeminiCli() {
61
+ const settingsPath = join(homedir(), '.gemini', 'settings.json');
62
+ let settings = {};
63
+ if (existsSync(settingsPath)) {
64
+ try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { settings = {}; }
65
+ } else {
66
+ mkdirSync(dirname(settingsPath), { recursive: true });
67
+ }
68
+
69
+ if (!settings.hooks) settings.hooks = {};
70
+ if (!settings.hooks.SessionEnd) settings.hooks.SessionEnd = [];
71
+
72
+ if (hasVibeUsageHook(settings.hooks.SessionEnd)) {
73
+ return { injected: false, reason: 'already installed' };
74
+ }
75
+
76
+ settings.hooks.SessionEnd.push({ type: 'command', command: SYNC_CMD });
77
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
78
+ return { injected: true };
79
+ }
80
+
81
+ export const TOOLS = [
82
+ {
83
+ name: 'Claude Code',
84
+ id: 'claude-code',
85
+ dataDir: join(homedir(), '.claude', 'projects'),
86
+ inject: injectClaudeCode,
87
+ },
88
+ {
89
+ name: 'Codex CLI',
90
+ id: 'codex',
91
+ dataDir: join(homedir(), '.codex', 'sessions'),
92
+ inject: injectCodex,
93
+ },
94
+ {
95
+ name: 'Gemini CLI',
96
+ id: 'gemini-cli',
97
+ dataDir: join(homedir(), '.gemini', 'tmp'),
98
+ inject: injectGeminiCli,
99
+ },
100
+ {
101
+ name: 'OpenCode',
102
+ id: 'opencode',
103
+ dataDir: join(homedir(), '.local', 'share', 'opencode'),
104
+ inject: null,
105
+ },
106
+ {
107
+ name: 'OpenClaw',
108
+ id: 'openclaw',
109
+ dataDir: join(homedir(), '.openclaw', 'agents'),
110
+ inject: null,
111
+ },
112
+ ];
113
+
114
+ export function detectInstalledTools() {
115
+ return TOOLS.filter(t => existsSync(t.dataDir));
116
+ }
package/src/index.js ADDED
@@ -0,0 +1,83 @@
1
+ import { loadConfig, getConfigPath } from './config.js';
2
+ import { detectInstalledTools, TOOLS } from './hooks.js';
3
+ import { existsSync } from 'node:fs';
4
+
5
+ async function showStatus() {
6
+ const config = loadConfig();
7
+ console.log('\nvibe-usage status\n');
8
+
9
+ if (!config?.apiKey) {
10
+ console.log(' Config: not configured');
11
+ console.log(` Run \`npx vibe-usage init\` to set up.\n`);
12
+ } else {
13
+ console.log(` Config: ${getConfigPath()}`);
14
+ console.log(` API key: ${config.apiKey.slice(0, 8)}...`);
15
+ console.log(` API URL: ${config.apiUrl || 'https://vibecafe.ai'}`);
16
+ console.log(` Last sync: ${config.lastSync || 'never'}`);
17
+ }
18
+
19
+ console.log('\n Detected tools:');
20
+ const detected = detectInstalledTools();
21
+ if (detected.length === 0) {
22
+ console.log(' (none)\n');
23
+ } else {
24
+ for (const tool of detected) {
25
+ const hookStatus = tool.inject ? 'auto-sync' : 'manual only';
26
+ console.log(` ${tool.name} (${hookStatus})`);
27
+ }
28
+ console.log();
29
+ }
30
+
31
+ console.log(' All supported tools:');
32
+ for (const tool of TOOLS) {
33
+ const installed = existsSync(tool.dataDir) ? 'installed' : 'not found';
34
+ console.log(` ${tool.name}: ${installed}`);
35
+ }
36
+ console.log();
37
+ }
38
+
39
+ export async function run(args) {
40
+ const command = args[0];
41
+
42
+ switch (command) {
43
+ case 'init': {
44
+ const { runInit } = await import('./init.js');
45
+ await runInit();
46
+ break;
47
+ }
48
+ case 'sync': {
49
+ const { runSync } = await import('./sync.js');
50
+ await runSync();
51
+ break;
52
+ }
53
+ case 'status': {
54
+ await showStatus();
55
+ break;
56
+ }
57
+ case 'help':
58
+ case '--help':
59
+ case '-h': {
60
+ console.log(`
61
+ vibe-usage - Vibe Usage Tracker by VibeCaf\u00e9
62
+
63
+ Usage:
64
+ npx vibe-usage Init (first run) or sync
65
+ npx vibe-usage init Set up API key and hooks
66
+ npx vibe-usage sync Manually sync usage data
67
+ npx vibe-usage status Show config and detected tools
68
+ npx vibe-usage help Show this help
69
+ `);
70
+ break;
71
+ }
72
+ default: {
73
+ const config = loadConfig();
74
+ if (!config?.apiKey) {
75
+ const { runInit } = await import('./init.js');
76
+ await runInit();
77
+ } else {
78
+ const { runSync } = await import('./sync.js');
79
+ await runSync();
80
+ }
81
+ }
82
+ }
83
+ }
package/src/init.js ADDED
@@ -0,0 +1,99 @@
1
+ import { createInterface } from 'node:readline';
2
+ import { exec } from 'node:child_process';
3
+ import { platform } from 'node:os';
4
+ import { existsSync } from 'node:fs';
5
+ import { loadConfig, saveConfig } from './config.js';
6
+ import { detectInstalledTools } from './hooks.js';
7
+ import { ingest } from './api.js';
8
+ import { runSync } from './sync.js';
9
+
10
+ function prompt(question) {
11
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
12
+ return new Promise((resolve) => {
13
+ rl.question(question, (answer) => {
14
+ rl.close();
15
+ resolve(answer.trim());
16
+ });
17
+ });
18
+ }
19
+
20
+ function openBrowser(url) {
21
+ const cmds = { darwin: 'open', linux: 'xdg-open', win32: 'start' };
22
+ const cmd = cmds[platform()] || cmds.linux;
23
+ exec(`${cmd} ${url}`, () => {});
24
+ }
25
+
26
+ export async function runInit() {
27
+ console.log('\n vibe-usage - Vibe Usage Tracker by VibeCaf\u00e9\n');
28
+
29
+ const existing = loadConfig();
30
+ if (existing?.apiKey) {
31
+ const answer = await prompt('Config already exists. Overwrite? (y/N) ');
32
+ if (answer.toLowerCase() !== 'y') {
33
+ console.log('Cancelled.');
34
+ return;
35
+ }
36
+ }
37
+
38
+ const apiUrl = process.env.VIBE_USAGE_API_URL || 'https://vibecafe.ai';
39
+ console.log(`Get your API key at: ${apiUrl}/usage/setup\n`);
40
+ openBrowser(`${apiUrl}/usage/setup`);
41
+
42
+ let apiKey;
43
+ while (true) {
44
+ apiKey = await prompt('Paste your API key: ');
45
+ if (apiKey.startsWith('vbu_')) break;
46
+ console.log('Invalid key — must start with "vbu_". Try again.');
47
+ }
48
+
49
+ console.log(`\nVerifying key ${apiKey.slice(0, 8)}...`);
50
+ try {
51
+ await ingest(apiUrl, apiKey, []);
52
+ console.log('Key verified.\n');
53
+ } catch (err) {
54
+ if (err.message === 'UNAUTHORIZED') {
55
+ console.error('Invalid API key. Please check and try again.');
56
+ process.exit(1);
57
+ }
58
+ console.log('Could not verify key (network error). Saving anyway.\n');
59
+ }
60
+
61
+ const config = {
62
+ apiKey,
63
+ apiUrl,
64
+ lastSync: null,
65
+ };
66
+ saveConfig(config);
67
+
68
+ const tools = detectInstalledTools();
69
+ const hooked = [];
70
+ const manualOnly = [];
71
+
72
+ for (const tool of tools) {
73
+ if (tool.inject) {
74
+ try {
75
+ const result = tool.inject();
76
+ hooked.push(tool.name + (result.injected ? '' : ' (already installed)'));
77
+ } catch (err) {
78
+ console.error(` warn: Failed to inject hook for ${tool.name}: ${err.message}`);
79
+ }
80
+ } else {
81
+ manualOnly.push(tool.name);
82
+ }
83
+ }
84
+
85
+ if (hooked.length > 0) {
86
+ console.log(`Hooks installed for: ${hooked.join(', ')}`);
87
+ }
88
+ for (const name of manualOnly) {
89
+ console.log(`${name} detected — use \`npx vibe-usage sync\` to sync manually.`);
90
+ }
91
+ if (tools.length === 0) {
92
+ console.log('No AI coding tools detected. Install one and re-run init.');
93
+ }
94
+
95
+ console.log('\nRunning initial sync...');
96
+ await runSync();
97
+
98
+ console.log('\nSetup complete! Usage data will sync automatically after each session.');
99
+ }
@@ -0,0 +1,34 @@
1
+ import { loadSessionData } from 'ccusage/data-loader';
2
+ import { aggregateToBuckets } from './index.js';
3
+
4
+ export async function parse(lastSync) {
5
+ let sessions;
6
+ try {
7
+ sessions = await loadSessionData({ mode: 'display' });
8
+ } catch {
9
+ return [];
10
+ }
11
+
12
+ if (!sessions || sessions.length === 0) return [];
13
+
14
+ const entries = [];
15
+
16
+ for (const session of sessions) {
17
+ if (lastSync && session.lastActivity <= lastSync) continue;
18
+
19
+ for (const breakdown of session.modelBreakdowns || []) {
20
+ entries.push({
21
+ source: 'claude-code',
22
+ model: breakdown.modelName,
23
+ project: session.projectPath || 'unknown',
24
+ timestamp: new Date(session.lastActivity),
25
+ inputTokens: breakdown.inputTokens,
26
+ outputTokens: breakdown.outputTokens,
27
+ cachedInputTokens: breakdown.cacheReadTokens,
28
+ reasoningOutputTokens: 0,
29
+ });
30
+ }
31
+ }
32
+
33
+ return aggregateToBuckets(entries);
34
+ }
@@ -0,0 +1,80 @@
1
+ import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { aggregateToBuckets } from './index.js';
5
+
6
+ const SESSIONS_DIR = join(homedir(), '.codex', 'sessions');
7
+
8
+ export async function parse(lastSync) {
9
+ if (!existsSync(SESSIONS_DIR)) return [];
10
+
11
+ const entries = [];
12
+ let files;
13
+ try {
14
+ files = readdirSync(SESSIONS_DIR).filter(f => f.endsWith('.jsonl'));
15
+ } catch {
16
+ return [];
17
+ }
18
+
19
+ for (const file of files) {
20
+ const filePath = join(SESSIONS_DIR, file);
21
+ if (lastSync) {
22
+ try {
23
+ const stat = statSync(filePath);
24
+ if (stat.mtime <= new Date(lastSync)) continue;
25
+ } catch {
26
+ continue;
27
+ }
28
+ }
29
+
30
+ const project = basename(file, '.jsonl');
31
+
32
+ let content;
33
+ try {
34
+ content = readFileSync(filePath, 'utf-8');
35
+ } catch {
36
+ continue;
37
+ }
38
+
39
+ for (const line of content.split('\n')) {
40
+ if (!line.trim()) continue;
41
+ try {
42
+ const obj = JSON.parse(line);
43
+
44
+
45
+ if (obj.type !== 'event_msg') continue;
46
+
47
+ const payload = obj.payload;
48
+ if (!payload || payload.type !== 'token_count') continue;
49
+
50
+ const info = payload.info;
51
+ if (!info) continue;
52
+
53
+ const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
54
+ if (!timestamp || isNaN(timestamp.getTime())) continue;
55
+ if (lastSync && timestamp <= new Date(lastSync)) continue;
56
+
57
+
58
+ const usage = info.last_token_usage || info.total_token_usage;
59
+ if (!usage) continue;
60
+
61
+ const model = info.model || payload.model || 'unknown';
62
+
63
+ entries.push({
64
+ source: 'codex',
65
+ model,
66
+ project,
67
+ timestamp,
68
+ inputTokens: usage.input_tokens || 0,
69
+ outputTokens: usage.output_tokens || 0,
70
+ cachedInputTokens: usage.cached_input_tokens || usage.cache_read_input_tokens || 0,
71
+ reasoningOutputTokens: usage.reasoning_output_tokens || 0,
72
+ });
73
+ } catch {
74
+ continue;
75
+ }
76
+ }
77
+ }
78
+
79
+ return aggregateToBuckets(entries);
80
+ }
@@ -0,0 +1,81 @@
1
+ import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { aggregateToBuckets } from './index.js';
5
+
6
+ const TMP_DIR = join(homedir(), '.gemini', 'tmp');
7
+
8
+ function findSessionFiles(baseDir) {
9
+ const results = [];
10
+ if (!existsSync(baseDir)) return results;
11
+
12
+ try {
13
+ for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
14
+ if (!entry.isDirectory()) continue;
15
+ const chatsDir = join(baseDir, entry.name, 'chats');
16
+ if (!existsSync(chatsDir)) continue;
17
+ try {
18
+ for (const f of readdirSync(chatsDir)) {
19
+ if (f.startsWith('session-') && f.endsWith('.json')) {
20
+ results.push(join(chatsDir, f));
21
+ }
22
+ }
23
+ } catch {
24
+ continue;
25
+ }
26
+ }
27
+ } catch {
28
+ return results;
29
+ }
30
+ return results;
31
+ }
32
+
33
+ export async function parse(lastSync) {
34
+ const sessionFiles = findSessionFiles(TMP_DIR);
35
+ if (sessionFiles.length === 0) return [];
36
+
37
+ const entries = [];
38
+
39
+ for (const filePath of sessionFiles) {
40
+ if (lastSync) {
41
+ try {
42
+ const stat = statSync(filePath);
43
+ if (stat.mtime <= new Date(lastSync)) continue;
44
+ } catch {
45
+ continue;
46
+ }
47
+ }
48
+
49
+ let data;
50
+ try {
51
+ data = JSON.parse(readFileSync(filePath, 'utf-8'));
52
+ } catch {
53
+ continue;
54
+ }
55
+
56
+ const messages = data.messages || data.history || [];
57
+ for (const msg of messages) {
58
+ const usage = msg.usage || msg.usageMetadata || msg.token_count;
59
+ if (!usage) continue;
60
+
61
+ const timestamp = msg.timestamp || msg.createTime || data.createTime;
62
+ if (!timestamp) continue;
63
+ const ts = new Date(timestamp);
64
+ if (isNaN(ts.getTime())) continue;
65
+ if (lastSync && ts <= new Date(lastSync)) continue;
66
+
67
+ entries.push({
68
+ source: 'gemini-cli',
69
+ model: msg.model || data.model || 'unknown',
70
+ project: 'unknown',
71
+ timestamp: ts,
72
+ inputTokens: usage.promptTokenCount || usage.input_tokens || 0,
73
+ outputTokens: usage.candidatesTokenCount || usage.output_tokens || 0,
74
+ cachedInputTokens: usage.cachedContentTokenCount || 0,
75
+ reasoningOutputTokens: usage.thoughtsTokenCount || 0,
76
+ });
77
+ }
78
+ }
79
+
80
+ return aggregateToBuckets(entries);
81
+ }
@@ -0,0 +1,51 @@
1
+ import { parse as parseClaudeCode } from './claude-code.js';
2
+ import { parse as parseCodex } from './codex.js';
3
+ import { parse as parseGeminiCli } from './gemini-cli.js';
4
+ import { parse as parseOpencode } from './opencode.js';
5
+ import { parse as parseOpenclaw } from './openclaw.js';
6
+
7
+ export const parsers = {
8
+ 'claude-code': parseClaudeCode,
9
+ 'codex': parseCodex,
10
+ 'gemini-cli': parseGeminiCli,
11
+ 'opencode': parseOpencode,
12
+ 'openclaw': parseOpenclaw,
13
+ };
14
+
15
+ export function roundToHalfHour(date) {
16
+ const d = new Date(date);
17
+ d.setMinutes(d.getMinutes() < 30 ? 0 : 30, 0, 0);
18
+ return d;
19
+ }
20
+
21
+ export function aggregateToBuckets(entries) {
22
+ const map = new Map();
23
+
24
+ for (const e of entries) {
25
+ const bucketStart = roundToHalfHour(e.timestamp).toISOString();
26
+ const key = `${e.source}|${e.model}|${e.project}|${bucketStart}`;
27
+
28
+ if (!map.has(key)) {
29
+ map.set(key, {
30
+ source: e.source,
31
+ model: e.model,
32
+ project: e.project,
33
+ bucketStart,
34
+ inputTokens: 0,
35
+ outputTokens: 0,
36
+ cachedInputTokens: 0,
37
+ reasoningOutputTokens: 0,
38
+ totalTokens: 0,
39
+ });
40
+ }
41
+
42
+ const b = map.get(key);
43
+ b.inputTokens += e.inputTokens || 0;
44
+ b.outputTokens += e.outputTokens || 0;
45
+ b.cachedInputTokens += e.cachedInputTokens || 0;
46
+ b.reasoningOutputTokens += e.reasoningOutputTokens || 0;
47
+ b.totalTokens += (e.inputTokens || 0) + (e.outputTokens || 0);
48
+ }
49
+
50
+ return Array.from(map.values());
51
+ }
@@ -0,0 +1,82 @@
1
+ import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { aggregateToBuckets } from './index.js';
5
+
6
+ const AGENTS_DIR = join(homedir(), '.openclaw', 'agents');
7
+
8
+ export async function parse(lastSync) {
9
+ if (!existsSync(AGENTS_DIR)) return [];
10
+
11
+ const entries = [];
12
+ let agentDirs;
13
+ try {
14
+ agentDirs = readdirSync(AGENTS_DIR, { withFileTypes: true })
15
+ .filter(d => d.isDirectory());
16
+ } catch {
17
+ return [];
18
+ }
19
+
20
+ for (const agentDir of agentDirs) {
21
+ const project = agentDir.name;
22
+ const sessionsDir = join(AGENTS_DIR, agentDir.name, 'sessions');
23
+ if (!existsSync(sessionsDir)) continue;
24
+
25
+ let files;
26
+ try {
27
+ files = readdirSync(sessionsDir).filter(f => f.endsWith('.jsonl'));
28
+ } catch {
29
+ continue;
30
+ }
31
+
32
+ for (const file of files) {
33
+ const filePath = join(sessionsDir, file);
34
+ if (lastSync) {
35
+ try {
36
+ const stat = statSync(filePath);
37
+ if (stat.mtime <= new Date(lastSync)) continue;
38
+ } catch {
39
+ continue;
40
+ }
41
+ }
42
+
43
+ let content;
44
+ try {
45
+ content = readFileSync(filePath, 'utf-8');
46
+ } catch {
47
+ continue;
48
+ }
49
+
50
+ for (const line of content.split('\n')) {
51
+ if (!line.trim()) continue;
52
+ try {
53
+ const obj = JSON.parse(line);
54
+
55
+ const usage = obj.usage || obj.message?.usage;
56
+ if (!usage) continue;
57
+
58
+ const timestamp = obj.timestamp || obj.created_at;
59
+ if (!timestamp) continue;
60
+ const ts = new Date(timestamp);
61
+ if (isNaN(ts.getTime())) continue;
62
+ if (lastSync && ts <= new Date(lastSync)) continue;
63
+
64
+ entries.push({
65
+ source: 'openclaw',
66
+ model: obj.model || obj.message?.model || 'unknown',
67
+ project,
68
+ timestamp: ts,
69
+ inputTokens: usage.input_tokens || 0,
70
+ outputTokens: usage.output_tokens || 0,
71
+ cachedInputTokens: usage.cache_read_input_tokens || 0,
72
+ reasoningOutputTokens: 0,
73
+ });
74
+ } catch {
75
+ continue;
76
+ }
77
+ }
78
+ }
79
+ }
80
+
81
+ return aggregateToBuckets(entries);
82
+ }
@@ -0,0 +1,78 @@
1
+ import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
2
+ import { join, basename } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+ import { aggregateToBuckets } from './index.js';
5
+
6
+ const DATA_DIR = join(homedir(), '.local', 'share', 'opencode');
7
+ const MESSAGES_DIR = join(DATA_DIR, 'storage', 'message');
8
+
9
+ export async function parse(lastSync) {
10
+ if (!existsSync(MESSAGES_DIR)) return [];
11
+
12
+ const entries = [];
13
+ let sessionDirs;
14
+ try {
15
+ sessionDirs = readdirSync(MESSAGES_DIR, { withFileTypes: true })
16
+ .filter(d => d.isDirectory() && d.name.startsWith('ses_'));
17
+ } catch {
18
+ return [];
19
+ }
20
+
21
+ for (const sessionDir of sessionDirs) {
22
+ const sessionPath = join(MESSAGES_DIR, sessionDir.name);
23
+ let msgFiles;
24
+ try {
25
+ msgFiles = readdirSync(sessionPath).filter(f => f.endsWith('.json'));
26
+ } catch {
27
+ continue;
28
+ }
29
+
30
+ for (const file of msgFiles) {
31
+ const filePath = join(sessionPath, file);
32
+ if (lastSync) {
33
+ try {
34
+ const stat = statSync(filePath);
35
+ if (stat.mtime <= new Date(lastSync)) continue;
36
+ } catch {
37
+ continue;
38
+ }
39
+ }
40
+
41
+ let data;
42
+ try {
43
+ data = JSON.parse(readFileSync(filePath, 'utf-8'));
44
+ } catch {
45
+ continue;
46
+ }
47
+
48
+
49
+ if (!data.modelID) continue;
50
+
51
+
52
+ const tokens = data.tokens;
53
+ if (!tokens) continue;
54
+ if (!tokens.input && !tokens.output) continue;
55
+
56
+ const timestamp = new Date(data.time?.created);
57
+ if (isNaN(timestamp.getTime())) continue;
58
+ if (lastSync && timestamp <= new Date(lastSync)) continue;
59
+
60
+
61
+ const rootPath = data.path?.root;
62
+ const project = rootPath ? basename(rootPath) : 'unknown';
63
+
64
+ entries.push({
65
+ source: 'opencode',
66
+ model: data.modelID || 'unknown',
67
+ project,
68
+ timestamp,
69
+ inputTokens: tokens.input || 0,
70
+ outputTokens: tokens.output || 0,
71
+ cachedInputTokens: tokens.cache?.read || 0,
72
+ reasoningOutputTokens: tokens.reasoning || 0,
73
+ });
74
+ }
75
+ }
76
+
77
+ return aggregateToBuckets(entries);
78
+ }
package/src/sync.js ADDED
@@ -0,0 +1,49 @@
1
+ import { loadConfig, saveConfig } from './config.js';
2
+ import { ingest } from './api.js';
3
+ import { parsers } from './parsers/index.js';
4
+
5
+ export async function runSync() {
6
+ const config = loadConfig();
7
+ if (!config?.apiKey) {
8
+ console.error('Not configured. Run `npx vibe-usage init` first.');
9
+ process.exit(1);
10
+ }
11
+
12
+ const lastSync = config.lastSync || null;
13
+ const allBuckets = [];
14
+
15
+ for (const [source, parse] of Object.entries(parsers)) {
16
+ try {
17
+ const buckets = await parse(lastSync);
18
+ if (buckets.length > 0) {
19
+ allBuckets.push(...buckets);
20
+ }
21
+ } catch (err) {
22
+ process.stderr.write(`warn: ${source} parser failed: ${err.message}\n`);
23
+ }
24
+ }
25
+
26
+ if (allBuckets.length === 0) {
27
+ console.log('No new usage data found.');
28
+ return 0;
29
+ }
30
+
31
+ try {
32
+ const result = await ingest(
33
+ config.apiUrl || 'https://vibecafe.ai',
34
+ config.apiKey,
35
+ allBuckets
36
+ );
37
+ config.lastSync = new Date().toISOString();
38
+ saveConfig(config);
39
+ console.log(`Synced ${result.ingested ?? allBuckets.length} buckets.`);
40
+ return result.ingested ?? allBuckets.length;
41
+ } catch (err) {
42
+ if (err.message === 'UNAUTHORIZED') {
43
+ console.error('Invalid API key. Run `npx vibe-usage init` to reconfigure.');
44
+ process.exit(1);
45
+ }
46
+ console.error(`Sync failed: ${err.message}`);
47
+ process.exit(1);
48
+ }
49
+ }