@vibe-cafe/vibe-usage 0.7.7 → 0.7.9
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 +1 -1
- package/src/api.js +13 -6
- package/src/parsers/hermes.js +102 -68
package/README.md
CHANGED
|
@@ -57,14 +57,14 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
57
57
|
| Kimi Code | `~/.kimi/sessions/` |
|
|
58
58
|
| Amp | `~/.local/share/amp/threads/` |
|
|
59
59
|
| Droid | `~/.factory/sessions/` |
|
|
60
|
-
| Hermes | `~/.hermes/state.db` (SQLite) |
|
|
60
|
+
| Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
|
|
61
61
|
|
|
62
62
|
## How It Works
|
|
63
63
|
|
|
64
64
|
- Parses local session logs from each AI coding tool
|
|
65
65
|
- Aggregates token usage into 30-minute buckets
|
|
66
66
|
- Extracts session metadata from all parsers: active time (AI generation time, excluding queue/TTFT wait), total duration, message counts
|
|
67
|
-
- Uploads buckets + sessions to your vibecafe.ai dashboard
|
|
67
|
+
- Uploads buckets + sessions to your vibecafe.ai dashboard (gzip-compressed when ≥ 1 KB, ~94% smaller)
|
|
68
68
|
- Stateless: computes full totals from local logs each sync (idempotent, no state files)
|
|
69
69
|
- For continuous syncing, use `npx @vibe-cafe/vibe-usage daemon` or the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app)
|
|
70
70
|
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import https from 'node:https';
|
|
2
2
|
import http from 'node:http';
|
|
3
3
|
import { URL } from 'node:url';
|
|
4
|
+
import { gzipSync } from 'node:zlib';
|
|
4
5
|
|
|
5
6
|
const MAX_RETRIES = 3;
|
|
6
7
|
const INITIAL_DELAY = 1000;
|
|
8
|
+
const GZIP_MIN_BYTES = 1024;
|
|
7
9
|
|
|
8
10
|
export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
|
|
9
11
|
let lastError;
|
|
@@ -30,18 +32,23 @@ function _send(apiUrl, apiKey, buckets, onProgress, sessions) {
|
|
|
30
32
|
const url = new URL('/api/usage/ingest', apiUrl);
|
|
31
33
|
const payload = { buckets };
|
|
32
34
|
if (sessions && sessions.length > 0) payload.sessions = sessions;
|
|
33
|
-
const
|
|
35
|
+
const raw = Buffer.from(JSON.stringify(payload));
|
|
36
|
+
const useGzip = raw.length >= GZIP_MIN_BYTES;
|
|
37
|
+
const body = useGzip ? gzipSync(raw) : raw;
|
|
34
38
|
const totalBytes = body.length;
|
|
35
39
|
const mod = url.protocol === 'https:' ? https : http;
|
|
36
40
|
|
|
41
|
+
const headers = {
|
|
42
|
+
'Content-Type': 'application/json',
|
|
43
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
44
|
+
'Content-Length': totalBytes,
|
|
45
|
+
};
|
|
46
|
+
if (useGzip) headers['Content-Encoding'] = 'gzip';
|
|
47
|
+
|
|
37
48
|
const req = mod.request(url, {
|
|
38
49
|
method: 'POST',
|
|
39
50
|
timeout: 60_000,
|
|
40
|
-
headers
|
|
41
|
-
'Content-Type': 'application/json',
|
|
42
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
43
|
-
'Content-Length': totalBytes,
|
|
44
|
-
},
|
|
51
|
+
headers,
|
|
45
52
|
}, (res) => {
|
|
46
53
|
let data = '';
|
|
47
54
|
res.on('data', (chunk) => { data += chunk; });
|
package/src/parsers/hermes.js
CHANGED
|
@@ -1,95 +1,129 @@
|
|
|
1
1
|
import { execFileSync } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
6
6
|
|
|
7
7
|
const HERMES_HOME = process.env.HERMES_HOME || join(homedir(), '.hermes');
|
|
8
|
-
const DB_PATH = join(HERMES_HOME, 'state.db');
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
|
-
* Parse Hermes Agent usage data from its SQLite
|
|
10
|
+
* Parse Hermes Agent usage data from its SQLite databases.
|
|
11
|
+
*
|
|
12
|
+
* Hermes supports multiple profiles — the default profile lives at
|
|
13
|
+
* ~/.hermes/state.db, while named profiles live at ~/.hermes/profiles/<name>/state.db.
|
|
14
|
+
* Each profile is an independent HERMES_HOME with its own state.db, so we scan all of them.
|
|
12
15
|
*
|
|
13
16
|
* Token buckets come from the sessions table (cumulative per-session totals).
|
|
14
17
|
* Session timing comes from the messages table (per-message role + timestamp).
|
|
15
18
|
*/
|
|
16
19
|
export async function parse() {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
let sessionRows;
|
|
20
|
-
try {
|
|
21
|
-
sessionRows = queryDb(`SELECT
|
|
22
|
-
id,
|
|
23
|
-
model,
|
|
24
|
-
started_at as startedAt,
|
|
25
|
-
input_tokens as inputTokens,
|
|
26
|
-
output_tokens as outputTokens,
|
|
27
|
-
cache_read_tokens as cacheReadTokens,
|
|
28
|
-
reasoning_tokens as reasoningTokens
|
|
29
|
-
FROM sessions
|
|
30
|
-
WHERE input_tokens > 0 OR output_tokens > 0`);
|
|
31
|
-
} catch (err) {
|
|
32
|
-
if (err.message && err.message.includes('ENOENT')) {
|
|
33
|
-
throw new Error('sqlite3 CLI not found. Install sqlite3 to sync Hermes data.');
|
|
34
|
-
}
|
|
35
|
-
throw err;
|
|
36
|
-
}
|
|
20
|
+
const dbs = discoverDbPaths(HERMES_HOME);
|
|
21
|
+
if (dbs.length === 0) return { buckets: [], sessions: [] };
|
|
37
22
|
|
|
38
23
|
const entries = [];
|
|
39
|
-
|
|
40
|
-
// started_at is a Unix timestamp (float)
|
|
41
|
-
const timestamp = new Date(row.startedAt * 1000);
|
|
42
|
-
if (isNaN(timestamp.getTime())) continue;
|
|
43
|
-
|
|
44
|
-
// Hermes stores input_tokens exclusive of cache (Anthropic-style semantics)
|
|
45
|
-
entries.push({
|
|
46
|
-
source: 'hermes',
|
|
47
|
-
model: row.model || 'unknown',
|
|
48
|
-
project: 'unknown',
|
|
49
|
-
timestamp,
|
|
50
|
-
inputTokens: row.inputTokens || 0,
|
|
51
|
-
outputTokens: row.outputTokens || 0,
|
|
52
|
-
cachedInputTokens: row.cacheReadTokens || 0,
|
|
53
|
-
reasoningOutputTokens: row.reasoningTokens || 0,
|
|
54
|
-
});
|
|
55
|
-
}
|
|
24
|
+
const sessionEvents = [];
|
|
56
25
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
26
|
+
for (const { path: dbPath, profile } of dbs) {
|
|
27
|
+
let sessionRows;
|
|
28
|
+
try {
|
|
29
|
+
sessionRows = queryDb(dbPath, `SELECT
|
|
30
|
+
id,
|
|
31
|
+
model,
|
|
32
|
+
started_at as startedAt,
|
|
33
|
+
input_tokens as inputTokens,
|
|
34
|
+
output_tokens as outputTokens,
|
|
35
|
+
cache_read_tokens as cacheReadTokens,
|
|
36
|
+
reasoning_tokens as reasoningTokens
|
|
37
|
+
FROM sessions
|
|
38
|
+
WHERE input_tokens > 0 OR output_tokens > 0`);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (err.message && err.message.includes('ENOENT')) {
|
|
41
|
+
throw new Error('sqlite3 CLI not found. Install sqlite3 to sync Hermes data.');
|
|
42
|
+
}
|
|
43
|
+
throw err;
|
|
44
|
+
}
|
|
71
45
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
46
|
+
for (const row of sessionRows) {
|
|
47
|
+
// started_at is a Unix timestamp (float)
|
|
48
|
+
const timestamp = new Date(row.startedAt * 1000);
|
|
49
|
+
if (isNaN(timestamp.getTime())) continue;
|
|
50
|
+
|
|
51
|
+
// Hermes stores input_tokens exclusive of cache (Anthropic-style semantics)
|
|
52
|
+
entries.push({
|
|
53
|
+
source: 'hermes',
|
|
54
|
+
model: row.model || 'unknown',
|
|
55
|
+
project: profile,
|
|
56
|
+
timestamp,
|
|
57
|
+
inputTokens: row.inputTokens || 0,
|
|
58
|
+
outputTokens: row.outputTokens || 0,
|
|
59
|
+
cachedInputTokens: row.cacheReadTokens || 0,
|
|
60
|
+
reasoningOutputTokens: row.reasoningTokens || 0,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let messageRows;
|
|
65
|
+
try {
|
|
66
|
+
messageRows = queryDb(dbPath, `SELECT
|
|
67
|
+
session_id as sessionId,
|
|
68
|
+
role,
|
|
69
|
+
timestamp
|
|
70
|
+
FROM messages
|
|
71
|
+
WHERE role IN ('user', 'assistant')
|
|
72
|
+
ORDER BY timestamp`);
|
|
73
|
+
} catch {
|
|
74
|
+
// Messages query failed for this profile — skip its session events
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const row of messageRows) {
|
|
79
|
+
const timestamp = new Date(row.timestamp * 1000);
|
|
80
|
+
if (isNaN(timestamp.getTime())) continue;
|
|
81
|
+
|
|
82
|
+
sessionEvents.push({
|
|
83
|
+
sessionId: row.sessionId,
|
|
84
|
+
source: 'hermes',
|
|
85
|
+
project: profile,
|
|
86
|
+
timestamp,
|
|
87
|
+
role: row.role === 'user' ? 'user' : 'assistant',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
84
90
|
}
|
|
85
91
|
|
|
86
92
|
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
87
93
|
}
|
|
88
94
|
|
|
89
|
-
function
|
|
95
|
+
function discoverDbPaths(home) {
|
|
96
|
+
const dbs = [];
|
|
97
|
+
|
|
98
|
+
const defaultDb = join(home, 'state.db');
|
|
99
|
+
if (existsSync(defaultDb)) dbs.push({ path: defaultDb, profile: 'default' });
|
|
100
|
+
|
|
101
|
+
const profilesDir = join(home, 'profiles');
|
|
102
|
+
if (existsSync(profilesDir)) {
|
|
103
|
+
let entries;
|
|
104
|
+
try {
|
|
105
|
+
entries = readdirSync(profilesDir, { withFileTypes: true });
|
|
106
|
+
} catch {
|
|
107
|
+
return dbs;
|
|
108
|
+
}
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
if (!entry.isDirectory()) continue;
|
|
111
|
+
const profileDb = join(profilesDir, entry.name, 'state.db');
|
|
112
|
+
try {
|
|
113
|
+
if (statSync(profileDb).isFile()) dbs.push({ path: profileDb, profile: entry.name });
|
|
114
|
+
} catch {
|
|
115
|
+
// missing or unreadable — skip
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return dbs;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function queryDb(dbPath, sql) {
|
|
90
124
|
const output = execFileSync('sqlite3', [
|
|
91
125
|
'-json',
|
|
92
|
-
|
|
126
|
+
dbPath,
|
|
93
127
|
sql,
|
|
94
128
|
], { encoding: 'utf-8', maxBuffer: 100 * 1024 * 1024, timeout: 30000 });
|
|
95
129
|
|