@vibe-cafe/vibe-usage 0.10.10 → 0.10.12

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,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
- }
package/src/sync.js CHANGED
@@ -6,8 +6,10 @@ import {
6
6
  } from './state.js';
7
7
  import { ingest, fetchSettings } from './api.js';
8
8
  import { createSyncClient, forBatch } from './client-meta.js';
9
- import { aggregateToBuckets, parsers } from './parsers/index.js';
10
- import { success, failure, arrow, link, dim } from './output.js';
9
+ import { parsers } from './parsers/index.js';
10
+ import { aggregateToBuckets } from './parsers/aggregate.js';
11
+ import { normalizeParserResult } from './parsers/contract.js';
12
+ import { success, failure, warn, arrow, link, dim } from './output.js';
11
13
 
12
14
  const BATCH_SIZE = 100;
13
15
  const SESSION_BATCH_SIZE = 500;
@@ -27,6 +29,13 @@ export function resolveUploadProjectSetting(settings) {
27
29
  return settings.uploadProject;
28
30
  }
29
31
 
32
+ export function resolveCachedUploadProjectSetting(config, apiUrl) {
33
+ if (config?.lastUploadProjectApiUrl !== apiUrl) return undefined;
34
+ return typeof config.lastUploadProject === 'boolean'
35
+ ? config.lastUploadProject
36
+ : undefined;
37
+ }
38
+
30
39
  export function resolveCodexExtraHome(configured, temporary) {
31
40
  return temporary ?? configured;
32
41
  }
@@ -41,6 +50,27 @@ export function reaggregateHiddenProjectBuckets(buckets) {
41
50
  })));
42
51
  }
43
52
 
53
+ // Parser execution is I/O bound (log reads, occasional network calls). Run a
54
+ // bounded number at once to cut wall-clock sync time without the memory spike
55
+ // of loading every tool's logs simultaneously.
56
+ export const PARSER_CONCURRENCY = 4;
57
+
58
+ // Run `fn` over `items` with at most `limit` in flight, preserving order.
59
+ export async function mapWithConcurrency(items, limit, fn) {
60
+ const results = new Array(items.length);
61
+ let nextIndex = 0;
62
+ const workerCount = Math.max(1, Math.min(limit, items.length));
63
+ const workers = Array.from({ length: workerCount }, async () => {
64
+ while (true) {
65
+ const index = nextIndex++;
66
+ if (index >= items.length) return;
67
+ results[index] = await fn(items[index], index);
68
+ }
69
+ });
70
+ await Promise.all(workers);
71
+ return results;
72
+ }
73
+
44
74
  export async function runSync({
45
75
  throws = false,
46
76
  quiet = false,
@@ -70,14 +100,34 @@ export async function runSync({
70
100
  try {
71
101
  const settings = await fetchSettings(apiUrl, config.apiKey);
72
102
  uploadProject = resolveUploadProjectSetting(settings);
103
+ // Scope the cached privacy choice to the server that returned it. Reusing
104
+ // the value after `apiUrl` changes could expose project names to a
105
+ // different server during its first settings outage.
106
+ if (
107
+ config.lastUploadProject !== uploadProject
108
+ || config.lastUploadProjectApiUrl !== apiUrl
109
+ ) {
110
+ config.lastUploadProject = uploadProject;
111
+ config.lastUploadProjectApiUrl = apiUrl;
112
+ saveConfig(config);
113
+ }
73
114
  } catch (err) {
74
115
  if (err.message === 'UNAUTHORIZED') {
75
116
  console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
117
+ if (throws) throw err;
118
+ process.exit(1);
119
+ }
120
+ // Settings endpoint unreachable (not auth): degrade to the last confirmed
121
+ // choice for this same server rather than hard-aborting every upload.
122
+ const cachedUploadProject = resolveCachedUploadProjectSetting(config, apiUrl);
123
+ if (typeof cachedUploadProject === 'boolean') {
124
+ uploadProject = cachedUploadProject;
125
+ if (!quiet) console.log(warn('设置接口不可用,沿用上次的项目名设置。'));
76
126
  } else {
77
127
  console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
128
+ if (throws) throw err;
129
+ process.exit(1);
78
130
  }
79
- if (throws) throw err;
80
- process.exit(1);
81
131
  }
82
132
 
83
133
  let allBuckets = [];
@@ -89,36 +139,52 @@ export async function runSync({
89
139
  // state and force a full re-upload next run.
90
140
  const okSources = new Set();
91
141
 
92
- for (const [source, parse] of Object.entries(parsers)) {
93
- try {
94
- const result = source === 'codex'
95
- ? await parse({ codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome) })
96
- : await parse();
97
- const buckets = Array.isArray(result) ? result : result.buckets;
98
- const sessions = Array.isArray(result) ? [] : (result.sessions || []);
99
- if (!Array.isArray(buckets) || !Array.isArray(sessions)) {
100
- throw new TypeError('Parser returned an invalid result');
142
+ // Run parsers concurrently (bounded) so one slow parser (Cursor's network
143
+ // fetch, a cold Codex index) can't stall the rest. Results are collected in
144
+ // registry order so output and merged arrays stay deterministic.
145
+ const parserOutcomes = await mapWithConcurrency(
146
+ Object.entries(parsers),
147
+ PARSER_CONCURRENCY,
148
+ async ([source, parse]) => {
149
+ try {
150
+ const result = source === 'codex'
151
+ ? await parse({ codexExtraHome: resolveCodexExtraHome(config.codexExtraHome, codexExtraHome) })
152
+ : await parse();
153
+ return { source, result };
154
+ } catch (err) {
155
+ return { source, error: err };
101
156
  }
102
- if (result?.indexing) {
103
- parserProgress.push({ source, ...result.indexing });
104
- }
105
- if (Array.isArray(result?.warnings)) {
106
- for (const message of result.warnings) {
107
- process.stderr.write(`${dim(` ${message}`)}\n`);
108
- }
109
- }
110
- // A parser may deliberately suppress a transient error (Cursor network
111
- // timeout) to keep daemon logs quiet. Its empty result is not proof that
112
- // its prior data disappeared, so it must not be pruned this run.
113
- if (!result?.skipped) okSources.add(source);
114
- if (buckets.length > 0) allBuckets.push(...buckets);
115
- if (sessions.length > 0) allSessions.push(...sessions);
116
- if (buckets.length > 0 || sessions.length > 0) {
117
- parserResults.push({ source, buckets: buckets.length, sessions: sessions.length });
118
- }
119
- } catch (err) {
157
+ },
158
+ );
159
+
160
+ for (const { source, result, error } of parserOutcomes) {
161
+ if (error) {
120
162
  // Parser errors are non-fatal — pass-through in dim gray (no translation).
163
+ process.stderr.write(`${dim(` ${source}: ${error.message}`)}\n`);
164
+ continue;
165
+ }
166
+ let normalized;
167
+ try {
168
+ normalized = normalizeParserResult(source, result);
169
+ } catch (err) {
121
170
  process.stderr.write(`${dim(` ${source}: ${err.message}`)}\n`);
171
+ continue;
172
+ }
173
+ const { buckets, sessions, skipped, warnings, indexing } = normalized;
174
+ if (indexing) {
175
+ parserProgress.push({ source, ...indexing });
176
+ }
177
+ for (const message of warnings) {
178
+ process.stderr.write(`${dim(` ${message}`)}\n`);
179
+ }
180
+ // A parser may deliberately suppress a transient error (Cursor network
181
+ // timeout) to keep daemon logs quiet. Its empty result is not proof that
182
+ // its prior data disappeared, so it must not be pruned this run.
183
+ if (!skipped) okSources.add(source);
184
+ if (buckets.length > 0) allBuckets.push(...buckets);
185
+ if (sessions.length > 0) allSessions.push(...sessions);
186
+ if (buckets.length > 0 || sessions.length > 0) {
187
+ parserResults.push({ source, buckets: buckets.length, sessions: sessions.length });
122
188
  }
123
189
  }
124
190
 
@@ -271,10 +337,10 @@ export async function runSync({
271
337
  }
272
338
  totalProtectedBuckets += Number(result.protected?.buckets) || 0;
273
339
 
274
- // Commit only this batch's hashes, only after it uploaded successfully.
275
- // A batch that throws aborts the loop with its keys still absent from
276
- // state, so the next sync re-sends exactly those items — no data loss,
277
- // no silent gaps.
340
+ // Commit only hashes from this successful batch. Persist before starting
341
+ // the next batch: if a later upload fails or the process exits abruptly,
342
+ // the next sync retries only the uncommitted suffix.
343
+ let batchStateChanged = false;
278
344
  for (const b of batch) {
279
345
  // A source unknown to an older backend may become valid after deploy.
280
346
  // Leave those hashes uncommitted so the next sync retries them instead
@@ -282,14 +348,20 @@ export async function runSync({
282
348
  if (batchUnknownSources.has(b.source)) continue;
283
349
  const key = bucketKey(b);
284
350
  const entry = pendingBucketState.get(key);
285
- if (entry) state.buckets[key] = entry;
351
+ if (entry) {
352
+ state.buckets[key] = entry;
353
+ batchStateChanged = true;
354
+ }
286
355
  }
287
356
  for (const s of batchSessions) {
288
357
  const key = sessionKey(s);
289
358
  const entry = pendingSessionState.get(key);
290
- if (entry) state.sessions[key] = entry;
359
+ if (entry) {
360
+ state.sessions[key] = entry;
361
+ batchStateChanged = true;
362
+ }
291
363
  }
292
- saveState(state);
364
+ if (batchStateChanged) saveState(state);
293
365
  }
294
366
 
295
367
  if (totalBatches > 1 || allBucketsToSend.length > 0) {