@vibe-cafe/vibe-usage 0.7.19 → 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 +24 -2
- package/package.json +1 -1
- package/src/api.js +4 -1
- package/src/state.js +0 -0
- package/src/sync.js +82 -8
package/README.md
CHANGED
|
@@ -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/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);
|