@vibe-cafe/vibe-usage 0.7.19 → 0.8.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 +25 -3
- package/package.json +1 -1
- package/src/api.js +4 -1
- package/src/parsers/cursor.js +4 -0
- package/src/state.js +0 -0
- package/src/sync.js +86 -10
package/README.md
CHANGED
|
@@ -49,7 +49,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
49
49
|
| Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only) |
|
|
50
50
|
| Codex CLI | `~/.codex/sessions/` and `~/.codex/archived_sessions/` |
|
|
51
51
|
| GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
|
|
52
|
-
| Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`) |
|
|
52
|
+
| Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`); cloud data is stamped with a fixed `cursor-cloud` hostname so multi-machine setups don't double-count |
|
|
53
53
|
| Gemini CLI | `~/.gemini/tmp/` |
|
|
54
54
|
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite, `json_extract` query) |
|
|
55
55
|
| OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments) |
|
|
@@ -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/cursor.js
CHANGED
|
@@ -223,6 +223,10 @@ export async function parse() {
|
|
|
223
223
|
source: 'cursor',
|
|
224
224
|
model,
|
|
225
225
|
project: 'unknown',
|
|
226
|
+
// Cursor usage is pulled from the cloud API — it reflects the same account
|
|
227
|
+
// data on every machine. Use a fixed sentinel so all machines share one row
|
|
228
|
+
// per (model, bucket_start) rather than duplicating per hostname.
|
|
229
|
+
hostname: 'cursor-cloud',
|
|
226
230
|
timestamp,
|
|
227
231
|
inputTokens: inputCacheWrite + inputNoCache,
|
|
228
232
|
outputTokens: output,
|
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';
|
|
@@ -67,8 +71,10 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
67
71
|
config.hostname = host;
|
|
68
72
|
saveConfig(config);
|
|
69
73
|
}
|
|
70
|
-
|
|
71
|
-
|
|
74
|
+
// Cloud-sourced parsers (e.g. cursor) pre-set their own hostname sentinel so
|
|
75
|
+
// the same account data isn't stored as separate rows per machine.
|
|
76
|
+
for (const b of allBuckets) if (!b.hostname) b.hostname = host;
|
|
77
|
+
for (const s of allSessions) if (!s.hostname) s.hostname = host;
|
|
72
78
|
|
|
73
79
|
// Privacy: check if user allows project name upload
|
|
74
80
|
const apiUrl = config.apiUrl || 'https://vibecafe.ai';
|
|
@@ -87,18 +93,72 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
87
93
|
for (const s of allSessions) s.project = 'unknown';
|
|
88
94
|
}
|
|
89
95
|
|
|
96
|
+
// Incremental diff: parsers above always read the full local history (cheap,
|
|
97
|
+
// local-only). Here we drop anything whose content matches what we already
|
|
98
|
+
// uploaded, so only new/changed items go over the network. A quiet machine
|
|
99
|
+
// sends zero bytes; an active one sends just the current 30-min bucket.
|
|
100
|
+
// Missing/corrupt state.json => empty maps => one-time full upload, then
|
|
101
|
+
// incremental forever after.
|
|
102
|
+
const state = loadState();
|
|
103
|
+
const changedBuckets = [];
|
|
104
|
+
const changedSessions = [];
|
|
105
|
+
const liveBucketKeys = new Set();
|
|
106
|
+
const liveSessionKeys = new Set();
|
|
107
|
+
// key -> hash, committed to state only after the owning batch's upload
|
|
108
|
+
// succeeds (a failed batch re-sends next sync — no silent gap).
|
|
109
|
+
const pendingBucketState = new Map();
|
|
110
|
+
const pendingSessionState = new Map();
|
|
111
|
+
|
|
112
|
+
for (const b of allBuckets) {
|
|
113
|
+
const key = bucketKey(b);
|
|
114
|
+
const h = bucketHash(b);
|
|
115
|
+
liveBucketKeys.add(key);
|
|
116
|
+
if (state.buckets[key] === h) continue;
|
|
117
|
+
changedBuckets.push(b);
|
|
118
|
+
pendingBucketState.set(key, h);
|
|
119
|
+
}
|
|
120
|
+
for (const s of allSessions) {
|
|
121
|
+
const key = sessionKey(s);
|
|
122
|
+
const h = sessionHash(s);
|
|
123
|
+
liveSessionKeys.add(key);
|
|
124
|
+
if (state.sessions[key] === h) continue;
|
|
125
|
+
changedSessions.push(s);
|
|
126
|
+
pendingSessionState.set(key, h);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Drop entries the parsers no longer emit (deleted logs) so state.json can't
|
|
130
|
+
// grow forever. Done by liveness, never by age — an old bucket's hash never
|
|
131
|
+
// changes, so keeping it is exactly what prevents re-uploading it.
|
|
132
|
+
//
|
|
133
|
+
// Persist the pruned state unconditionally and immediately: removing dead
|
|
134
|
+
// keys is independent of whether anything uploads, so it must NOT be coupled
|
|
135
|
+
// to upload success. If we deferred this to the batch loop, a first-batch
|
|
136
|
+
// failure would throw before any saveState and the prune would be lost.
|
|
137
|
+
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
138
|
+
pruneState(state, liveBucketKeys, liveSessionKeys);
|
|
139
|
+
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
140
|
+
if (pruned > 0) saveState(state);
|
|
141
|
+
|
|
142
|
+
if (changedBuckets.length === 0 && changedSessions.length === 0) {
|
|
143
|
+
if (!quiet) console.log(dim('无新增数据。'));
|
|
144
|
+
return 0;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const allBucketsToSend = changedBuckets;
|
|
148
|
+
const allSessionsToSend = changedSessions;
|
|
149
|
+
|
|
90
150
|
let totalIngested = 0;
|
|
91
151
|
let totalSessionsSynced = 0;
|
|
92
152
|
let totalDroppedBuckets = 0;
|
|
93
153
|
const droppedSources = new Set();
|
|
94
|
-
const bucketBatches = Math.ceil(
|
|
95
|
-
const sessionBatches = Math.ceil(
|
|
154
|
+
const bucketBatches = Math.ceil(allBucketsToSend.length / BATCH_SIZE);
|
|
155
|
+
const sessionBatches = Math.ceil(allSessionsToSend.length / SESSION_BATCH_SIZE);
|
|
96
156
|
const totalBatches = Math.max(bucketBatches, sessionBatches, 1);
|
|
97
157
|
|
|
98
158
|
try {
|
|
99
159
|
for (let batchIdx = 0; batchIdx < totalBatches; batchIdx++) {
|
|
100
|
-
const batch =
|
|
101
|
-
const batchSessions =
|
|
160
|
+
const batch = allBucketsToSend.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
|
|
161
|
+
const batchSessions = allSessionsToSend.slice(batchIdx * SESSION_BATCH_SIZE, (batchIdx + 1) * SESSION_BATCH_SIZE);
|
|
102
162
|
const batchNum = batchIdx + 1;
|
|
103
163
|
const prefix = totalBatches > 1 ? ` ${dim(`[${batchNum}/${totalBatches}]`)} 上传中 ` : ' 上传中 ';
|
|
104
164
|
|
|
@@ -114,9 +174,25 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
114
174
|
totalDroppedBuckets += Number(result.dropped.buckets) || 0;
|
|
115
175
|
for (const s of result.dropped.unknownSources || []) droppedSources.add(s);
|
|
116
176
|
}
|
|
177
|
+
|
|
178
|
+
// Commit only this batch's hashes, only after it uploaded successfully.
|
|
179
|
+
// A batch that throws aborts the loop with its keys still absent from
|
|
180
|
+
// state, so the next sync re-sends exactly those items — no data loss,
|
|
181
|
+
// no silent gaps.
|
|
182
|
+
for (const b of batch) {
|
|
183
|
+
const key = bucketKey(b);
|
|
184
|
+
const entry = pendingBucketState.get(key);
|
|
185
|
+
if (entry) state.buckets[key] = entry;
|
|
186
|
+
}
|
|
187
|
+
for (const s of batchSessions) {
|
|
188
|
+
const key = sessionKey(s);
|
|
189
|
+
const entry = pendingSessionState.get(key);
|
|
190
|
+
if (entry) state.sessions[key] = entry;
|
|
191
|
+
}
|
|
192
|
+
saveState(state);
|
|
117
193
|
}
|
|
118
194
|
|
|
119
|
-
if (totalBatches > 1 ||
|
|
195
|
+
if (totalBatches > 1 || allBucketsToSend.length > 0) {
|
|
120
196
|
process.stdout.write('\r\x1b[K');
|
|
121
197
|
}
|
|
122
198
|
const syncParts = [`${totalIngested} buckets`];
|
|
@@ -132,9 +208,9 @@ export async function runSync({ throws = false, quiet = false } = {}) {
|
|
|
132
208
|
}
|
|
133
209
|
|
|
134
210
|
if (!quiet && totalSessionsSynced > 0) {
|
|
135
|
-
const totalActive =
|
|
136
|
-
const totalDuration =
|
|
137
|
-
const totalMsgs =
|
|
211
|
+
const totalActive = allSessionsToSend.reduce((s, x) => s + x.activeSeconds, 0);
|
|
212
|
+
const totalDuration = allSessionsToSend.reduce((s, x) => s + x.durationSeconds, 0);
|
|
213
|
+
const totalMsgs = allSessionsToSend.reduce((s, x) => s + x.messageCount, 0);
|
|
138
214
|
const fmtTime = (secs) => {
|
|
139
215
|
if (secs < 60) return `${secs}s`;
|
|
140
216
|
const h = Math.floor(secs / 3600);
|