@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 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 when ≥ 1 KB, ~94% smaller)
72
- - Stateless: computes full totals from local logs each sync (idempotent, no state files)
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.7.19",
3
+ "version": "0.8.1",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
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
- const GZIP_MIN_BYTES = 1024;
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;
@@ -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
- for (const b of allBuckets) b.hostname = host;
71
- for (const s of allSessions) s.hostname = host;
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(allBuckets.length / BATCH_SIZE);
95
- const sessionBatches = Math.ceil(allSessions.length / SESSION_BATCH_SIZE);
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 = allBuckets.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
101
- const batchSessions = allSessions.slice(batchIdx * SESSION_BATCH_SIZE, (batchIdx + 1) * SESSION_BATCH_SIZE);
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 || allBuckets.length > 0) {
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 = allSessions.reduce((s, x) => s + x.activeSeconds, 0);
136
- const totalDuration = allSessions.reduce((s, x) => s + x.durationSeconds, 0);
137
- const totalMsgs = allSessions.reduce((s, x) => s + x.messageCount, 0);
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);