@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 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/<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 |
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`; 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
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.9.14",
3
+ "version": "0.9.16",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
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
  }
@@ -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, { encoding: 'utf-8' }),
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 replayTokenCount = parentAtSpawn == null
265
- ? 0
266
- : longestReplayPrefix(
267
- meta.tokenFingerprints,
268
- parent.tokenFingerprints.slice(0, parentAtSpawn)
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(replayTokenCount, direct.rawTokenCount),
330
+ rawTokenCount: Math.max(
331
+ replayTokenCount,
332
+ partialReplayTokenCount,
333
+ direct.rawTokenCount
334
+ ),
296
335
  recordIndex: direct.recordIndex,
297
336
  };
298
337
  }
299
- return { rawTokenCount: replayTokenCount, recordIndex: null };
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 = dirs.flatMap(findJsonlFiles);
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) };
@@ -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);
@@ -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/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
-