@vibe-cafe/vibe-usage 0.11.1 → 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 +3 -2
- package/package.json +1 -1
- package/src/codearts-roots.js +60 -0
- package/src/daemon-service.js +23 -2
- package/src/parsers/codearts-agent.js +184 -0
- package/src/parsers/cursor.js +19 -1
- package/src/parsers/droid.js +50 -5
- package/src/parsers/grok.js +102 -0
- package/src/parsers/index.js +2 -0
- package/src/parsers/opencode.js +57 -8
- package/src/quotas/providers/kimi-code.js +29 -2
- package/src/state.js +7 -2
- package/src/tools.js +33 -0
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.
|
|
@@ -76,6 +76,7 @@ Quota results are never uploaded or added to incremental sync state. Credential-
|
|
|
76
76
|
| Alma | Electron app-data `alma/chat_threads.db` (macOS: `~/Library/Application Support/alma/chat_threads.db`; fixture/relocation override: `VIBE_USAGE_ALMA_DB`). Reads the `usage_records` ledger plus workspace names without selecting chat bodies, message metadata, provider credentials, or full workspace paths. Provider-prefixed model identifiers are normalized to their final model segment. Cache writes are included in input usage. The ledger contains assistant responses only, so Alma emits token buckets without session timing. |
|
|
77
77
|
| Claude Code + Claude Desktop Code/Cowork | Claude Code data in `~/.claude/projects/` (tokens + sessions) and `~/.claude/transcripts/` (sessions only), plus Claude Desktop Cowork's per-session `.claude/projects/` directories. Also scans `$CLAUDE_CONFIG_DIR` and data-bearing `~/.claude-*` profiles. All variants use the existing `claude-code` source; the parser selects the most complete copy of each session so shared/copied transcripts are not counted twice. Logs are streamed and cache creation tokens are included in input usage. |
|
|
78
78
|
| Cindy | Per-owner SQLite ledgers in the two regional Electron user-data roots: macOS `~/Library/Application Support/{CindyGlobal,Cindy}/cindy-*.db`, Windows `%APPDATA%\{CindyGlobal,Cindy}\cindy-*.db`, Linux `${XDG_CONFIG_HOME:-~/.config}/{CindyGlobal,Cindy}/cindy-*.db` (fixture/relocation override: `VIBE_USAGE_CINDY_DIRS`). Cindy-launched Claude Code already writes ordinary `~/.claude` transcripts, so it remains attributed to **Claude Code** and is not read again. Cindy's otherwise-private Codex and Pi daily/model ledger rows augment the existing **Codex** and **pi** sources. Currency rows are summed and cache creation joins input; chat messages, credentials, costs, and owner ids are never selected. The ledger adds token buckets only, without project or session timing. |
|
|
79
|
+
| CodeArts Agent (CodeArts Doer for Coding) | The embedded agent kernel's WAL SQLite store at `~/.codeartsdoer/codearts-data/opencode.db` (fixture/relocation override: `VIBE_USAGE_CODEARTS_AGENT_DIRS`, whose entries may be the database, `codearts-data`, or its `.codeartsdoer` parent). Reads only allow-listed identity/timing/model/project/token fields from `message` joined to `session`; prompt text, tool payloads, errors, costs, credentials and account/quota data are never selected. Input, output, cache reads and reasoning are separate counters; an untyped cache write is priced as the cheaper 5m cache-creation bucket rather than folded into input, since the store gives no per-TTL breakdown to price it more precisely. Child-agent calls keep contributing token usage, while their injected user messages fold into the top-level logical session instead of inflating human prompt/session counts. Copied stores deduplicate by session + message id, and lock/read failures use a disposable WAL snapshot or preserve prior incremental state. |
|
|
79
80
|
| Codex CLI | `$CODEX_HOME/sessions/` and `$CODEX_HOME/archived_sessions/` (default `~/.codex`), plus an optional temporary `--extra-codex-home`, legacy `codexExtraHome`, or explicitly added Codex/Multica roots; a versioned local index avoids re-reading unchanged rollouts and reads only safe append tails for ordinary sessions, same-session continuation files are combined without counting exact live/archive/cross-root copies twice, and fork/sub-agent replay remains excluded |
|
|
80
81
|
| Cola | `~/.cola/sessions/<scope>/*.jsonl` (or `$COLA_DATA_DIR/sessions/`), verified with Cola 1.4.4. Reads assistant token usage and session timing through the shared Pi reader; cache writes join input, cache reads remain separate, and reasoning is split from output. Copied transcripts with new session headers are deduplicated using the original record metadata and attributed to the earliest available session copy. Project names come from `cwd`, never channel/scope names. |
|
|
81
82
|
| Grok | `$GROK_HOME/sessions/<encoded-cwd>/<session-id>/` (default `~/.grok`) plus explicitly added Grok Homes; token usage from `updates.jsonl` `turn_completed.usage` (per-model `modelUsage`, cache reads, reasoning); project from `summary.json` cwd; copied sessions keep the more complete local record |
|
|
@@ -84,7 +85,7 @@ Quota results are never uploaded or added to incremental sync state. Credential-
|
|
|
84
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 |
|
|
85
86
|
| DimAgent | `$DIMCODE_HOME/dimcode.sqlite` (default `~/.dimcode/v2/dimcode.sqlite`); exact usage from `usage_ledger`, with forked ledger/history copies deduplicated |
|
|
86
87
|
| Gemini CLI | `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl` (current line-delimited format) and legacy `session-*.json`; recurses into nested subagent sessions |
|
|
87
|
-
| 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 |
|
|
88
89
|
| OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments); cache-creation/cache-write tokens are included in input usage |
|
|
89
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 |
|
|
90
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
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { accessSync, constants, realpathSync, statSync } from 'node:fs';
|
|
2
|
+
import { basename, delimiter, join, resolve } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
function normalizeRoot(path, home) {
|
|
6
|
+
if (path === '~') return home;
|
|
7
|
+
if (path.startsWith('~/') || path.startsWith('~\\')) return join(home, path.slice(2));
|
|
8
|
+
return resolve(path);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function resolveCodeartsAgentRoots(env = process.env, home = homedir()) {
|
|
12
|
+
const override = env.VIBE_USAGE_CODEARTS_AGENT_DIRS?.trim();
|
|
13
|
+
if (!override) return [join(home, '.codeartsdoer', 'codearts-data')];
|
|
14
|
+
return override.split(delimiter)
|
|
15
|
+
.map(path => path.trim())
|
|
16
|
+
.filter(Boolean)
|
|
17
|
+
.map(path => normalizeRoot(path, home));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function findDb(root) {
|
|
21
|
+
const stat = statSync(root);
|
|
22
|
+
const candidates = stat.isFile()
|
|
23
|
+
? [root]
|
|
24
|
+
: [join(root, 'opencode.db'), join(root, 'codearts-data', 'opencode.db')];
|
|
25
|
+
|
|
26
|
+
for (const candidate of candidates) {
|
|
27
|
+
try {
|
|
28
|
+
if (!statSync(candidate).isFile()) continue;
|
|
29
|
+
if (basename(candidate) !== 'opencode.db') continue;
|
|
30
|
+
accessSync(candidate, constants.R_OK);
|
|
31
|
+
return realpathSync(candidate);
|
|
32
|
+
} catch (err) {
|
|
33
|
+
if (err.code !== 'ENOENT' && err.code !== 'ENOTDIR') throw err;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Locate CodeArts Agent's accounting databases. An override may point at the
|
|
41
|
+
* data directory, its `.codeartsdoer` parent, or `opencode.db` itself. The
|
|
42
|
+
* default matches the home-relative location used by the desktop agent kernel;
|
|
43
|
+
* the override also covers relocated and platform-specific profiles.
|
|
44
|
+
*/
|
|
45
|
+
export function findCodeartsAgentDbs({ env = process.env, home = homedir(), onWarning = () => {} } = {}) {
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const dbs = [];
|
|
48
|
+
for (const root of resolveCodeartsAgentRoots(env, home)) {
|
|
49
|
+
try {
|
|
50
|
+
const db = findDb(root);
|
|
51
|
+
if (!db || seen.has(db)) continue;
|
|
52
|
+
seen.add(db);
|
|
53
|
+
dbs.push(db);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
if (err.code === 'ENOENT' || err.code === 'ENOTDIR') continue;
|
|
56
|
+
onWarning(`CodeArts Agent: 无法读取数据目录 ${root}: ${err.message}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return dbs;
|
|
60
|
+
}
|
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) {
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { projectFromCwd, toCount } from './fs-utils.js';
|
|
2
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
3
|
+
import {
|
|
4
|
+
isSqliteUnavailableError,
|
|
5
|
+
queryDbJsonSnapshotOnLock,
|
|
6
|
+
sqliteUnavailableError,
|
|
7
|
+
} from './sqlite.js';
|
|
8
|
+
import { findCodeartsAgentDbs } from '../codearts-roots.js';
|
|
9
|
+
|
|
10
|
+
const SOURCE = 'codearts-agent';
|
|
11
|
+
|
|
12
|
+
// CodeArts Agent 26.9.101 uses an OpenCode-derived WAL database, but it is a
|
|
13
|
+
// separate product/account and therefore a separate Vibe Usage source. The
|
|
14
|
+
// recursive map folds child-agent sessions into their top-level user session;
|
|
15
|
+
// all child model calls remain billable while their injected `user` messages
|
|
16
|
+
// never become human prompts. Only identity, timing, model, project and token
|
|
17
|
+
// counters are selected. Message/part content, tools, errors, costs, account
|
|
18
|
+
// data and the CodeArts JSON/log files stay unread.
|
|
19
|
+
const USAGE_SQL = `
|
|
20
|
+
WITH RECURSIVE session_tree(
|
|
21
|
+
sessionId, logicalSessionId, logicalDirectory, isChild, trail
|
|
22
|
+
) AS (
|
|
23
|
+
SELECT s.id, s.id, s.directory, 0, ',' || s.id || ','
|
|
24
|
+
FROM session AS s
|
|
25
|
+
WHERE s.parent_id IS NULL
|
|
26
|
+
OR NOT EXISTS (SELECT 1 FROM session AS p WHERE p.id = s.parent_id)
|
|
27
|
+
UNION ALL
|
|
28
|
+
SELECT s.id, t.logicalSessionId, t.logicalDirectory, 1,
|
|
29
|
+
t.trail || s.id || ','
|
|
30
|
+
FROM session AS s
|
|
31
|
+
JOIN session_tree AS t ON s.parent_id = t.sessionId
|
|
32
|
+
WHERE instr(t.trail, ',' || s.id || ',') = 0
|
|
33
|
+
), session_map AS (
|
|
34
|
+
SELECT
|
|
35
|
+
s.id AS sessionId,
|
|
36
|
+
coalesce(t.logicalSessionId, s.id) AS logicalSessionId,
|
|
37
|
+
coalesce(t.logicalDirectory, s.directory) AS logicalDirectory,
|
|
38
|
+
s.directory AS physicalDirectory,
|
|
39
|
+
coalesce(t.isChild, 0) AS isChild
|
|
40
|
+
FROM session AS s
|
|
41
|
+
LEFT JOIN session_tree AS t ON t.sessionId = s.id
|
|
42
|
+
)
|
|
43
|
+
SELECT
|
|
44
|
+
m.id AS messageId,
|
|
45
|
+
m.session_id AS physicalSessionId,
|
|
46
|
+
coalesce(sm.logicalSessionId, m.session_id) AS logicalSessionId,
|
|
47
|
+
coalesce(sm.isChild, 0) AS isChild,
|
|
48
|
+
sm.logicalDirectory AS logicalDirectory,
|
|
49
|
+
sm.physicalDirectory AS physicalDirectory,
|
|
50
|
+
m.time_created AS columnCreated,
|
|
51
|
+
json_extract(m.data, '$.role') AS role,
|
|
52
|
+
json_extract(m.data, '$.time.created') AS dataCreated,
|
|
53
|
+
coalesce(
|
|
54
|
+
json_extract(m.data, '$.modelID'),
|
|
55
|
+
json_extract(m.data, '$.modelId'),
|
|
56
|
+
json_extract(m.data, '$.model.modelID'),
|
|
57
|
+
json_extract(m.data, '$.model.modelId')
|
|
58
|
+
) AS model,
|
|
59
|
+
json_extract(m.data, '$.path.root') AS rootPath,
|
|
60
|
+
json_extract(m.data, '$.path.cwd') AS cwdPath,
|
|
61
|
+
json_extract(m.data, '$.tokens.input') AS inputTokens,
|
|
62
|
+
json_extract(m.data, '$.tokens.output') AS outputTokens,
|
|
63
|
+
json_extract(m.data, '$.tokens.cache.read') AS cacheReadTokens,
|
|
64
|
+
json_extract(m.data, '$.tokens.cache.write') AS cacheWriteTokens,
|
|
65
|
+
json_extract(m.data, '$.tokens.reasoning') AS reasoningTokens
|
|
66
|
+
FROM message AS m
|
|
67
|
+
LEFT JOIN session_map AS sm ON sm.sessionId = m.session_id
|
|
68
|
+
ORDER BY m.time_created, m.id
|
|
69
|
+
`;
|
|
70
|
+
|
|
71
|
+
function timestampFrom(value, fallback) {
|
|
72
|
+
const raw = value ?? fallback;
|
|
73
|
+
if (raw == null) return null;
|
|
74
|
+
const number = Number(raw);
|
|
75
|
+
let date;
|
|
76
|
+
if (Number.isFinite(number) && number > 0) {
|
|
77
|
+
date = new Date(number < 1e12 ? number * 1000 : number);
|
|
78
|
+
} else if (typeof raw === 'string') {
|
|
79
|
+
date = new Date(raw);
|
|
80
|
+
} else {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function tokenFootprint(row) {
|
|
87
|
+
return toCount(row.inputTokens) + toCount(row.outputTokens)
|
|
88
|
+
+ toCount(row.cacheReadTokens) + toCount(row.cacheWriteTokens)
|
|
89
|
+
+ toCount(row.reasoningTokens);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function projectFor(row) {
|
|
93
|
+
return projectFromCwd(
|
|
94
|
+
row.rootPath || row.cwdPath || row.logicalDirectory || row.physicalDirectory,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function parse() {
|
|
99
|
+
const warnings = [];
|
|
100
|
+
const dbs = findCodeartsAgentDbs({ onWarning: message => warnings.push(message) });
|
|
101
|
+
const records = new Map();
|
|
102
|
+
|
|
103
|
+
for (const dbPath of dbs) {
|
|
104
|
+
let rows;
|
|
105
|
+
try {
|
|
106
|
+
rows = queryDbJsonSnapshotOnLock(dbPath, USAGE_SQL, {
|
|
107
|
+
tempPrefix: 'vibe-usage-codearts-agent',
|
|
108
|
+
});
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('CodeArts Agent');
|
|
111
|
+
warnings.push(`CodeArts Agent: 无法读取 ${dbPath}: ${err.message}`);
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const [index, row] of rows.entries()) {
|
|
116
|
+
const timestamp = timestampFrom(row.dataCreated, row.columnCreated);
|
|
117
|
+
if (!timestamp) continue;
|
|
118
|
+
const sessionId = row.physicalSessionId == null ? '' : String(row.physicalSessionId);
|
|
119
|
+
const messageId = row.messageId == null ? '' : String(row.messageId);
|
|
120
|
+
const key = sessionId && messageId
|
|
121
|
+
? JSON.stringify([sessionId, messageId])
|
|
122
|
+
: JSON.stringify([dbPath, index]);
|
|
123
|
+
const candidate = { ...row, timestamp };
|
|
124
|
+
const previous = records.get(key);
|
|
125
|
+
if (!previous || tokenFootprint(candidate) > tokenFootprint(previous)) {
|
|
126
|
+
records.set(key, candidate);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// A partial multi-profile read would look like legitimate deletion to the
|
|
132
|
+
// incremental sync layer. Suppress the entire source until every discovered
|
|
133
|
+
// database can be read again.
|
|
134
|
+
if (warnings.length) return { buckets: [], sessions: [], skipped: true, warnings };
|
|
135
|
+
|
|
136
|
+
const entries = [];
|
|
137
|
+
const events = [];
|
|
138
|
+
const sessionsWithUserPrompt = new Set();
|
|
139
|
+
|
|
140
|
+
for (const row of records.values()) {
|
|
141
|
+
const project = projectFor(row);
|
|
142
|
+
const logicalSessionId = String(row.logicalSessionId || row.physicalSessionId || 'unknown');
|
|
143
|
+
if (row.role) {
|
|
144
|
+
const isHumanPrompt = row.role === 'user' && !row.isChild;
|
|
145
|
+
events.push({
|
|
146
|
+
sessionId: logicalSessionId,
|
|
147
|
+
source: SOURCE,
|
|
148
|
+
project,
|
|
149
|
+
timestamp: row.timestamp,
|
|
150
|
+
role: isHumanPrompt ? 'user' : 'assistant',
|
|
151
|
+
});
|
|
152
|
+
if (isHumanPrompt) sessionsWithUserPrompt.add(logicalSessionId);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (row.role !== 'assistant') continue;
|
|
156
|
+
const inputTokens = toCount(row.inputTokens);
|
|
157
|
+
const outputTokens = toCount(row.outputTokens);
|
|
158
|
+
const cachedInputTokens = toCount(row.cacheReadTokens);
|
|
159
|
+
const reasoningOutputTokens = toCount(row.reasoningTokens);
|
|
160
|
+
// The store only gives one cache-write total with no per-TTL breakdown,
|
|
161
|
+
// so an untyped write is priced as the cheaper 5m bucket rather than
|
|
162
|
+
// folded into input: better to undercharge than overcharge an unknown TTL.
|
|
163
|
+
const cacheCreation5mTokens = toCount(row.cacheWriteTokens);
|
|
164
|
+
if (inputTokens + outputTokens + cachedInputTokens + reasoningOutputTokens
|
|
165
|
+
+ cacheCreation5mTokens === 0) continue;
|
|
166
|
+
|
|
167
|
+
entries.push({
|
|
168
|
+
source: SOURCE,
|
|
169
|
+
model: row.model || 'unknown',
|
|
170
|
+
project,
|
|
171
|
+
timestamp: row.timestamp,
|
|
172
|
+
inputTokens,
|
|
173
|
+
outputTokens,
|
|
174
|
+
cachedInputTokens,
|
|
175
|
+
reasoningOutputTokens,
|
|
176
|
+
cacheCreation5mTokens,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
buckets: aggregateToBuckets(entries),
|
|
182
|
+
sessions: extractSessions(events.filter(event => sessionsWithUserPrompt.has(event.sessionId))),
|
|
183
|
+
};
|
|
184
|
+
}
|
package/src/parsers/cursor.js
CHANGED
|
@@ -264,7 +264,25 @@ export async function parse() {
|
|
|
264
264
|
const cacheReadIdx = idx('Cache Read');
|
|
265
265
|
const outputIdx = idx('Output Tokens');
|
|
266
266
|
|
|
267
|
-
|
|
267
|
+
// A renamed column used to degrade silently: every row then parsed as zero
|
|
268
|
+
// tokens, the run uploaded an empty snapshot, and sync.js pruned Cursor's
|
|
269
|
+
// incremental state as if the account had gone quiet — the dashboard just
|
|
270
|
+
// stopped counting Cursor. Treat a header we do not recognise as a skipped
|
|
271
|
+
// run instead: the state survives and the user gets a warning.
|
|
272
|
+
const hasTokenColumn =
|
|
273
|
+
inputCacheWriteIdx >= 0 || inputNoCacheIdx >= 0 || cacheReadIdx >= 0 || outputIdx >= 0;
|
|
274
|
+
if (dateIdx < 0 || modelIdx < 0 || !hasTokenColumn) {
|
|
275
|
+
return {
|
|
276
|
+
buckets: [],
|
|
277
|
+
sessions: [],
|
|
278
|
+
skipped: true,
|
|
279
|
+
warnings: [
|
|
280
|
+
'cursor: 导出表头与预期不符(需要 Date、Model 及至少一个 token 列),本次已跳过以保护增量状态;' +
|
|
281
|
+
'Cursor 可能更改了导出接口,请反馈。' +
|
|
282
|
+
`当前表头: ${header.slice(0, 8).join(', ')}`,
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
}
|
|
268
286
|
|
|
269
287
|
const entries = [];
|
|
270
288
|
for (let r = 1; r < rows.length; r++) {
|
package/src/parsers/droid.js
CHANGED
|
@@ -1,9 +1,45 @@
|
|
|
1
1
|
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
2
|
import { join, basename, dirname } from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import { getDroidSessionsDir, getDroidSettingsPaths } from '../tools.js';
|
|
4
4
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
|
+
import { readJsonSafe } from './fs-utils.js';
|
|
6
|
+
|
|
7
|
+
// Factory session sidecars store the local slot id (`custom:gpt-6-astra-[gw]-0`),
|
|
8
|
+
// not the API model. `customModels[].model` is what the provider actually sees.
|
|
9
|
+
// A bare routing word would collide with Cursor's pricing entry (PR #83).
|
|
10
|
+
const ROUTING_TIER_IDS = new Set([
|
|
11
|
+
'auto', 'default', 'default-model', 'fast', 'turbo', 'lite', 'ultimate', 'performance', 'efficient',
|
|
12
|
+
]);
|
|
13
|
+
const CUSTOM_SLOT_ID = /^custom:(.+)-\[([^\]]+)\]-(\d+)$/;
|
|
14
|
+
|
|
15
|
+
export function loadDroidCustomModelCatalog() {
|
|
16
|
+
const catalog = new Map();
|
|
17
|
+
for (const settingsPath of getDroidSettingsPaths()) {
|
|
18
|
+
const data = readJsonSafe(settingsPath);
|
|
19
|
+
if (!data) continue;
|
|
20
|
+
for (const list of [data.customModels, data.custom_models]) {
|
|
21
|
+
if (!Array.isArray(list)) continue;
|
|
22
|
+
for (const entry of list) {
|
|
23
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
24
|
+
const id = typeof entry.id === 'string' ? entry.id.trim() : '';
|
|
25
|
+
const model = typeof entry.model === 'string' ? entry.model.trim() : '';
|
|
26
|
+
if (id && model) catalog.set(id, model);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return catalog;
|
|
31
|
+
}
|
|
5
32
|
|
|
6
|
-
|
|
33
|
+
export function resolveDroidModel(raw, catalog = new Map()) {
|
|
34
|
+
const id = typeof raw === 'string' ? raw.trim() : '';
|
|
35
|
+
if (!id) return 'unknown';
|
|
36
|
+
const mapped = catalog instanceof Map ? catalog.get(id) : undefined;
|
|
37
|
+
const resolved = (typeof mapped === 'string' && mapped.trim())
|
|
38
|
+
? mapped.trim()
|
|
39
|
+
: (id.match(CUSTOM_SLOT_ID)?.[1] || id);
|
|
40
|
+
const lower = resolved.toLowerCase();
|
|
41
|
+
return ROUTING_TIER_IDS.has(lower) ? `droid-${lower}` : resolved;
|
|
42
|
+
}
|
|
7
43
|
|
|
8
44
|
function findJsonlFiles(dir) {
|
|
9
45
|
const results = [];
|
|
@@ -37,7 +73,8 @@ function toSafeNumber(value) {
|
|
|
37
73
|
export async function parse() {
|
|
38
74
|
const entries = [];
|
|
39
75
|
const sessionEvents = [];
|
|
40
|
-
const sessionFiles = findJsonlFiles(
|
|
76
|
+
const sessionFiles = findJsonlFiles(getDroidSessionsDir());
|
|
77
|
+
const catalog = loadDroidCustomModelCatalog();
|
|
41
78
|
|
|
42
79
|
for (const filePath of sessionFiles) {
|
|
43
80
|
const sessionId = basename(filePath, '.jsonl');
|
|
@@ -92,20 +129,28 @@ export async function parse() {
|
|
|
92
129
|
const tokenUsage = settings?.tokenUsage;
|
|
93
130
|
if (!tokenUsage) continue;
|
|
94
131
|
|
|
132
|
+
// Factory already stores uncached prompt in inputTokens. Its session log
|
|
133
|
+
// records `inputTokens` + `cacheReadInputTokens` = `totalInputTokens`;
|
|
134
|
+
// subtracting cacheReadTokens here zeros BYOK input whenever cache > input.
|
|
95
135
|
const cacheReadTokens = toSafeNumber(tokenUsage.cacheReadTokens);
|
|
96
136
|
const thinkingTokens = toSafeNumber(tokenUsage.thinkingTokens);
|
|
97
|
-
const
|
|
137
|
+
const cacheCreation5mTokens = toSafeNumber(tokenUsage.cacheCreationTokens);
|
|
138
|
+
const inputTokens = toSafeNumber(tokenUsage.inputTokens);
|
|
98
139
|
const outputTokens = Math.max(0, toSafeNumber(tokenUsage.outputTokens) - thinkingTokens);
|
|
140
|
+
if (inputTokens + outputTokens + cacheReadTokens + thinkingTokens + cacheCreation5mTokens === 0) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
99
143
|
|
|
100
144
|
entries.push({
|
|
101
145
|
source: 'droid',
|
|
102
|
-
model: settings.model
|
|
146
|
+
model: resolveDroidModel(settings.model, catalog),
|
|
103
147
|
project,
|
|
104
148
|
timestamp: firstMessageTimestamp,
|
|
105
149
|
inputTokens,
|
|
106
150
|
outputTokens,
|
|
107
151
|
cachedInputTokens: cacheReadTokens,
|
|
108
152
|
reasoningOutputTokens: thinkingTokens,
|
|
153
|
+
cacheCreation5mTokens,
|
|
109
154
|
});
|
|
110
155
|
}
|
|
111
156
|
|
package/src/parsers/grok.js
CHANGED
|
@@ -148,6 +148,62 @@ async function forEachJsonlLine(filePath, onLine, strict = false) {
|
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
/**
|
|
152
|
+
* Grok 1.0 moved per-turn token accounting out of the ACP stream into a
|
|
153
|
+
* dedicated `<session>/usage.json` ledger — `grok usage <session-id>` is its
|
|
154
|
+
* documented reader, and the sessions guide says to use that "instead of
|
|
155
|
+
* reading session files". Older builds (0.2.x) wrote the same numbers into
|
|
156
|
+
* `updates.jsonl` `turn_completed.usage`; those sessions have no usage.json.
|
|
157
|
+
*
|
|
158
|
+
* The ledger holds token totals per turn but neither timestamps nor a model
|
|
159
|
+
* id, so callers pair its turns with the session's `turn_completed` events by
|
|
160
|
+
* order and fall back to the summary's model.
|
|
161
|
+
*/
|
|
162
|
+
function readUsageLedger(sessionPath, strict = false) {
|
|
163
|
+
const ledgerPath = join(sessionPath, 'usage.json');
|
|
164
|
+
if (!existsSync(ledgerPath)) return null;
|
|
165
|
+
let parsed;
|
|
166
|
+
try {
|
|
167
|
+
parsed = JSON.parse(readFileSync(ledgerPath, 'utf-8'));
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (strict) throw err;
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
173
|
+
const turns = Array.isArray(parsed.turns) ? parsed.turns : [];
|
|
174
|
+
const session = parsed.session && typeof parsed.session === 'object' ? parsed.session : null;
|
|
175
|
+
if (turns.length === 0 && !session) return null;
|
|
176
|
+
return { session, turns };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Map one ledger record (turn or session totals) onto the usage shape the
|
|
180
|
+
* ACP stream uses. Cache writes are folded into input — Grok publishes no
|
|
181
|
+
* separate cache-write rate and pre-1.0 those tokens were part of
|
|
182
|
+
* `inputTokens`, so folding keeps cross-version totals identical and leaves
|
|
183
|
+
* the Anthropic-only cache-creation columns untouched. Any per-model
|
|
184
|
+
* `modelUsage` map is carried through (and folded the same way) so the
|
|
185
|
+
* caller's model attribution works exactly like the ACP path. */
|
|
186
|
+
function ledgerRecordUsage(record) {
|
|
187
|
+
if (!record || typeof record !== 'object') return null;
|
|
188
|
+
const fold = (entry) => {
|
|
189
|
+
if (!entry || typeof entry !== 'object') return entry;
|
|
190
|
+
const input = Math.max(0, Number(entry.inputTokens) || 0);
|
|
191
|
+
const cacheCreation = Math.max(0, Number(entry.cacheCreationTokens) || 0);
|
|
192
|
+
return { ...entry, inputTokens: input + cacheCreation };
|
|
193
|
+
};
|
|
194
|
+
const folded = fold(record);
|
|
195
|
+
const modelUsage = record.modelUsage && typeof record.modelUsage === 'object'
|
|
196
|
+
? Object.fromEntries(Object.entries(record.modelUsage).map(([model, entry]) => [model, fold(entry)]))
|
|
197
|
+
: null;
|
|
198
|
+
const usage = { ...folded, ...(modelUsage ? { modelUsage } : {}) };
|
|
199
|
+
const total =
|
|
200
|
+
Number(usage.inputTokens || 0) +
|
|
201
|
+
Number(usage.cachedReadTokens || 0) +
|
|
202
|
+
Number(usage.outputTokens || 0) +
|
|
203
|
+
Number(usage.reasoningTokens || 0);
|
|
204
|
+
return total > 0 ? usage : null;
|
|
205
|
+
}
|
|
206
|
+
|
|
151
207
|
function listSessionDirs(sessionsDir, strict = false) {
|
|
152
208
|
const results = [];
|
|
153
209
|
if (!existsSync(sessionsDir)) {
|
|
@@ -231,6 +287,7 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
231
287
|
|
|
232
288
|
const entries = [];
|
|
233
289
|
const sessionEvents = [];
|
|
290
|
+
const unreadSessions = [];
|
|
234
291
|
|
|
235
292
|
const candidates = [];
|
|
236
293
|
for (const sessionsDir of roots) {
|
|
@@ -287,9 +344,14 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
287
344
|
const cwd = summary.info?.cwd || summary.git_root_dir || null;
|
|
288
345
|
const project = cwd ? projectFromPath(cwd) : projectFallback;
|
|
289
346
|
const fallbackModel = summary.current_model_id || 'unknown';
|
|
347
|
+
const sessionEntryStart = entries.length;
|
|
290
348
|
|
|
291
349
|
// Prefer updates.jsonl turn_completed for exact usage + message timings.
|
|
350
|
+
// `turnTimestamps` keeps their order so a 1.x usage.json ledger (which
|
|
351
|
+
// has no timestamps of its own) can be paired turn-by-turn below.
|
|
292
352
|
let sawUserOrAssistant = false;
|
|
353
|
+
let usageFromUpdates = 0;
|
|
354
|
+
const turnTimestamps = [];
|
|
293
355
|
await forEachJsonlLine(join(sessionPath, 'updates.jsonl'), (obj) => {
|
|
294
356
|
const update = obj?.params?.update;
|
|
295
357
|
if (!update || typeof update !== 'object') return;
|
|
@@ -298,12 +360,15 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
298
360
|
const timestamp = toDate(obj.timestamp);
|
|
299
361
|
|
|
300
362
|
if (kind === 'turn_completed' && timestamp) {
|
|
363
|
+
turnTimestamps.push(timestamp);
|
|
364
|
+
const before = entries.length;
|
|
301
365
|
emitTurnUsage(entries, {
|
|
302
366
|
usage: update.usage,
|
|
303
367
|
project,
|
|
304
368
|
timestamp,
|
|
305
369
|
fallbackModel,
|
|
306
370
|
});
|
|
371
|
+
if (entries.length > before) usageFromUpdates += 1;
|
|
307
372
|
}
|
|
308
373
|
|
|
309
374
|
if (!timestamp) return;
|
|
@@ -329,6 +394,37 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
329
394
|
}
|
|
330
395
|
}, strict);
|
|
331
396
|
|
|
397
|
+
// 1.x sessions keep per-turn totals in usage.json instead of the ACP
|
|
398
|
+
// stream. Consulted only when updates.jsonl yielded no usage, so a
|
|
399
|
+
// session that carries both is never counted twice.
|
|
400
|
+
if (usageFromUpdates === 0) {
|
|
401
|
+
const ledger = readUsageLedger(sessionPath, strict);
|
|
402
|
+
const records = ledger?.turns?.length ? ledger.turns : (ledger?.session ? [ledger.session] : []);
|
|
403
|
+
if (records.length > 0) {
|
|
404
|
+
const sessionTimestamp = toDate(summary.updated_at || summary.last_active_at || summary.created_at);
|
|
405
|
+
for (const [index, record] of records.entries()) {
|
|
406
|
+
const usage = ledgerRecordUsage(record);
|
|
407
|
+
if (!usage) continue;
|
|
408
|
+
const timestamp = turnTimestamps[index] || sessionTimestamp;
|
|
409
|
+
if (!timestamp) continue;
|
|
410
|
+
emitTurnUsage(entries, { usage, project, timestamp, fallbackModel });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// Canary for the next on-disk format move: a session whose signals.json
|
|
416
|
+
// reports completed turns (and a model) but whose usage we could not read
|
|
417
|
+
// from either source is a silent collection gap, not an idle session —
|
|
418
|
+
// 1.0 moved the ledger to usage.json exactly like this.
|
|
419
|
+
if (entries.length === sessionEntryStart) {
|
|
420
|
+
const signals = readJsonSafe(join(sessionPath, 'signals.json'));
|
|
421
|
+
const turnCount = Number(signals?.turnCount) || 0;
|
|
422
|
+
const modelsUsed = Array.isArray(signals?.modelsUsed) ? signals.modelsUsed.filter(Boolean) : [];
|
|
423
|
+
if (turnCount > 0 && modelsUsed.length > 0) {
|
|
424
|
+
unreadSessions.push({ sessionId, turnCount });
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
332
428
|
// Fallback timing from events.jsonl when updates lack message chunks
|
|
333
429
|
// (short/aborted sessions, older builds).
|
|
334
430
|
if (!sawUserOrAssistant) {
|
|
@@ -391,5 +487,11 @@ export async function parse({ extraRoots = [] } = {}) {
|
|
|
391
487
|
return {
|
|
392
488
|
buckets: aggregateToBuckets(entries),
|
|
393
489
|
sessions: extractSessions(sessionEvents),
|
|
490
|
+
...(unreadSessions.length > 0 && {
|
|
491
|
+
warnings: [
|
|
492
|
+
`grok: ${unreadSessions.length} 个会话有已完成轮次但未读到用量(例如 ${unreadSessions[0].sessionId},${unreadSessions[0].turnCount} 轮),` +
|
|
493
|
+
'可能是 Grok 又更改了用量落盘格式,请反馈;本次未上传这些会话的用量。',
|
|
494
|
+
],
|
|
495
|
+
}),
|
|
394
496
|
};
|
|
395
497
|
}
|
package/src/parsers/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { parse as parseClaudeCode } from './claude-code.js';
|
|
2
2
|
import { parse as parseCline } from './cline.js';
|
|
3
|
+
import { parse as parseCodeartsAgent } from './codearts-agent.js';
|
|
3
4
|
import { parse as parseCodex } from './codex.js';
|
|
4
5
|
import { parse as parseCopilotCli } from './copilot-cli.js';
|
|
5
6
|
import { parse as parseCraftAgent } from './craft-agent.js';
|
|
@@ -33,6 +34,7 @@ import { parse as parseCodebuddy } from './codebuddy.js';
|
|
|
33
34
|
|
|
34
35
|
export const parsers = {
|
|
35
36
|
'claude-code': parseClaudeCode,
|
|
37
|
+
'codearts-agent': parseCodeartsAgent,
|
|
36
38
|
'codex': parseCodex,
|
|
37
39
|
'cola': parseCola,
|
|
38
40
|
'grok': parseGrok,
|
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) {
|
package/src/state.js
CHANGED
|
@@ -121,8 +121,13 @@ export function clearState() {
|
|
|
121
121
|
}
|
|
122
122
|
|
|
123
123
|
// Composite key must mirror the server dedup key so a project rename or
|
|
124
|
-
// uploadProject toggle changes the key and naturally forces a re-send
|
|
125
|
-
//
|
|
124
|
+
// uploadProject toggle changes the key and naturally forces a re-send under
|
|
125
|
+
// the new name. Nothing retires the row uploaded under the OLD key: ingest
|
|
126
|
+
// only upserts on (user_id, source, model, project, hostname, bucket_start),
|
|
127
|
+
// and there is no server-side reconciliation pass. The stale row survives
|
|
128
|
+
// until `vibe-usage reset` or a one-off server-side cleanup, so a rename
|
|
129
|
+
// double-counts tokens on the dashboard in the meantime. Verified against the
|
|
130
|
+
// server 2026-09-19; weigh this before changing what a parser reports.
|
|
126
131
|
export function bucketKey(b) {
|
|
127
132
|
return `${b.source}|${b.model}|${b.project}|${b.hostname}|${b.bucketStart}`;
|
|
128
133
|
}
|
package/src/tools.js
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
grokSessionsDir,
|
|
13
13
|
} from './extra-roots.js';
|
|
14
14
|
import { findClineDataDirs } from './cline-roots.js';
|
|
15
|
+
import { findCodeartsAgentDbs, resolveCodeartsAgentRoots } from './codearts-roots.js';
|
|
15
16
|
import { findColaDataDirs, getColaSessionsDir } from './cola-roots.js';
|
|
16
17
|
import { findCraftDataDirs } from './craft-roots.js';
|
|
17
18
|
import { findHermesDataDirs, getHermesHome } from './hermes-roots.js';
|
|
@@ -145,6 +146,31 @@ export function getDshSessionsDir() {
|
|
|
145
146
|
return join(getDshHome(), 'sessions');
|
|
146
147
|
}
|
|
147
148
|
|
|
149
|
+
export function getDroidSessionsDir() {
|
|
150
|
+
const testDir = process.env.VIBE_USAGE_DROID_SESSIONS?.trim();
|
|
151
|
+
if (testDir) return testDir;
|
|
152
|
+
return join(homedir(), '.factory', 'sessions');
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Factory settings hold customModels[].id → API model. Fixture sessions must
|
|
156
|
+
// not read the real ~/.factory/settings.json (API keys, and it would leak
|
|
157
|
+
// machine catalog into tests).
|
|
158
|
+
export function getDroidSettingsPaths() {
|
|
159
|
+
const override = process.env.VIBE_USAGE_DROID_SETTINGS?.trim();
|
|
160
|
+
if (override) return [override];
|
|
161
|
+
if (process.env.VIBE_USAGE_DROID_SESSIONS?.trim()) return [];
|
|
162
|
+
const home = join(homedir(), '.factory');
|
|
163
|
+
return [
|
|
164
|
+
join(home, 'settings.json'),
|
|
165
|
+
join(home, 'settings.local.json'),
|
|
166
|
+
join(home, 'config.json'),
|
|
167
|
+
];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function findDroidDataDirs() {
|
|
171
|
+
return [getDroidSessionsDir()].filter(existsSync);
|
|
172
|
+
}
|
|
173
|
+
|
|
148
174
|
// Detect DeepSeek Harness when its sessions tree exists (or the test override).
|
|
149
175
|
export function findDshDataDirs() {
|
|
150
176
|
return [getDshSessionsDir()].filter(existsSync);
|
|
@@ -279,6 +305,12 @@ export const TOOLS = [
|
|
|
279
305
|
dataDir: join(homedir(), '.claude', 'projects'),
|
|
280
306
|
detectDataDirs: ({ extraRoots } = {}) => findClaudeCodeDataDirs(extraRootList(extraRoots?.['claude-code'])),
|
|
281
307
|
},
|
|
308
|
+
{
|
|
309
|
+
name: 'CodeArts Agent',
|
|
310
|
+
id: 'codearts-agent',
|
|
311
|
+
dataDir: resolveCodeartsAgentRoots()[0],
|
|
312
|
+
detectDataDirs: () => findCodeartsAgentDbs(),
|
|
313
|
+
},
|
|
282
314
|
{
|
|
283
315
|
name: 'Codex CLI',
|
|
284
316
|
id: 'codex',
|
|
@@ -402,6 +434,7 @@ export const TOOLS = [
|
|
|
402
434
|
name: 'Droid',
|
|
403
435
|
id: 'droid',
|
|
404
436
|
dataDir: join(homedir(), '.factory', 'sessions'),
|
|
437
|
+
detectDataDirs: findDroidDataDirs,
|
|
405
438
|
},
|
|
406
439
|
{
|
|
407
440
|
name: 'DeepSeek Harness',
|