@vibe-cafe/vibe-usage 0.9.14 → 0.9.16
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 +5 -3
- package/package.json +1 -1
- package/src/config.js +14 -3
- package/src/daemon-service.js +15 -3
- package/src/index.js +21 -7
- package/src/parsers/codex.js +83 -18
- package/src/parsers/cursor.js +3 -1
- package/src/parsers/kimi-code.js +38 -18
- package/src/reset.js +28 -12
- package/src/state.js +0 -0
- package/src/sync.js +20 -2
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
|
|
@@ -60,7 +60,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
60
60
|
| OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments) |
|
|
61
61
|
| pi | `~/.pi/agent/sessions/` |
|
|
62
62
|
| Qwen Code | `~/.qwen/tmp/` |
|
|
63
|
-
| 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 |
|
|
64
64
|
| Amp | `~/.local/share/amp/threads/` |
|
|
65
65
|
| Droid | `~/.factory/sessions/` |
|
|
66
66
|
| Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
|
|
@@ -77,7 +77,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
77
77
|
- Aggregates token usage into 30-minute buckets
|
|
78
78
|
- Extracts session metadata from all parsers: active time (AI generation time, excluding queue/TTFT wait), total duration, message counts
|
|
79
79
|
- Uploads buckets + sessions to your vibecafe.ai dashboard (always gzip-compressed, ~94% smaller)
|
|
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`;
|
|
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
|
|
81
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
|
|
82
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)
|
|
83
83
|
|
|
@@ -125,6 +125,8 @@ npx skills add vibe-cafe/vibe-usage
|
|
|
125
125
|
|
|
126
126
|
## Development
|
|
127
127
|
|
|
128
|
+
Run the Node test suite locally with `npm test`. CI covers Node 20 and 22 on Ubuntu and macOS.
|
|
129
|
+
|
|
128
130
|
Test against a local vibe-cafe dev server without publishing:
|
|
129
131
|
|
|
130
132
|
```bash
|
package/package.json
CHANGED
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/codex.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createReadStream, readdirSync, existsSync } from 'node:fs';
|
|
1
|
+
import { createReadStream, readdirSync, existsSync, statSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { createInterface } from 'node:readline';
|
|
@@ -42,9 +42,15 @@ function findJsonlFiles(dir) {
|
|
|
42
42
|
return results;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
function readLines(filePath) {
|
|
45
|
+
function readLines(filePath, snapshotSize) {
|
|
46
46
|
return createInterface({
|
|
47
|
-
input: createReadStream(filePath, {
|
|
47
|
+
input: createReadStream(filePath, {
|
|
48
|
+
encoding: 'utf-8',
|
|
49
|
+
// Rollouts are append-only while Codex is working. Bound both parser
|
|
50
|
+
// passes to the size captured before pass 1 so they see the same prefix
|
|
51
|
+
// even when the live file grows between reads.
|
|
52
|
+
...(snapshotSize == null ? {} : { end: snapshotSize - 1 }),
|
|
53
|
+
}),
|
|
48
54
|
crlfDelay: Infinity,
|
|
49
55
|
});
|
|
50
56
|
}
|
|
@@ -143,6 +149,34 @@ function longestReplayPrefix(child, parent) {
|
|
|
143
149
|
return matched;
|
|
144
150
|
}
|
|
145
151
|
|
|
152
|
+
/**
|
|
153
|
+
* Return the longest prefix of `child` found contiguously anywhere in
|
|
154
|
+
* `parent`. A live sub-agent rollout can be observed while Codex is still
|
|
155
|
+
* copying the parent block, before that copy reaches the parent snapshot's
|
|
156
|
+
* end. In that state the exact records are inherited history even though the
|
|
157
|
+
* stricter completed-replay suffix match above deliberately rejects them.
|
|
158
|
+
*/
|
|
159
|
+
function longestPartialReplayPrefix(child, parent) {
|
|
160
|
+
if (child.length === 0 || parent.length === 0) return 0;
|
|
161
|
+
|
|
162
|
+
const prefix = new Array(child.length).fill(0);
|
|
163
|
+
for (let i = 1, matched = 0; i < child.length; i++) {
|
|
164
|
+
while (matched > 0 && child[i] !== child[matched]) matched = prefix[matched - 1];
|
|
165
|
+
if (child[i] === child[matched]) matched++;
|
|
166
|
+
prefix[i] = matched;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
let matched = 0;
|
|
170
|
+
let longest = 0;
|
|
171
|
+
for (const fingerprint of parent) {
|
|
172
|
+
while (matched > 0 && fingerprint !== child[matched]) matched = prefix[matched - 1];
|
|
173
|
+
if (fingerprint === child[matched]) matched++;
|
|
174
|
+
longest = Math.max(longest, matched);
|
|
175
|
+
if (matched === child.length) matched = prefix[matched - 1];
|
|
176
|
+
}
|
|
177
|
+
return longest;
|
|
178
|
+
}
|
|
179
|
+
|
|
146
180
|
// `task_started.started_at` is stored at one-second precision while the
|
|
147
181
|
// canonical session timestamp has milliseconds. Real Codex Desktop rollouts
|
|
148
182
|
// start the child task within a few seconds of creating the child session.
|
|
@@ -160,7 +194,7 @@ const OWN_TASK_START_WINDOW_MS = 5_000;
|
|
|
160
194
|
* full prefix. Together they bound matching to source records that existed at
|
|
161
195
|
* spawn without over-skipping child work when the parent later grows.
|
|
162
196
|
*/
|
|
163
|
-
async function indexSessionFile(filePath) {
|
|
197
|
+
async function indexSessionFile(filePath, snapshotSize) {
|
|
164
198
|
let sessionId = null;
|
|
165
199
|
let forkedFromId = null;
|
|
166
200
|
let parentThreadId = null;
|
|
@@ -178,7 +212,7 @@ async function indexSessionFile(filePath) {
|
|
|
178
212
|
let firstTaskBoundary = null;
|
|
179
213
|
let ownTaskBoundary = null;
|
|
180
214
|
|
|
181
|
-
for await (const line of readLines(filePath)) {
|
|
215
|
+
for await (const line of readLines(filePath, snapshotSize)) {
|
|
182
216
|
if (!line.trim()) continue;
|
|
183
217
|
try {
|
|
184
218
|
const obj = JSON.parse(line);
|
|
@@ -261,12 +295,13 @@ function replayBoundary(meta, sessionById) {
|
|
|
261
295
|
const parentAtSpawn = parent && meta.sessionStartedAtMs != null
|
|
262
296
|
? upperBound(parent.tokenTimes, meta.sessionStartedAtMs)
|
|
263
297
|
: null;
|
|
264
|
-
const
|
|
265
|
-
?
|
|
266
|
-
:
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
)
|
|
298
|
+
const parentSnapshot = parentAtSpawn == null
|
|
299
|
+
? []
|
|
300
|
+
: parent.tokenFingerprints.slice(0, parentAtSpawn);
|
|
301
|
+
const replayTokenCount = longestReplayPrefix(meta.tokenFingerprints, parentSnapshot);
|
|
302
|
+
const partialReplayTokenCount = meta.isSubagent
|
|
303
|
+
? longestPartialReplayPrefix(meta.tokenFingerprints, parentSnapshot)
|
|
304
|
+
: 0;
|
|
270
305
|
|
|
271
306
|
if (meta.isSubagent) {
|
|
272
307
|
// Direct evidence inside the child wins. Legacy single-meta rollouts did
|
|
@@ -292,11 +327,25 @@ function replayBoundary(meta, sessionById) {
|
|
|
292
327
|
: null);
|
|
293
328
|
if (direct) {
|
|
294
329
|
return {
|
|
295
|
-
rawTokenCount: Math.max(
|
|
330
|
+
rawTokenCount: Math.max(
|
|
331
|
+
replayTokenCount,
|
|
332
|
+
partialReplayTokenCount,
|
|
333
|
+
direct.rawTokenCount
|
|
334
|
+
),
|
|
296
335
|
recordIndex: direct.recordIndex,
|
|
297
336
|
};
|
|
298
337
|
}
|
|
299
|
-
|
|
338
|
+
|
|
339
|
+
// A recognized sub-agent can be synced while Codex is only partway
|
|
340
|
+
// through appending the copied parent block. The completed-replay matcher
|
|
341
|
+
// correctly rejects that interior slice, but counting it would create a
|
|
342
|
+
// temporary spike that disappears on the next sync. Exact payload overlap
|
|
343
|
+
// with the known parent is sufficient evidence to defer those leading
|
|
344
|
+
// records until the rollout reaches a stable suffix or task boundary.
|
|
345
|
+
return {
|
|
346
|
+
rawTokenCount: Math.max(replayTokenCount, partialReplayTokenCount),
|
|
347
|
+
recordIndex: null,
|
|
348
|
+
};
|
|
300
349
|
}
|
|
301
350
|
|
|
302
351
|
if (meta.forkedFromId) {
|
|
@@ -311,7 +360,16 @@ export async function parse() {
|
|
|
311
360
|
|
|
312
361
|
const entries = [];
|
|
313
362
|
const sessionEvents = [];
|
|
314
|
-
const files =
|
|
363
|
+
const files = [];
|
|
364
|
+
for (const filePath of dirs.flatMap(findJsonlFiles)) {
|
|
365
|
+
try {
|
|
366
|
+
const snapshotSize = statSync(filePath).size;
|
|
367
|
+
if (snapshotSize > 0) files.push({ filePath, snapshotSize });
|
|
368
|
+
} catch {
|
|
369
|
+
// The file may move to archived_sessions between discovery and stat.
|
|
370
|
+
// Its archived copy will be picked up on the next sync.
|
|
371
|
+
}
|
|
372
|
+
}
|
|
315
373
|
if (files.length === 0) return { buckets: [], sessions: [] };
|
|
316
374
|
|
|
317
375
|
// Pass 1: build a compact per-file index. Keep the most complete physical
|
|
@@ -319,10 +377,10 @@ export async function parse() {
|
|
|
319
377
|
// sessions/ and archived_sessions/ cannot double its token buckets.
|
|
320
378
|
const sessionById = new Map();
|
|
321
379
|
const fileMeta = new Map();
|
|
322
|
-
for (const filePath of files) {
|
|
380
|
+
for (const { filePath, snapshotSize } of files) {
|
|
323
381
|
let meta;
|
|
324
382
|
try {
|
|
325
|
-
meta = await indexSessionFile(filePath);
|
|
383
|
+
meta = await indexSessionFile(filePath, snapshotSize);
|
|
326
384
|
} catch {
|
|
327
385
|
continue;
|
|
328
386
|
}
|
|
@@ -336,7 +394,7 @@ export async function parse() {
|
|
|
336
394
|
}
|
|
337
395
|
|
|
338
396
|
// Pass 2: parse usage while skipping the replay prefix resolved above.
|
|
339
|
-
for (const filePath of files) {
|
|
397
|
+
for (const { filePath, snapshotSize } of files) {
|
|
340
398
|
const fm = fileMeta.get(filePath);
|
|
341
399
|
if (!fm) continue;
|
|
342
400
|
if (fm.sessionId && sessionById.get(fm.sessionId)?.filePath !== filePath) continue;
|
|
@@ -357,7 +415,7 @@ export async function parse() {
|
|
|
357
415
|
let turnContextModel = 'unknown';
|
|
358
416
|
let prevTotal = null;
|
|
359
417
|
let prevCumulativeTotal = null;
|
|
360
|
-
for await (const line of readLines(filePath)) {
|
|
418
|
+
for await (const line of readLines(filePath, snapshotSize)) {
|
|
361
419
|
if (!line.trim()) continue;
|
|
362
420
|
try {
|
|
363
421
|
const obj = JSON.parse(line);
|
|
@@ -483,6 +541,13 @@ export async function parse() {
|
|
|
483
541
|
continue;
|
|
484
542
|
}
|
|
485
543
|
}
|
|
544
|
+
|
|
545
|
+
// The byte bound makes append-only growth invisible to both passes. If a
|
|
546
|
+
// rollout was instead truncated or replaced in place, fail the Codex
|
|
547
|
+
// parser rather than upload totals produced from two different snapshots.
|
|
548
|
+
if (parsedRecordIndex !== fm.parsedRecordCount || rawTokenSeen !== fm.rawTokenCount) {
|
|
549
|
+
throw new Error('Codex rollout changed while syncing; retry on the next sync');
|
|
550
|
+
}
|
|
486
551
|
}
|
|
487
552
|
|
|
488
553
|
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
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);
|
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/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
|
-
|