@vibe-cafe/vibe-usage 0.9.13 → 0.9.15

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
@@ -40,7 +40,7 @@ npx @vibe-cafe/vibe-usage daemon status # Show background service status
40
40
  npx @vibe-cafe/vibe-usage daemon stop # Stop background service
41
41
  npx @vibe-cafe/vibe-usage daemon restart # Restart background service
42
42
  npx @vibe-cafe/vibe-usage reset # Delete all data and re-upload from local logs
43
- npx @vibe-cafe/vibe-usage reset --local # Delete this host's data only and re-upload
43
+ npx @vibe-cafe/vibe-usage reset --local # Delete this host's data only and re-upload (`--host` remains a legacy alias)
44
44
  npx @vibe-cafe/vibe-usage skill # Install skill for AI coding assistants
45
45
  npx @vibe-cafe/vibe-usage skill --remove # Remove installed skills
46
46
  npx @vibe-cafe/vibe-usage status # Show config & detected tools
@@ -52,6 +52,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
52
52
  |------|---------------|
53
53
  | Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only); also scans `$CLAUDE_CONFIG_DIR` when set (deduped), so relocated configs and GUI/CLI env mismatches are both covered |
54
54
  | Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`); forked and sub-agent rollouts replay parent metadata, tasks, and `token_count` records at spawn time — full or last-N-turn replay blocks are matched as a fingerprinted parent suffix plus the child's own task boundary, duplicate cumulative emissions count once, and live/archive copies of the same session are deduplicated |
55
+ | Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`); token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; honors `GROK_HOME` |
55
56
  | GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
56
57
  | 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 |
57
58
  | Gemini CLI | `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl` (current line-delimited format) and legacy `session-*.json`; recurses into nested subagent sessions |
@@ -59,7 +60,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
59
60
  | OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments) |
60
61
  | pi | `~/.pi/agent/sessions/` |
61
62
  | Qwen Code | `~/.qwen/tmp/` |
62
- | Kimi Code | `~/.kimi/sessions/<md5(workdir)>/<session-id>/wire.jsonl` (wire protocol 1.9, model from `~/.kimi/config.toml`, project from `~/.kimi/kimi.json`) |
63
+ | Kimi Code | Current `~/.kimi-code/sessions/wd_<slug>_<hash>/session_<id>/agents/<agent>/wire.jsonl` (`usage.record` deltas, including retry/compaction scope and cache creation; main/subagent wires form one session), with project names from `session_index.jsonl`; legacy `~/.kimi/sessions/` remains supported |
63
64
  | Amp | `~/.local/share/amp/threads/` |
64
65
  | Droid | `~/.factory/sessions/` |
65
66
  | Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
@@ -76,7 +77,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
76
77
  - Aggregates token usage into 30-minute buckets
77
78
  - Extracts session metadata from all parsers: active time (AI generation time, excluding queue/TTFT wait), total duration, message counts
78
79
  - Uploads buckets + sessions to your vibecafe.ai dashboard (always gzip-compressed, ~94% smaller)
79
- - 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
80
+ - 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`; failed parsers retain their prior state, while deleted local logs are pruned. Deleting the state file triggers a one-time full re-upload, and `reset` clears it automatically after deleting cloud data
80
81
  - 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
81
82
  - For continuous syncing, use `npx @vibe-cafe/vibe-usage daemon` or the [Vibe Usage Mac app](https://github.com/vibe-cafe/vibe-usage-app)
82
83
 
@@ -124,6 +125,8 @@ npx skills add vibe-cafe/vibe-usage
124
125
 
125
126
  ## Development
126
127
 
128
+ Run the Node test suite locally with `npm test`. CI covers Node 20 and 22 on Ubuntu and macOS.
129
+
127
130
  Test against a local vibe-cafe dev server without publishing:
128
131
 
129
132
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.9.13",
3
+ "version": "0.9.15",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -23,6 +23,7 @@
23
23
  "tokens",
24
24
  "claude",
25
25
  "codex",
26
+ "grok",
26
27
  "gemini"
27
28
  ],
28
29
  "dependencies": {},
package/src/config.js CHANGED
@@ -1,8 +1,9 @@
1
- import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync, chmodSync, mkdirSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
 
5
- const CONFIG_DIR = join(homedir(), '.vibe-usage');
5
+ // VIBE_USAGE_CONFIG_DIR overrides the dir (test hook).
6
+ const CONFIG_DIR = process.env.VIBE_USAGE_CONFIG_DIR?.trim() || join(homedir(), '.vibe-usage');
6
7
  const isDev = process.env.VIBE_USAGE_DEV === '1';
7
8
  const CONFIG_FILE = join(CONFIG_DIR, isDev ? 'config.dev.json' : 'config.json');
8
9
 
@@ -21,5 +22,15 @@ export function loadConfig() {
21
22
 
22
23
  export function saveConfig(config) {
23
24
  mkdirSync(CONFIG_DIR, { recursive: true });
24
- writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', 'utf-8');
25
+ // The file holds the vbu_ API key — never leave it group/world-readable.
26
+ // mode only applies at file creation, so chmod explicitly for pre-existing
27
+ // files written before this hardening.
28
+ writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 });
29
+ try {
30
+ chmodSync(CONFIG_FILE, 0o600);
31
+ } catch (err) {
32
+ // Windows permission models vary; on POSIX, do not silently leave an API
33
+ // key file broader than owner-only if hardening a pre-existing file fails.
34
+ if (process.platform !== 'win32') throw err;
35
+ }
25
36
  }
@@ -236,7 +236,16 @@ function stop() {
236
236
  }
237
237
 
238
238
  if (plat === 'launchd') {
239
- const result = run('launchctl', ['stop', LAUNCHD_LABEL]);
239
+ // The plist sets KeepAlive=true, so `launchctl stop` is useless here —
240
+ // launchd immediately relaunches the job and the daemon keeps running.
241
+ // unload removes the job from launchd entirely; the plist file stays on
242
+ // disk, so `daemon restart` (load) and status detection keep working.
243
+ const paths = getServicePaths(plat);
244
+ if (!existsSync(paths.file)) {
245
+ console.log(dim('未安装 daemon 服务。'));
246
+ return;
247
+ }
248
+ const result = run('launchctl', ['unload', paths.file]);
240
249
  console.log(result.ok ? success('服务已停止。') : failure(`停止失败: ${result.output}`));
241
250
  }
242
251
  }
@@ -254,8 +263,11 @@ function restart() {
254
263
  }
255
264
 
256
265
  if (plat === 'launchd') {
257
- run('launchctl', ['stop', LAUNCHD_LABEL]);
258
- const result = run('launchctl', ['start', LAUNCHD_LABEL]);
266
+ // unload + load (not stop + start): stop can't win against KeepAlive, and
267
+ // load re-runs the job immediately thanks to RunAtLoad=true.
268
+ const paths = getServicePaths(plat);
269
+ run('launchctl', ['unload', paths.file]);
270
+ const result = run('launchctl', ['load', paths.file]);
259
271
  console.log(result.ok ? success('服务已重启。') : failure(`重启失败: ${result.output}`));
260
272
  }
261
273
  }
package/src/index.js CHANGED
@@ -146,14 +146,17 @@ export async function run(rawArgs) {
146
146
  case 'daemon':
147
147
  case '--daemon': {
148
148
  const sub = args[1];
149
- if (sub && ['install', 'uninstall', 'status', 'stop', 'restart'].includes(sub)) {
150
- printSmallHeader();
151
- const { manageDaemon } = await import('./daemon-service.js');
152
- await manageDaemon(sub);
153
- } else {
149
+ if (sub === undefined) {
154
150
  // Foreground daemon loop — no header, just start syncing
155
151
  const { runDaemon } = await import('./daemon.js');
156
152
  await runDaemon();
153
+ } else {
154
+ // manageDaemon validates the subcommand and exits 1 on unknown ones —
155
+ // a typo (e.g. `daemon stauts`) must never fall through to the
156
+ // infinite foreground loop.
157
+ printSmallHeader();
158
+ const { manageDaemon } = await import('./daemon-service.js');
159
+ await manageDaemon(sub);
157
160
  }
158
161
  break;
159
162
  }
@@ -191,7 +194,7 @@ export async function run(rawArgs) {
191
194
  npx @vibe-cafe/vibe-usage daemon stop Stop background service
192
195
  npx @vibe-cafe/vibe-usage daemon restart Restart background service
193
196
  npx @vibe-cafe/vibe-usage reset Delete all data and re-upload
194
- npx @vibe-cafe/vibe-usage reset --local Delete data for this host only and re-upload
197
+ npx @vibe-cafe/vibe-usage reset --local Delete data for this host only and re-upload (--host is a legacy alias)
195
198
  npx @vibe-cafe/vibe-usage skill Install skill for AI coding tools
196
199
  npx @vibe-cafe/vibe-usage skill --remove Remove installed skills
197
200
  npx @vibe-cafe/vibe-usage status Show config and detected tools
@@ -202,7 +205,9 @@ export async function run(rawArgs) {
202
205
  `);
203
206
  break;
204
207
  }
205
- default: {
208
+ case undefined: {
209
+ // Bare invocation (no command): first run OR a one-shot --key setup →
210
+ // init; already configured → sync.
206
211
  const config = loadConfig();
207
212
  if (!config?.apiKey || apiKey) {
208
213
  // First run OR user passed --key for a one-shot setup — init.js prints the big header
@@ -214,6 +219,15 @@ export async function run(rawArgs) {
214
219
  const { runSync } = await import('./sync.js');
215
220
  await runSync();
216
221
  }
222
+ break;
223
+ }
224
+ default: {
225
+ // Compatibility is explicit above: --key, --daemon, reset --host, and
226
+ // the no-command init/sync behavior remain supported. Unknown words were
227
+ // never public commands; failing them avoids typo-triggered side effects.
228
+ console.error(`Unknown command: ${command}`);
229
+ console.error('Run `vibe-usage help` to see available commands.');
230
+ process.exit(1);
217
231
  }
218
232
  }
219
233
  }
@@ -210,7 +210,9 @@ export async function parse() {
210
210
  } catch (err) {
211
211
  // Network/timeout → silent skip (avoid noisy daemon logs every 5 min).
212
212
  // Auth failure → bubble up so user sees they need to re-login in Cursor.
213
- if (err && err.skip) return { buckets: [], sessions: [] };
213
+ // Tell sync.js this was not a successful empty snapshot so it preserves
214
+ // Cursor's incremental state instead of pruning it as dead history.
215
+ if (err && err.skip) return { buckets: [], sessions: [], skipped: true };
214
216
  throw err;
215
217
  }
216
218
  const rows = parseCsv(csv);
@@ -0,0 +1,325 @@
1
+ import { createReadStream, existsSync, readdirSync, readFileSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import { basename, join } from 'node:path';
4
+ import { findGrokDataDirs, getGrokSessionsDir } from '../tools.js';
5
+ import { aggregateToBuckets, extractSessions } from './index.js';
6
+
7
+ const SOURCE = 'grok';
8
+
9
+ /**
10
+ * Grok (Grok Build TUI / CLI) parser.
11
+ *
12
+ * Layout (see ~/.grok/docs/user-guide/17-sessions.md):
13
+ * $GROK_HOME/sessions/<url-encoded-cwd>/<session-id>/
14
+ * summary.json — cwd, model, timestamps
15
+ * updates.jsonl — ACP session updates; turn_completed carries exact usage
16
+ * events.jsonl — turn_started / turn_ended timing
17
+ *
18
+ * GROK_HOME defaults to ~/.grok. Override with GROK_HOME or
19
+ * VIBE_USAGE_GROK_SESSIONS (tests / relocated session trees).
20
+ *
21
+ * Token usage comes from updates.jsonl `turn_completed.usage` (and per-model
22
+ * `modelUsage` when present). inputTokens is non-cached prompt (total − cache
23
+ * reads), matching Codex/Copilot so totalTokens does not double-count cache.
24
+ */
25
+
26
+ function readJsonSafe(path) {
27
+ try {
28
+ return JSON.parse(readFileSync(path, 'utf-8'));
29
+ } catch {
30
+ return null;
31
+ }
32
+ }
33
+
34
+ function projectFromPath(absPath) {
35
+ if (!absPath || typeof absPath !== 'string') return 'unknown';
36
+ const trimmed = absPath.replace(/[\\/]+$/, '');
37
+ const name = basename(trimmed);
38
+ return name || 'unknown';
39
+ }
40
+
41
+ /** Decode a sessions group dirname; fall back to basename after decode. */
42
+ function projectFromGroupDir(groupName, groupPath) {
43
+ const cwdFile = join(groupPath, '.cwd');
44
+ if (existsSync(cwdFile)) {
45
+ try {
46
+ const raw = readFileSync(cwdFile, 'utf-8').trim();
47
+ if (raw) return projectFromPath(raw);
48
+ } catch {
49
+ // ignore
50
+ }
51
+ }
52
+ try {
53
+ const decoded = decodeURIComponent(groupName);
54
+ if (decoded.includes('/') || decoded.includes('\\')) {
55
+ return projectFromPath(decoded);
56
+ }
57
+ } catch {
58
+ // not URI-encoded
59
+ }
60
+ return groupName || 'unknown';
61
+ }
62
+
63
+ function toDate(value) {
64
+ if (value == null) return null;
65
+ if (value instanceof Date) {
66
+ return Number.isNaN(value.getTime()) ? null : value;
67
+ }
68
+ if (typeof value === 'number' && Number.isFinite(value)) {
69
+ // Unix seconds (Grok updates.jsonl) vs milliseconds
70
+ const ms = value < 1e12 ? value * 1000 : value;
71
+ const d = new Date(ms);
72
+ return Number.isNaN(d.getTime()) ? null : d;
73
+ }
74
+ if (typeof value === 'string' && value.trim()) {
75
+ const d = new Date(value);
76
+ return Number.isNaN(d.getTime()) ? null : d;
77
+ }
78
+ return null;
79
+ }
80
+
81
+ function pushUsageEntry(entries, { model, project, timestamp, usage }) {
82
+ if (!usage || typeof usage !== 'object') return;
83
+ if (!timestamp) return;
84
+
85
+ const totalInput = Math.max(0, Number(usage.inputTokens) || 0);
86
+ const cached = Math.max(0, Number(usage.cachedReadTokens) || 0);
87
+ const output = Math.max(0, Number(usage.outputTokens) || 0);
88
+ const reasoning = Math.max(0, Number(usage.reasoningTokens) || 0);
89
+
90
+ // Prefer exclusive fields when both are present (Codex-style).
91
+ const inputTokens = Math.max(0, totalInput - cached);
92
+ const outputTokens = Math.max(0, output - reasoning);
93
+
94
+ if (inputTokens + outputTokens + cached + reasoning === 0) return;
95
+
96
+ entries.push({
97
+ source: SOURCE,
98
+ model: model || 'unknown',
99
+ project,
100
+ timestamp,
101
+ inputTokens,
102
+ outputTokens,
103
+ cachedInputTokens: cached,
104
+ reasoningOutputTokens: reasoning,
105
+ });
106
+ }
107
+
108
+ function emitTurnUsage(entries, { usage, project, timestamp, fallbackModel }) {
109
+ if (!usage || typeof usage !== 'object') return;
110
+
111
+ const modelUsage = usage.modelUsage;
112
+ if (modelUsage && typeof modelUsage === 'object' && Object.keys(modelUsage).length > 0) {
113
+ for (const [model, mUsage] of Object.entries(modelUsage)) {
114
+ pushUsageEntry(entries, {
115
+ model,
116
+ project,
117
+ timestamp,
118
+ usage: mUsage && typeof mUsage === 'object' ? mUsage : usage,
119
+ });
120
+ }
121
+ return;
122
+ }
123
+
124
+ pushUsageEntry(entries, {
125
+ model: fallbackModel,
126
+ project,
127
+ timestamp,
128
+ usage,
129
+ });
130
+ }
131
+
132
+ async function forEachJsonlLine(filePath, onLine) {
133
+ if (!existsSync(filePath)) return;
134
+ let stream;
135
+ try {
136
+ stream = createReadStream(filePath, { encoding: 'utf-8' });
137
+ } catch {
138
+ return;
139
+ }
140
+
141
+ const rl = createInterface({ input: stream, crlfDelay: Infinity });
142
+ try {
143
+ for await (const line of rl) {
144
+ const trimmed = line.trim();
145
+ if (!trimmed) continue;
146
+ let obj;
147
+ try {
148
+ obj = JSON.parse(trimmed);
149
+ } catch {
150
+ continue;
151
+ }
152
+ onLine(obj);
153
+ }
154
+ } catch {
155
+ // unreadable / truncated mid-write — keep what we have
156
+ } finally {
157
+ rl.close();
158
+ stream.destroy();
159
+ }
160
+ }
161
+
162
+ function listSessionDirs(sessionsDir) {
163
+ const results = [];
164
+ if (!existsSync(sessionsDir)) return results;
165
+
166
+ let groups;
167
+ try {
168
+ groups = readdirSync(sessionsDir, { withFileTypes: true });
169
+ } catch {
170
+ return results;
171
+ }
172
+
173
+ for (const group of groups) {
174
+ if (!group.isDirectory()) continue;
175
+ // Skip non-project group dirs (e.g. future index folders).
176
+ const groupPath = join(sessionsDir, group.name);
177
+ let children;
178
+ try {
179
+ children = readdirSync(groupPath, { withFileTypes: true });
180
+ } catch {
181
+ continue;
182
+ }
183
+
184
+ const projectFallback = projectFromGroupDir(group.name, groupPath);
185
+
186
+ for (const child of children) {
187
+ if (!child.isDirectory()) continue;
188
+ const sessionPath = join(groupPath, child.name);
189
+ // A real session always has summary.json (or at least updates/chat history).
190
+ if (
191
+ !existsSync(join(sessionPath, 'summary.json')) &&
192
+ !existsSync(join(sessionPath, 'updates.jsonl'))
193
+ ) {
194
+ continue;
195
+ }
196
+ results.push({
197
+ sessionId: child.name,
198
+ sessionPath,
199
+ projectFallback,
200
+ });
201
+ }
202
+ }
203
+
204
+ return results;
205
+ }
206
+
207
+ /**
208
+ * Parse all Grok sessions under the configured sessions root(s).
209
+ * @returns {Promise<{ buckets: object[], sessions: object[] }>}
210
+ */
211
+ export async function parse() {
212
+ const sessionRoots = findGrokDataDirs();
213
+ // findGrokDataDirs returns sessions dirs; also allow empty → try default once
214
+ const roots = sessionRoots.length > 0 ? sessionRoots : [getGrokSessionsDir()].filter(existsSync);
215
+ if (roots.length === 0) return { buckets: [], sessions: [] };
216
+
217
+ const entries = [];
218
+ const sessionEvents = [];
219
+
220
+ for (const sessionsDir of roots) {
221
+ for (const { sessionId, sessionPath, projectFallback } of listSessionDirs(sessionsDir)) {
222
+ const summary = readJsonSafe(join(sessionPath, 'summary.json')) || {};
223
+ const cwd = summary.info?.cwd || summary.git_root_dir || null;
224
+ const project = cwd ? projectFromPath(cwd) : projectFallback;
225
+ const fallbackModel = summary.current_model_id || 'unknown';
226
+
227
+ // Prefer updates.jsonl turn_completed for exact usage + message timings.
228
+ let sawUserOrAssistant = false;
229
+ await forEachJsonlLine(join(sessionPath, 'updates.jsonl'), (obj) => {
230
+ const update = obj?.params?.update;
231
+ if (!update || typeof update !== 'object') return;
232
+
233
+ const kind = update.sessionUpdate;
234
+ const timestamp = toDate(obj.timestamp);
235
+
236
+ if (kind === 'turn_completed' && timestamp) {
237
+ emitTurnUsage(entries, {
238
+ usage: update.usage,
239
+ project,
240
+ timestamp,
241
+ fallbackModel,
242
+ });
243
+ }
244
+
245
+ if (!timestamp) return;
246
+
247
+ if (kind === 'user_message_chunk') {
248
+ sawUserOrAssistant = true;
249
+ sessionEvents.push({
250
+ sessionId,
251
+ source: SOURCE,
252
+ project,
253
+ timestamp,
254
+ role: 'user',
255
+ });
256
+ } else if (kind === 'agent_message_chunk' || kind === 'turn_completed') {
257
+ sawUserOrAssistant = true;
258
+ sessionEvents.push({
259
+ sessionId,
260
+ source: SOURCE,
261
+ project,
262
+ timestamp,
263
+ role: 'assistant',
264
+ });
265
+ }
266
+ });
267
+
268
+ // Fallback timing from events.jsonl when updates lack message chunks
269
+ // (short/aborted sessions, older builds).
270
+ if (!sawUserOrAssistant) {
271
+ await forEachJsonlLine(join(sessionPath, 'events.jsonl'), (obj) => {
272
+ const timestamp = toDate(obj.ts || obj.timestamp);
273
+ if (!timestamp) return;
274
+ if (obj.type === 'turn_started') {
275
+ sessionEvents.push({
276
+ sessionId,
277
+ source: SOURCE,
278
+ project,
279
+ timestamp,
280
+ role: 'user',
281
+ });
282
+ } else if (obj.type === 'turn_ended' || obj.type === 'first_token') {
283
+ sessionEvents.push({
284
+ sessionId,
285
+ source: SOURCE,
286
+ project,
287
+ timestamp,
288
+ role: 'assistant',
289
+ });
290
+ }
291
+ });
292
+ }
293
+
294
+ // Last-resort session envelope from summary timestamps so a session with
295
+ // no parseable turns still appears once usage lands later.
296
+ if (sessionEvents.every((e) => e.sessionId !== sessionId)) {
297
+ const created = toDate(summary.created_at || summary.info?.created_at);
298
+ const updated = toDate(summary.updated_at || summary.last_active_at);
299
+ if (created) {
300
+ sessionEvents.push({
301
+ sessionId,
302
+ source: SOURCE,
303
+ project,
304
+ timestamp: created,
305
+ role: 'user',
306
+ });
307
+ }
308
+ if (updated && (!created || updated.getTime() !== created.getTime())) {
309
+ sessionEvents.push({
310
+ sessionId,
311
+ source: SOURCE,
312
+ project,
313
+ timestamp: updated,
314
+ role: 'assistant',
315
+ });
316
+ }
317
+ }
318
+ }
319
+ }
320
+
321
+ return {
322
+ buckets: aggregateToBuckets(entries),
323
+ sessions: extractSessions(sessionEvents),
324
+ };
325
+ }
@@ -6,6 +6,7 @@ import { parse as parseCopilotCli } from './copilot-cli.js';
6
6
  import { parse as parseCursor } from './cursor.js';
7
7
  import { parse as parseRooCode } from './roo-code.js';
8
8
  import { parse as parseGeminiCli } from './gemini-cli.js';
9
+ import { parse as parseGrok } from './grok.js';
9
10
  import { parse as parseOpencode } from './opencode.js';
10
11
  import { parse as parseOpenclaw } from './openclaw.js';
11
12
  import { parse as parseQwenCode } from './qwen-code.js';
@@ -21,24 +22,25 @@ import { parse as parseTraeCli } from './trae-cli.js';
21
22
 
22
23
  export const parsers = {
23
24
  'claude-code': parseClaudeCode,
24
- 'cline': parseCline,
25
25
  'codex': parseCodex,
26
+ 'grok': parseGrok,
26
27
  'copilot-cli': parseCopilotCli,
27
28
  'cursor': parseCursor,
28
- 'roo-code': parseRooCode,
29
29
  'gemini-cli': parseGeminiCli,
30
30
  'opencode': parseOpencode,
31
31
  'openclaw': parseOpenclaw,
32
+ 'pi-coding-agent': parsePiCodingAgent,
32
33
  'qwen-code': parseQwenCode,
33
34
  'kimi-code': parseKimiCode,
34
35
  'amp': parseAmp,
35
36
  'droid': parseDroid,
36
37
  'antigravity': parseAntigravity,
38
+ 'trae-cli': parseTraeCli,
37
39
  'hermes': parseHermes,
38
40
  'kiro': parseKiro,
39
- 'pi-coding-agent': parsePiCodingAgent,
41
+ 'cline': parseCline,
42
+ 'roo-code': parseRooCode,
40
43
  'zcode': parseZcode,
41
- 'trae-cli': parseTraeCli,
42
44
  };
43
45
 
44
46
 
@@ -33,7 +33,8 @@ import { aggregateToBuckets, extractSessions } from './index.js';
33
33
  // Current format: ~/.kimi-code
34
34
  // ---------------------------------------------------------------------------
35
35
 
36
- const KIMI_CODE_DIR = join(homedir(), '.kimi-code');
36
+ // VIBE_USAGE_KIMI_CODE_DIR overrides the root (test hook).
37
+ const KIMI_CODE_DIR = process.env.VIBE_USAGE_KIMI_CODE_DIR?.trim() || join(homedir(), '.kimi-code');
37
38
  const KIMI_CODE_SESSIONS_DIR = join(KIMI_CODE_DIR, 'sessions');
38
39
  const KIMI_CODE_SESSION_INDEX = join(KIMI_CODE_DIR, 'session_index.jsonl');
39
40
 
@@ -76,6 +77,11 @@ function projectFromBucketName(name) {
76
77
  return m ? m[1] : name;
77
78
  }
78
79
 
80
+ function usageTokens(value) {
81
+ const n = Number(value);
82
+ return Number.isFinite(n) && n > 0 ? n : 0;
83
+ }
84
+
79
85
  // Collect every agents/<id>/wire.jsonl under sessions/wd_<...>/session_<...>/.
80
86
  function findKimiCodeWireFiles(baseDir) {
81
87
  const results = [];
@@ -148,30 +154,45 @@ function parseKimiCode() {
148
154
  try { evt = JSON.parse(line); } catch { continue; }
149
155
 
150
156
  const type = evt.type;
151
- // Top-level `time` is integer milliseconds since epoch.
152
- const time = typeof evt.time === 'number' ? evt.time : null;
153
-
154
- // Session timing: a user turn vs. anything the model emits.
155
- if (type === 'turn.prompt' && evt.origin?.kind === 'user' && time) {
156
- const ts = new Date(time);
157
- if (!isNaN(ts.getTime())) {
158
- sessionEvents.push({ sessionId: wireFile, source: 'kimi-code', project, timestamp: ts, role: 'user' });
157
+ // Top-level `time` is integer milliseconds since epoch. Validate it:
158
+ // JSON numbers can overflow to Infinity (e.g. 1e400 parses fine), and
159
+ // any out-of-range value yields an Invalid Date that later crashes
160
+ // aggregateToBuckets() (RangeError from toISOString) one corrupt line
161
+ // would take down this tool's entire parse.
162
+ const time = typeof evt.time === 'number' && Number.isFinite(evt.time) ? evt.time : null;
163
+ const ts = time !== null ? new Date(time) : null;
164
+ const tsValid = ts !== null && !isNaN(ts.getTime());
165
+
166
+ // Session timing: a user turn vs. anything the model emits. All agent
167
+ // wires under one sessionDir belong to the same logical user session;
168
+ // grouping by wireFile would create separate zero-user sessions for
169
+ // subagents instead of attributing their work to the parent turn.
170
+ if (type === 'turn.prompt' && evt.origin?.kind === 'user') {
171
+ if (tsValid) {
172
+ sessionEvents.push({ sessionId: sessionDir, source: 'kimi-code', project, timestamp: ts, role: 'user' });
159
173
  }
160
174
  continue;
161
175
  }
162
176
 
163
177
  if (type !== 'usage.record') continue;
178
+ // Every usage.record is a delta. `turn` is the normal successful-step
179
+ // scope; `session` is used for other real model calls (for example
180
+ // retry/compaction work), not for a cumulative summary. Count both.
181
+ // No valid timestamp → can't bucket accurately. Skip instead of stamping
182
+ // "now": this parser is stateless, so a "now" fallback would re-key the
183
+ // same record into a fresh 30-min bucket on every sync (duplicates).
184
+ if (!tsValid) continue;
164
185
 
165
186
  const usage = evt.usage;
166
187
  if (!usage) continue;
167
188
 
168
- const inputTokens = usage.inputOther || 0;
169
- const outputTokens = usage.output || 0;
170
- const cachedInputTokens = usage.inputCacheRead || 0;
189
+ // Cache creation is billed non-cached input, matching the common bucket
190
+ // model used by the other parsers; cache reads stay in their own field.
191
+ const inputTokens = usageTokens(usage.inputOther) + usageTokens(usage.inputCacheCreation);
192
+ const outputTokens = usageTokens(usage.output);
193
+ const cachedInputTokens = usageTokens(usage.inputCacheRead);
171
194
  if (!inputTokens && !outputTokens && !cachedInputTokens) continue;
172
195
 
173
- const ts = time ? new Date(time) : new Date();
174
-
175
196
  entries.push({
176
197
  source: 'kimi-code',
177
198
  model: evt.model || 'unknown',
@@ -185,9 +206,7 @@ function parseKimiCode() {
185
206
 
186
207
  // Each usage.record marks an assistant step completing — use it as an
187
208
  // assistant timing event so active-time math has both sides of a turn.
188
- if (time && !isNaN(ts.getTime())) {
189
- sessionEvents.push({ sessionId: wireFile, source: 'kimi-code', project, timestamp: ts, role: 'assistant' });
190
- }
209
+ sessionEvents.push({ sessionId: sessionDir, source: 'kimi-code', project, timestamp: ts, role: 'assistant' });
191
210
  }
192
211
  }
193
212
 
@@ -198,7 +217,8 @@ function parseKimiCode() {
198
217
  // Legacy format: ~/.kimi (kept for users who haven't migrated to ~/.kimi-code)
199
218
  // ---------------------------------------------------------------------------
200
219
 
201
- const KIMI_DIR = join(homedir(), '.kimi');
220
+ // VIBE_USAGE_KIMI_DIR overrides the legacy root (test hook).
221
+ const KIMI_DIR = process.env.VIBE_USAGE_KIMI_DIR?.trim() || join(homedir(), '.kimi');
202
222
  const KIMI_SESSIONS_DIR = join(KIMI_DIR, 'sessions');
203
223
  const KIMI_WORKDIRS_JSON = join(KIMI_DIR, 'kimi.json');
204
224
  const KIMI_CONFIG_TOML = join(KIMI_DIR, 'config.toml');
package/src/reset.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { hostname as getHostname } from 'node:os';
3
- import { loadConfig, saveConfig } from './config.js';
3
+ import { loadConfig } from './config.js';
4
4
  import { deleteAllData } from './api.js';
5
5
  import { runSync } from './sync.js';
6
+ import { clearState } from './state.js';
6
7
  import { success, failure, arrow, link, dim } from './output.js';
7
8
 
8
9
  function prompt(question) {
@@ -15,19 +16,32 @@ function prompt(question) {
15
16
  });
16
17
  }
17
18
 
18
- export async function runReset(args = []) {
19
- const hostOnly = args.includes('--local');
19
+ export async function runReset(args = [], deps = {}) {
20
+ // Injectable for tests — the production defaults hit readline, the network,
21
+ // and the real sync pipeline.
22
+ const ask = deps.prompt ?? prompt;
23
+ const deleteRemote = deps.deleteAllData ?? deleteAllData;
24
+ const resync = deps.runSync ?? runSync;
25
+
26
+ // --host was the original public spelling before --local replaced it.
27
+ // Keep the old flag as an alias so existing reset scripts stay safe: losing
28
+ // the filter would turn a host-only reset into a destructive account-wide
29
+ // reset.
30
+ const hostOnly = args.includes('--local') || args.includes('--host');
20
31
  const config = loadConfig();
21
32
  if (!config?.apiKey) {
22
33
  console.error(failure('尚未配置,请先运行 `npx @vibe-cafe/vibe-usage init`。'));
23
34
  process.exit(1);
24
35
  }
25
36
 
26
- const currentHost = getHostname().replace(/\.local$/, '');
37
+ // Target the hostname persisted at init — the same one sync.js uploads
38
+ // under. A fresh os.hostname() can have drifted since (macOS mDNS adds -2
39
+ // suffixes), which would delete zero rows, or another machine's rows.
40
+ const currentHost = config.hostname || getHostname().replace(/\.local$/, '');
27
41
  const apiUrl = config.apiUrl || 'https://vibecafe.ai';
28
42
 
29
43
  if (hostOnly) {
30
- const answer = await prompt(`将删除当前机器(${currentHost})的用量数据并从本地日志重新上传,继续? (y/N) `);
44
+ const answer = await ask(`将删除当前机器(${currentHost})的用量数据并从本地日志重新上传,继续? (y/N) `);
31
45
  if (answer.toLowerCase() !== 'y') {
32
46
  console.log(dim('已取消。'));
33
47
  return;
@@ -35,7 +49,7 @@ export async function runReset(args = []) {
35
49
 
36
50
  console.log(dim(` 正在删除 ${currentHost} 的云端数据...`));
37
51
  try {
38
- const result = await deleteAllData(apiUrl, config.apiKey, { hostname: currentHost });
52
+ const result = await deleteRemote(apiUrl, config.apiKey, { hostname: currentHost });
39
53
  console.log(success(`已删除 ${result.deleted} buckets · ${result.sessions ?? 0} sessions`));
40
54
  } catch (err) {
41
55
  if (err.message === 'UNAUTHORIZED') {
@@ -46,7 +60,7 @@ export async function runReset(args = []) {
46
60
  process.exit(1);
47
61
  }
48
62
  } else {
49
- const answer = await prompt('将删除所有用量数据并从本地日志重新上传,继续? (y/N) ');
63
+ const answer = await ask('将删除所有用量数据并从本地日志重新上传,继续? (y/N) ');
50
64
  if (answer.toLowerCase() !== 'y') {
51
65
  console.log(dim('已取消。'));
52
66
  return;
@@ -54,7 +68,7 @@ export async function runReset(args = []) {
54
68
 
55
69
  console.log(dim(' 正在删除所有云端数据...'));
56
70
  try {
57
- const result = await deleteAllData(apiUrl, config.apiKey);
71
+ const result = await deleteRemote(apiUrl, config.apiKey);
58
72
  console.log(success(`已删除 ${result.deleted} buckets · ${result.sessions ?? 0} sessions`));
59
73
  } catch (err) {
60
74
  if (err.message === 'UNAUTHORIZED') {
@@ -66,13 +80,15 @@ export async function runReset(args = []) {
66
80
  }
67
81
  }
68
82
 
69
- // Clear local state (legacy no state files needed for current parsers)
70
- config.lastSync = null;
71
- saveConfig(config);
83
+ // The remote rows are gone, so every local item must count as "changed" on
84
+ // the re-sync below. Without this, sync.js's incremental diff matches every
85
+ // item against state.json and uploads zero bytes — the deleted data would
86
+ // never come back.
87
+ clearState();
72
88
 
73
89
  console.log();
74
90
  console.log(dim(' 从本地日志重新同步...'));
75
- await runSync();
91
+ await resync();
76
92
 
77
93
  console.log();
78
94
  console.log(`${arrow('Dashboard')} ${link(`${apiUrl}/usage`)}`);
package/src/skill.js CHANGED
@@ -68,7 +68,7 @@ Track and answer questions about the user's AI coding token spend via [Vibe Usag
68
68
  - \`summary\` 读 \`~/.vibe-usage/config.json\` 里已有的 API key,不需要用户额外输入
69
69
  - summary 输出已是 markdown,**原样展示,不要复述**
70
70
  - 用户没装过 vibe-usage?提示运行 \`npx @vibe-cafe/vibe-usage\` 先用浏览器登录链接账号
71
- - 支持的工具:Claude Code, Codex CLI, Copilot CLI, Gemini CLI, OpenCode, OpenClaw, Qwen Code, Kimi Code, Amp, Droid
71
+ - 支持的工具:Claude Code, Codex, Grok
72
72
  `;
73
73
 
74
74
  export async function runSkill(args = []) {
package/src/state.js CHANGED
Binary file
package/src/sync.js CHANGED
@@ -34,12 +34,23 @@ export async function runSync({ throws = false, quiet = false } = {}) {
34
34
  const allBuckets = [];
35
35
  const allSessions = [];
36
36
  const parserResults = [];
37
+ // Sources whose parser ran to completion this sync. pruneState() below is
38
+ // scoped to these so a transient parser failure doesn't evict that tool's
39
+ // state and force a full re-upload next run.
40
+ const okSources = new Set();
37
41
 
38
42
  for (const [source, parse] of Object.entries(parsers)) {
39
43
  try {
40
44
  const result = await parse();
41
45
  const buckets = Array.isArray(result) ? result : result.buckets;
42
46
  const sessions = Array.isArray(result) ? [] : (result.sessions || []);
47
+ if (!Array.isArray(buckets) || !Array.isArray(sessions)) {
48
+ throw new TypeError('Parser returned an invalid result');
49
+ }
50
+ // A parser may deliberately suppress a transient error (Cursor network
51
+ // timeout) to keep daemon logs quiet. Its empty result is not proof that
52
+ // its prior data disappeared, so it must not be pruned this run.
53
+ if (!result?.skipped) okSources.add(source);
43
54
  if (buckets.length > 0) allBuckets.push(...buckets);
44
55
  if (sessions.length > 0) allSessions.push(...sessions);
45
56
  if (buckets.length > 0 || sessions.length > 0) {
@@ -52,6 +63,14 @@ export async function runSync({ throws = false, quiet = false } = {}) {
52
63
  }
53
64
 
54
65
  if (allBuckets.length === 0 && allSessions.length === 0) {
66
+ // Successful parsers emitted no live items. Prune their old keys even on
67
+ // this fast path; otherwise deleting the final local log would leave dead
68
+ // state entries forever. Failed-parser sources remain protected.
69
+ const state = loadState();
70
+ const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
71
+ pruneState(state, new Set(), new Set(), okSources);
72
+ const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
73
+ if (pruned > 0) saveState(state);
55
74
  if (!quiet) console.log(dim('暂无新数据。'));
56
75
  return 0;
57
76
  }
@@ -135,7 +154,7 @@ export async function runSync({ throws = false, quiet = false } = {}) {
135
154
  // to upload success. If we deferred this to the batch loop, a first-batch
136
155
  // failure would throw before any saveState and the prune would be lost.
137
156
  const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
138
- pruneState(state, liveBucketKeys, liveSessionKeys);
157
+ pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
139
158
  const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
140
159
  if (pruned > 0) saveState(state);
141
160
 
@@ -241,4 +260,3 @@ export async function runSync({ throws = false, quiet = false } = {}) {
241
260
  process.exit(1);
242
261
  }
243
262
  }
244
-
package/src/tools.js CHANGED
@@ -136,37 +136,47 @@ export function findTraeCliDataDirs() {
136
136
  return [join(xdgCacheHome, 'trae-cli', 'sessions')].filter(existsSync);
137
137
  }
138
138
 
139
+ /** Grok home: GROK_HOME env (same as the Grok CLI) or ~/.grok. */
140
+ export function getGrokHome() {
141
+ const envHome = process.env.GROK_HOME?.trim();
142
+ if (envHome) {
143
+ return envHome.startsWith('~') ? join(homedir(), envHome.slice(1)) : envHome;
144
+ }
145
+ return join(homedir(), '.grok');
146
+ }
147
+
148
+ export function getGrokSessionsDir() {
149
+ const testDir = process.env.VIBE_USAGE_GROK_SESSIONS?.trim();
150
+ if (testDir) return testDir;
151
+ return join(getGrokHome(), 'sessions');
152
+ }
153
+
154
+ // Detect Grok when sessions/ exists under GROK_HOME (or the test override).
155
+ export function findGrokDataDirs() {
156
+ const testDir = process.env.VIBE_USAGE_GROK_SESSIONS?.trim();
157
+ if (testDir) return [testDir].filter(existsSync);
158
+ return [join(getGrokHome(), 'sessions')].filter(existsSync);
159
+ }
160
+
139
161
  export const TOOLS = [
140
- {
141
- name: 'Trae CLI',
142
- id: 'trae-cli',
143
- dataDir: join(homedir(), 'Library', 'Caches', 'trae-cli', 'sessions'),
144
- detectDataDirs: findTraeCliDataDirs,
145
- },
146
- {
147
- name: 'Antigravity',
148
- id: 'antigravity',
149
- dataDir: join(homedir(), '.gemini', 'antigravity'),
150
- detectDataDirs: findAntigravityDataDirs,
151
- },
152
162
  {
153
163
  name: 'Claude Code',
154
164
  id: 'claude-code',
155
165
  dataDir: join(homedir(), '.claude', 'projects'),
156
166
  detectDataDirs: findClaudeCodeDataDirs,
157
167
  },
158
- {
159
- name: 'Cline',
160
- id: 'cline',
161
- dataDir: join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev'),
162
- detectDataDirs: findClineDataDirs,
163
- },
164
168
  {
165
169
  name: 'Codex CLI',
166
170
  id: 'codex',
167
171
  dataDir: join(homedir(), '.codex', 'sessions'),
168
172
  detectDataDirs: findCodexDataDirs,
169
173
  },
174
+ {
175
+ name: 'Grok',
176
+ id: 'grok',
177
+ dataDir: join(homedir(), '.grok', 'sessions'),
178
+ detectDataDirs: findGrokDataDirs,
179
+ },
170
180
  {
171
181
  name: 'GitHub Copilot CLI',
172
182
  id: 'copilot-cli',
@@ -221,6 +231,18 @@ export const TOOLS = [
221
231
  id: 'droid',
222
232
  dataDir: join(homedir(), '.factory', 'sessions'),
223
233
  },
234
+ {
235
+ name: 'Antigravity',
236
+ id: 'antigravity',
237
+ dataDir: join(homedir(), '.gemini', 'antigravity'),
238
+ detectDataDirs: findAntigravityDataDirs,
239
+ },
240
+ {
241
+ name: 'Trae CLI',
242
+ id: 'trae-cli',
243
+ dataDir: join(homedir(), 'Library', 'Caches', 'trae-cli', 'sessions'),
244
+ detectDataDirs: findTraeCliDataDirs,
245
+ },
224
246
  {
225
247
  name: 'Hermes',
226
248
  id: 'hermes',
@@ -231,6 +253,12 @@ export const TOOLS = [
231
253
  id: 'kiro',
232
254
  dataDir: getKiroAgentPath(),
233
255
  },
256
+ {
257
+ name: 'Cline',
258
+ id: 'cline',
259
+ dataDir: join(homedir(), 'Library', 'Application Support', 'Code', 'User', 'globalStorage', 'saoudrizwan.claude-dev'),
260
+ detectDataDirs: findClineDataDirs,
261
+ },
234
262
  {
235
263
  name: 'Roo Code',
236
264
  id: 'roo-code',