@vibe-cafe/vibe-usage 0.10.32 → 0.10.33

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
@@ -92,6 +92,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
92
92
  | ZCode | `~/.zcode/cli/db/db.sqlite` (SQLite; reads the `message` table for per-message tokens, model, and project `cwd`/`root`, joined to `session.directory`; fixture/relocation override: `VIBE_USAGE_ZCODE_DB`) |
93
93
  | Qoder | International edition (qoder.com). IDE store `~/Library/Application Support/Qoder/SharedClientCache/cache/db/local.db` (Windows `%APPDATA%\Qoder`, Linux `~/.config/Qoder`; honors `QODER_HOME`, fixture override `VIBE_USAGE_QODER_DB`) gives real tokens from `chat_message.token_info` (prompt includes cached; split out) with `model_key` usually a routing tier, reported as `qoder-auto` / `qoder-ultimate` / … so it never collides with a priced model id; message content is never selected, and lock/schema failures fall back to a snapshot or `skipped`. CLI + desktop app transcripts `~/.qoder/projects/**/*.jsonl` (honors `QODER_CONFIG_DIR`, fixture override `VIBE_USAGE_QODER_PROJECTS`; sub-agents under `<session>/subagents/`) are credit-billed with every token field at 0, so they contribute sessions only — credits are account funding and are not collected |
94
94
  | Qoder CN | China edition (qoder.com.cn, separate account). Same two shapes under `~/Library/Application Support/QoderCN/SharedClientCache/cache/db/local.db` (`QODER_CN_HOME` / `VIBE_USAGE_QODER_CN_DB`) and `~/.qoder-cn/projects/` (`QODERCN_CONFIG_DIR` / `VIBE_USAGE_QODER_CN_PROJECTS`); reported as source `qoder-cn` |
95
+ | Devin | `$XDG_DATA_HOME/devin/cli/sessions.db` (default `~/.local/share/devin/cli/sessions.db`; fixture override `VIBE_USAGE_DEVIN_DB`). Devin CLI and Devin Desktop share this one WAL store. Reads only allow-listed fields from `message_nodes.chat_message` via `json_extract` — per-request `metadata.metrics` token counters on assistant messages, `generation_model`, `is_user_input`, timestamps — joined to `sessions.working_directory`/`model`; message content and the session credit/ACU billing fields are never selected. Cache writes fold into input, cache reads stay separate. The node forest stores some logical messages at several nodes, so rows are deduplicated by session + message id. Synthetic user records (cache keepalives) are not counted as human prompts |
95
96
 
96
97
  ## How It Works
97
98
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.32",
3
+ "version": "0.10.33",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -52,6 +52,12 @@ export function aggregateToBuckets(entries) {
52
52
  outputTokens: 0,
53
53
  cachedInputTokens: 0,
54
54
  reasoningOutputTokens: 0,
55
+ // Prompt-cache *writes*, split by TTL. Anthropic prices them at 1.25x
56
+ // (5m) and 2x (1h) the base input rate, so they cannot be folded into
57
+ // inputTokens without under-billing. Parsers that cannot tell the two
58
+ // TTLs apart leave these at 0 and keep their existing behaviour.
59
+ cacheCreation5mTokens: 0,
60
+ cacheCreation1hTokens: 0,
55
61
  });
56
62
  }
57
63
 
@@ -60,6 +66,8 @@ export function aggregateToBuckets(entries) {
60
66
  b.outputTokens += e.outputTokens || 0;
61
67
  b.cachedInputTokens += e.cachedInputTokens || 0;
62
68
  b.reasoningOutputTokens += e.reasoningOutputTokens || 0;
69
+ b.cacheCreation5mTokens += e.cacheCreation5mTokens || 0;
70
+ b.cacheCreation1hTokens += e.cacheCreation1hTokens || 0;
63
71
  }
64
72
 
65
73
  // Clamp after summation, not per entry — rounding each entry first would
@@ -69,13 +77,22 @@ export function aggregateToBuckets(entries) {
69
77
  const outputTokens = toTokenCount(b.outputTokens);
70
78
  const cachedInputTokens = toTokenCount(b.cachedInputTokens);
71
79
  const reasoningOutputTokens = toTokenCount(b.reasoningOutputTokens);
80
+ const cacheCreation5mTokens = toTokenCount(b.cacheCreation5mTokens);
81
+ const cacheCreation1hTokens = toTokenCount(b.cacheCreation1hTokens);
72
82
  return {
73
83
  ...b,
74
84
  inputTokens,
75
85
  outputTokens,
76
86
  cachedInputTokens,
77
87
  reasoningOutputTokens,
78
- totalTokens: inputTokens + outputTokens + reasoningOutputTokens,
88
+ cacheCreation5mTokens,
89
+ cacheCreation1hTokens,
90
+ // Cache writes stay inside totalTokens: they used to arrive folded into
91
+ // inputTokens, and the server uses this field only as a `> 0` liveness
92
+ // filter. Keeping them in makes the number bit-identical to what the same
93
+ // logs produced before the split, so no bucket drops out of any view.
94
+ totalTokens: inputTokens + outputTokens + reasoningOutputTokens +
95
+ cacheCreation5mTokens + cacheCreation1hTokens,
79
96
  };
80
97
  });
81
98
  }
@@ -57,15 +57,48 @@ function projectFromRelative(relative) {
57
57
  return parts.at(-1) || 'unknown';
58
58
  }
59
59
 
60
- function cacheCreationTokens(usage) {
60
+ /**
61
+ * Cache-creation (prompt-cache write) tokens, split by TTL.
62
+ *
63
+ * Anthropic bills the two TTLs at different multiples of the base input rate
64
+ * (5-minute writes 1.25x, 1-hour writes 2x — platform.claude.com/docs/en/
65
+ * about-claude/pricing), so the split is a price-changing dimension and has to
66
+ * survive to the server. Folding both into `input_tokens` (what this parser did
67
+ * before 2026-09-16) under-billed every Claude bucket by 13-33%.
68
+ *
69
+ * Current Claude logs carry both the `cache_creation_input_tokens` total and its
70
+ * `cache_creation` TTL breakdown. When the breakdown is missing, or adds up to
71
+ * less than the total, the unexplained remainder is booked to the **5m** bucket:
72
+ * that is the cheaper of the two multipliers, so a partially populated log can
73
+ * only ever under-state cost, never over-state it. This preserves the old
74
+ * max(direct, split) total exactly — only its attribution is new.
75
+ */
76
+ function cacheCreationSplit(usage) {
61
77
  const direct = toCount(usage.cache_creation_input_tokens);
62
78
  const breakdown = usage.cache_creation || {};
63
- const split =
64
- toCount(breakdown.ephemeral_5m_input_tokens) +
65
- toCount(breakdown.ephemeral_1h_input_tokens);
66
- // Current Claude logs carry both the total and its TTL breakdown. max()
67
- // avoids double-counting while remaining tolerant of partially populated logs.
68
- return Math.max(direct, split);
79
+ const fiveMinute = toCount(breakdown.ephemeral_5m_input_tokens);
80
+ const oneHour = toCount(breakdown.ephemeral_1h_input_tokens);
81
+ const split = fiveMinute + oneHour;
82
+ if (split >= direct) return { fiveMinute, oneHour };
83
+ return { fiveMinute: fiveMinute + (direct - split), oneHour };
84
+ }
85
+
86
+ // Fast mode (research preview, Claude Opus 5 / Opus 4.8) is billed at 2x the
87
+ // standard input and output rate, with the cache multipliers stacking on top.
88
+ // Claude Code records it as `message.usage.speed` ('standard' | 'fast'); accept
89
+ // `message.speed` too so a build that moves the field keeps working. The server
90
+ // pricing map keys the premium rate off a trailing `-fast` marker
91
+ // (TIER_MARKER_SUFFIX -> tiers.priority), so tag the model here. A model with no
92
+ // published priority tier falls back to its base rate server-side, which makes
93
+ // the marker safe to append unconditionally.
94
+ function isFastMode(usage, message) {
95
+ const speed = usage?.speed ?? message?.speed;
96
+ return typeof speed === 'string' && speed.trim().toLowerCase() === 'fast';
97
+ }
98
+
99
+ function applySpeedMarker(model, fast) {
100
+ if (!fast || !model) return model;
101
+ return model.endsWith('-fast') ? model : `${model}-fast`;
69
102
  }
70
103
 
71
104
  function candidateIsBetter(next, current) {
@@ -225,13 +258,20 @@ async function scanProjectCandidate(candidate) {
225
258
  ? obj.message.model.trim()
226
259
  : '';
227
260
  if (rawModel && rawModel !== '<synthetic>') lastModel = rawModel;
228
- const model = rawModel && rawModel !== '<synthetic>'
261
+ const baseModel = rawModel && rawModel !== '<synthetic>'
229
262
  ? rawModel
230
263
  : lastModel || 'claude-unknown';
231
- const inputTokens = toCount(usage.input_tokens) + cacheCreationTokens(usage);
264
+ const model = applySpeedMarker(baseModel, isFastMode(usage, obj.message));
265
+ const cacheCreation = cacheCreationSplit(usage);
266
+ const inputTokens = toCount(usage.input_tokens);
232
267
  const outputTokens = toCount(usage.output_tokens);
233
268
  const cachedInputTokens = toCount(usage.cache_read_input_tokens);
234
- const usageScore = inputTokens + outputTokens + cachedInputTokens;
269
+ const cacheCreation5mTokens = cacheCreation.fiveMinute;
270
+ const cacheCreation1hTokens = cacheCreation.oneHour;
271
+ // Unchanged from when cache writes lived inside inputTokens, so the
272
+ // "keep the most complete duplicate" ranking keeps its old ordering.
273
+ const usageScore = inputTokens + outputTokens + cachedInputTokens +
274
+ cacheCreation5mTokens + cacheCreation1hTokens;
235
275
 
236
276
  // Synthetic bookkeeping messages are common and carry zero usage. Do not
237
277
  // inflate the CLI's bucket count with rows the server will discard anyway.
@@ -248,6 +288,8 @@ async function scanProjectCandidate(candidate) {
248
288
  outputTokens,
249
289
  cachedInputTokens,
250
290
  reasoningOutputTokens: 0,
291
+ cacheCreation5mTokens,
292
+ cacheCreation1hTokens,
251
293
  });
252
294
  });
253
295
 
@@ -0,0 +1,184 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { projectFromPath, toCount } from './fs-utils.js';
3
+ import { aggregateToBuckets, extractSessions } from './aggregate.js';
4
+ import {
5
+ queryDbJsonSnapshotOnLock,
6
+ isSqliteUnavailableError,
7
+ sqliteUnavailableError,
8
+ } from './sqlite.js';
9
+ import { getDevinDbPath } from '../tools.js';
10
+
11
+ const SOURCE = 'devin';
12
+
13
+ // Devin (the CLI and the Desktop app's embedded agent share one backend) keeps
14
+ // every session in a single WAL SQLite database,
15
+ // $XDG_DATA_HOME/devin/cli/sessions.db (default ~/.local/share/devin/cli/
16
+ // sessions.db). Per-request token usage lives inside
17
+ // message_nodes.chat_message → metadata.metrics on assistant rows:
18
+ // input_tokens is the uncached prompt portion, cache_creation_tokens and
19
+ // cache_read_tokens are separate counters, and output_tokens is the full
20
+ // completion (there is no separate reasoning field).
21
+ //
22
+ // message_nodes is a forest: the same logical message can be stored at several
23
+ // adjacent nodes (verified on a live DB — duplicated rows carry byte-identical
24
+ // metrics), so rows are deduplicated by (session_id, message_id). Only
25
+ // allow-listed identity/accounting fields are extracted via json_extract;
26
+ // message content, cogs_json, and sessions.metadata (which carries
27
+ // credit/ACU billing totals — account funding, never collected) are never
28
+ // selected.
29
+ const NODE_COLUMNS = ['session_id', 'node_id', 'chat_message', 'created_at'];
30
+ const SESSION_COLUMNS = ['id', 'working_directory', 'model'];
31
+
32
+ const USAGE_SQL = `
33
+ SELECT
34
+ m.session_id AS sessionId,
35
+ m.row_id AS rowId,
36
+ m.created_at AS nodeCreated,
37
+ json_extract(m.chat_message, '$.message_id') AS messageId,
38
+ json_extract(m.chat_message, '$.role') AS role,
39
+ json_extract(m.chat_message, '$.metadata.is_user_input') AS isUserInput,
40
+ json_extract(m.chat_message, '$.metadata.created_at') AS msgCreatedAt,
41
+ json_extract(m.chat_message, '$.metadata.generation_model') AS generationModel,
42
+ json_extract(m.chat_message, '$.metadata.metrics.input_tokens') AS inputTokens,
43
+ json_extract(m.chat_message, '$.metadata.metrics.output_tokens') AS outputTokens,
44
+ json_extract(m.chat_message, '$.metadata.metrics.cache_read_tokens') AS cacheReadTokens,
45
+ json_extract(m.chat_message, '$.metadata.metrics.cache_creation_tokens') AS cacheCreationTokens,
46
+ s.working_directory AS workingDir,
47
+ s.model AS sessionModel
48
+ FROM message_nodes AS m
49
+ LEFT JOIN sessions AS s ON s.id = m.session_id
50
+ `;
51
+
52
+ export function resolveDevinDbPath(env = process.env, home) {
53
+ return getDevinDbPath(env, home);
54
+ }
55
+
56
+ /**
57
+ * Per-message instant: metadata.created_at is an ISO-8601 string with
58
+ * millisecond precision; the node column is integer unix seconds (defensive
59
+ * ms/seconds sniffing matches the sibling parsers).
60
+ */
61
+ function resolveTimestamp(row) {
62
+ if (typeof row.msgCreatedAt === 'string') {
63
+ const d = new Date(row.msgCreatedAt);
64
+ if (!Number.isNaN(d.getTime())) return d;
65
+ }
66
+ const n = Number(row.nodeCreated);
67
+ if (!Number.isFinite(n) || n <= 0) return null;
68
+ const d = new Date(n < 1e12 ? n * 1000 : n);
69
+ return Number.isNaN(d.getTime()) ? null : d;
70
+ }
71
+
72
+ /**
73
+ * Timing-event role. Only `is_user_input` user rows are human prompts —
74
+ * Devin writes synthetic user records (e.g. `cache_keepalive` "continue"
75
+ * prompts) that must not inflate the user-prompt count; they and tool results
76
+ * still mark agent activity, so they join the assistant side. `system` rows
77
+ * are prompt-assembly artifacts re-written on resume and are skipped.
78
+ */
79
+ function eventRole(row) {
80
+ if (row.role === 'system') return null;
81
+ if (row.role === 'user' && (row.isUserInput === 1 || row.isUserInput === true)) {
82
+ return 'user';
83
+ }
84
+ return 'assistant';
85
+ }
86
+
87
+ function dbHasColumns(dbPath, table, columns) {
88
+ const info = queryDbJsonSnapshotOnLock(
89
+ dbPath,
90
+ `PRAGMA table_info(${table})`,
91
+ { tempPrefix: 'vibe-usage-devin' },
92
+ );
93
+ const present = new Set(info.map(row => String(row.name)));
94
+ return columns.every(col => present.has(col));
95
+ }
96
+
97
+ export async function parse() {
98
+ const dbPath = resolveDevinDbPath();
99
+ if (!existsSync(dbPath)) return { buckets: [], sessions: [] };
100
+
101
+ // Schema guard: if a future Devin build renames or drops a relied-upon
102
+ // column, fail soft (skipped) so incremental sync keeps this source's last
103
+ // good upload state.
104
+ let schemaOk;
105
+ try {
106
+ schemaOk =
107
+ dbHasColumns(dbPath, 'message_nodes', NODE_COLUMNS) &&
108
+ dbHasColumns(dbPath, 'sessions', SESSION_COLUMNS);
109
+ } catch (err) {
110
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('Devin');
111
+ return { buckets: [], sessions: [], skipped: true };
112
+ }
113
+ if (!schemaOk) {
114
+ return { buckets: [], sessions: [], skipped: true };
115
+ }
116
+
117
+ let rows;
118
+ try {
119
+ rows = queryDbJsonSnapshotOnLock(dbPath, USAGE_SQL, {
120
+ tempPrefix: 'vibe-usage-devin',
121
+ });
122
+ } catch (err) {
123
+ if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('Devin');
124
+ return { buckets: [], sessions: [], skipped: true };
125
+ }
126
+
127
+ const entries = [];
128
+ const events = [];
129
+ const sessionsWithUserPrompt = new Set();
130
+ const seen = new Set();
131
+
132
+ for (const row of rows) {
133
+ const sessionId = row.sessionId != null ? String(row.sessionId) : '';
134
+ if (!sessionId) continue;
135
+
136
+ // The node forest stores the same logical message at several adjacent
137
+ // nodes; dedupe on the stable message id (row id as fallback when absent).
138
+ const messageId = row.messageId != null ? String(row.messageId) : `row:${row.rowId}`;
139
+ const dedupKey = `${sessionId}|${messageId}`;
140
+ if (seen.has(dedupKey)) continue;
141
+ seen.add(dedupKey);
142
+
143
+ const timestamp = resolveTimestamp(row);
144
+ if (!timestamp) continue;
145
+
146
+ const project = row.workingDir ? projectFromPath(String(row.workingDir)) : 'unknown';
147
+ const role = eventRole(row);
148
+ if (role) {
149
+ events.push({ sessionId, source: SOURCE, project, timestamp, role });
150
+ if (role === 'user') sessionsWithUserPrompt.add(sessionId);
151
+ }
152
+
153
+ if (row.role !== 'assistant') continue;
154
+
155
+ // Cache creation folds into input (the shared bucket schema has no
156
+ // cache-write column); cache reads stay separate. Devin reports no
157
+ // reasoning split, so output is taken as-is.
158
+ const inputTokens = toCount(row.inputTokens) + toCount(row.cacheCreationTokens);
159
+ const outputTokens = toCount(row.outputTokens);
160
+ const cachedInputTokens = toCount(row.cacheReadTokens);
161
+ if (inputTokens + outputTokens + cachedInputTokens === 0) continue;
162
+
163
+ entries.push({
164
+ source: SOURCE,
165
+ model: row.generationModel || row.sessionModel || 'unknown',
166
+ project,
167
+ timestamp,
168
+ inputTokens,
169
+ outputTokens,
170
+ cachedInputTokens,
171
+ reasoningOutputTokens: 0,
172
+ });
173
+ }
174
+
175
+ // Only sessions containing a real human prompt reach extractSessions() —
176
+ // keepalive-only or otherwise automated sessions still contribute their
177
+ // token usage to buckets above.
178
+ const sessionEvents = events.filter(e => sessionsWithUserPrompt.has(e.sessionId));
179
+
180
+ return {
181
+ buckets: aggregateToBuckets(entries),
182
+ sessions: extractSessions(sessionEvents),
183
+ };
184
+ }
@@ -28,6 +28,7 @@ import { parse as parseZcode } from './zcode.js';
28
28
  import { parse as parseTraeCli } from './trae-cli.js';
29
29
  import { parse as parseWorkbuddy } from './workbuddy.js';
30
30
  import { parseQoder, parseQoderCn } from './qoder.js';
31
+ import { parse as parseDevin } from './devin.js';
31
32
 
32
33
  export const parsers = {
33
34
  'claude-code': parseClaudeCode,
@@ -61,6 +62,7 @@ export const parsers = {
61
62
  'roo-code': parseRooCode,
62
63
  'workbuddy': parseWorkbuddy,
63
64
  'zcode': parseZcode,
65
+ 'devin': parseDevin,
64
66
  };
65
67
 
66
68
  export { roundToHalfHour, aggregateToBuckets, extractSessions } from './aggregate.js';
package/src/state.js CHANGED
@@ -75,6 +75,11 @@ export function bucketHash(b) {
75
75
  b.cachedInputTokens || 0,
76
76
  b.reasoningOutputTokens || 0,
77
77
  b.totalTokens || 0,
78
+ // Cache writes carry a different unit price per TTL, so a bucket whose only
79
+ // change is a 5m<->1h reclassification must still re-upload — totalTokens
80
+ // alone cannot see that move.
81
+ b.cacheCreation5mTokens || 0,
82
+ b.cacheCreation1hTokens || 0,
78
83
  ]);
79
84
  }
80
85
 
package/src/sync.js CHANGED
@@ -411,6 +411,10 @@ export async function runSync({
411
411
  }
412
412
  }
413
413
  for (const s of batchSessions) {
414
+ // Same uncommitted-on-drop rule as buckets: a session the backend
415
+ // rejected for an unknown source must be retried on the next sync
416
+ // rather than permanently lost.
417
+ if (batchUnknownSources.has(s.source)) continue;
414
418
  const key = sessionKey(s);
415
419
  const entry = pendingSessionState.get(key);
416
420
  if (entry) {
package/src/tools.js CHANGED
@@ -161,6 +161,16 @@ export function getMcodeDbPath(env = process.env, home = homedir()) {
161
161
  return join(root, 'v2', 'sqlite', 'runtime-state.sqlite');
162
162
  }
163
163
 
164
+ // Devin (CLI and Desktop share one agent backend) keeps all sessions in a
165
+ // single WAL database: $XDG_DATA_HOME/devin/cli/sessions.db, defaulting to
166
+ // ~/.local/share/devin/cli/sessions.db. Fixture override: VIBE_USAGE_DEVIN_DB.
167
+ export function getDevinDbPath(env = process.env, home = homedir()) {
168
+ const override = env.VIBE_USAGE_DEVIN_DB?.trim();
169
+ if (override) return isAbsolute(override) ? override : resolve(override);
170
+ const dataHome = env.XDG_DATA_HOME?.trim() || join(home, '.local', 'share');
171
+ return join(dataHome, 'devin', 'cli', 'sessions.db');
172
+ }
173
+
164
174
  export function getMimocodeDbPath(env = process.env) {
165
175
  if (env.MIMOCODE_HOME && !isAbsolute(env.MIMOCODE_HOME)) {
166
176
  throw new Error(`MIMOCODE_HOME must be an absolute path, got: ${JSON.stringify(env.MIMOCODE_HOME)}`);
@@ -441,6 +451,12 @@ export const TOOLS = [
441
451
  id: 'zcode',
442
452
  dataDir: getZcodeDbPath(),
443
453
  },
454
+ {
455
+ name: 'Devin',
456
+ id: 'devin',
457
+ dataDir: getDevinDbPath(),
458
+ detectDataDirs: () => [getDevinDbPath()].filter(existsSync),
459
+ },
444
460
  ];
445
461
 
446
462
  export function detectInstalledTools(options = {}) {