@vibe-cafe/vibe-usage 0.10.2 → 0.10.4
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 +54 -10
- package/src/parsers/kimi-code.js +32 -9
- package/src/sync.js +29 -5
package/README.md
CHANGED
|
@@ -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 | 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/`
|
|
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), data root resolved via `$KIMI_CODE_HOME` like the CLI itself, with project names from `session_index.jsonl`; legacy `~/.kimi/sessions/` is parsed alongside (`kimi migrate` never carries usage over, so both stores are always merged) |
|
|
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) |
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -10,6 +10,17 @@ const INITIAL_DELAY = 1000;
|
|
|
10
10
|
// guaranteeing no uncompressed request ever leaves the client.
|
|
11
11
|
const GZIP_MIN_BYTES = 0;
|
|
12
12
|
|
|
13
|
+
export function retryDelayMs(attempt, random = Math.random) {
|
|
14
|
+
const ceiling = INITIAL_DELAY * 2 ** attempt;
|
|
15
|
+
// Equal jitter keeps a real backoff floor while preventing every desktop
|
|
16
|
+
// client from retrying a shared outage on the same 1s / 2s boundaries.
|
|
17
|
+
return Math.round(ceiling / 2 + random() * ceiling / 2);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sleep(ms) {
|
|
21
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
|
|
14
25
|
let lastError;
|
|
15
26
|
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
@@ -22,8 +33,7 @@ export async function ingest(apiUrl, apiKey, buckets, opts, sessions) {
|
|
|
22
33
|
throw err;
|
|
23
34
|
}
|
|
24
35
|
if (attempt < MAX_RETRIES - 1) {
|
|
25
|
-
|
|
26
|
-
await new Promise(r => setTimeout(r, delay));
|
|
36
|
+
await sleep(retryDelayMs(attempt));
|
|
27
37
|
}
|
|
28
38
|
}
|
|
29
39
|
}
|
|
@@ -222,13 +232,36 @@ function _jsonRequest(apiUrl, path, method, body, timeoutMs) {
|
|
|
222
232
|
|
|
223
233
|
/**
|
|
224
234
|
* GET user settings from the vibecafe API.
|
|
225
|
-
* Returns null
|
|
235
|
+
* Returns null after transient failures are exhausted. A 401 remains distinct
|
|
236
|
+
* so callers can surface invalid credentials instead of calling it an outage.
|
|
226
237
|
* @param {string} apiUrl
|
|
227
238
|
* @param {string} apiKey
|
|
228
239
|
* @returns {Promise<{uploadProject: boolean} | null>}
|
|
229
240
|
*/
|
|
230
|
-
export function fetchSettings(apiUrl, apiKey) {
|
|
231
|
-
|
|
241
|
+
export async function fetchSettings(apiUrl, apiKey, retry = {}) {
|
|
242
|
+
const wait = retry.sleep ?? sleep;
|
|
243
|
+
const random = retry.random ?? Math.random;
|
|
244
|
+
|
|
245
|
+
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
|
|
246
|
+
try {
|
|
247
|
+
return await fetchSettingsOnce(apiUrl, apiKey);
|
|
248
|
+
} catch (err) {
|
|
249
|
+
if (err.message === 'UNAUTHORIZED') throw err;
|
|
250
|
+
// Retrying a permanent client response cannot make it valid. 429 is the
|
|
251
|
+
// exception: it is transient load shedding and benefits from backoff.
|
|
252
|
+
if (err.statusCode >= 400 && err.statusCode < 500 && err.statusCode !== 429) {
|
|
253
|
+
return null;
|
|
254
|
+
}
|
|
255
|
+
if (attempt < MAX_RETRIES - 1) {
|
|
256
|
+
await wait(retryDelayMs(attempt, random));
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function fetchSettingsOnce(apiUrl, apiKey) {
|
|
264
|
+
return new Promise((resolve, reject) => {
|
|
232
265
|
const url = new URL('/api/usage/settings', apiUrl);
|
|
233
266
|
const mod = url.protocol === 'https:' ? https : http;
|
|
234
267
|
|
|
@@ -242,20 +275,31 @@ export function fetchSettings(apiUrl, apiKey) {
|
|
|
242
275
|
let data = '';
|
|
243
276
|
res.on('data', (chunk) => { data += chunk; });
|
|
244
277
|
res.on('end', () => {
|
|
278
|
+
if (res.statusCode === 401) {
|
|
279
|
+
reject(new Error('UNAUTHORIZED'));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
245
282
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
246
|
-
|
|
283
|
+
const err = new Error(`HTTP ${res.statusCode}: ${data}`);
|
|
284
|
+
err.statusCode = res.statusCode;
|
|
285
|
+
reject(err);
|
|
247
286
|
return;
|
|
248
287
|
}
|
|
249
288
|
try {
|
|
250
|
-
|
|
289
|
+
const settings = JSON.parse(data);
|
|
290
|
+
if (typeof settings?.uploadProject !== 'boolean') {
|
|
291
|
+
reject(new Error('Invalid settings response'));
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
resolve(settings);
|
|
251
295
|
} catch {
|
|
252
|
-
|
|
296
|
+
reject(new Error('Invalid settings response'));
|
|
253
297
|
}
|
|
254
298
|
});
|
|
255
299
|
});
|
|
256
300
|
|
|
257
|
-
req.on('error',
|
|
258
|
-
req.on('timeout', () => { req.destroy(
|
|
301
|
+
req.on('error', reject);
|
|
302
|
+
req.on('timeout', () => { req.destroy(new Error('Settings request timed out')); });
|
|
259
303
|
req.end();
|
|
260
304
|
});
|
|
261
305
|
}
|
package/src/parsers/kimi-code.js
CHANGED
|
@@ -27,14 +27,25 @@ import { aggregateToBuckets, extractSessions } from './index.js';
|
|
|
27
27
|
* with a different envelope (StatusUpdate.payload.token_usage, float-second
|
|
28
28
|
* `timestamp`) and the model in ~/.kimi/config.toml. Kept for users who have
|
|
29
29
|
* not migrated; see parseLegacyKimi() below.
|
|
30
|
+
*
|
|
31
|
+
* Both stores are always parsed and merged. `kimi migrate` translates legacy
|
|
32
|
+
* context.jsonl into the new wire format but DROPS the `_usage` records, so
|
|
33
|
+
* historical token usage only ever exists in the legacy store — parsing both
|
|
34
|
+
* cannot double-count, while skipping legacy whenever ~/.kimi-code has any
|
|
35
|
+
* session silently loses a migrated user's entire history.
|
|
30
36
|
*/
|
|
31
37
|
|
|
32
38
|
// ---------------------------------------------------------------------------
|
|
33
39
|
// Current format: ~/.kimi-code
|
|
34
40
|
// ---------------------------------------------------------------------------
|
|
35
41
|
|
|
36
|
-
// VIBE_USAGE_KIMI_CODE_DIR overrides the root (test hook).
|
|
37
|
-
|
|
42
|
+
// VIBE_USAGE_KIMI_CODE_DIR overrides the root (test hook). Otherwise resolve
|
|
43
|
+
// the data root the same way the CLI itself does: $KIMI_CODE_HOME, then
|
|
44
|
+
// ~/.kimi-code. Ignoring KIMI_CODE_HOME means users with a custom home get
|
|
45
|
+
// zero usage parsed.
|
|
46
|
+
const KIMI_CODE_DIR = process.env.VIBE_USAGE_KIMI_CODE_DIR?.trim()
|
|
47
|
+
|| process.env.KIMI_CODE_HOME?.trim()
|
|
48
|
+
|| join(homedir(), '.kimi-code');
|
|
38
49
|
const KIMI_CODE_SESSIONS_DIR = join(KIMI_CODE_DIR, 'sessions');
|
|
39
50
|
const KIMI_CODE_SESSION_INDEX = join(KIMI_CODE_DIR, 'session_index.jsonl');
|
|
40
51
|
|
|
@@ -362,7 +373,8 @@ function parseLegacyKimi() {
|
|
|
362
373
|
|
|
363
374
|
const tokenUsage = payload.token_usage;
|
|
364
375
|
if (!tokenUsage) continue;
|
|
365
|
-
if (!tokenUsage.input_other && !tokenUsage.output
|
|
376
|
+
if (!tokenUsage.input_other && !tokenUsage.output
|
|
377
|
+
&& !tokenUsage.input_cache_read && !tokenUsage.input_cache_creation) continue;
|
|
366
378
|
|
|
367
379
|
const messageId = payload.message_id;
|
|
368
380
|
if (messageId) {
|
|
@@ -370,14 +382,21 @@ function parseLegacyKimi() {
|
|
|
370
382
|
seenMessageIds.add(messageId);
|
|
371
383
|
}
|
|
372
384
|
|
|
373
|
-
|
|
385
|
+
// No valid timestamp → skip instead of stamping "now": this parser is
|
|
386
|
+
// stateless, so a "now" fallback would re-key the same record into a
|
|
387
|
+
// fresh 30-min bucket on every sync (duplicates).
|
|
388
|
+
if (!lastTimestamp) continue;
|
|
389
|
+
const ts = new Date(lastTimestamp);
|
|
390
|
+
if (isNaN(ts.getTime())) continue;
|
|
374
391
|
|
|
375
392
|
entries.push({
|
|
376
393
|
source: 'kimi-code',
|
|
377
394
|
model: currentModel,
|
|
378
395
|
project,
|
|
379
396
|
timestamp: ts,
|
|
380
|
-
|
|
397
|
+
// Cache creation is billed non-cached input, matching the current
|
|
398
|
+
// parser and the common bucket model used by the other parsers.
|
|
399
|
+
inputTokens: (tokenUsage.input_other || 0) + (tokenUsage.input_cache_creation || 0),
|
|
381
400
|
outputTokens: tokenUsage.output || 0,
|
|
382
401
|
cachedInputTokens: tokenUsage.input_cache_read || 0,
|
|
383
402
|
reasoningOutputTokens: 0,
|
|
@@ -389,9 +408,13 @@ function parseLegacyKimi() {
|
|
|
389
408
|
}
|
|
390
409
|
|
|
391
410
|
export async function parse() {
|
|
392
|
-
//
|
|
393
|
-
//
|
|
411
|
+
// Always parse both stores and merge (see the header comment): legacy usage
|
|
412
|
+
// is never carried into ~/.kimi-code by `kimi migrate`, so a migrated user's
|
|
413
|
+
// history exists only in ~/.kimi.
|
|
394
414
|
const current = parseKimiCode();
|
|
395
|
-
|
|
396
|
-
return
|
|
415
|
+
const legacy = parseLegacyKimi();
|
|
416
|
+
return {
|
|
417
|
+
buckets: [...(current?.buckets ?? []), ...legacy.buckets],
|
|
418
|
+
sessions: [...(current?.sessions ?? []), ...legacy.sessions],
|
|
419
|
+
};
|
|
397
420
|
}
|
package/src/sync.js
CHANGED
|
@@ -18,6 +18,15 @@ function formatBytes(bytes) {
|
|
|
18
18
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export function resolveUploadProjectSetting(settings) {
|
|
22
|
+
if (typeof settings?.uploadProject !== 'boolean') {
|
|
23
|
+
const error = new Error('SETTINGS_UNAVAILABLE');
|
|
24
|
+
error.code = 'SETTINGS_UNAVAILABLE';
|
|
25
|
+
throw error;
|
|
26
|
+
}
|
|
27
|
+
return settings.uploadProject;
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
export async function runSync({ throws = false, quiet = false, surface = 'cli' } = {}) {
|
|
22
31
|
const config = loadConfig();
|
|
23
32
|
if (!config?.apiKey) {
|
|
@@ -32,6 +41,26 @@ export async function runSync({ throws = false, quiet = false, surface = 'cli' }
|
|
|
32
41
|
saveConfig(config);
|
|
33
42
|
}
|
|
34
43
|
|
|
44
|
+
// Privacy is a required input, not an optional hint. If the settings API is
|
|
45
|
+
// unavailable, treating it as `false` changes every project-bearing item's
|
|
46
|
+
// incremental identity to `unknown` and can trigger a full-history upload.
|
|
47
|
+
// Resolve it before parsing or loading upload state so failure is a true
|
|
48
|
+
// no-op: no data upload and no state mutation.
|
|
49
|
+
const apiUrl = config.apiUrl || 'https://vibecafe.ai';
|
|
50
|
+
let uploadProject;
|
|
51
|
+
try {
|
|
52
|
+
const settings = await fetchSettings(apiUrl, config.apiKey);
|
|
53
|
+
uploadProject = resolveUploadProjectSetting(settings);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.message === 'UNAUTHORIZED') {
|
|
56
|
+
console.error(failure('API Key 无效,请运行 `npx @vibe-cafe/vibe-usage init` 重新配置。'));
|
|
57
|
+
} else {
|
|
58
|
+
console.error(failure('暂时无法读取上传设置,本次同步已安全取消(未上传数据)。请稍后重试。'));
|
|
59
|
+
}
|
|
60
|
+
if (throws) throw err;
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
|
|
35
64
|
const allBuckets = [];
|
|
36
65
|
const allSessions = [];
|
|
37
66
|
const parserResults = [];
|
|
@@ -116,11 +145,6 @@ export async function runSync({ throws = false, quiet = false, surface = 'cli' }
|
|
|
116
145
|
for (const b of allBuckets) if (!b.hostname) b.hostname = host;
|
|
117
146
|
for (const s of allSessions) if (!s.hostname) s.hostname = host;
|
|
118
147
|
|
|
119
|
-
// Privacy: check if user allows project name upload
|
|
120
|
-
const apiUrl = config.apiUrl || 'https://vibecafe.ai';
|
|
121
|
-
const settings = await fetchSettings(apiUrl, config.apiKey);
|
|
122
|
-
const uploadProject = settings?.uploadProject === true;
|
|
123
|
-
|
|
124
148
|
if (!quiet) {
|
|
125
149
|
if (uploadProject) {
|
|
126
150
|
console.log(dim(' 项目名: 上传(可在 Web 设置中关闭)'));
|