@vibe-cafe/vibe-usage 0.10.9 → 0.10.11

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.
@@ -1,4 +1,3 @@
1
- import { createHash } from 'node:crypto';
2
1
  import { parse as parseClaudeCode } from './claude-code.js';
3
2
  import { parse as parseCline } from './cline.js';
4
3
  import { parse as parseCodex } from './codex.js';
@@ -17,6 +16,7 @@ import { parse as parseKimiCode } from './kimi-code.js';
17
16
  import { parse as parseAmp } from './amp.js';
18
17
  import { parse as parseAlma } from './alma.js';
19
18
  import { parse as parseDroid } from './droid.js';
19
+ import { parse as parseDsh } from './dsh.js';
20
20
  import { parse as parseAntigravity } from './antigravity.js';
21
21
  import { parse as parseHermes } from './hermes.js';
22
22
  import { parse as parseKiro } from './kiro.js';
@@ -44,6 +44,7 @@ export const parsers = {
44
44
  'amp': parseAmp,
45
45
  'alma': parseAlma,
46
46
  'droid': parseDroid,
47
+ 'dsh': parseDsh,
47
48
  'antigravity': parseAntigravity,
48
49
  'trae-cli': parseTraeCli,
49
50
  'hermes': parseHermes,
@@ -55,150 +56,4 @@ export const parsers = {
55
56
  'zcode': parseZcode,
56
57
  };
57
58
 
58
-
59
- export function roundToHalfHour(date) {
60
- const d = new Date(date);
61
- d.setMinutes(d.getMinutes() < 30 ? 0 : 30, 0, 0);
62
- return d;
63
- }
64
-
65
- // Server column limits (usage_buckets: model varchar(100), project varchar(200)).
66
- // Anything longer aborts the whole INSERT chunk with 22001, so clamp here.
67
- const MODEL_MAX_LENGTH = 100;
68
- const PROJECT_MAX_LENGTH = 200;
69
-
70
- // Server token columns are bigint — a single fractional/NaN value aborts the
71
- // whole INSERT chunk with 22P02, taking every other tool's rows in the batch
72
- // down with it.
73
- function toTokenCount(value) {
74
- const n = Number(value);
75
- if (!Number.isFinite(n) || n <= 0) return 0;
76
- return Math.round(n);
77
- }
78
-
79
- export function aggregateToBuckets(entries) {
80
- const map = new Map();
81
-
82
- for (const e of entries) {
83
- const model = String(e.model || 'unknown').slice(0, MODEL_MAX_LENGTH);
84
- const project = String(e.project || 'unknown').slice(0, PROJECT_MAX_LENGTH);
85
- const bucketStart = roundToHalfHour(e.timestamp).toISOString();
86
- const key = `${e.source}|${model}|${project}|${e.hostname || ''}|${bucketStart}`;
87
-
88
- if (!map.has(key)) {
89
- map.set(key, {
90
- source: e.source,
91
- model,
92
- project,
93
- // Cloud-sourced parsers (cursor) pre-set a fixed hostname sentinel; it
94
- // must survive aggregation, or sync.js stamps the machine hostname and
95
- // every machine gets its own duplicate row server-side.
96
- ...(e.hostname ? { hostname: e.hostname } : {}),
97
- bucketStart,
98
- inputTokens: 0,
99
- outputTokens: 0,
100
- cachedInputTokens: 0,
101
- reasoningOutputTokens: 0,
102
- });
103
- }
104
-
105
- const b = map.get(key);
106
- b.inputTokens += e.inputTokens || 0;
107
- b.outputTokens += e.outputTokens || 0;
108
- b.cachedInputTokens += e.cachedInputTokens || 0;
109
- b.reasoningOutputTokens += e.reasoningOutputTokens || 0;
110
- }
111
-
112
- // Clamp after summation, not per entry — rounding each entry first would
113
- // discard sub-integer values instead of letting them accumulate.
114
- return Array.from(map.values()).map((b) => {
115
- const inputTokens = toTokenCount(b.inputTokens);
116
- const outputTokens = toTokenCount(b.outputTokens);
117
- const cachedInputTokens = toTokenCount(b.cachedInputTokens);
118
- const reasoningOutputTokens = toTokenCount(b.reasoningOutputTokens);
119
- return {
120
- ...b,
121
- inputTokens,
122
- outputTokens,
123
- cachedInputTokens,
124
- reasoningOutputTokens,
125
- totalTokens: inputTokens + outputTokens + reasoningOutputTokens,
126
- };
127
- });
128
- }
129
-
130
- /**
131
- * Extract session metadata from timing events.
132
- * Each event: { sessionId, source, project, timestamp: Date, role: 'user'|'assistant' }
133
- *
134
- * Turn = first AI response → last AI response before next user prompt.
135
- * activeSeconds = sum(generation durations), excluding queue/TTFT wait.
136
- * durationSeconds = wall clock from first to last message.
137
- */
138
- export function extractSessions(events) {
139
- const groups = new Map();
140
- for (const e of events) {
141
- if (!groups.has(e.sessionId)) groups.set(e.sessionId, []);
142
- groups.get(e.sessionId).push(e);
143
- }
144
-
145
- const sessions = [];
146
- for (const [sessionId, sessionEvents] of groups) {
147
- sessionEvents.sort((a, b) => a.timestamp - b.timestamp);
148
-
149
- const first = sessionEvents[0];
150
- const last = sessionEvents[sessionEvents.length - 1];
151
- const durationSeconds = Math.round((last.timestamp - first.timestamp) / 1000);
152
-
153
- let activeSeconds = 0;
154
- let turnStart = null;
155
- let turnEnd = null;
156
- let waitingForFirstResponse = false;
157
-
158
- for (const event of sessionEvents) {
159
- if (event.role === 'user') {
160
- if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
161
- activeSeconds += Math.round((turnEnd - turnStart) / 1000);
162
- }
163
- turnStart = null;
164
- turnEnd = null;
165
- waitingForFirstResponse = true;
166
- } else if (waitingForFirstResponse) {
167
- turnStart = event.timestamp;
168
- turnEnd = event.timestamp;
169
- waitingForFirstResponse = false;
170
- } else if (turnStart !== null) {
171
- turnEnd = event.timestamp;
172
- }
173
- }
174
- if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
175
- activeSeconds += Math.round((turnEnd - turnStart) / 1000);
176
- }
177
-
178
- const userPromptHours = new Array(24).fill(0);
179
- let userMessageCount = 0;
180
- for (const event of sessionEvents) {
181
- if (event.role === 'user') {
182
- userMessageCount++;
183
- userPromptHours[event.timestamp.getUTCHours()]++;
184
- }
185
- }
186
-
187
- const sessionHash = createHash('sha256').update(sessionId).digest('hex').slice(0, 16);
188
-
189
- sessions.push({
190
- source: first.source,
191
- project: first.project || 'unknown',
192
- sessionHash,
193
- firstMessageAt: first.timestamp.toISOString(),
194
- lastMessageAt: last.timestamp.toISOString(),
195
- durationSeconds,
196
- activeSeconds,
197
- messageCount: sessionEvents.length,
198
- userMessageCount,
199
- userPromptHours,
200
- });
201
- }
202
-
203
- return sessions;
204
- }
59
+ export { roundToHalfHour, aggregateToBuckets, extractSessions } from './aggregate.js';
@@ -2,7 +2,7 @@ import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { join, basename } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createHash } from 'node:crypto';
5
- import { aggregateToBuckets, extractSessions } from './index.js';
5
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
6
6
 
7
7
  /**
8
8
  * Kimi Code CLI parser. MoonshotAI/kimi-cli (a.k.a. "Kimi Code").
@@ -1,18 +1,15 @@
1
1
  import {
2
- copyFileSync,
3
2
  createReadStream,
4
3
  existsSync,
5
- mkdtempSync,
6
4
  readFileSync,
7
5
  readdirSync,
8
- rmSync,
9
6
  statSync,
10
7
  } from 'node:fs';
11
8
  import { createInterface } from 'node:readline';
12
9
  import { dirname, join, resolve } from 'node:path';
13
- import { homedir, tmpdir } from 'node:os';
14
- import { aggregateToBuckets } from './index.js';
15
- import { queryDbJson } from './sqlite.js';
10
+ import { homedir } from 'node:os';
11
+ import { aggregateToBuckets } from './aggregate.js';
12
+ import { queryDbJsonSnapshotOnLock, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
16
13
 
17
14
  const KIRO_AGENT_RELATIVE = join('User', 'globalStorage', 'kiro.kiroagent');
18
15
  const KIRO_USER_RELATIVE = 'User';
@@ -122,37 +119,9 @@ export function getKiroCliSessionsDir() {
122
119
  return existsSync(def) ? def : null;
123
120
  }
124
121
 
125
- function isLockError(err) {
126
- return err && typeof err.message === 'string' && /database is locked/i.test(err.message);
127
- }
128
-
129
- function queryDb(dbPath, sql) {
130
- return queryDbJson(dbPath, sql);
131
- }
132
-
133
- function queryDbSnapshotOnLock(dbPath, sql) {
134
- try {
135
- return queryDb(dbPath, sql);
136
- } catch (err) {
137
- if (!isLockError(err)) throw err;
138
- const snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-kiro-'));
139
- const queryPath = join(snapshotDir, 'data.sqlite3');
140
- copyFileSync(dbPath, queryPath);
141
- for (const suffix of ['-shm', '-wal']) {
142
- const companion = `${dbPath}${suffix}`;
143
- if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
144
- }
145
- try {
146
- return queryDb(queryPath, sql);
147
- } finally {
148
- rmSync(snapshotDir, { recursive: true, force: true });
149
- }
150
- }
151
- }
152
-
153
122
  function queryOptionalDb(dbPath, sql) {
154
123
  try {
155
- return queryDbSnapshotOnLock(dbPath, sql);
124
+ return queryDbJsonSnapshotOnLock(dbPath, sql, { tempPrefix: 'vibe-usage-kiro-' });
156
125
  } catch (err) {
157
126
  const msg = err && typeof err.message === 'string' ? err.message : '';
158
127
  if (/no such table|no such column/i.test(msg)) return [];
@@ -167,7 +136,7 @@ const TOKENS_SQL =
167
136
  'ORDER BY id ASC';
168
137
 
169
138
  function readLegacyDb(dbPath) {
170
- return queryDbSnapshotOnLock(dbPath, TOKENS_SQL);
139
+ return queryDbJsonSnapshotOnLock(dbPath, TOKENS_SQL, { tempPrefix: 'vibe-usage-kiro-' });
171
140
  }
172
141
 
173
142
  // Legacy Kiro dev telemetry fallback. This is opt-in because recent Kiro builds
@@ -790,9 +759,7 @@ export async function parse() {
790
759
  return { buckets: aggregateToBuckets(estimateEntries), sessions: [] };
791
760
  }
792
761
  } catch (err) {
793
- if (err && typeof err.message === 'string' && err.message.includes('ENOENT')) {
794
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Kiro CLI data.');
795
- }
762
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('Kiro CLI');
796
763
  throw err;
797
764
  }
798
765
 
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { basename } from 'node:path';
3
3
  import { getMimocodeDbPath } from '../tools.js';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
5
- import { queryDbJson } from './sqlite.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
6
 
7
7
  export { getMimocodeDbPath as resolveMimocodeDbPath };
8
8
 
@@ -36,9 +36,7 @@ export async function parse() {
36
36
  ${externalImportFilter}
37
37
  `);
38
38
  } catch (err) {
39
- if (err.status === 127 || err.message?.includes('ENOENT')) {
40
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync MiMoCode data.');
41
- }
39
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('MiMoCode');
42
40
  throw err;
43
41
  }
44
42
 
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
5
 
6
6
  // OpenClaw stores data at ~/.openclaw/agents/<agentId>/sessions/*.jsonl
7
7
  // Profile deployments use ~/.openclaw-<profile>/agents/...
@@ -1,8 +1,8 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { join, basename } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
5
- import { queryDbJson } from './sqlite.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
6
 
7
7
  const DATA_DIR = join(homedir(), '.local', 'share', 'opencode');
8
8
  const DB_PATH = join(DATA_DIR, 'opencode.db');
@@ -37,9 +37,7 @@ function parseFromSqlite() {
37
37
  try {
38
38
  rows = queryDbJson(DB_PATH, query);
39
39
  } catch (err) {
40
- if (err.status === 127 || (err.message && err.message.includes('ENOENT'))) {
41
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync opencode data.');
42
- }
40
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('OpenCode');
43
41
  throw err;
44
42
  }
45
43
  if (!rows.length) return { buckets: [], sessions: [] };
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
2
  import { basename, join, relative } from 'node:path';
3
- import { aggregateToBuckets, extractSessions } from './index.js';
3
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
4
+ import { projectFromCwd, toCount } from './fs-utils.js';
4
5
 
5
6
  const MAX_WARNINGS = 20;
6
7
 
@@ -28,17 +29,6 @@ function findJsonlFiles(dir, includeFile, ctx) {
28
29
  return files;
29
30
  }
30
31
 
31
- function tokenCount(value) {
32
- const number = Number(value);
33
- return Number.isFinite(number) && number > 0 ? number : 0;
34
- }
35
-
36
- export function projectFromCwd(cwd) {
37
- if (typeof cwd !== 'string') return 'unknown';
38
- const parts = cwd.replace(/\\/g, '/').split('/').filter(Boolean);
39
- return parts.at(-1) || 'unknown';
40
- }
41
-
42
32
  export function projectFromFirstDir(filePath, sessionsDir) {
43
33
  const first = relative(sessionsDir, filePath).split(/[\\/]/)[0];
44
34
  if (!first) return 'unknown';
@@ -105,12 +95,12 @@ export async function parsePiSessionJsonl({
105
95
 
106
96
  if (message.role !== 'assistant' || !message.usage) continue;
107
97
  const usage = message.usage;
108
- const inputTokens = tokenCount(usage.input) + tokenCount(usage.cacheWrite);
109
- const reasoningOutputTokens = tokenCount(usage.reasoningTokens);
98
+ const inputTokens = toCount(usage.input) + toCount(usage.cacheWrite);
99
+ const reasoningOutputTokens = toCount(usage.reasoningTokens);
110
100
  // OMP/Pi usage.output includes reasoning; the shared bucket contract
111
101
  // stores non-reasoning output and reasoning separately.
112
- const outputTokens = Math.max(0, tokenCount(usage.output) - reasoningOutputTokens);
113
- const cachedInputTokens = tokenCount(usage.cacheRead);
102
+ const outputTokens = Math.max(0, toCount(usage.output) - reasoningOutputTokens);
103
+ const cachedInputTokens = toCount(usage.cacheRead);
114
104
  const score = inputTokens + outputTokens + cachedInputTokens + reasoningOutputTokens;
115
105
  if (score === 0) continue;
116
106
 
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { join, basename, sep } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
5
 
6
6
  /**
7
7
  * Qwen Code parser (Gemini CLI fork).
@@ -1,7 +1,8 @@
1
- import { readFileSync, readdirSync, statSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
1
+ import { readdirSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { readJsonSafe, projectFromPath } from './fs-utils.js';
5
6
 
6
7
  const EXTENSION_ID = 'rooveterinaryinc.roo-cline';
7
8
 
@@ -35,17 +36,6 @@ export function findRooCodeExtensionDirs() {
35
36
  return dirs;
36
37
  }
37
38
 
38
- function readJsonSafe(path) {
39
- try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
40
- }
41
-
42
- function projectFromPath(absPath) {
43
- if (!absPath || typeof absPath !== 'string') return 'unknown';
44
- const trimmed = absPath.replace(/[\\/]+$/, '');
45
- const name = basename(trimmed);
46
- return name || 'unknown';
47
- }
48
-
49
39
  // Read all HistoryItems from `_index.json` if present, else fall back to
50
40
  // scanning per-task `history_item.json` files (Roo migrated to per-task
51
41
  // files in 2025; the index is a cache).
@@ -1,5 +1,8 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { createRequire } from 'node:module';
3
+ import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { basename, join } from 'node:path';
3
6
 
4
7
  const require = createRequire(import.meta.url);
5
8
 
@@ -72,3 +75,46 @@ function queryViaCli(dbPath, sql, { timeout, maxBuffer }) {
72
75
  if (!trimmed || trimmed === '[]') return [];
73
76
  return JSON.parse(trimmed);
74
77
  }
78
+
79
+ /** Standard "sqlite3 unavailable" hint, reused by every SQLite-backed parser. */
80
+ export function sqliteUnavailableError(label) {
81
+ return new Error(`sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync ${label} data.`);
82
+ }
83
+
84
+ /** True when the error is the "no sqlite3" hint (node:sqlite absent + CLI absent). */
85
+ export function isSqliteUnavailableError(err) {
86
+ return !!err && (
87
+ err.code === 'ENOENT'
88
+ || err.status === 127
89
+ || /ENOENT|sqlite3.*not found/i.test(err?.message || '')
90
+ );
91
+ }
92
+
93
+ export function isLockError(err) {
94
+ return !!err && typeof err.message === 'string' && /database is locked/i.test(err.message);
95
+ }
96
+
97
+ /**
98
+ * Run a query, and if the source app holds a write lock on the database, copy
99
+ * the DB (plus its -wal/-shm companions) to a temp dir and re-query the
100
+ * snapshot. Shared by Cursor / Antigravity / Kiro.
101
+ */
102
+ export function queryDbJsonSnapshotOnLock(dbPath, sql, { tempPrefix = 'vibe-usage-sqlite', opts } = {}) {
103
+ try {
104
+ return queryDbJson(dbPath, sql, opts);
105
+ } catch (err) {
106
+ if (!isLockError(err)) throw err;
107
+ const snapshotDir = mkdtempSync(join(tmpdir(), tempPrefix));
108
+ const queryPath = join(snapshotDir, basename(dbPath));
109
+ try {
110
+ copyFileSync(dbPath, queryPath);
111
+ for (const suffix of ['-shm', '-wal']) {
112
+ const companion = `${dbPath}${suffix}`;
113
+ if (existsSync(companion)) copyFileSync(companion, `${queryPath}${suffix}`);
114
+ }
115
+ return queryDbJson(queryPath, sql, opts);
116
+ } finally {
117
+ rmSync(snapshotDir, { recursive: true, force: true });
118
+ }
119
+ }
120
+ }
@@ -1,18 +1,8 @@
1
1
  import { readFileSync, readdirSync } from 'node:fs';
2
- import { basename, join } from 'node:path';
2
+ import { join } from 'node:path';
3
3
  import { findTraeCliDataDirs } from '../tools.js';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
5
-
6
- function readJsonSafe(path) {
7
- try { return JSON.parse(readFileSync(path, 'utf-8')); } catch { return null; }
8
- }
9
-
10
- function projectFromPath(absPath) {
11
- if (!absPath || typeof absPath !== 'string') return 'unknown';
12
- const trimmed = absPath.replace(/[\\/]+$/, '');
13
- const name = basename(trimmed);
14
- return name || 'unknown';
15
- }
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { readJsonSafe, projectFromPath } from './fs-utils.js';
16
6
 
17
7
  function parseJsonlSafe(path) {
18
8
  try {
@@ -2,7 +2,7 @@ import { createReadStream, readdirSync, statSync } from 'node:fs';
2
2
  import { createInterface } from 'node:readline';
3
3
  import { basename, join, relative, sep } from 'node:path';
4
4
  import { findWorkbuddyDataDirs } from '../workbuddy-roots.js';
5
- import { aggregateToBuckets, extractSessions } from './index.js';
5
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
6
6
 
7
7
  const SOURCE = 'workbuddy';
8
8
  const MAX_WARNINGS = 20;
@@ -1,8 +1,8 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join, basename } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { aggregateToBuckets, extractSessions } from './index.js';
5
- import { queryDbJson } from './sqlite.js';
4
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
+ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
6
6
 
7
7
  // ZCode (z.ai / Zhipu's coding agent) stores everything in a SQLite database
8
8
  // at ~/.zcode/cli/db/db.sqlite. The `message` table is the canonical source:
@@ -46,9 +46,7 @@ export async function parse() {
46
46
  try {
47
47
  rows = queryDbJson(DB_PATH, query);
48
48
  } catch (err) {
49
- if (err.status === 127 || (err.message && err.message.includes('ENOENT'))) {
50
- throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync ZCode data.');
51
- }
49
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('ZCode');
52
50
  throw err;
53
51
  }
54
52
  if (!rows.length) return { buckets: [], sessions: [] };
package/src/state.js CHANGED
@@ -1,7 +1,7 @@
1
- import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
1
+ import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync, renameSync, rmSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
- import { createHash } from 'node:crypto';
4
+ import { createHash, randomBytes } from 'node:crypto';
5
5
 
6
6
  // Persisted sync state, kept next to config.js's files (same dir + dev split).
7
7
  // Maps a stable item key -> hash of its mutable fields, recording what we have
@@ -34,7 +34,18 @@ export function loadState() {
34
34
 
35
35
  export function saveState(state) {
36
36
  mkdirSync(STATE_DIR, { recursive: true });
37
- writeFileSync(STATE_FILE, JSON.stringify(state) + '\n', 'utf-8');
37
+ // Atomic replace: write to a unique temp file then rename over the target.
38
+ // A crash mid-write can no longer truncate state.json into an unreadable
39
+ // file that loadState() would treat as empty (triggering a full re-upload).
40
+ const tempPath = `${STATE_FILE}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
41
+ try {
42
+ writeFileSync(tempPath, JSON.stringify(state) + '\n', 'utf-8');
43
+ renameSync(tempPath, STATE_FILE);
44
+ } finally {
45
+ // No-op after a successful rename (the temp file is already gone); cleans
46
+ // up the partial write if writeFileSync threw.
47
+ rmSync(tempPath, { force: true });
48
+ }
38
49
  }
39
50
 
40
51
  // Drop all recorded upload state so the next sync re-uploads everything.
package/src/summary.js CHANGED
@@ -1,32 +1,30 @@
1
- import https from 'node:https';
2
- import http from 'node:http';
3
- import { URL } from 'node:url';
4
1
  import { loadConfig } from './config.js';
2
+ import { getJson } from './api.js';
3
+ import { failure } from './output.js';
5
4
 
6
5
  export async function runSummary(args = []) {
7
6
  const days = parseDays(args);
8
7
  const config = loadConfig();
9
8
  if (!config?.apiKey) {
10
- console.error('No vibe-usage config found. Run `npx @vibe-cafe/vibe-usage init` first.');
9
+ console.error(failure('尚未配置,请先运行 `npx @vibe-cafe/vibe-usage init`。'));
11
10
  process.exit(1);
12
11
  }
13
12
 
14
- const url = new URL('/api/usage', config.apiUrl || 'https://vibecafe.ai');
15
- url.searchParams.set('days', String(days));
13
+ const apiUrl = config.apiUrl || 'https://vibecafe.ai';
16
14
 
17
15
  let data;
18
16
  try {
19
- data = await fetchJson(url, config.apiKey);
17
+ data = await getJson(apiUrl, config.apiKey, `/api/usage?days=${days}`, { timeoutMs: 15_000 });
20
18
  } catch (err) {
21
19
  if (err.statusCode === 401) {
22
- console.error('API key invalid or revoked. Run `npx @vibe-cafe/vibe-usage init` to re-link.');
20
+ console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
23
21
  } else {
24
- console.error(`Failed to fetch usage: ${err.message}`);
22
+ console.error(failure(`获取用量数据失败: ${err.message}`));
25
23
  }
26
24
  process.exit(1);
27
25
  }
28
26
 
29
- console.log(render(data, days));
27
+ console.log(render(data, days, apiUrl));
30
28
  }
31
29
 
32
30
  function parseDays(args) {
@@ -38,12 +36,13 @@ function parseDays(args) {
38
36
  return v;
39
37
  }
40
38
 
41
- function render(data, days) {
39
+ function render(data, days, apiUrl) {
42
40
  const buckets = Array.isArray(data?.buckets) ? data.buckets : [];
43
41
  const sessions = Array.isArray(data?.sessions) ? data.sessions : [];
42
+ const dashboard = `${apiUrl}/usage`;
44
43
 
45
44
  if (buckets.length === 0) {
46
- return `# Vibe Usage Summary (Last ${days} ${days === 1 ? 'day' : 'days'})\n\n暂无数据。运行 \`npx @vibe-cafe/vibe-usage sync\` 上传本地 token 记录。\n\n详情: https://vibecafe.ai/usage\n`;
45
+ return `# Vibe Usage Summary (Last ${days} ${days === 1 ? 'day' : 'days'})\n\n暂无数据。运行 \`npx @vibe-cafe/vibe-usage sync\` 上传本地 token 记录。\n\n详情: ${dashboard}\n`;
47
46
  }
48
47
 
49
48
  let totalCost = 0;
@@ -94,7 +93,7 @@ function render(data, days) {
94
93
  }
95
94
  lines.push('');
96
95
 
97
- lines.push('详情: https://vibecafe.ai/usage');
96
+ lines.push(`详情: ${dashboard}`);
98
97
  return lines.join('\n');
99
98
  }
100
99
 
@@ -115,32 +114,3 @@ function formatTokens(n) {
115
114
  if (n >= 1_000) return (n / 1_000).toFixed(0) + 'K';
116
115
  return String(n);
117
116
  }
118
-
119
- function fetchJson(url, apiKey) {
120
- return new Promise((resolve, reject) => {
121
- const mod = url.protocol === 'https:' ? https : http;
122
- const req = mod.request(url, {
123
- method: 'GET',
124
- timeout: 15_000,
125
- headers: { 'Authorization': `Bearer ${apiKey}` },
126
- }, (res) => {
127
- let data = '';
128
- res.on('data', (chunk) => { data += chunk; });
129
- res.on('end', () => {
130
- if (res.statusCode === 401) {
131
- const err = new Error('Unauthorized'); err.statusCode = 401; reject(err); return;
132
- }
133
- if (res.statusCode < 200 || res.statusCode >= 300) {
134
- const err = new Error(`HTTP ${res.statusCode}: ${data.slice(0, 200)}`);
135
- err.statusCode = res.statusCode;
136
- reject(err); return;
137
- }
138
- try { resolve(JSON.parse(data)); }
139
- catch { reject(new Error('Invalid JSON response')); }
140
- });
141
- });
142
- req.on('error', reject);
143
- req.on('timeout', () => { req.destroy(); reject(new Error('timeout (15s)')); });
144
- req.end();
145
- });
146
- }