@vibe-cafe/vibe-usage 0.11.1 → 0.12.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
@@ -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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -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
+ }
@@ -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
+ }
@@ -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
- if (dateIdx < 0 || modelIdx < 0) return { buckets: [], sessions: [] };
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++) {
@@ -1,9 +1,45 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
2
  import { join, basename, dirname } from 'node:path';
3
- import { homedir } from 'node:os';
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
- const DROID_SESSIONS_DIR = join(homedir(), '.factory', 'sessions');
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(DROID_SESSIONS_DIR);
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 inputTokens = Math.max(0, toSafeNumber(tokenUsage.inputTokens) - cacheReadTokens);
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 || 'unknown',
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
 
@@ -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
  }
@@ -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/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
- // (server cleanup pass then reconciles the stale-named rows).
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',