@vibe-cafe/vibe-usage 0.2.6 → 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/README.md +10 -9
- package/package.json +1 -1
- package/src/api.js +51 -0
- package/src/hooks.js +2 -148
- package/src/index.js +10 -5
- package/src/init.js +4 -26
- package/src/parsers/claude-code.js +1 -2
- package/src/parsers/codex.js +1 -10
- package/src/parsers/gemini-cli.js +1 -10
- package/src/parsers/openclaw.js +1 -10
- package/src/parsers/opencode.js +5 -19
- package/src/reset.js +92 -0
- package/src/sync.js +10 -50
package/README.md
CHANGED
|
@@ -11,8 +11,7 @@ npx vibe-usage
|
|
|
11
11
|
This will:
|
|
12
12
|
1. Ask for your API key (get one at https://vibecafe.ai/usage/setup)
|
|
13
13
|
2. Detect installed AI coding tools
|
|
14
|
-
3.
|
|
15
|
-
4. Run an initial sync of your usage data
|
|
14
|
+
3. Run an initial sync of your usage data
|
|
16
15
|
|
|
17
16
|
## Commands
|
|
18
17
|
|
|
@@ -20,18 +19,19 @@ This will:
|
|
|
20
19
|
npx vibe-usage # Init (first run) or sync (subsequent runs)
|
|
21
20
|
npx vibe-usage init # Re-run setup
|
|
22
21
|
npx vibe-usage sync # Manual sync
|
|
22
|
+
npx vibe-usage reset # Delete all data and re-upload from local logs
|
|
23
23
|
npx vibe-usage status # Show config & detected tools
|
|
24
24
|
```
|
|
25
25
|
|
|
26
26
|
## Supported Tools
|
|
27
27
|
|
|
28
|
-
| Tool |
|
|
29
|
-
|
|
30
|
-
| Claude Code |
|
|
31
|
-
| Codex CLI |
|
|
32
|
-
| Gemini CLI |
|
|
33
|
-
| OpenCode |
|
|
34
|
-
| OpenClaw |
|
|
28
|
+
| Tool | Data Location |
|
|
29
|
+
|------|---------------|
|
|
30
|
+
| Claude Code | `~/.claude/projects/` |
|
|
31
|
+
| Codex CLI | `~/.codex/sessions/` |
|
|
32
|
+
| Gemini CLI | `~/.gemini/tmp/` |
|
|
33
|
+
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite) |
|
|
34
|
+
| OpenClaw | `~/.openclaw/agents/` |
|
|
35
35
|
|
|
36
36
|
## How It Works
|
|
37
37
|
|
|
@@ -39,6 +39,7 @@ npx vibe-usage status # Show config & detected tools
|
|
|
39
39
|
- Aggregates token usage into 30-minute buckets
|
|
40
40
|
- Uploads to your vibecafe.ai dashboard
|
|
41
41
|
- Only syncs new data since last sync (incremental)
|
|
42
|
+
- For continuous syncing, use the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app) (auto-syncs every 5 minutes)
|
|
42
43
|
|
|
43
44
|
## Config
|
|
44
45
|
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -100,3 +100,54 @@ function _send(apiUrl, apiKey, buckets, onProgress) {
|
|
|
100
100
|
writeNext();
|
|
101
101
|
});
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* DELETE usage data for the authenticated user.
|
|
106
|
+
* @param {string} apiUrl
|
|
107
|
+
* @param {string} apiKey
|
|
108
|
+
* @param {{hostname?: string}} [opts]
|
|
109
|
+
* @returns {Promise<{deleted: number}>}
|
|
110
|
+
*/
|
|
111
|
+
export function deleteAllData(apiUrl, apiKey, opts) {
|
|
112
|
+
return new Promise((resolve, reject) => {
|
|
113
|
+
const url = new URL('/api/usage/ingest', apiUrl);
|
|
114
|
+
if (opts?.hostname) url.searchParams.set('hostname', opts.hostname);
|
|
115
|
+
const mod = url.protocol === 'https:' ? https : http;
|
|
116
|
+
|
|
117
|
+
const req = mod.request(url, {
|
|
118
|
+
method: 'DELETE',
|
|
119
|
+
timeout: 60_000,
|
|
120
|
+
headers: {
|
|
121
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
122
|
+
},
|
|
123
|
+
}, (res) => {
|
|
124
|
+
let data = '';
|
|
125
|
+
res.on('data', (chunk) => { data += chunk; });
|
|
126
|
+
res.on('end', () => {
|
|
127
|
+
if (res.statusCode === 401) {
|
|
128
|
+
reject(new Error('UNAUTHORIZED'));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
132
|
+
const err = new Error(`HTTP ${res.statusCode}: ${data}`);
|
|
133
|
+
err.statusCode = res.statusCode;
|
|
134
|
+
reject(err);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
try {
|
|
138
|
+
resolve(JSON.parse(data));
|
|
139
|
+
} catch {
|
|
140
|
+
reject(new Error(`Invalid JSON response: ${data}`));
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
req.on('error', (err) => reject(err));
|
|
146
|
+
req.on('timeout', () => {
|
|
147
|
+
req.destroy();
|
|
148
|
+
reject(new Error('Request timed out (60s)'));
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
req.end();
|
|
152
|
+
});
|
|
153
|
+
}
|
package/src/hooks.js
CHANGED
|
@@ -1,178 +1,32 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
|
|
5
|
-
const SYNC_CMD = 'npx @vibe-cafe/vibe-usage sync 2>/dev/null &';
|
|
6
|
-
|
|
7
|
-
/**
|
|
8
|
-
* Check if a SessionEnd hook array (new or old format) already contains a vibe-usage hook.
|
|
9
|
-
*/
|
|
10
|
-
function hasVibeUsageHook(hooks) {
|
|
11
|
-
if (!Array.isArray(hooks)) return false;
|
|
12
|
-
return hooks.some(entry => {
|
|
13
|
-
// New format: { matcher?: "...", hooks: [{ type, command }] }
|
|
14
|
-
if (Array.isArray(entry.hooks)) {
|
|
15
|
-
return entry.hooks.some(h => h.command && h.command.includes('vibe-usage'));
|
|
16
|
-
}
|
|
17
|
-
// Old format: { type, command } directly
|
|
18
|
-
if (entry.command && entry.command.includes('vibe-usage')) return true;
|
|
19
|
-
return false;
|
|
20
|
-
});
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Migrate old-format hook entries to the new matcher format.
|
|
25
|
-
* Old: [{ type: "command", command: "..." }]
|
|
26
|
-
* New: [{ hooks: [{ type: "command", command: "..." }] }]
|
|
27
|
-
*/
|
|
28
|
-
function migrateOldFormatHooks(hooks) {
|
|
29
|
-
if (!Array.isArray(hooks)) return hooks;
|
|
30
|
-
return hooks.map(entry => {
|
|
31
|
-
// Already new format (has "hooks" array)
|
|
32
|
-
if (Array.isArray(entry.hooks)) return entry;
|
|
33
|
-
// Old format: bare handler → wrap in matcher group
|
|
34
|
-
if (entry.type && entry.command) {
|
|
35
|
-
return { hooks: [entry] };
|
|
36
|
-
}
|
|
37
|
-
return entry;
|
|
38
|
-
});
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function injectClaudeCode() {
|
|
42
|
-
const settingsPath = join(homedir(), '.claude', 'settings.json');
|
|
43
|
-
let settings = {};
|
|
44
|
-
if (existsSync(settingsPath)) {
|
|
45
|
-
try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { settings = {}; }
|
|
46
|
-
} else {
|
|
47
|
-
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (!settings.hooks) settings.hooks = {};
|
|
51
|
-
if (!settings.hooks.SessionEnd) settings.hooks.SessionEnd = [];
|
|
52
|
-
|
|
53
|
-
// Migrate any old-format hooks first
|
|
54
|
-
settings.hooks.SessionEnd = migrateOldFormatHooks(settings.hooks.SessionEnd);
|
|
55
|
-
|
|
56
|
-
if (hasVibeUsageHook(settings.hooks.SessionEnd)) {
|
|
57
|
-
// Update the command in existing hook to use latest
|
|
58
|
-
for (const group of settings.hooks.SessionEnd) {
|
|
59
|
-
if (Array.isArray(group.hooks)) {
|
|
60
|
-
for (const h of group.hooks) {
|
|
61
|
-
if (h.command && h.command.includes('vibe-usage')) {
|
|
62
|
-
h.command = SYNC_CMD;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
68
|
-
return { injected: false, reason: 'already installed (updated)' };
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// New format: matcher group with hooks array
|
|
72
|
-
settings.hooks.SessionEnd.push({
|
|
73
|
-
hooks: [{ type: 'command', command: SYNC_CMD }],
|
|
74
|
-
});
|
|
75
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
76
|
-
return { injected: true };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function injectCodex() {
|
|
80
|
-
const configPath = join(homedir(), '.codex', 'config.toml');
|
|
81
|
-
let content = '';
|
|
82
|
-
if (existsSync(configPath)) {
|
|
83
|
-
content = readFileSync(configPath, 'utf-8');
|
|
84
|
-
} else {
|
|
85
|
-
mkdirSync(dirname(configPath), { recursive: true });
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
const notifyLine = `notify = "sh -c \\"${SYNC_CMD}\\""`;
|
|
89
|
-
|
|
90
|
-
if (content.includes('vibe-usage')) {
|
|
91
|
-
// Migrate broken [[notify]] / [notify] table format and array format from previous versions
|
|
92
|
-
// to correct string format: notify = "sh -c \"...\""
|
|
93
|
-
content = content.replace(
|
|
94
|
-
/^\[\[?notify\]\]?\n(?:command\s*=\s*["'][^"']*["']\n?)?/gm,
|
|
95
|
-
notifyLine + '\n',
|
|
96
|
-
);
|
|
97
|
-
// Migrate array format: notify = ["sh", "-c", "..."]
|
|
98
|
-
content = content.replace(
|
|
99
|
-
/^notify\s*=\s*\[.*vibe-usage.*\]$/gm,
|
|
100
|
-
notifyLine,
|
|
101
|
-
);
|
|
102
|
-
// Update existing string format notify = "..." to use latest command
|
|
103
|
-
content = content.replace(
|
|
104
|
-
/^notify\s*=\s*".*vibe-usage.*"$/gm,
|
|
105
|
-
notifyLine,
|
|
106
|
-
);
|
|
107
|
-
writeFileSync(configPath, content, 'utf-8');
|
|
108
|
-
return { injected: false, reason: 'already installed (updated)' };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// Check if any notify line already exists
|
|
112
|
-
const hasNotify = /^notify\s*=/m.test(content);
|
|
113
|
-
if (hasNotify) {
|
|
114
|
-
// Replace existing notify value
|
|
115
|
-
content = content.replace(/^notify\s*=\s*.+$/gm, notifyLine);
|
|
116
|
-
} else {
|
|
117
|
-
content += `\n${notifyLine}\n`;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
writeFileSync(configPath, content, 'utf-8');
|
|
121
|
-
return { injected: true };
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export function injectGeminiCli() {
|
|
125
|
-
const settingsPath = join(homedir(), '.gemini', 'settings.json');
|
|
126
|
-
let settings = {};
|
|
127
|
-
if (existsSync(settingsPath)) {
|
|
128
|
-
try { settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); } catch { settings = {}; }
|
|
129
|
-
} else {
|
|
130
|
-
mkdirSync(dirname(settingsPath), { recursive: true });
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
if (!settings.hooks) settings.hooks = {};
|
|
134
|
-
if (!settings.hooks.SessionEnd) settings.hooks.SessionEnd = [];
|
|
135
|
-
|
|
136
|
-
if (hasVibeUsageHook(settings.hooks.SessionEnd)) {
|
|
137
|
-
return { injected: false, reason: 'already installed' };
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
// Gemini CLI still uses the flat format (no matcher groups)
|
|
141
|
-
settings.hooks.SessionEnd.push({ type: 'command', command: SYNC_CMD });
|
|
142
|
-
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
143
|
-
return { injected: true };
|
|
144
|
-
}
|
|
145
|
-
|
|
146
5
|
export const TOOLS = [
|
|
147
6
|
{
|
|
148
7
|
name: 'Claude Code',
|
|
149
8
|
id: 'claude-code',
|
|
150
9
|
dataDir: join(homedir(), '.claude', 'projects'),
|
|
151
|
-
inject: injectClaudeCode,
|
|
152
10
|
},
|
|
153
11
|
{
|
|
154
12
|
name: 'Codex CLI',
|
|
155
13
|
id: 'codex',
|
|
156
14
|
dataDir: join(homedir(), '.codex', 'sessions'),
|
|
157
|
-
inject: injectCodex,
|
|
158
15
|
},
|
|
159
16
|
{
|
|
160
17
|
name: 'Gemini CLI',
|
|
161
18
|
id: 'gemini-cli',
|
|
162
19
|
dataDir: join(homedir(), '.gemini', 'tmp'),
|
|
163
|
-
inject: injectGeminiCli,
|
|
164
20
|
},
|
|
165
21
|
{
|
|
166
22
|
name: 'OpenCode',
|
|
167
23
|
id: 'opencode',
|
|
168
24
|
dataDir: join(homedir(), '.local', 'share', 'opencode'),
|
|
169
|
-
inject: null,
|
|
170
25
|
},
|
|
171
26
|
{
|
|
172
27
|
name: 'OpenClaw',
|
|
173
28
|
id: 'openclaw',
|
|
174
29
|
dataDir: join(homedir(), '.openclaw', 'agents'),
|
|
175
|
-
inject: null,
|
|
176
30
|
},
|
|
177
31
|
];
|
|
178
32
|
|
package/src/index.js
CHANGED
|
@@ -13,7 +13,6 @@ async function showStatus() {
|
|
|
13
13
|
console.log(` Config: ${getConfigPath()}`);
|
|
14
14
|
console.log(` API key: ${config.apiKey.slice(0, 8)}...`);
|
|
15
15
|
console.log(` API URL: ${config.apiUrl || 'https://vibecafe.ai'}`);
|
|
16
|
-
console.log(` Last sync: ${config.lastSync || 'never'}`);
|
|
17
16
|
}
|
|
18
17
|
|
|
19
18
|
console.log('\n Detected tools:');
|
|
@@ -22,8 +21,7 @@ async function showStatus() {
|
|
|
22
21
|
console.log(' (none)\n');
|
|
23
22
|
} else {
|
|
24
23
|
for (const tool of detected) {
|
|
25
|
-
|
|
26
|
-
console.log(` ${tool.name} (${hookStatus})`);
|
|
24
|
+
console.log(` ${tool.name}`);
|
|
27
25
|
}
|
|
28
26
|
console.log();
|
|
29
27
|
}
|
|
@@ -36,7 +34,7 @@ async function showStatus() {
|
|
|
36
34
|
console.log();
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
const VALID_CONFIG_KEYS = ['apiKey', 'apiUrl'
|
|
37
|
+
const VALID_CONFIG_KEYS = ['apiKey', 'apiUrl'];
|
|
40
38
|
|
|
41
39
|
function handleConfig(args) {
|
|
42
40
|
const sub = args[0];
|
|
@@ -104,6 +102,11 @@ export async function run(args) {
|
|
|
104
102
|
await runSync();
|
|
105
103
|
break;
|
|
106
104
|
}
|
|
105
|
+
case 'reset': {
|
|
106
|
+
const { runReset } = await import('./reset.js');
|
|
107
|
+
await runReset(args.slice(1));
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
107
110
|
case 'config': {
|
|
108
111
|
handleConfig(args.slice(1));
|
|
109
112
|
break;
|
|
@@ -120,8 +123,10 @@ export async function run(args) {
|
|
|
120
123
|
|
|
121
124
|
Usage:
|
|
122
125
|
npx vibe-usage Init (first run) or sync
|
|
123
|
-
npx vibe-usage init Set up API key
|
|
126
|
+
npx vibe-usage init Set up API key
|
|
124
127
|
npx vibe-usage sync Manually sync usage data
|
|
128
|
+
npx vibe-usage reset Delete all data and re-upload
|
|
129
|
+
npx vibe-usage reset --host Delete data for this host only and re-upload
|
|
125
130
|
npx vibe-usage status Show config and detected tools
|
|
126
131
|
npx vibe-usage config show Show full config as JSON
|
|
127
132
|
npx vibe-usage config get <key> Get a config value
|
package/src/init.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import { createInterface } from 'node:readline';
|
|
2
2
|
import { execFile } from 'node:child_process';
|
|
3
3
|
import { platform } from 'node:os';
|
|
4
|
-
import { existsSync } from 'node:fs';
|
|
5
4
|
import { loadConfig, saveConfig } from './config.js';
|
|
6
|
-
import { detectInstalledTools } from './hooks.js';
|
|
7
5
|
import { ingest } from './api.js';
|
|
8
6
|
import { runSync } from './sync.js';
|
|
7
|
+
import { detectInstalledTools } from './hooks.js';
|
|
9
8
|
|
|
10
9
|
function prompt(question) {
|
|
11
10
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
@@ -62,34 +61,13 @@ export async function runInit() {
|
|
|
62
61
|
const config = {
|
|
63
62
|
apiKey,
|
|
64
63
|
apiUrl,
|
|
65
|
-
lastSync: existing?.lastSync || null,
|
|
66
64
|
};
|
|
67
65
|
saveConfig(config);
|
|
68
66
|
|
|
69
67
|
const tools = detectInstalledTools();
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
for (const tool of tools) {
|
|
74
|
-
if (tool.inject) {
|
|
75
|
-
try {
|
|
76
|
-
const result = tool.inject();
|
|
77
|
-
hooked.push(tool.name + (result.injected ? '' : ' (already installed)'));
|
|
78
|
-
} catch (err) {
|
|
79
|
-
console.error(` warn: Failed to inject hook for ${tool.name}: ${err.message}`);
|
|
80
|
-
}
|
|
81
|
-
} else {
|
|
82
|
-
manualOnly.push(tool.name);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (hooked.length > 0) {
|
|
87
|
-
console.log(`Hooks installed for: ${hooked.join(', ')}`);
|
|
88
|
-
}
|
|
89
|
-
for (const name of manualOnly) {
|
|
90
|
-
console.log(`${name} detected — use \`npx @vibe-cafe/vibe-usage sync\` to sync manually.`);
|
|
91
|
-
}
|
|
92
|
-
if (tools.length === 0) {
|
|
68
|
+
if (tools.length > 0) {
|
|
69
|
+
console.log(`Detected tools: ${tools.map(t => t.name).join(', ')}`);
|
|
70
|
+
} else {
|
|
93
71
|
console.log('No AI coding tools detected. Install one and re-run init.');
|
|
94
72
|
}
|
|
95
73
|
|
|
@@ -35,7 +35,7 @@ export function commitState() {
|
|
|
35
35
|
}
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
export async function parse(
|
|
38
|
+
export async function parse() {
|
|
39
39
|
let sessions;
|
|
40
40
|
try {
|
|
41
41
|
sessions = await loadSessionData({ mode: 'display' });
|
|
@@ -50,7 +50,6 @@ export async function parse(lastSync) {
|
|
|
50
50
|
const entries = [];
|
|
51
51
|
|
|
52
52
|
for (const session of sessions) {
|
|
53
|
-
if (lastSync && new Date(session.lastActivity) <= new Date(lastSync)) continue;
|
|
54
53
|
|
|
55
54
|
const project = resolveProject(session);
|
|
56
55
|
const sessionKey = `${session.projectPath}\0${session.sessionId}`;
|
package/src/parsers/codex.js
CHANGED
|
@@ -27,21 +27,13 @@ function findJsonlFiles(dir) {
|
|
|
27
27
|
return results;
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
export async function parse(
|
|
30
|
+
export async function parse() {
|
|
31
31
|
if (!existsSync(SESSIONS_DIR)) return [];
|
|
32
32
|
|
|
33
33
|
const entries = [];
|
|
34
34
|
const files = findJsonlFiles(SESSIONS_DIR);
|
|
35
35
|
if (files.length === 0) return [];
|
|
36
36
|
for (const filePath of files) {
|
|
37
|
-
if (lastSync) {
|
|
38
|
-
try {
|
|
39
|
-
const stat = statSync(filePath);
|
|
40
|
-
if (stat.mtime <= new Date(lastSync)) continue;
|
|
41
|
-
} catch {
|
|
42
|
-
continue;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
37
|
|
|
46
38
|
let content;
|
|
47
39
|
try {
|
|
@@ -99,7 +91,6 @@ export async function parse(lastSync) {
|
|
|
99
91
|
|
|
100
92
|
const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
|
|
101
93
|
if (!timestamp || isNaN(timestamp.getTime())) continue;
|
|
102
|
-
if (lastSync && timestamp <= new Date(lastSync)) continue;
|
|
103
94
|
|
|
104
95
|
// Prefer incremental per-request usage; compute delta from cumulative total as fallback
|
|
105
96
|
let usage = info.last_token_usage;
|
|
@@ -30,21 +30,13 @@ function findSessionFiles(baseDir) {
|
|
|
30
30
|
return results;
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
export async function parse(
|
|
33
|
+
export async function parse() {
|
|
34
34
|
const sessionFiles = findSessionFiles(TMP_DIR);
|
|
35
35
|
if (sessionFiles.length === 0) return [];
|
|
36
36
|
|
|
37
37
|
const entries = [];
|
|
38
38
|
|
|
39
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
40
|
|
|
49
41
|
let data;
|
|
50
42
|
try {
|
|
@@ -65,7 +57,6 @@ export async function parse(lastSync) {
|
|
|
65
57
|
if (!timestamp) continue;
|
|
66
58
|
const ts = new Date(timestamp);
|
|
67
59
|
if (isNaN(ts.getTime())) continue;
|
|
68
|
-
if (lastSync && ts <= new Date(lastSync)) continue;
|
|
69
60
|
|
|
70
61
|
if (tokens) {
|
|
71
62
|
// New format: { input, output, cached, thoughts, tool, total }
|
package/src/parsers/openclaw.js
CHANGED
|
@@ -20,7 +20,7 @@ function getTokens(usage, ...keys) {
|
|
|
20
20
|
return 0;
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
export async function parse(
|
|
23
|
+
export async function parse() {
|
|
24
24
|
const entries = [];
|
|
25
25
|
|
|
26
26
|
for (const root of POSSIBLE_ROOTS) {
|
|
@@ -49,14 +49,6 @@ export async function parse(lastSync) {
|
|
|
49
49
|
|
|
50
50
|
for (const file of files) {
|
|
51
51
|
const filePath = join(sessionsDir, file);
|
|
52
|
-
if (lastSync) {
|
|
53
|
-
try {
|
|
54
|
-
const stat = statSync(filePath);
|
|
55
|
-
if (stat.mtime <= new Date(lastSync)) continue;
|
|
56
|
-
} catch {
|
|
57
|
-
continue;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
52
|
|
|
61
53
|
let content;
|
|
62
54
|
try {
|
|
@@ -82,7 +74,6 @@ export async function parse(lastSync) {
|
|
|
82
74
|
if (!timestamp) continue;
|
|
83
75
|
const ts = new Date(typeof timestamp === 'number' ? timestamp : timestamp);
|
|
84
76
|
if (isNaN(ts.getTime())) continue;
|
|
85
|
-
if (lastSync && ts <= new Date(lastSync)) continue;
|
|
86
77
|
|
|
87
78
|
entries.push({
|
|
88
79
|
source: 'openclaw',
|
package/src/parsers/opencode.js
CHANGED
|
@@ -12,26 +12,22 @@ const MESSAGES_DIR = join(DATA_DIR, 'storage', 'message');
|
|
|
12
12
|
* Parse opencode usage data.
|
|
13
13
|
* Tries SQLite database first (opencode >= v0.2), falls back to legacy JSON files.
|
|
14
14
|
*/
|
|
15
|
-
export async function parse(
|
|
15
|
+
export async function parse() {
|
|
16
16
|
if (existsSync(DB_PATH)) {
|
|
17
17
|
try {
|
|
18
|
-
return parseFromSqlite(
|
|
18
|
+
return parseFromSqlite();
|
|
19
19
|
} catch (err) {
|
|
20
20
|
process.stderr.write(`warn: opencode sqlite parse failed (${err.message}), trying legacy json...\n`);
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
return parseFromJson(
|
|
23
|
+
return parseFromJson();
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
-
function parseFromSqlite(
|
|
26
|
+
function parseFromSqlite() {
|
|
27
27
|
// Build WHERE clause: only messages with token data
|
|
28
28
|
const conditions = [
|
|
29
29
|
"(json_extract(data, '$.tokens.input') > 0 OR json_extract(data, '$.tokens.output') > 0)",
|
|
30
30
|
];
|
|
31
|
-
if (lastSync) {
|
|
32
|
-
const sinceMs = new Date(lastSync).getTime();
|
|
33
|
-
conditions.push(`time_created > ${sinceMs}`);
|
|
34
|
-
}
|
|
35
31
|
|
|
36
32
|
const query = `SELECT data FROM message WHERE ${conditions.join(' AND ')}`;
|
|
37
33
|
|
|
@@ -76,7 +72,6 @@ function parseFromSqlite(lastSync) {
|
|
|
76
72
|
|
|
77
73
|
const timestamp = new Date(data.time?.created);
|
|
78
74
|
if (isNaN(timestamp.getTime())) continue;
|
|
79
|
-
if (lastSync && timestamp <= new Date(lastSync)) continue;
|
|
80
75
|
|
|
81
76
|
const rootPath = data.path?.root;
|
|
82
77
|
const project = rootPath ? basename(rootPath) : 'unknown';
|
|
@@ -97,7 +92,7 @@ function parseFromSqlite(lastSync) {
|
|
|
97
92
|
}
|
|
98
93
|
|
|
99
94
|
/** Legacy parser: reads JSON files from storage/message directories. */
|
|
100
|
-
function parseFromJson(
|
|
95
|
+
function parseFromJson() {
|
|
101
96
|
if (!existsSync(MESSAGES_DIR)) return [];
|
|
102
97
|
|
|
103
98
|
const entries = [];
|
|
@@ -120,14 +115,6 @@ function parseFromJson(lastSync) {
|
|
|
120
115
|
|
|
121
116
|
for (const file of msgFiles) {
|
|
122
117
|
const filePath = join(sessionPath, file);
|
|
123
|
-
if (lastSync) {
|
|
124
|
-
try {
|
|
125
|
-
const stat = statSync(filePath);
|
|
126
|
-
if (stat.mtime <= new Date(lastSync)) continue;
|
|
127
|
-
} catch {
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
118
|
|
|
132
119
|
let data;
|
|
133
120
|
try {
|
|
@@ -144,7 +131,6 @@ function parseFromJson(lastSync) {
|
|
|
144
131
|
|
|
145
132
|
const timestamp = new Date(data.time?.created);
|
|
146
133
|
if (isNaN(timestamp.getTime())) continue;
|
|
147
|
-
if (lastSync && timestamp <= new Date(lastSync)) continue;
|
|
148
134
|
|
|
149
135
|
const rootPath = data.path?.root;
|
|
150
136
|
const project = rootPath ? basename(rootPath) : 'unknown';
|
package/src/reset.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createInterface } from 'node:readline';
|
|
2
|
+
import { existsSync, unlinkSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { homedir, hostname as getHostname } from 'node:os';
|
|
5
|
+
import { loadConfig, saveConfig } from './config.js';
|
|
6
|
+
import { deleteAllData } from './api.js';
|
|
7
|
+
import { runSync } from './sync.js';
|
|
8
|
+
|
|
9
|
+
const STATE_FILES = [
|
|
10
|
+
join(homedir(), '.vibe-usage', 'claude-code-state.json'),
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
function prompt(question) {
|
|
14
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
rl.question(question, (answer) => {
|
|
17
|
+
rl.close();
|
|
18
|
+
resolve(answer.trim());
|
|
19
|
+
});
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runReset(args = []) {
|
|
24
|
+
const hostOnly = args.includes('--host');
|
|
25
|
+
const config = loadConfig();
|
|
26
|
+
if (!config?.apiKey) {
|
|
27
|
+
console.error('Not configured. Run `npx @vibe-cafe/vibe-usage init` first.');
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const currentHost = getHostname();
|
|
32
|
+
const apiUrl = config.apiUrl || 'https://vibecafe.ai';
|
|
33
|
+
|
|
34
|
+
if (hostOnly) {
|
|
35
|
+
const answer = await prompt(`This will delete usage data for this host (${currentHost}) and re-upload from local logs. Continue? (y/N) `);
|
|
36
|
+
if (answer.toLowerCase() !== 'y') {
|
|
37
|
+
console.log('Cancelled.');
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 1. Delete remote data for this host
|
|
42
|
+
console.log(`Deleting remote data for host: ${currentHost}...`);
|
|
43
|
+
try {
|
|
44
|
+
const result = await deleteAllData(apiUrl, config.apiKey, { hostname: currentHost });
|
|
45
|
+
console.log(`Deleted ${result.deleted} buckets from server.`);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
if (err.message === 'UNAUTHORIZED') {
|
|
48
|
+
console.error('Invalid API key. Run `npx @vibe-cafe/vibe-usage init` to reconfigure.');
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
console.error(`Failed to delete remote data: ${err.message}`);
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
} else {
|
|
55
|
+
const answer = await prompt('This will delete ALL your usage data and re-upload from local logs. Continue? (y/N) ');
|
|
56
|
+
if (answer.toLowerCase() !== 'y') {
|
|
57
|
+
console.log('Cancelled.');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 1. Delete all remote data
|
|
62
|
+
console.log('Deleting all remote data...');
|
|
63
|
+
try {
|
|
64
|
+
const result = await deleteAllData(apiUrl, config.apiKey);
|
|
65
|
+
console.log(`Deleted ${result.deleted} buckets from server.`);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
if (err.message === 'UNAUTHORIZED') {
|
|
68
|
+
console.error('Invalid API key. Run `npx @vibe-cafe/vibe-usage init` to reconfigure.');
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
console.error(`Failed to delete remote data: ${err.message}`);
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// 2. Clear local state
|
|
77
|
+
config.lastSync = null;
|
|
78
|
+
saveConfig(config);
|
|
79
|
+
|
|
80
|
+
for (const stateFile of STATE_FILES) {
|
|
81
|
+
if (existsSync(stateFile)) {
|
|
82
|
+
unlinkSync(stateFile);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
console.log('Cleared local sync state.');
|
|
86
|
+
|
|
87
|
+
// 3. Re-upload everything
|
|
88
|
+
console.log('\nRe-syncing all data...');
|
|
89
|
+
await runSync();
|
|
90
|
+
|
|
91
|
+
console.log(`\nReset complete! View your dashboard at: ${apiUrl}/usage`);
|
|
92
|
+
}
|
package/src/sync.js
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { hostname as osHostname } from 'node:os';
|
|
2
|
-
import { existsSync, readFileSync, unlinkSync } from 'node:fs';
|
|
3
|
-
import { join } from 'node:path';
|
|
4
|
-
import { homedir } from 'node:os';
|
|
5
2
|
import { loadConfig, saveConfig } from './config.js';
|
|
6
3
|
import { ingest } from './api.js';
|
|
7
4
|
import { parsers, postSyncHooks } from './parsers/index.js';
|
|
8
|
-
import { TOOLS } from './hooks.js';
|
|
9
5
|
|
|
10
6
|
const BATCH_SIZE = 100;
|
|
11
7
|
|
|
@@ -16,21 +12,23 @@ function formatBytes(bytes) {
|
|
|
16
12
|
}
|
|
17
13
|
|
|
18
14
|
export async function runSync() {
|
|
19
|
-
// Self-heal: re-inject any missing hooks before syncing
|
|
20
|
-
ensureHooks();
|
|
21
|
-
|
|
22
15
|
const config = loadConfig();
|
|
23
16
|
if (!config?.apiKey) {
|
|
24
17
|
console.error('Not configured. Run `npx @vibe-cafe/vibe-usage init` first.');
|
|
25
18
|
process.exit(1);
|
|
26
19
|
}
|
|
27
20
|
|
|
28
|
-
|
|
21
|
+
// Migration: remove deprecated lastSync field from config
|
|
22
|
+
if ('lastSync' in config) {
|
|
23
|
+
delete config.lastSync;
|
|
24
|
+
saveConfig(config);
|
|
25
|
+
}
|
|
26
|
+
|
|
29
27
|
const allBuckets = [];
|
|
30
28
|
|
|
31
29
|
for (const [source, parse] of Object.entries(parsers)) {
|
|
32
30
|
try {
|
|
33
|
-
const buckets = await parse(
|
|
31
|
+
const buckets = await parse();
|
|
34
32
|
if (buckets.length > 0) {
|
|
35
33
|
allBuckets.push(...buckets);
|
|
36
34
|
}
|
|
@@ -65,14 +63,12 @@ export async function runSync() {
|
|
|
65
63
|
const result = await ingest(apiUrl, config.apiKey, batch, {
|
|
66
64
|
onProgress(sent, total) {
|
|
67
65
|
const pct = Math.round((sent / total) * 100);
|
|
68
|
-
process.stdout.write(
|
|
66
|
+
process.stdout.write(`\r${prefix}${formatBytes(sent)}/${formatBytes(total)} (${pct}%)\x1b[K`);
|
|
69
67
|
},
|
|
70
68
|
});
|
|
71
69
|
totalIngested += result.ingested ?? batch.length;
|
|
72
70
|
|
|
73
|
-
//
|
|
74
|
-
config.lastSync = new Date().toISOString();
|
|
75
|
-
saveConfig(config);
|
|
71
|
+
// State commit happens after ALL batches complete (see postSyncHooks below)
|
|
76
72
|
}
|
|
77
73
|
|
|
78
74
|
|
|
@@ -93,7 +89,7 @@ export async function runSync() {
|
|
|
93
89
|
console.error('Invalid API key. Run `npx @vibe-cafe/vibe-usage init` to reconfigure.');
|
|
94
90
|
process.exit(1);
|
|
95
91
|
}
|
|
96
|
-
//
|
|
92
|
+
// Report partial success
|
|
97
93
|
if (totalIngested > 0) {
|
|
98
94
|
console.error(`Sync partially completed (${totalIngested} buckets uploaded). ${err.message}`);
|
|
99
95
|
} else {
|
|
@@ -102,39 +98,3 @@ export async function runSync() {
|
|
|
102
98
|
process.exit(1);
|
|
103
99
|
}
|
|
104
100
|
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Re-inject hooks for any installed tool whose hook is missing.
|
|
108
|
-
* Runs silently — meant as a self-healing side effect of sync.
|
|
109
|
-
*/
|
|
110
|
-
function ensureHooks() {
|
|
111
|
-
// Skip hook injection if Vibe Usage Mac app is running
|
|
112
|
-
const markerPath = join(homedir(), '.vibe-usage', 'mac-app-active');
|
|
113
|
-
if (existsSync(markerPath)) {
|
|
114
|
-
try {
|
|
115
|
-
const marker = JSON.parse(readFileSync(markerPath, 'utf-8'));
|
|
116
|
-
if (marker.pid) {
|
|
117
|
-
try {
|
|
118
|
-
process.kill(marker.pid, 0);
|
|
119
|
-
return;
|
|
120
|
-
} catch {
|
|
121
|
-
try { unlinkSync(markerPath); } catch { /* ignore */ }
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
} catch {
|
|
125
|
-
// Malformed marker file — ignore
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
for (const tool of TOOLS) {
|
|
130
|
-
if (!tool.inject) continue;
|
|
131
|
-
try {
|
|
132
|
-
const result = tool.inject();
|
|
133
|
-
if (result.injected) {
|
|
134
|
-
process.stderr.write(`hook: re-installed ${tool.name} hook\n`);
|
|
135
|
-
}
|
|
136
|
-
} catch {
|
|
137
|
-
// ignore — best effort
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|