@vibe-cafe/vibe-usage 0.12.0 → 0.12.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 CHANGED
@@ -62,7 +62,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
62
62
 
63
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
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.
65
+ - **Kimi Code** reads the official Kimi CLI OAuth file — `$KIMI_SHARE_DIR/credentials/kimi-code.json` when configured, otherwise the current CLI home (`$KIMI_CODE_HOME`, else `~/.kimi-code`) and then the legacy `~/.kimi` home, whichever holds a login — 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
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
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
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.
@@ -85,7 +85,7 @@ Quota results are never uploaded or added to incremental sync state. Credential-
85
85
  | Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`); cloud data is stamped with a fixed `cursor-cloud` hostname so multi-machine setups don't double-count |
86
86
  | DimAgent | `$DIMCODE_HOME/dimcode.sqlite` (default `~/.dimcode/v2/dimcode.sqlite`); exact usage from `usage_ledger`, with forked ledger/history copies deduplicated |
87
87
  | Gemini CLI | `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl` (current line-delimited format) and legacy `session-*.json`; recurses into nested subagent sessions |
88
- | OpenCode | `~/.local/share/opencode/opencode.db` (SQLite), with `storage/message/` as the legacy alternative; supports explicitly added data roots |
88
+ | OpenCode | `~/.local/share/opencode/opencode.db` (SQLite) — both the legacy `message` table and the 2.x `session_message` projection are read, merged by message id — with `storage/message/` as the legacy alternative; cache writes are counted as 5m cache creation; supports explicitly added data roots |
89
89
  | OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments); cache-creation/cache-write tokens are included in input usage |
90
90
  | Oh My Pi | `~/.omp/agent/sessions/`, `~/.omp/profiles/*/agent/sessions/`, and `$XDG_DATA_HOME/omp/{sessions,profiles/*/sessions}`; recognizes OMP's `$PI_CODING_AGENT_DIR`, current v3 title slots and path/hashed session directories, deduplicates copied records, includes cache writes in input, and splits reasoning from OMP's inclusive output count |
91
91
  | pi | `~/.pi/agent/sessions/` or `$PI_CODING_AGENT_DIR/sessions/`, plus the session directory Pi itself was pointed at via `PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in `~/.pi/agent/settings.json`, plus explicitly added `pi-coding-agent` roots for stores only reachable through `pi --session <file>` (fixture/relocation override: `VIBE_USAGE_PI_SESSION_DIRS`). Cache writes are included in input usage; reasoning is read from Pi's `usage.reasoning` (legacy `usage.reasoningTokens` still accepted) and split out of the inclusive output total |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.12.0",
3
+ "version": "0.12.1",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -114,19 +114,40 @@ function escapeXml(value) {
114
114
 
115
115
  // Variables that relocate a tool's on-disk store. The service runs from a
116
116
  // launchd/systemd unit that inherits nothing, so anything the parsers read for
117
- // discovery has to be captured into the unit at install time.
117
+ // discovery has to be captured into the unit at install time. Keep this list in
118
+ // sync with the store-locating variables the parsers and roots modules read
119
+ // (`grep -rho 'process\.env\.[A-Z_]*' src/parsers src/*-roots.js`): a variable
120
+ // missing here makes the background service sync a different store than the
121
+ // foreground CLI (issue #112). Test-only overrides (`VIBE_USAGE_*`) and
122
+ // accounting knobs are deliberately absent -- they do not relocate a store.
118
123
  const PRESERVED_SERVICE_ENV = [
124
+ 'AMP_DATA_DIR',
119
125
  'CLINE_DIR',
120
126
  'CLINE_DATA_DIR',
121
127
  'CLINE_SESSION_DATA_DIR',
128
+ 'CODEBUDDY_CONFIG_DIR',
129
+ 'CODEX_HOME',
122
130
  'COLA_DATA_DIR',
131
+ 'CRAFT_AGENT_DIR',
132
+ 'CRAFTAGENT_DIR',
133
+ 'CURSOR_CONFIG_DIR',
134
+ 'DIMCODE_HOME',
135
+ 'DSH_HOME',
136
+ 'GROK_HOME',
123
137
  'HERMES_HOME',
138
+ 'KIMI_CODE_HOME',
139
+ 'KIRO_BASE_PATH',
140
+ 'KIRO_CLI_DB_PATH',
141
+ 'KIRO_CLI_SESSIONS_DIR',
142
+ 'KIRO_SESSIONS_DIR',
143
+ 'KIRO_USER_PATH',
124
144
  'MCODE_HOME',
125
145
  'MIMOCODE_HOME',
126
146
  'MIMOCODE_DB',
127
- 'XDG_DATA_HOME',
147
+ 'PI_CONFIG_DIR',
128
148
  'PI_CODING_AGENT_DIR',
129
149
  'PI_CODING_AGENT_SESSION_DIR',
150
+ 'XDG_DATA_HOME',
130
151
  ];
131
152
 
132
153
  function serviceEnvironment(claudeConfigDir, env) {
@@ -4,18 +4,57 @@ import { aggregateToBuckets, extractSessions } from './aggregate.js';
4
4
  import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
5
5
  import { getOpenCodeStores } from '../opencode-roots.js';
6
6
 
7
- function readSqlite(path) {
8
- // Select only accounting/timing metadata, never message text or tool inputs.
9
- // Keep the existing top-level model/project precedence for old uploads.
10
- const query = `SELECT id, session_id AS sessionID,
7
+ // Select only accounting/timing metadata, never message text or tool inputs.
8
+ // Keep the existing top-level model/project precedence for old uploads.
9
+ const V1_QUERY = `SELECT id, session_id AS sessionID,
11
10
  json_extract(data, '$.role') AS role,
12
11
  json_extract(data, '$.time.created') AS created,
13
12
  coalesce(json_extract(data, '$.modelID'), json_extract(data, '$.model.modelID')) AS modelID,
14
13
  json_extract(data, '$.tokens') AS tokens,
15
14
  json_extract(data, '$.path.root') AS rootPath
16
15
  FROM message ORDER BY id`;
17
- try { return queryDbJson(path, query); }
18
- catch (err) {
16
+
17
+ // OpenCode 2.x stores the same accounting in the event-sourced projection
18
+ // `session_message`: the row's `type` is the role, `data.model.id` the model,
19
+ // `data.tokens` the counters. Message data carries no `path`, so the project
20
+ // comes from the session row's `directory`; the session table is `session_v2`
21
+ // in 2.x and `session` before that (both names in the wild, issue #114). Only
22
+ // column/JSON expressions are selected -- never message text, tool payloads,
23
+ // or costs.
24
+ function v2Query(sessionTable) {
25
+ return `SELECT m.id AS id, m.session_id AS sessionID,
26
+ m.type AS role,
27
+ coalesce(json_extract(m.data, '$.time.created'), m.time_created) AS created,
28
+ coalesce(json_extract(m.data, '$.model.id'), json_extract(m.data, '$.modelID')) AS modelID,
29
+ json_extract(m.data, '$.tokens') AS tokens,
30
+ s.directory AS directory
31
+ FROM session_message m
32
+ ${sessionTable ? `LEFT JOIN ${sessionTable} s ON s.id = m.session_id` : ''}
33
+ WHERE m.type IN ('user', 'assistant')
34
+ ORDER BY m.id`;
35
+ }
36
+
37
+ function readSqlite(path) {
38
+ try {
39
+ const tables = new Set(queryDbJson(path,
40
+ `SELECT name FROM sqlite_master WHERE type = 'table'
41
+ AND name IN ('message', 'session_message', 'session', 'session_v2')`)
42
+ .map(row => row.name));
43
+ if (!tables.has('message') && !tables.has('session_message')) {
44
+ throw new Error(`不认识的表结构(没有 message / session_message 表): ${path}`);
45
+ }
46
+ // Read both shapes when a migrated store keeps both: copies are merged by
47
+ // (session, message id), and the legacy row is seen first so an equal copy
48
+ // never renames a project that earlier uploads already used.
49
+ const rows = [];
50
+ if (tables.has('message')) rows.push(...queryDbJson(path, V1_QUERY));
51
+ if (tables.has('session_message')) {
52
+ const sessionTable = tables.has('session_v2') ? 'session_v2'
53
+ : (tables.has('session') ? 'session' : null);
54
+ rows.push(...queryDbJson(path, v2Query(sessionTable)));
55
+ }
56
+ return rows;
57
+ } catch (err) {
19
58
  if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('OpenCode');
20
59
  throw err;
21
60
  }
@@ -71,14 +110,24 @@ export async function parse({ extraRoots = [] } = {}) {
71
110
  for (const row of records.values()) {
72
111
  // Keep the existing project derivation and token semantics. Additional roots
73
112
  // must not rename previously uploaded projects/models or alter their counts.
74
- const project = row.rootPath ? basename(row.rootPath) : 'unknown';
113
+ // V2 message rows carry no `path`, so they fall back to the session
114
+ // directory; they come from a store the parser could not read before, so
115
+ // nothing previously uploaded is relabelled by that fallback.
116
+ const project = row.rootPath ? basename(row.rootPath)
117
+ : (row.directory ? basename(row.directory) : 'unknown');
75
118
  const base = { source: 'opencode', project, timestamp: row.timestamp };
76
119
  events.push({ ...base, sessionId: row.sessionId, role: row.role === 'user' ? 'user' : 'assistant' });
77
120
  const tokens = row.tokens;
78
121
  if (!row.modelID || !tokens || (!tokens.input && !tokens.output)) continue;
79
122
  entries.push({ ...base, model: row.modelID,
80
123
  inputTokens: tokens.input || 0, outputTokens: tokens.output || 0,
81
- cachedInputTokens: tokens.cache?.read || 0, reasoningOutputTokens: tokens.reasoning || 0 });
124
+ cachedInputTokens: tokens.cache?.read || 0, reasoningOutputTokens: tokens.reasoning || 0,
125
+ // The store writes one cache-write total with no per-TTL breakdown and no
126
+ // separate `tokens.total` column to reconcile against, so it goes to the
127
+ // cheaper 5m cache-creation bucket -- the same rule as CodeArts Agent,
128
+ // which reads this exact layout. Dropping it (the behaviour until
129
+ // 2026-09-26) under-billed every Claude/Anthropic run through OpenCode.
130
+ cacheCreation5mTokens: tokens.cache?.write || 0 });
82
131
  }
83
132
  return { buckets: aggregateToBuckets(entries), sessions: extractSessions(events) };
84
133
  }
@@ -2,6 +2,7 @@ import {
2
2
  accessSync,
3
3
  closeSync,
4
4
  constants as fsConstants,
5
+ existsSync,
5
6
  fsyncSync,
6
7
  mkdirSync,
7
8
  openSync,
@@ -134,9 +135,35 @@ export function parseKimiUsage(payload, now = new Date()) {
134
135
  });
135
136
  }
136
137
 
138
+ // Kimi Code has two credential locations in the wild. kimi-cli (1.x) keeps
139
+ // `$KIMI_SHARE_DIR/credentials/kimi-code.json`, defaulting to `~/.kimi`, while
140
+ // the current CLI (2.x) writes `$KIMI_CODE_HOME/credentials/kimi-code.json`,
141
+ // defaulting to `~/.kimi-code` -- the same home the session parser already
142
+ // resolves. Reading only the legacy path reported `missing_credentials` for
143
+ // every 2.x user whose login sits in the new home (issue #112), while their
144
+ // usage parsed fine, so the quota card contradicted the token tables.
145
+ export function kimiCredentialPaths(environment = process.env, home = homedir()) {
146
+ const directories = [
147
+ environment.KIMI_SHARE_DIR?.trim(),
148
+ environment.KIMI_CODE_HOME?.trim(),
149
+ join(home, '.kimi-code'),
150
+ join(home, '.kimi'),
151
+ ];
152
+ return directories.filter(Boolean).map(directory => join(directory, 'credentials', 'kimi-code.json'));
153
+ }
154
+
155
+ /**
156
+ * The credential file to read and, on refresh, to rotate atomically.
157
+ *
158
+ * A login that exists wins over one that does not, in the order above: an
159
+ * explicitly configured home outranks the CLI default, and the current CLI
160
+ * home outranks the legacy one. With no login anywhere the current CLI's path
161
+ * is returned so `missing_credentials` (and any rotation that follows a later
162
+ * login) names the file the installed CLI actually writes.
163
+ */
137
164
  export function kimiCredentialPath(environment = process.env, home = homedir()) {
138
- const shareDirectory = environment.KIMI_SHARE_DIR?.trim() || join(home, '.kimi');
139
- return join(shareDirectory, 'credentials', 'kimi-code.json');
165
+ const paths = kimiCredentialPaths(environment, home);
166
+ return paths.find(path => existsSync(path)) || paths[0];
140
167
  }
141
168
 
142
169
  function readCredentials(path) {