@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 +6 -3
- package/package.json +2 -1
- package/src/config.js +14 -3
- package/src/daemon-service.js +15 -3
- package/src/index.js +21 -7
- package/src/parsers/cursor.js +3 -1
- package/src/parsers/grok.js +325 -0
- package/src/parsers/index.js +6 -4
- package/src/parsers/kimi-code.js +38 -18
- package/src/reset.js +28 -12
- package/src/skill.js +1 -1
- package/src/state.js +0 -0
- package/src/sync.js +20 -2
- package/src/tools.js +46 -18
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
|
|
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`;
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/daemon-service.js
CHANGED
|
@@ -236,7 +236,16 @@ function stop() {
|
|
|
236
236
|
}
|
|
237
237
|
|
|
238
238
|
if (plat === 'launchd') {
|
|
239
|
-
|
|
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
|
-
|
|
258
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
package/src/parsers/cursor.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/parsers/index.js
CHANGED
|
@@ -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
|
-
'
|
|
41
|
+
'cline': parseCline,
|
|
42
|
+
'roo-code': parseRooCode,
|
|
40
43
|
'zcode': parseZcode,
|
|
41
|
-
'trae-cli': parseTraeCli,
|
|
42
44
|
};
|
|
43
45
|
|
|
44
46
|
|
package/src/parsers/kimi-code.js
CHANGED
|
@@ -33,7 +33,8 @@ import { aggregateToBuckets, extractSessions } from './index.js';
|
|
|
33
33
|
// Current format: ~/.kimi-code
|
|
34
34
|
// ---------------------------------------------------------------------------
|
|
35
35
|
|
|
36
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
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
|
|
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
|
|
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',
|