@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 +2 -2
- package/package.json +1 -1
- package/src/daemon-service.js +23 -2
- package/src/parsers/opencode.js +57 -8
- package/src/quotas/providers/kimi-code.js +29 -2
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
|
|
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
package/src/daemon-service.js
CHANGED
|
@@ -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
|
-
'
|
|
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) {
|
package/src/parsers/opencode.js
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
|
139
|
-
return
|
|
165
|
+
const paths = kimiCredentialPaths(environment, home);
|
|
166
|
+
return paths.find(path => existsSync(path)) || paths[0];
|
|
140
167
|
}
|
|
141
168
|
|
|
142
169
|
function readCredentials(path) {
|