@vibe-cafe/vibe-usage 0.7.18 → 0.8.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 +25 -3
- package/package.json +1 -1
- package/src/api.js +4 -1
- package/src/parsers/codex.js +21 -4
- package/src/state.js +0 -0
- package/src/sync.js +82 -8
- package/src/tools.js +11 -0
package/README.md
CHANGED
|
@@ -47,7 +47,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
47
47
|
| Tool | Data Location |
|
|
48
48
|
|------|---------------|
|
|
49
49
|
| Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only) |
|
|
50
|
-
| Codex CLI | `~/.codex/sessions/` |
|
|
50
|
+
| Codex CLI | `~/.codex/sessions/` and `~/.codex/archived_sessions/` |
|
|
51
51
|
| GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
|
|
52
52
|
| Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`) |
|
|
53
53
|
| Gemini CLI | `~/.gemini/tmp/` |
|
|
@@ -68,11 +68,33 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
68
68
|
- Parses local session logs from each AI coding tool
|
|
69
69
|
- Aggregates token usage into 30-minute buckets
|
|
70
70
|
- Extracts session metadata from all parsers: active time (AI generation time, excluding queue/TTFT wait), total duration, message counts
|
|
71
|
-
- Uploads buckets + sessions to your vibecafe.ai dashboard (gzip-compressed
|
|
72
|
-
-
|
|
71
|
+
- Uploads buckets + sessions to your vibecafe.ai dashboard (always gzip-compressed, ~94% smaller)
|
|
72
|
+
- Incremental: parsers still compute full totals from local logs each sync (idempotent), but only buckets/sessions that are new or changed since the last successful upload are sent — a quiet machine uploads nothing. Sync state is kept in `~/.vibe-usage/state.json`; deleting it just triggers a one-time full re-upload
|
|
73
73
|
- 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
|
|
74
74
|
- For continuous syncing, use `npx @vibe-cafe/vibe-usage daemon` or the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app)
|
|
75
75
|
|
|
76
|
+
## Trust Model
|
|
77
|
+
|
|
78
|
+
vibe-usage parses **local tool logs and local application state** on a machine the user fully controls. The reported data is self-reported telemetry — local logs, parsers, and upload requests can all be modified by the user.
|
|
79
|
+
|
|
80
|
+
**Good for visibility, not sufficient for settlement.**
|
|
81
|
+
|
|
82
|
+
Suitable for:
|
|
83
|
+
|
|
84
|
+
- personal analytics and efficiency review
|
|
85
|
+
- team-internal AI coding adoption visibility
|
|
86
|
+
- token usage trends across tools, models, and projects
|
|
87
|
+
- rough cost estimation and anomaly detection
|
|
88
|
+
|
|
89
|
+
Not sufficient for:
|
|
90
|
+
|
|
91
|
+
- financial settlement or team expense reimbursement
|
|
92
|
+
- user rewards, credits, token, or airdrop allocation
|
|
93
|
+
- agent contribution scoring or marketplace revenue sharing
|
|
94
|
+
- proof-of-work / proof-of-usage or contractual billing
|
|
95
|
+
|
|
96
|
+
In short: this solves the *visibility* problem, not the *verifiability* problem. High-trust use cases need additional, independently verifiable metering layers.
|
|
97
|
+
|
|
76
98
|
## AI Skill
|
|
77
99
|
|
|
78
100
|
Install vibe-usage as a skill for your AI coding assistant, so it knows how to sync usage data on your behalf:
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -5,7 +5,10 @@ import { gzipSync } from 'node:zlib';
|
|
|
5
5
|
|
|
6
6
|
const MAX_RETRIES = 3;
|
|
7
7
|
const INITIAL_DELAY = 1000;
|
|
8
|
-
|
|
8
|
+
// Always gzip: ingest bodies are repetitive JSON that compresses ~10:1, and
|
|
9
|
+
// the few-byte gzip header overhead on a tiny body is irrelevant next to
|
|
10
|
+
// guaranteeing no uncompressed request ever leaves the client.
|
|
11
|
+
const GZIP_MIN_BYTES = 0;
|
|
9
12
|
|
|
10
13
|
export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
|
|
11
14
|
let lastError;
|
package/src/parsers/codex.js
CHANGED
|
@@ -4,7 +4,18 @@ import { homedir } from 'node:os';
|
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
5
5
|
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
// Codex stores live sessions in ~/.codex/sessions and, once a session is
|
|
8
|
+
// "completed", moves its rollout file verbatim into ~/.codex/archived_sessions.
|
|
9
|
+
// A session can be archived between two syncs, so scanning only the live dir
|
|
10
|
+
// loses that session's usage forever. We scan both: the parser is stateless
|
|
11
|
+
// and the server dedups on (source, sessionHash/bucket), so re-reading an
|
|
12
|
+
// archived file that was already synced from sessions/ is idempotent. Indexing
|
|
13
|
+
// both together also keeps fork replay-skip correct when a fork and its parent
|
|
14
|
+
// end up split across the two directories.
|
|
15
|
+
const SESSIONS_DIRS = [
|
|
16
|
+
join(homedir(), '.codex', 'sessions'),
|
|
17
|
+
join(homedir(), '.codex', 'archived_sessions'),
|
|
18
|
+
];
|
|
8
19
|
|
|
9
20
|
/**
|
|
10
21
|
* Recursively find all .jsonl files under a directory.
|
|
@@ -80,11 +91,11 @@ async function indexSessionFile(filePath) {
|
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
export async function parse() {
|
|
83
|
-
if (!existsSync
|
|
94
|
+
if (!SESSIONS_DIRS.some(existsSync)) return { buckets: [], sessions: [] };
|
|
84
95
|
|
|
85
96
|
const entries = [];
|
|
86
97
|
const sessionEvents = [];
|
|
87
|
-
const files = findJsonlFiles
|
|
98
|
+
const files = SESSIONS_DIRS.flatMap(findJsonlFiles);
|
|
88
99
|
if (files.length === 0) return { buckets: [], sessions: [] };
|
|
89
100
|
|
|
90
101
|
// Pass 1: index every session by its UUID and count its token_count
|
|
@@ -134,6 +145,12 @@ export async function parse() {
|
|
|
134
145
|
let tokenCountSeen = 0;
|
|
135
146
|
|
|
136
147
|
const sessionProject = fm.sessionProject;
|
|
148
|
+
// Group timing events by the real Codex session id, not the file path: the
|
|
149
|
+
// same session can briefly exist in both sessions/ and archived_sessions/
|
|
150
|
+
// (mid-archive, or a re-synced archive). Path-keyed grouping would emit it
|
|
151
|
+
// as two different sessionHashes and double-count its session stats. Fall
|
|
152
|
+
// back to the path only when the id is unknown (corrupt/missing meta).
|
|
153
|
+
const sessionKey = fm.sessionId || filePath;
|
|
137
154
|
|
|
138
155
|
let turnContextModel = 'unknown';
|
|
139
156
|
const prevTotal = new Map();
|
|
@@ -161,7 +178,7 @@ export async function parse() {
|
|
|
161
178
|
if (!isReplay) {
|
|
162
179
|
const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
|
|
163
180
|
sessionEvents.push({
|
|
164
|
-
sessionId:
|
|
181
|
+
sessionId: sessionKey,
|
|
165
182
|
source: 'codex',
|
|
166
183
|
project: sessionProject,
|
|
167
184
|
timestamp: evTs,
|
package/src/state.js
ADDED
|
Binary file
|
package/src/sync.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { hostname as osHostname } from 'node:os';
|
|
2
2
|
import { loadConfig, saveConfig } from './config.js';
|
|
3
|
+
import {
|
|
4
|
+
loadState, saveState, pruneState,
|
|
5
|
+
bucketKey, bucketHash, sessionKey, sessionHash,
|
|
6
|
+
} from './state.js';
|
|
3
7
|
import { ingest, fetchSettings } from './api.js';
|
|
4
8
|
import { parsers } from './parsers/index.js';
|
|
5
9
|
import { success, failure, arrow, link, dim } from './output.js';
|
|
@@ -87,18 +91,72 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
87
91
|
for (const s of allSessions) s.project = 'unknown';
|
|
88
92
|
}
|
|
89
93
|
|
|
94
|
+
// Incremental diff: parsers above always read the full local history (cheap,
|
|
95
|
+
// local-only). Here we drop anything whose content matches what we already
|
|
96
|
+
// uploaded, so only new/changed items go over the network. A quiet machine
|
|
97
|
+
// sends zero bytes; an active one sends just the current 30-min bucket.
|
|
98
|
+
// Missing/corrupt state.json => empty maps => one-time full upload, then
|
|
99
|
+
// incremental forever after.
|
|
100
|
+
const state = loadState();
|
|
101
|
+
const changedBuckets = [];
|
|
102
|
+
const changedSessions = [];
|
|
103
|
+
const liveBucketKeys = new Set();
|
|
104
|
+
const liveSessionKeys = new Set();
|
|
105
|
+
// key -> hash, committed to state only after the owning batch's upload
|
|
106
|
+
// succeeds (a failed batch re-sends next sync — no silent gap).
|
|
107
|
+
const pendingBucketState = new Map();
|
|
108
|
+
const pendingSessionState = new Map();
|
|
109
|
+
|
|
110
|
+
for (const b of allBuckets) {
|
|
111
|
+
const key = bucketKey(b);
|
|
112
|
+
const h = bucketHash(b);
|
|
113
|
+
liveBucketKeys.add(key);
|
|
114
|
+
if (state.buckets[key] === h) continue;
|
|
115
|
+
changedBuckets.push(b);
|
|
116
|
+
pendingBucketState.set(key, h);
|
|
117
|
+
}
|
|
118
|
+
for (const s of allSessions) {
|
|
119
|
+
const key = sessionKey(s);
|
|
120
|
+
const h = sessionHash(s);
|
|
121
|
+
liveSessionKeys.add(key);
|
|
122
|
+
if (state.sessions[key] === h) continue;
|
|
123
|
+
changedSessions.push(s);
|
|
124
|
+
pendingSessionState.set(key, h);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Drop entries the parsers no longer emit (deleted logs) so state.json can't
|
|
128
|
+
// grow forever. Done by liveness, never by age — an old bucket's hash never
|
|
129
|
+
// changes, so keeping it is exactly what prevents re-uploading it.
|
|
130
|
+
//
|
|
131
|
+
// Persist the pruned state unconditionally and immediately: removing dead
|
|
132
|
+
// keys is independent of whether anything uploads, so it must NOT be coupled
|
|
133
|
+
// to upload success. If we deferred this to the batch loop, a first-batch
|
|
134
|
+
// failure would throw before any saveState and the prune would be lost.
|
|
135
|
+
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
136
|
+
pruneState(state, liveBucketKeys, liveSessionKeys);
|
|
137
|
+
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
138
|
+
if (pruned > 0) saveState(state);
|
|
139
|
+
|
|
140
|
+
if (changedBuckets.length === 0 && changedSessions.length === 0) {
|
|
141
|
+
if (!quiet) console.log(dim('无新增数据。'));
|
|
142
|
+
return 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const allBucketsToSend = changedBuckets;
|
|
146
|
+
const allSessionsToSend = changedSessions;
|
|
147
|
+
|
|
90
148
|
let totalIngested = 0;
|
|
91
149
|
let totalSessionsSynced = 0;
|
|
92
150
|
let totalDroppedBuckets = 0;
|
|
93
151
|
const droppedSources = new Set();
|
|
94
|
-
const bucketBatches = Math.ceil(
|
|
95
|
-
const sessionBatches = Math.ceil(
|
|
152
|
+
const bucketBatches = Math.ceil(allBucketsToSend.length / BATCH_SIZE);
|
|
153
|
+
const sessionBatches = Math.ceil(allSessionsToSend.length / SESSION_BATCH_SIZE);
|
|
96
154
|
const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
|
|
97
155
|
|
|
98
156
|
try {
|
|
99
157
|
for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
|
|
100
|
-
const batch =
|
|
101
|
-
const batchSessions =
|
|
158
|
+
const batch = allBucketsToSend.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
|
|
159
|
+
const batchSessions = allSessionsToSend.slice(batchIdx * SESSION_BATCH_SIZE, (batchIdx + 1) * SESSION_BATCH_SIZE);
|
|
102
160
|
const batchNum = batchIdx + 1;
|
|
103
161
|
const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
|
|
104
162
|
|
|
@@ -114,9 +172,25 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
114
172
|
totalDroppedBuckets += Number(result.dropped.buckets) || 0;
|
|
115
173
|
for (const s of result.dropped.unknownSources || []) droppedSources.add(s);
|
|
116
174
|
}
|
|
175
|
+
|
|
176
|
+
// Commit only this batch's hashes, only after it uploaded successfully.
|
|
177
|
+
// A batch that throws aborts the loop with its keys still absent from
|
|
178
|
+
// state, so the next sync re-sends exactly those items — no data loss,
|
|
179
|
+
// no silent gaps.
|
|
180
|
+
for (const b of batch) {
|
|
181
|
+
const key = bucketKey(b);
|
|
182
|
+
const entry = pendingBucketState.get(key);
|
|
183
|
+
if (entry) state.buckets[key] = entry;
|
|
184
|
+
}
|
|
185
|
+
for (const s of batchSessions) {
|
|
186
|
+
const key = sessionKey(s);
|
|
187
|
+
const entry = pendingSessionState.get(key);
|
|
188
|
+
if (entry) state.sessions[key] = entry;
|
|
189
|
+
}
|
|
190
|
+
saveState(state);
|
|
117
191
|
}
|
|
118
192
|
|
|
119
|
-
if (totalBatches > 1 ||
|
|
193
|
+
if (totalBatches > 1 || allBucketsToSend.length > 0) {
|
|
120
194
|
process.stdout.write('\r\x1b[K');
|
|
121
195
|
}
|
|
122
196
|
const syncParts = [`${totalIngested} buckets`];
|
|
@@ -132,9 +206,9 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
132
206
|
}
|
|
133
207
|
|
|
134
208
|
if (!quiet && totalSessionsSynced > 0) {
|
|
135
|
-
const totalActive =
|
|
136
|
-
const totalDuration =
|
|
137
|
-
const totalMsgs =
|
|
209
|
+
const totalActive = allSessionsToSend.reduce((s, x) => s + x.activeSeconds, 0);
|
|
210
|
+
const totalDuration = allSessionsToSend.reduce((s, x) => s + x.durationSeconds, 0);
|
|
211
|
+
const totalMsgs = allSessionsToSend.reduce((s, x) => s + x.messageCount, 0);
|
|
138
212
|
const fmtTime = (secs) => {
|
|
139
213
|
if (secs < 60) return `${secs}s`;
|
|
140
214
|
const h = Math.floor(secs / 3600);
|
package/src/tools.js
CHANGED
|
@@ -80,6 +80,16 @@ function findOpenclawDataDirs() {
|
|
|
80
80
|
return dirs;
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
// Codex keeps live sessions in ~/.codex/sessions and moves completed ones to
|
|
84
|
+
// ~/.codex/archived_sessions. Detect Codex if either dir exists, so a user
|
|
85
|
+
// whose sessions have all been archived is still recognized.
|
|
86
|
+
function findCodexDataDirs() {
|
|
87
|
+
return [
|
|
88
|
+
join(homedir(), '.codex', 'sessions'),
|
|
89
|
+
join(homedir(), '.codex', 'archived_sessions'),
|
|
90
|
+
].filter(existsSync);
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
export const TOOLS = [
|
|
84
94
|
{
|
|
85
95
|
name: 'Antigravity',
|
|
@@ -101,6 +111,7 @@ export const TOOLS = [
|
|
|
101
111
|
name: 'Codex CLI',
|
|
102
112
|
id: 'codex',
|
|
103
113
|
dataDir: join(homedir(), '.codex', 'sessions'),
|
|
114
|
+
detectDataDirs: findCodexDataDirs,
|
|
104
115
|
},
|
|
105
116
|
{
|
|
106
117
|
name: 'GitHub Copilot CLI',
|