@vibe-cafe/vibe-usage 0.10.33 → 0.11.0

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 CHANGED
@@ -51,11 +51,24 @@ npx @vibe-cafe/vibe-usage reset --local # Delete this host's data only and re-u
51
51
  npx @vibe-cafe/vibe-usage skill # Install skill for AI coding assistants
52
52
  npx @vibe-cafe/vibe-usage skill --remove # Remove installed skills
53
53
  npx @vibe-cafe/vibe-usage status # Config, detected tools, and what each tool has uploaded so far
54
+ npx @vibe-cafe/vibe-usage quota discover --json # Detect subscription-quota products locally
55
+ npx @vibe-cafe/vibe-usage quota fetch --product kimi-code --product zcode --product grok --json # Fetch only selected quotas
54
56
  npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short version)
55
57
  ```
56
58
 
57
59
  </details>
58
60
 
61
+ ## Subscription Quotas
62
+
63
+ The versioned `quota` JSON contract is designed for local desktop clients. `discover` only checks ordinary app, config-directory, and executable presence signals: it does not open credentials or use the network. `fetch` invokes only products explicitly named with `--product`, and one provider failure does not prevent results for the others.
64
+
65
+ - **Kimi Code** reads the official Kimi CLI OAuth file (`$KIMI_SHARE_DIR/credentials/kimi-code.json`, otherwise `~/.kimi/credentials/kimi-code.json`) and calls `https://api.kimi.com/coding/v1/usages`. When the short-lived access token is close to expiry, it uses Kimi's standard OAuth refresh flow and atomically rotates the official credential with owner-only permissions. Refreshes are serialized across Vibe Usage processes and re-check the file before writing so a concurrent Kimi CLI refresh wins safely.
66
+ - **ZCode / GLM Coding Plan** accepts only a caller-supplied regional key: `BIGMODEL_API_KEY` calls the domestic `https://open.bigmodel.cn/api/monitor/usage/quota/limit`, while the existing `Z_AI_API_KEY` keeps using `https://api.z.ai/api/monitor/usage/quota/limit`. If both are present, the explicitly named BigModel key wins. It does not read ZCode's private OAuth state.
67
+ - **Grok** reads at most the final 2 MiB of the official CLI's ordinary `$GROK_HOME/logs/unified.jsonl` (default `~/.grok/logs/unified.jsonl`). It accepts only the structured `billing: fetched credits config` event and projects the current utilization, period bounds, subscription tier, and event timestamp. It performs no network request, reads no credentials, and never returns or retains other log fields.
68
+ - **Cursor** is detected independently but remains non-fetchable until an official or stable quota protocol is available. Quota monitoring does not read Cursor's login token/database, browser cookies, another app's Keychain, network traffic, or UI.
69
+
70
+ Quota results are never uploaded or added to incremental sync state. Credential-backed providers may use the disposable cache at `~/.vibe-usage/quota-cache.json`; it contains normalized meters only, is scoped to a one-way hash of the active credential, rejects expired windows, and has a seven-day hard expiry. Grok's local-log result is not cached. No credential is stored in that cache, logs, or command output; the only credential write is Kimi's standard token rotation back to Kimi's own credential file.
71
+
59
72
  ## Supported Tools
60
73
 
61
74
  | Tool | Data Location |
@@ -89,6 +102,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
89
102
  | Trae CLI | macOS: `~/Library/Caches/trae-cli/sessions/`; Windows: `%LOCALAPPDATA%/trae-cli/cache/sessions/`; Linux: `~/.cache/trae-cli/sessions/` (CLI telemetry only; Trae IDE/Trae Work chats are not supported). Token usage is summed per unique LLM call (`model.stream.eino`, plus `model.generate` failovers); nested duplicate spans that share a session `traceID` are not max-merged. `traces.jsonl` / `events.jsonl` are streamed line-by-line so a multi-hundred-MB events file cannot hit Node's string-length limit. |
90
103
  | Antigravity | Scans App 2.0 `~/.gemini/antigravity/conversations/`, `agy` CLI `~/.gemini/antigravity-cli/conversations/`, and standalone IDE `~/.gemini/antigravity-ide/conversations/`. `.db` stores, including the same paths below explicitly added alternate Homes, are parsed offline (tokens, model, project, sessions). When Gemini blobs omit `chatStartMetadata.createdAt` or `modelDisplayName`, timestamps fall back to `steps.metadata` and model names to `responseModel`. `.pb` history in the default stores requires the corresponding App/IDE language server to be running; when several servers are open, the parser tries the others for unreadable conversations. Unavailable legacy history produces a warning and preserves prior sync state. |
91
104
  | WorkBuddy | Current releases: `~/.workbuddy-ai/projects/**/*.jsonl`; legacy releases: `~/.workbuddy/projects/**/*.jsonl` (fixture/relocation override: `VIBE_USAGE_WORKBUDDY_DIRS`). Reads usage-bearing completed assistant and `function_call` records, using the routed model identifier exposed as `providerData.requestModelId`. Splits cache reads and reasoning from inclusive input/output totals, deduplicates copied record IDs, and extracts local session timing without uploading message content. |
105
+ | CodeBuddy | Tencent's CodeBuddy Code CLI (`@tencent-ai/codebuddy-code`). Home is `$CODEBUDDY_CONFIG_DIR` or `~/.codebuddy` (fixture/relocation override: `VIBE_USAGE_CODEBUDDY_DIRS`); transcripts at `projects/<compressed-cwd>/<sessionId>.jsonl`, including nested subagent directories. Only the API-message records' `message.usage` is read: `input_tokens` + `cache_creation_input_tokens` fold into input (the store writes no per-TTL breakdown), `cache_read_input_tokens` stays separate, `output_tokens` is the full completion. Retries and copies dedupe onto one logical call using `message.id`, `providerData.messageId`, or the record id (the CLI leaves `message.id` empty on some builds; `conversationRequestId` is a turn id, never a dedup key). The model comes from `message.model` and falls back to `providerData.requestModelId`/`model`, because successful calls can write `message.model: null`; routing-tier labels are namespaced (`codebuddy-auto`) so they can never match another vendor's price. Prompt text, thinking signatures, and tool payloads are never read. |
92
106
  | ZCode | `~/.zcode/cli/db/db.sqlite` (SQLite; reads the `message` table for per-message tokens, model, and project `cwd`/`root`, joined to `session.directory`; fixture/relocation override: `VIBE_USAGE_ZCODE_DB`) |
93
107
  | Qoder | International edition (qoder.com). IDE store `~/Library/Application Support/Qoder/SharedClientCache/cache/db/local.db` (Windows `%APPDATA%\Qoder`, Linux `~/.config/Qoder`; honors `QODER_HOME`, fixture override `VIBE_USAGE_QODER_DB`) gives real tokens from `chat_message.token_info` (prompt includes cached; split out) with `model_key` usually a routing tier, reported as `qoder-auto` / `qoder-ultimate` / … so it never collides with a priced model id; message content is never selected, and lock/schema failures fall back to a snapshot or `skipped`. CLI + desktop app transcripts `~/.qoder/projects/**/*.jsonl` (honors `QODER_CONFIG_DIR`, fixture override `VIBE_USAGE_QODER_PROJECTS`; sub-agents under `<session>/subagents/`) are credit-billed with every token field at 0, so they contribute sessions only — credits are account funding and are not collected |
94
108
  | Qoder CN | China edition (qoder.com.cn, separate account). Same two shapes under `~/Library/Application Support/QoderCN/SharedClientCache/cache/db/local.db` (`QODER_CN_HOME` / `VIBE_USAGE_QODER_CN_DB`) and `~/.qoder-cn/projects/` (`QODERCN_CONFIG_DIR` / `VIBE_USAGE_QODER_CN_PROJECTS`); reported as source `qoder-cn` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.33",
3
+ "version": "0.11.0",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,6 +1,6 @@
1
1
  import { execFileSync } from 'node:child_process';
2
2
  import { writeFileSync, readFileSync, unlinkSync, mkdirSync, existsSync } from 'node:fs';
3
- import { join, dirname, win32 as winPath } from 'node:path';
3
+ import { join, win32 as winPath, posix as posixPath } from 'node:path';
4
4
  import { homedir, platform } from 'node:os';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { success, failure, warn, dim } from './output.js';
@@ -41,8 +41,9 @@ export function isNpxCachePath(binPath) {
41
41
  * falls back to pinning the path and warning, as before).
42
42
  */
43
43
  export function npxLauncher(nodePath, exists = existsSync, os = platform()) {
44
- const nodeDir = dirname(nodePath);
45
- const npxPath = join(nodeDir, os === 'win32' ? 'npx.cmd' : 'npx');
44
+ const paths = os === 'win32' ? winPath : posixPath;
45
+ const nodeDir = paths.dirname(nodePath);
46
+ const npxPath = paths.join(nodeDir, os === 'win32' ? 'npx.cmd' : 'npx');
46
47
  return exists(npxPath) ? { mode: 'npx', npxPath, nodeDir } : null;
47
48
  }
48
49
 
package/src/index.js CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  import { dim as dimText, failure, hint, smallHeader, warn } from './output.js';
12
12
  import { loadState } from './state.js';
13
13
  import { fetchAccount } from './api.js';
14
+ import { COLLECTOR_VERSION } from './client-meta.js';
14
15
 
15
16
  function printSmallHeader() {
16
17
  console.log();
@@ -298,6 +299,8 @@ const FULL_HELP = `
298
299
  ${BARE} skill Install skill for AI coding tools
299
300
  ${BARE} skill --remove Remove installed skills
300
301
  ${BARE} status Show config and detected tools
302
+ ${BARE} quota discover --json Detect subscription-quota products locally
303
+ ${BARE} quota fetch --product <id> --json Fetch only selected subscription quotas
301
304
  ${BARE} config show Show full config as JSON
302
305
  ${BARE} config get <key> Get a config value
303
306
  ${BARE} config set <key> <value> Set a config value
@@ -307,6 +310,7 @@ const FULL_HELP = `
307
310
  ${BARE} config roots Show added data roots as JSON
308
311
  ${BARE} help Show the short help
309
312
  ${BARE} help --all Show this full list
313
+ ${BARE} --version Print the installed CLI version
310
314
  `;
311
315
 
312
316
  export async function run(rawArgs) {
@@ -358,6 +362,16 @@ export async function run(rawArgs) {
358
362
  await runSummary(args.slice(1));
359
363
  break;
360
364
  }
365
+ case 'quota': {
366
+ const { runQuota } = await import('./quotas/index.js');
367
+ try {
368
+ await runQuota(args.slice(1));
369
+ } catch (error) {
370
+ console.error(error?.message || String(error));
371
+ process.exitCode = 1;
372
+ }
373
+ break;
374
+ }
361
375
  case 'reset': {
362
376
  printSmallHeader();
363
377
  if (args.includes('--host')) hint('reset --host 已改名 reset --local,旧写法仍可用');
@@ -399,6 +413,11 @@ export async function run(rawArgs) {
399
413
  handleConfig(args.slice(1));
400
414
  break;
401
415
  }
416
+ case '--version':
417
+ case '-v': {
418
+ console.log(COLLECTOR_VERSION);
419
+ break;
420
+ }
402
421
  case 'status': {
403
422
  await showStatus();
404
423
  break;
@@ -384,6 +384,19 @@ export async function parse({ extraRoots = [] } = {}) {
384
384
  const roots = getClaudeRoots({
385
385
  onWarning: (message) => addWarning(ctx, message),
386
386
  extraRoots,
387
+ }).filter((root) => {
388
+ // On Windows readdir("file/projects") can return ENOENT rather than
389
+ // ENOTDIR. Validate the root first so that an invalid store cannot look
390
+ // like a successful empty scan and allow incremental state to be pruned.
391
+ try {
392
+ if (statSync(root).isDirectory()) return true;
393
+ addWarning(ctx, `Claude Code: cannot read directory ${root}: not a directory`);
394
+ } catch (err) {
395
+ if (err?.code !== 'ENOENT') {
396
+ addWarning(ctx, `Claude Code: cannot read directory ${root}: ${err.message}`);
397
+ }
398
+ }
399
+ return false;
387
400
  });
388
401
  const projectGroups = collectCandidates(roots, 'projects', ctx);
389
402
  const projectSessionIds = new Set();
@@ -0,0 +1,202 @@
1
+ import { existsSync, readdirSync } from 'node:fs';
2
+ import { createInterface } from 'node:readline';
3
+ import { createReadStream } from 'node:fs';
4
+ import { basename, join, sep } from 'node:path';
5
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
6
+ import { projectFromCwd, toCount } from './fs-utils.js';
7
+ import { getCodebuddyRoots } from '../tools.js';
8
+
9
+ // CodeBuddy Code (Tencent's terminal agent, `@tencent-ai/codebuddy-code`). The
10
+ // store follows Claude Code's layout:
11
+ // <home>/projects/<compressed-cwd>/<sessionId>.jsonl (+ nested subagent dirs)
12
+ // where <home> is `$CODEBUDDY_CONFIG_DIR` or ~/.codebuddy.
13
+ //
14
+ // Two record shapes live in those transcripts (verified against 2.151.0's own
15
+ // writer and a real store): local turns are `{type:"message", role:"user"|…,
16
+ // content, sessionId, cwd}`, while every successful model call is the API
17
+ // message shape — `{message:{id, model, role:"assistant",
18
+ // usage:{input_tokens, output_tokens, cache_read_input_tokens,
19
+ // cache_creation_input_tokens}}}` — so token accounting reads `message.usage`
20
+ // only. `usage.cache_creation` (the per-TTL breakdown) is written as `null`, so
21
+ // cache writes fold into input and cannot be priced per TTL, matching every
22
+ // parser that has no split.
23
+ const SOURCE = 'codebuddy';
24
+ const PROJECTS = 'projects';
25
+
26
+ export function resolveCodebuddyRoots(env = process.env, home) {
27
+ return getCodebuddyRoots(env, home);
28
+ }
29
+
30
+ /** Session id of a transcript file, and the project fallback from its folder. */
31
+ function fileIdentity(filePath, projectsDir) {
32
+ const sessionId = basename(filePath, '.jsonl');
33
+ const relative = filePath.startsWith(projectsDir + sep) ? filePath.slice(projectsDir.length + 1) : '';
34
+ const folder = relative.split(sep)[0] || '';
35
+ // Compressed cwd folders look like `private-tmp-my-project`; the last segment
36
+ // is the best guess when a record carries no cwd.
37
+ const fallback = folder.split('-').filter(Boolean).at(-1) || 'unknown';
38
+ return { sessionId, fallback };
39
+ }
40
+
41
+ function findTranscripts(root, onWarning) {
42
+ const projectsDir = join(root, PROJECTS);
43
+ if (!existsSync(projectsDir)) return { projectsDir, files: [] };
44
+ const files = [];
45
+ const walk = dir => {
46
+ let entries;
47
+ try { entries = readdirSync(dir, { withFileTypes: true }); }
48
+ catch (err) { onWarning(`codebuddy: 无法读取目录 ${dir}: ${err.message}`); return; }
49
+ for (const entry of entries) {
50
+ const path = join(dir, entry.name);
51
+ if (entry.isDirectory()) walk(path);
52
+ else if (entry.isFile() && entry.name.endsWith('.jsonl')) files.push(path);
53
+ }
54
+ };
55
+ walk(projectsDir);
56
+ return { projectsDir, files };
57
+ }
58
+
59
+ /**
60
+ * Provider routing/tier labels ('auto', 'default', …) are not model ids, and
61
+ * server-side pricing matches the model string alone: a bare `auto` would be
62
+ * billed at Cursor's `auto` rate — the collision PR #83 fixed for Qoder by
63
+ * renaming it `qoder-auto`. Namespace the tier labels with the tool prefix;
64
+ * concrete ids (`claude-sonnet-4-6`, …) pass through untouched.
65
+ */
66
+ const ROUTING_TIER_IDS = new Set([
67
+ 'auto', 'default', 'default-model', 'fast', 'turbo', 'lite', 'ultimate', 'performance', 'efficient',
68
+ ]);
69
+
70
+ function normalizeModel(model) {
71
+ const lower = model.toLowerCase();
72
+ return ROUTING_TIER_IDS.has(lower) ? `${SOURCE}-${lower}` : model;
73
+ }
74
+
75
+ /** One usage-bearing assistant message, keyed so retries/copies collapse. */
76
+ function usageEntry(obj, projectFallback, sessionId) {
77
+ const usage = obj.message?.usage;
78
+ if (!usage) return null;
79
+ const timestampMs = Number(obj.timestamp) || Date.parse(obj.message?.timestamp) || null;
80
+ const timestamp = Number.isFinite(timestampMs) ? new Date(timestampMs) : null;
81
+ if (!timestamp || isNaN(timestamp.getTime())) return null;
82
+
83
+ const cacheWrite = toCount(usage.cache_creation_input_tokens);
84
+ const inputTokens = toCount(usage.input_tokens) + cacheWrite;
85
+ const outputTokens = toCount(usage.output_tokens);
86
+ const cachedInputTokens = toCount(usage.cache_read_input_tokens);
87
+ if (!inputTokens && !outputTokens && !cachedInputTokens) return null;
88
+
89
+ const messageId = typeof obj.message?.id === 'string' ? obj.message.id.trim() : '';
90
+ const providerMessageId = typeof obj.providerData?.messageId === 'string' ? obj.providerData.messageId.trim() : '';
91
+ const ownId = typeof obj.id === 'string' ? obj.id.trim() : '';
92
+ // The CLI leaves `message.id` empty on some builds and keeps the per-message id
93
+ // in providerData; `conversationRequestId` is a *turn* id (one turn can hold
94
+ // several billable calls), so it is explicitly not a dedup key. Without this
95
+ // chain every call collapses onto one empty key and the session under-counts.
96
+ const identity = messageId || providerMessageId || ownId;
97
+ const dedupeKey = identity ? `call:${identity}` : null;
98
+
99
+ const model = normalizeModel([
100
+ obj.message?.model,
101
+ obj.providerData?.requestModelId,
102
+ obj.providerData?.model,
103
+ ].find(value => typeof value === 'string' && value.trim()) || 'unknown');
104
+
105
+ return {
106
+ dedupeKey,
107
+ usageScore: inputTokens + outputTokens + cachedInputTokens,
108
+ entry: {
109
+ source: SOURCE,
110
+ model,
111
+ project: projectFromCwd(obj.cwd, projectFallback),
112
+ timestamp,
113
+ inputTokens,
114
+ outputTokens,
115
+ cachedInputTokens,
116
+ reasoningOutputTokens: 0,
117
+ },
118
+ sessionId: typeof obj.sessionId === 'string' && obj.sessionId ? obj.sessionId : sessionId,
119
+ };
120
+ }
121
+
122
+ /** Human prompt? Local user turns only — the CLI marks injected/system text. */
123
+ function isHumanPrompt(obj) {
124
+ if (obj?.role !== 'user' && obj?.message?.role !== 'user') return false;
125
+ if (obj.providerData?.isMeta || obj.providerData?.skipRun) return false;
126
+ if (obj.providerData?.isSessionSeparator || obj.providerData?.isCompactSummary) return false;
127
+ return true;
128
+ }
129
+
130
+ function readTimestamp(obj) {
131
+ const ms = Number(obj?.timestamp);
132
+ return Number.isFinite(ms) ? new Date(ms) : null;
133
+ }
134
+
135
+ export async function parse() {
136
+ const warnings = [];
137
+ const onWarning = message => warnings.push(message);
138
+ const entries = [];
139
+ const events = [];
140
+ const byKey = new Map();
141
+ const anonymous = [];
142
+ let skipped = false;
143
+
144
+ for (const root of resolveCodebuddyRoots()) {
145
+ const { projectsDir, files } = findTranscripts(root, onWarning);
146
+ for (const file of files) {
147
+ const { sessionId, fallback } = fileIdentity(file, projectsDir);
148
+ let stream;
149
+ try {
150
+ stream = createReadStream(file);
151
+ const lines = createInterface({ input: stream, crlfDelay: Infinity });
152
+ for await (const line of lines) {
153
+ if (!line.trim()) continue;
154
+ let obj;
155
+ try { obj = JSON.parse(line); } catch { continue; }
156
+ if (!obj || typeof obj !== 'object') continue;
157
+
158
+ const entry = usageEntry(obj, fallback, sessionId);
159
+ if (entry) {
160
+ if (!entry.dedupeKey) anonymous.push(entry);
161
+ else {
162
+ const current = byKey.get(entry.dedupeKey);
163
+ if (!current || entry.usageScore > current.usageScore) byKey.set(entry.dedupeKey, entry);
164
+ }
165
+ const timestamp = readTimestamp(obj);
166
+ if (timestamp) events.push({ sessionId: entry.sessionId, source: SOURCE, project: entry.entry.project, timestamp, role: 'assistant' });
167
+ continue;
168
+ }
169
+
170
+ if (isHumanPrompt(obj)) {
171
+ const timestamp = readTimestamp(obj);
172
+ if (timestamp) {
173
+ events.push({
174
+ sessionId: typeof obj.sessionId === 'string' && obj.sessionId ? obj.sessionId : sessionId,
175
+ source: SOURCE,
176
+ project: projectFromCwd(obj.cwd) || fallback,
177
+ timestamp,
178
+ role: 'user',
179
+ });
180
+ }
181
+ }
182
+ }
183
+ } catch (err) {
184
+ // A transcript that cannot be read means this source's snapshot is
185
+ // incomplete: skip the source so its previous upload state survives.
186
+ skipped = true;
187
+ onWarning(`codebuddy: 无法读取会话 ${file}: ${err.message}`);
188
+ } finally {
189
+ stream?.close();
190
+ }
191
+ }
192
+ }
193
+
194
+ for (const entry of anonymous) entries.push(entry.entry);
195
+ for (const entry of byKey.values()) entries.push(entry.entry);
196
+
197
+ const buckets = aggregateToBuckets(entries);
198
+ const sessions = extractSessions(events);
199
+ return skipped
200
+ ? { buckets: [], sessions: [], skipped: true, warnings }
201
+ : { buckets, sessions, warnings };
202
+ }
@@ -29,6 +29,7 @@ import { parse as parseTraeCli } from './trae-cli.js';
29
29
  import { parse as parseWorkbuddy } from './workbuddy.js';
30
30
  import { parseQoder, parseQoderCn } from './qoder.js';
31
31
  import { parse as parseDevin } from './devin.js';
32
+ import { parse as parseCodebuddy } from './codebuddy.js';
32
33
 
33
34
  export const parsers = {
34
35
  'claude-code': parseClaudeCode,
@@ -63,6 +64,7 @@ export const parsers = {
63
64
  'workbuddy': parseWorkbuddy,
64
65
  'zcode': parseZcode,
65
66
  'devin': parseDevin,
67
+ 'codebuddy': parseCodebuddy,
66
68
  };
67
69
 
68
70
  export { roundToHalfHour, aggregateToBuckets, extractSessions } from './aggregate.js';
@@ -0,0 +1,91 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ import { createHash } from 'node:crypto';
5
+ import { normalizeMeter, quotaResult } from './schema.js';
6
+
7
+ const CACHE_VERSION = 1;
8
+ const MAX_CACHE_AGE_MS = 7 * 24 * 60 * 60 * 1000;
9
+
10
+ function cachePath(environment = process.env) {
11
+ const root = environment.VIBE_USAGE_QUOTA_CACHE_DIR?.trim()
12
+ || join(homedir(), '.vibe-usage');
13
+ return join(root, 'quota-cache.json');
14
+ }
15
+
16
+ function loadDocument(environment) {
17
+ try {
18
+ const parsed = JSON.parse(readFileSync(cachePath(environment), 'utf8'));
19
+ if (!isRecord(parsed) || parsed.version !== CACHE_VERSION || !isRecord(parsed.products)) return null;
20
+ return parsed;
21
+ } catch {
22
+ return null;
23
+ }
24
+ }
25
+
26
+ function isRecord(value) {
27
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
28
+ }
29
+
30
+ export function attachCacheScope(result, secret) {
31
+ const scope = createHash('sha256').update(`${result.id}\0${secret}`).digest('hex');
32
+ Object.defineProperty(result, 'cacheScope', { value: scope, enumerable: false });
33
+ return result;
34
+ }
35
+
36
+ export function loadCachedQuota(id, scope, environment = process.env, now = new Date()) {
37
+ if (!scope) return null;
38
+ const raw = loadDocument(environment)?.products?.[id];
39
+ if (!raw || raw.scope !== scope || raw.status !== 'ok' || !Array.isArray(raw.meters)) return null;
40
+ try {
41
+ const dataAsOf = new Date(raw.dataAsOf || raw.fetchedAt);
42
+ if (Number.isNaN(dataAsOf.getTime()) || now.getTime() - dataAsOf.getTime() > MAX_CACHE_AGE_MS) {
43
+ return null;
44
+ }
45
+ const meters = raw.meters.map(normalizeMeter).filter(meter => {
46
+ if (meter.resetsAt) return new Date(meter.resetsAt) > now;
47
+ if (meter.windowSeconds) {
48
+ return dataAsOf.getTime() + meter.windowSeconds * 1000 > now.getTime();
49
+ }
50
+ return true;
51
+ });
52
+ if (!meters.length) return null;
53
+ return quotaResult({
54
+ ...raw,
55
+ id,
56
+ status: 'ok',
57
+ meters,
58
+ source: 'cache',
59
+ fetchedAt: raw.fetchedAt,
60
+ dataAsOf: raw.dataAsOf || raw.fetchedAt,
61
+ });
62
+ } catch {
63
+ return null;
64
+ }
65
+ }
66
+
67
+ export function saveCachedQuota(result, scope, environment = process.env) {
68
+ if (result?.status !== 'ok' || !scope) return;
69
+ try {
70
+ // All disposable-cache work belongs inside the failure boundary, including
71
+ // path resolution and updating a document recovered from disk.
72
+ const path = cachePath(environment);
73
+ const document = loadDocument(environment) || { version: CACHE_VERSION, products: {} };
74
+ document.products[result.id] = {
75
+ id: result.id,
76
+ status: 'ok',
77
+ meters: result.meters,
78
+ planLabel: result.planLabel,
79
+ fetchedAt: result.fetchedAt,
80
+ dataAsOf: result.dataAsOf,
81
+ scope,
82
+ };
83
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
84
+ const temporary = `${path}.${process.pid}.tmp`;
85
+ writeFileSync(temporary, `${JSON.stringify(document)}\n`, { encoding: 'utf8', mode: 0o600 });
86
+ renameSync(temporary, path);
87
+ } catch {
88
+ // The cache is disposable. A read-only home must not turn a successful
89
+ // provider response into a CLI failure.
90
+ }
91
+ }
@@ -0,0 +1,35 @@
1
+ import { discoverQuotaProducts, fetchQuotaProducts } from './registry.js';
2
+
3
+ function fail(message) {
4
+ throw new Error(message);
5
+ }
6
+
7
+ function parseFetchArguments(args) {
8
+ const products = [];
9
+ for (let index = 0; index < args.length; index += 1) {
10
+ const argument = args[index];
11
+ if (argument === '--json') continue;
12
+ if (argument !== '--product') fail(`Unknown quota fetch option: ${argument}`);
13
+ const value = args[index + 1];
14
+ if (!value || value.startsWith('--')) fail('Option --product requires a value.');
15
+ products.push(value);
16
+ index += 1;
17
+ }
18
+ if (!products.length) fail('quota fetch requires at least one --product.');
19
+ return products;
20
+ }
21
+
22
+ export async function runQuota(args) {
23
+ const subcommand = args[0];
24
+ if (subcommand === 'discover') {
25
+ const unknown = args.slice(1).filter(argument => argument !== '--json');
26
+ if (unknown.length) fail(`Unknown quota discover option: ${unknown[0]}`);
27
+ console.log(JSON.stringify(discoverQuotaProducts()));
28
+ return;
29
+ }
30
+ if (subcommand === 'fetch') {
31
+ console.log(JSON.stringify(await fetchQuotaProducts(parseFetchArguments(args.slice(1)))));
32
+ return;
33
+ }
34
+ fail(`Unknown quota subcommand: ${subcommand || '(none)'}`);
35
+ }
@@ -0,0 +1,151 @@
1
+ import { closeSync, fstatSync, openSync, readSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { quotaResult } from '../schema.js';
5
+
6
+ const PRODUCT_ID = 'grok';
7
+ const DEFAULT_MAX_LOG_BYTES = 2 * 1024 * 1024;
8
+
9
+ export function grokBillingLogPath(environment = process.env, home = homedir()) {
10
+ const configured = environment.GROK_HOME?.trim();
11
+ let root = configured || join(home, '.grok');
12
+ if (root === '~') root = home;
13
+ else if (root.startsWith('~/') || root.startsWith('~\\')) root = join(home, root.slice(2));
14
+ return join(root, 'logs', 'unified.jsonl');
15
+ }
16
+
17
+ function readLogTail(path, maxBytes = DEFAULT_MAX_LOG_BYTES) {
18
+ const descriptor = openSync(path, 'r');
19
+ try {
20
+ const size = fstatSync(descriptor).size;
21
+ const length = Math.min(size, maxBytes);
22
+ if (length <= 0) return '';
23
+ const buffer = Buffer.allocUnsafe(length);
24
+ const offset = size - length;
25
+ let total = 0;
26
+ while (total < length) {
27
+ const count = readSync(descriptor, buffer, total, length - total, offset + total);
28
+ if (count <= 0) break;
29
+ total += count;
30
+ }
31
+ let text = buffer.subarray(0, total).toString('utf8');
32
+ // A bounded tail may begin in the middle of a UTF-8 JSON line. Drop only
33
+ // that incomplete line; the CLI emits this billing snapshot repeatedly.
34
+ if (offset > 0) {
35
+ const newline = text.indexOf('\n');
36
+ text = newline < 0 ? '' : text.slice(newline + 1);
37
+ }
38
+ return text;
39
+ } finally {
40
+ closeSync(descriptor);
41
+ }
42
+ }
43
+
44
+ function finiteNumber(value) {
45
+ const number = typeof value === 'number' ? value : Number(value);
46
+ return Number.isFinite(number) ? number : null;
47
+ }
48
+
49
+ function date(value) {
50
+ if (typeof value !== 'string' || !value.trim()) return null;
51
+ const parsed = new Date(value);
52
+ return Number.isNaN(parsed.getTime()) ? null : parsed;
53
+ }
54
+
55
+ function periodLabel(type, seconds) {
56
+ if (type === 'USAGE_PERIOD_TYPE_DAILY') return '1d';
57
+ if (type === 'USAGE_PERIOD_TYPE_WEEKLY') return '7d';
58
+ if (type === 'USAGE_PERIOD_TYPE_MONTHLY') return 'Month';
59
+ const days = seconds / 86_400;
60
+ return Number.isInteger(days) && days > 0 ? `${days}d` : 'Credits';
61
+ }
62
+
63
+ /**
64
+ * Reads only Grok CLI's structured, non-secret billing status event. Other log
65
+ * messages are parsed only far enough to reject them and are never returned,
66
+ * cached, logged, or uploaded by Vibe Usage.
67
+ */
68
+ export function parseGrokBillingLog(text, now = new Date()) {
69
+ if (typeof text !== 'string' || !text) return null;
70
+ const lines = text.split(/\r?\n/);
71
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
72
+ const line = lines[index].trim();
73
+ if (!line) continue;
74
+ let event;
75
+ try {
76
+ event = JSON.parse(line);
77
+ } catch {
78
+ continue;
79
+ }
80
+ if (event?.msg !== 'billing: fetched credits config') continue;
81
+ const config = event?.ctx?.config;
82
+ if (!config || typeof config !== 'object' || Array.isArray(config)) continue;
83
+ const utilization = finiteNumber(config.creditUsagePercent);
84
+ const dataAsOf = date(event.ts);
85
+ const startsAt = date(config.currentPeriod?.start || config.billingPeriodStart);
86
+ const resetsAt = date(config.currentPeriod?.end || config.billingPeriodEnd);
87
+ if (utilization === null || !dataAsOf || !startsAt || !resetsAt) continue;
88
+ const windowSeconds = (resetsAt.getTime() - startsAt.getTime()) / 1000;
89
+ if (windowSeconds <= 0) continue;
90
+ const planLabel = [event.ctx?.subscriptionTier, config.subscriptionTier]
91
+ .find(value => typeof value === 'string' && value.trim())?.trim();
92
+ return {
93
+ active: resetsAt > now,
94
+ dataAsOf,
95
+ meters: [{
96
+ id: 'subscription-credits',
97
+ label: periodLabel(config.currentPeriod?.type, windowSeconds),
98
+ utilization,
99
+ resetsAt: resetsAt.toISOString(),
100
+ windowSeconds,
101
+ }],
102
+ planLabel,
103
+ };
104
+ }
105
+ return null;
106
+ }
107
+
108
+ export function fetchGrokQuota({
109
+ environment = process.env,
110
+ home = homedir(),
111
+ now = new Date(),
112
+ maxLogBytes = DEFAULT_MAX_LOG_BYTES,
113
+ } = {}) {
114
+ let parsed;
115
+ try {
116
+ parsed = parseGrokBillingLog(
117
+ readLogTail(grokBillingLogPath(environment, home), maxLogBytes),
118
+ now
119
+ );
120
+ } catch (error) {
121
+ const status = error?.code === 'ENOENT' ? 'no_data' : 'retryable_error';
122
+ return quotaResult({
123
+ id: PRODUCT_ID,
124
+ status,
125
+ message: status === 'no_data'
126
+ ? 'Grok billing status is not available yet'
127
+ : 'Grok billing status could not be read',
128
+ fetchedAt: now,
129
+ source: 'local',
130
+ });
131
+ }
132
+ if (!parsed || !parsed.active) {
133
+ return quotaResult({
134
+ id: PRODUCT_ID,
135
+ status: 'no_data',
136
+ message: 'Grok billing status is not available yet',
137
+ fetchedAt: now,
138
+ dataAsOf: parsed?.dataAsOf,
139
+ source: 'local',
140
+ });
141
+ }
142
+ return quotaResult({
143
+ id: PRODUCT_ID,
144
+ status: 'ok',
145
+ meters: parsed.meters,
146
+ planLabel: parsed.planLabel,
147
+ fetchedAt: now,
148
+ dataAsOf: parsed.dataAsOf,
149
+ source: 'local',
150
+ });
151
+ }