@vibe-cafe/vibe-usage 0.10.34 → 0.11.1
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 +17 -0
- package/package.json +1 -1
- package/src/daemon-service.js +4 -3
- package/src/index.js +19 -0
- package/src/parsers/claude-code.js +13 -0
- package/src/quotas/cache.js +91 -0
- package/src/quotas/index.js +35 -0
- package/src/quotas/providers/grok.js +151 -0
- package/src/quotas/providers/kimi-code.js +504 -0
- package/src/quotas/providers/zai.js +143 -0
- package/src/quotas/registry.js +114 -0
- package/src/quotas/schema.js +94 -0
- package/src/state.js +66 -7
- package/src/sync.js +19 -6
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { accessSync, constants, existsSync } from 'node:fs';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { delimiter, join } from 'node:path';
|
|
4
|
+
import { loadCachedQuota, saveCachedQuota } from './cache.js';
|
|
5
|
+
import { fetchGrokQuota } from './providers/grok.js';
|
|
6
|
+
import { fetchKimiCodeQuota } from './providers/kimi-code.js';
|
|
7
|
+
import { fetchZaiQuota } from './providers/zai.js';
|
|
8
|
+
import { FETCHABLE_QUOTA_PRODUCT_IDS, quotaEnvelope, quotaResult } from './schema.js';
|
|
9
|
+
|
|
10
|
+
const providers = new Map([
|
|
11
|
+
['kimi-code', fetchKimiCodeQuota],
|
|
12
|
+
['zcode', fetchZaiQuota],
|
|
13
|
+
['grok', fetchGrokQuota],
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
function executableExists(name, environment, platform) {
|
|
17
|
+
const pathDelimiter = platform === 'win32' ? ';' : delimiter;
|
|
18
|
+
const candidateNames = [name];
|
|
19
|
+
if (platform === 'win32') {
|
|
20
|
+
const extensions = (environment.PATHEXT || '.COM;.EXE;.BAT;.CMD')
|
|
21
|
+
.split(';')
|
|
22
|
+
.map(value => value.trim())
|
|
23
|
+
.filter(Boolean)
|
|
24
|
+
.map(value => value.startsWith('.') ? value : `.${value}`);
|
|
25
|
+
for (const extension of extensions) {
|
|
26
|
+
candidateNames.push(`${name}${extension}`, `${name}${extension.toLowerCase()}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (environment.PATH || '').split(pathDelimiter).filter(Boolean).some(directory => (
|
|
31
|
+
candidateNames.some(candidate => {
|
|
32
|
+
const path = join(directory, candidate);
|
|
33
|
+
try {
|
|
34
|
+
// Windows does not expose POSIX execute bits; file presence plus a
|
|
35
|
+
// PATHEXT executable suffix is its ordinary command-discovery rule.
|
|
36
|
+
accessSync(path, platform === 'win32' ? constants.F_OK : constants.X_OK);
|
|
37
|
+
return true;
|
|
38
|
+
} catch {
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function discoverQuotaProducts({
|
|
46
|
+
environment = process.env,
|
|
47
|
+
home = homedir(),
|
|
48
|
+
platform = process.platform,
|
|
49
|
+
} = {}) {
|
|
50
|
+
const applications = platform === 'darwin'
|
|
51
|
+
? ['/Applications', join(home, 'Applications')] : [];
|
|
52
|
+
const existsAny = paths => paths.some(path => existsSync(path));
|
|
53
|
+
const configuredGrokHome = environment.GROK_HOME?.trim();
|
|
54
|
+
const grokHome = configuredGrokHome
|
|
55
|
+
? configuredGrokHome.replace(/^~(?=$|[\\/])/, home)
|
|
56
|
+
: join(home, '.grok');
|
|
57
|
+
return quotaEnvelope([
|
|
58
|
+
{
|
|
59
|
+
id: 'kimi-code',
|
|
60
|
+
detected: existsAny([join(home, '.kimi'), join(home, '.kimi-code')])
|
|
61
|
+
|| executableExists('kimi', environment, platform),
|
|
62
|
+
fetchable: true,
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
id: 'zcode',
|
|
66
|
+
detected: existsAny([join(home, '.zcode'), join(home, '.config', 'zcode'),
|
|
67
|
+
...applications.map(path => join(path, 'ZCode.app'))])
|
|
68
|
+
|| executableExists('zcode', environment, platform),
|
|
69
|
+
fetchable: true,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: 'grok',
|
|
73
|
+
detected: existsAny([grokHome]) || executableExists('grok', environment, platform),
|
|
74
|
+
fetchable: true,
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
id: 'cursor',
|
|
78
|
+
detected: existsAny([join(home, '.cursor'),
|
|
79
|
+
...applications.map(path => join(path, 'Cursor.app'))])
|
|
80
|
+
|| executableExists('cursor', environment, platform),
|
|
81
|
+
fetchable: false,
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function fetchQuotaProducts(ids, options = {}) {
|
|
87
|
+
const unique = [...new Set(ids)];
|
|
88
|
+
const invalid = unique.filter(id => !FETCHABLE_QUOTA_PRODUCT_IDS.includes(id));
|
|
89
|
+
if (invalid.length) throw new Error(`Unsupported quota product: ${invalid.join(', ')}`);
|
|
90
|
+
|
|
91
|
+
const fetched = await Promise.all(unique.map(async id => {
|
|
92
|
+
try {
|
|
93
|
+
return await providers.get(id)(options);
|
|
94
|
+
} catch {
|
|
95
|
+
return quotaResult({ id, status: 'retryable_error', message: 'Provider failed unexpectedly' });
|
|
96
|
+
}
|
|
97
|
+
}));
|
|
98
|
+
const results = fetched.map(result => {
|
|
99
|
+
if (result.status === 'ok') {
|
|
100
|
+
saveCachedQuota(result, result.cacheScope, options.environment);
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
if (result.status === 'retryable_error') {
|
|
104
|
+
return loadCachedQuota(
|
|
105
|
+
result.id,
|
|
106
|
+
result.cacheScope,
|
|
107
|
+
options.environment,
|
|
108
|
+
options.now || new Date()
|
|
109
|
+
) || result;
|
|
110
|
+
}
|
|
111
|
+
return result;
|
|
112
|
+
});
|
|
113
|
+
return quotaEnvelope(results);
|
|
114
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
export const QUOTA_SCHEMA_VERSION = 1;
|
|
2
|
+
|
|
3
|
+
export const QUOTA_PRODUCT_IDS = Object.freeze([
|
|
4
|
+
'kimi-code',
|
|
5
|
+
'zcode',
|
|
6
|
+
'grok',
|
|
7
|
+
'cursor',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export const FETCHABLE_QUOTA_PRODUCT_IDS = Object.freeze([
|
|
11
|
+
'kimi-code',
|
|
12
|
+
'zcode',
|
|
13
|
+
'grok',
|
|
14
|
+
]);
|
|
15
|
+
|
|
16
|
+
const FETCH_STATUSES = new Set([
|
|
17
|
+
'ok',
|
|
18
|
+
'no_data',
|
|
19
|
+
'missing_credentials',
|
|
20
|
+
'expired_credentials',
|
|
21
|
+
'unauthorized',
|
|
22
|
+
'retryable_error',
|
|
23
|
+
'unsupported',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
function finiteNumber(value, name) {
|
|
27
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
28
|
+
throw new TypeError(`${name} must be a finite number`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function optionalISODate(value, name) {
|
|
34
|
+
if (value === undefined || value === null) return undefined;
|
|
35
|
+
const date = value instanceof Date ? value : new Date(value);
|
|
36
|
+
if (Number.isNaN(date.getTime())) {
|
|
37
|
+
throw new TypeError(`${name} must be an ISO date string`);
|
|
38
|
+
}
|
|
39
|
+
return date.toISOString();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function normalizeMeter(raw, index = 0) {
|
|
43
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
44
|
+
throw new TypeError(`meters[${index}] must be an object`);
|
|
45
|
+
}
|
|
46
|
+
const id = String(raw.id || '').trim();
|
|
47
|
+
const label = String(raw.label || '').trim();
|
|
48
|
+
if (!id || !label) throw new TypeError(`meters[${index}] needs id and label`);
|
|
49
|
+
|
|
50
|
+
const utilization = Math.max(0, Math.min(100,
|
|
51
|
+
finiteNumber(raw.utilization, `meters[${index}].utilization`)));
|
|
52
|
+
const meter = { id, label, utilization };
|
|
53
|
+
const resetsAt = optionalISODate(raw.resetsAt, `meters[${index}].resetsAt`);
|
|
54
|
+
if (resetsAt) meter.resetsAt = resetsAt;
|
|
55
|
+
if (raw.windowSeconds !== undefined && raw.windowSeconds !== null) {
|
|
56
|
+
const seconds = finiteNumber(raw.windowSeconds, `meters[${index}].windowSeconds`);
|
|
57
|
+
if (seconds > 0) meter.windowSeconds = seconds;
|
|
58
|
+
}
|
|
59
|
+
return meter;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function quotaResult({
|
|
63
|
+
id,
|
|
64
|
+
status,
|
|
65
|
+
meters = [],
|
|
66
|
+
planLabel,
|
|
67
|
+
fetchedAt = new Date(),
|
|
68
|
+
dataAsOf = fetchedAt,
|
|
69
|
+
message,
|
|
70
|
+
source = 'live',
|
|
71
|
+
}) {
|
|
72
|
+
if (!FETCHABLE_QUOTA_PRODUCT_IDS.includes(id)) {
|
|
73
|
+
throw new TypeError(`unsupported quota product: ${id}`);
|
|
74
|
+
}
|
|
75
|
+
if (!FETCH_STATUSES.has(status)) {
|
|
76
|
+
throw new TypeError(`invalid quota status: ${status}`);
|
|
77
|
+
}
|
|
78
|
+
const result = {
|
|
79
|
+
id,
|
|
80
|
+
status,
|
|
81
|
+
meters: meters.map(normalizeMeter),
|
|
82
|
+
fetchedAt: new Date(fetchedAt).toISOString(),
|
|
83
|
+
source,
|
|
84
|
+
};
|
|
85
|
+
const normalizedDataAsOf = optionalISODate(dataAsOf, 'dataAsOf');
|
|
86
|
+
if (normalizedDataAsOf) result.dataAsOf = normalizedDataAsOf;
|
|
87
|
+
if (typeof planLabel === 'string' && planLabel.trim()) result.planLabel = planLabel.trim();
|
|
88
|
+
if (typeof message === 'string' && message.trim()) result.message = message.trim();
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function quotaEnvelope(products) {
|
|
93
|
+
return { schemaVersion: QUOTA_SCHEMA_VERSION, products };
|
|
94
|
+
}
|
package/src/state.js
CHANGED
|
@@ -8,6 +8,8 @@ import { createHash, randomBytes } from 'node:crypto';
|
|
|
8
8
|
// already uploaded successfully. Lets each sync skip re-sending unchanged
|
|
9
9
|
// history: parsers stay stateless (still parse everything from disk every run),
|
|
10
10
|
// but only new/changed items hit the network.
|
|
11
|
+
// The file also records the upload target it belongs to (`identity`), so state
|
|
12
|
+
// left by a previous account can never suppress that account's history.
|
|
11
13
|
// VIBE_USAGE_STATE_DIR overrides the dir (test hook).
|
|
12
14
|
const STATE_DIR = process.env.VIBE_USAGE_STATE_DIR?.trim() || join(homedir(), '.vibe-usage');
|
|
13
15
|
const isDev = process.env.VIBE_USAGE_DEV === '1';
|
|
@@ -17,14 +19,57 @@ export function getStatePath() {
|
|
|
17
19
|
return STATE_FILE;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
|
-
|
|
22
|
+
// The upload target this state belongs to: which server, and which account on
|
|
23
|
+
// it. state.json only records what was already uploaded *to that target*, so
|
|
24
|
+
// after a re-bind (`init` again, `config set apiKey`, or a desktop app
|
|
25
|
+
// rewriting config.json) the old hashes must not make sync skip history the new
|
|
26
|
+
// account has never received.
|
|
27
|
+
//
|
|
28
|
+
// The key is stored only as a fingerprint. The raw apiKey must never appear in
|
|
29
|
+
// state.json — it is an ordinary-permission file next to the parser state, not
|
|
30
|
+
// a credential store; config.json (mode 0600) remains the only place it lives.
|
|
31
|
+
export function stateIdentity({ apiUrl, apiKey } = {}) {
|
|
32
|
+
return {
|
|
33
|
+
apiUrl: apiUrl || '',
|
|
34
|
+
keyFingerprint: apiKey
|
|
35
|
+
? createHash('sha256').update(String(apiKey)).digest('hex').slice(0, 16)
|
|
36
|
+
: '',
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isIdentity(value) {
|
|
41
|
+
return !!value
|
|
42
|
+
&& typeof value === 'object'
|
|
43
|
+
&& typeof value.apiUrl === 'string'
|
|
44
|
+
&& typeof value.keyFingerprint === 'string';
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function sameIdentity(a, b) {
|
|
48
|
+
return a.apiUrl === b.apiUrl && a.keyFingerprint === b.keyFingerprint;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// `identity` is optional: callers that only read the recorded counts (e.g.
|
|
52
|
+
// `status`) pass nothing and keep the pre-0.11.1 behaviour of taking the file
|
|
53
|
+
// at face value.
|
|
54
|
+
export function loadState(identity) {
|
|
21
55
|
if (!existsSync(STATE_FILE)) return { buckets: {}, sessions: {} };
|
|
22
56
|
try {
|
|
23
57
|
const parsed = JSON.parse(readFileSync(STATE_FILE, 'utf-8'));
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
58
|
+
const buckets = parsed.buckets ?? {};
|
|
59
|
+
const sessions = parsed.sessions ?? {};
|
|
60
|
+
if (!isIdentity(identity) || !isIdentity(parsed.identity)) {
|
|
61
|
+
// No identity recorded — written by a CLI older than 0.11.1. Adopt the
|
|
62
|
+
// entries as-is rather than forcing a re-upload: on upgrade day that
|
|
63
|
+
// would make every installed client re-send its whole history at once.
|
|
64
|
+
// The next saveState() stamps the current identity, so any later re-bind
|
|
65
|
+
// is caught.
|
|
66
|
+
return { buckets, sessions };
|
|
67
|
+
}
|
|
68
|
+
if (sameIdentity(parsed.identity, identity)) return { buckets, sessions };
|
|
69
|
+
// Bound to a different account or server: nothing recorded here was ever
|
|
70
|
+
// uploaded to the current target, so start empty and re-send local history.
|
|
71
|
+
// `identityChanged` is a runtime signal for the caller, never persisted.
|
|
72
|
+
return { buckets: {}, sessions: {}, identityChanged: true };
|
|
28
73
|
} catch {
|
|
29
74
|
// Corrupt/unreadable state must not lose data — treat as empty, which
|
|
30
75
|
// triggers a one-time full re-upload (same as a fresh install).
|
|
@@ -32,14 +77,28 @@ export function loadState() {
|
|
|
32
77
|
}
|
|
33
78
|
}
|
|
34
79
|
|
|
35
|
-
export function saveState(state) {
|
|
80
|
+
export function saveState(state, identity) {
|
|
36
81
|
mkdirSync(STATE_DIR, { recursive: true });
|
|
82
|
+
// Only the durable fields are written: `identityChanged` is loadState()'s
|
|
83
|
+
// one-run signal, not state. A CLI older than 0.11.1 reads just
|
|
84
|
+
// buckets/sessions, so the extra top-level `identity` key is ignored there —
|
|
85
|
+
// a file written by this version stays readable by older clients.
|
|
86
|
+
const payload = {
|
|
87
|
+
buckets: state.buckets ?? {},
|
|
88
|
+
sessions: state.sessions ?? {},
|
|
89
|
+
};
|
|
90
|
+
if (isIdentity(identity)) {
|
|
91
|
+
payload.identity = {
|
|
92
|
+
apiUrl: identity.apiUrl,
|
|
93
|
+
keyFingerprint: identity.keyFingerprint,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
37
96
|
// Atomic replace: write to a unique temp file then rename over the target.
|
|
38
97
|
// A crash mid-write can no longer truncate state.json into an unreadable
|
|
39
98
|
// file that loadState() would treat as empty (triggering a full re-upload).
|
|
40
99
|
const tempPath = `${STATE_FILE}.${process.pid}.${randomBytes(6).toString('hex')}.tmp`;
|
|
41
100
|
try {
|
|
42
|
-
writeFileSync(tempPath, JSON.stringify(
|
|
101
|
+
writeFileSync(tempPath, JSON.stringify(payload) + '\n', 'utf-8');
|
|
43
102
|
renameSync(tempPath, STATE_FILE);
|
|
44
103
|
} finally {
|
|
45
104
|
// No-op after a successful rename (the temp file is already gone); cleans
|
package/src/sync.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { hostname as osHostname } from 'node:os';
|
|
2
2
|
import { loadConfig, saveConfig } from './config.js';
|
|
3
3
|
import {
|
|
4
|
-
loadState, saveState, pruneState,
|
|
4
|
+
loadState, saveState, pruneState, stateIdentity,
|
|
5
5
|
bucketKey, bucketHash, sessionKey, sessionHash,
|
|
6
6
|
} from './state.js';
|
|
7
7
|
import { ingest, fetchSettings } from './api.js';
|
|
@@ -119,6 +119,13 @@ export async function runSync({
|
|
|
119
119
|
// Resolve it before parsing or loading upload state so failure is a true
|
|
120
120
|
// no-op: no data upload and no state mutation.
|
|
121
121
|
const apiUrl = config.apiUrl || 'https://vibecafe.ai';
|
|
122
|
+
// state.json records what was already uploaded to *this* account on *this*
|
|
123
|
+
// server. Passing the identity into loadState() makes state left by a
|
|
124
|
+
// previous account fall away, so a re-bind re-uploads the local history
|
|
125
|
+
// instead of diffing it against uploads the new account never received.
|
|
126
|
+
// Built from the same `apiUrl` the ingest calls below use, so the recorded
|
|
127
|
+
// target and the actual target can never drift apart.
|
|
128
|
+
const identity = stateIdentity({ apiUrl, apiKey: config.apiKey });
|
|
122
129
|
let uploadProject;
|
|
123
130
|
try {
|
|
124
131
|
const settings = await fetchSettings(apiUrl, config.apiKey);
|
|
@@ -223,11 +230,14 @@ export async function runSync({
|
|
|
223
230
|
// Successful parsers emitted no live items. Prune their old keys even on
|
|
224
231
|
// this fast path; otherwise deleting the final local log would leave dead
|
|
225
232
|
// state entries forever. Failed-parser sources remain protected.
|
|
226
|
-
const state = loadState();
|
|
233
|
+
const state = loadState(identity);
|
|
234
|
+
if (state.identityChanged && !quiet) {
|
|
235
|
+
console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
|
|
236
|
+
}
|
|
227
237
|
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
228
238
|
pruneState(state, new Set(), new Set(), okSources);
|
|
229
239
|
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
230
|
-
if (pruned > 0) saveState(state);
|
|
240
|
+
if (pruned > 0) saveState(state, identity);
|
|
231
241
|
if (!quiet && parserProgress.length > 0) {
|
|
232
242
|
for (const p of parserProgress) {
|
|
233
243
|
console.log(dim(` ${p.source}: 正在建立本地索引 ${p.completed}/${p.total}(下次同步继续)`));
|
|
@@ -283,7 +293,10 @@ export async function runSync({
|
|
|
283
293
|
// an active one sends just the current 30-min bucket.
|
|
284
294
|
// Missing/corrupt state.json => empty maps => one-time full upload, then
|
|
285
295
|
// incremental forever after.
|
|
286
|
-
const state = loadState();
|
|
296
|
+
const state = loadState(identity);
|
|
297
|
+
if (state.identityChanged && !quiet) {
|
|
298
|
+
console.log(dim('检测到上传账号已更换,本次全量重传本地历史'));
|
|
299
|
+
}
|
|
287
300
|
const changedBuckets = [];
|
|
288
301
|
const changedSessions = [];
|
|
289
302
|
const liveBucketKeys = new Set();
|
|
@@ -321,7 +334,7 @@ export async function runSync({
|
|
|
321
334
|
const before = Object.keys(state.buckets).length + Object.keys(state.sessions).length;
|
|
322
335
|
pruneState(state, liveBucketKeys, liveSessionKeys, okSources);
|
|
323
336
|
const pruned = before - (Object.keys(state.buckets).length + Object.keys(state.sessions).length);
|
|
324
|
-
if (pruned > 0) saveState(state);
|
|
337
|
+
if (pruned > 0) saveState(state, identity);
|
|
325
338
|
|
|
326
339
|
if (changedBuckets.length === 0 && changedSessions.length === 0) {
|
|
327
340
|
if (!quiet) console.log(dim('无新增数据。'));
|
|
@@ -422,7 +435,7 @@ export async function runSync({
|
|
|
422
435
|
batchStateChanged = true;
|
|
423
436
|
}
|
|
424
437
|
}
|
|
425
|
-
if (batchStateChanged) saveState(state);
|
|
438
|
+
if (batchStateChanged) saveState(state, identity);
|
|
426
439
|
}
|
|
427
440
|
|
|
428
441
|
if (totalBatches > 1 || allBucketsToSend.length > 0) {
|