@vibe-cafe/vibe-usage 0.10.10 → 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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/api.js +56 -42
- package/src/parsers/aggregate.js +157 -0
- package/src/parsers/alma.js +18 -15
- package/src/parsers/amp.js +1 -1
- package/src/parsers/antigravity-db.js +8 -31
- package/src/parsers/antigravity.js +1 -1
- package/src/parsers/claude-code.js +2 -14
- package/src/parsers/cline.js +4 -14
- package/src/parsers/codex.js +1 -1
- package/src/parsers/contract.js +55 -0
- package/src/parsers/copilot-cli.js +1 -1
- package/src/parsers/cursor.js +12 -34
- package/src/parsers/dimagent.js +7 -13
- package/src/parsers/droid.js +1 -1
- package/src/parsers/dsh.js +1 -1
- package/src/parsers/fs-utils.js +36 -0
- package/src/parsers/gemini-cli.js +1 -1
- package/src/parsers/grok.js +3 -17
- package/src/parsers/hermes.js +3 -5
- package/src/parsers/index.js +1 -148
- package/src/parsers/kimi-code.js +1 -1
- package/src/parsers/kiro.js +6 -39
- package/src/parsers/mimocode.js +3 -5
- package/src/parsers/openclaw.js +1 -1
- package/src/parsers/opencode.js +3 -5
- package/src/parsers/pi-session-jsonl.js +6 -16
- package/src/parsers/qwen-code.js +1 -1
- package/src/parsers/roo-code.js +4 -14
- package/src/parsers/sqlite.js +46 -0
- package/src/parsers/trae-cli.js +3 -13
- package/src/parsers/workbuddy.js +1 -1
- package/src/parsers/zcode.js +3 -5
- package/src/state.js +14 -3
- package/src/summary.js +12 -42
- package/src/sync.js +110 -38
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
|
-
|
|
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('
|
|
9
|
+
console.error(failure('尚未配置,请先运行 `npx @vibe-cafe/vibe-usage init`。'));
|
|
11
10
|
process.exit(1);
|
|
12
11
|
}
|
|
13
12
|
|
|
14
|
-
const
|
|
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
|
|
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
|
|
20
|
+
console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
|
|
23
21
|
} else {
|
|
24
|
-
console.error(
|
|
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详情:
|
|
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(
|
|
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 {
|
|
10
|
-
import {
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
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)
|
|
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)
|
|
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) {
|