@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 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 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.18",
3
+ "version": "0.8.0",
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;
@@ -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
- const SESSIONS_DIR = join(homedir(), '.codex', 'sessions');
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(SESSIONS_DIR)) return { buckets: [], sessions: [] };
94
+ if (!SESSIONS_DIRS.some(existsSync)) return { buckets: [], sessions: [] };
84
95
 
85
96
  const entries = [];
86
97
  const sessionEvents = [];
87
- const files = findJsonlFiles(SESSIONS_DIR);
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: filePath,
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(allBuckets.length / BATCH_SIZE);
95
- const sessionBatches = Math.ceil(allSessions.length / SESSION_BATCH_SIZE);
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 = allBuckets.slice(batchIdx * BATCH_SIZE, (batchIdx + 1) * BATCH_SIZE);
101
- const batchSessions = allSessions.slice(batchIdx * SESSION_BATCH_SIZE, (batchIdx + 1) * SESSION_BATCH_SIZE);
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 || allBuckets.length > 0) {
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 = 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);
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',