@vibe-cafe/vibe-usage 0.10.32 → 0.10.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/parsers/aggregate.js +18 -1
- package/src/parsers/claude-code.js +52 -10
- package/src/parsers/codebuddy.js +202 -0
- package/src/parsers/devin.js +184 -0
- package/src/parsers/index.js +4 -0
- package/src/state.js +5 -0
- package/src/sync.js +4 -0
- package/src/tools.js +29 -1
package/README.md
CHANGED
|
@@ -89,9 +89,11 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
|
|
|
89
89
|
| Trae CLI | macOS: `~/Library/Caches/trae-cli/sessions/`; Windows: `%LOCALAPPDATA%/trae-cli/cache/sessions/`; Linux: `~/.cache/trae-cli/sessions/` (CLI telemetry only; Trae IDE/Trae Work chats are not supported). Token usage is summed per unique LLM call (`model.stream.eino`, plus `model.generate` failovers); nested duplicate spans that share a session `traceID` are not max-merged. `traces.jsonl` / `events.jsonl` are streamed line-by-line so a multi-hundred-MB events file cannot hit Node's string-length limit. |
|
|
90
90
|
| Antigravity | Scans App 2.0 `~/.gemini/antigravity/conversations/`, `agy` CLI `~/.gemini/antigravity-cli/conversations/`, and standalone IDE `~/.gemini/antigravity-ide/conversations/`. `.db` stores, including the same paths below explicitly added alternate Homes, are parsed offline (tokens, model, project, sessions). When Gemini blobs omit `chatStartMetadata.createdAt` or `modelDisplayName`, timestamps fall back to `steps.metadata` and model names to `responseModel`. `.pb` history in the default stores requires the corresponding App/IDE language server to be running; when several servers are open, the parser tries the others for unreadable conversations. Unavailable legacy history produces a warning and preserves prior sync state. |
|
|
91
91
|
| WorkBuddy | Current releases: `~/.workbuddy-ai/projects/**/*.jsonl`; legacy releases: `~/.workbuddy/projects/**/*.jsonl` (fixture/relocation override: `VIBE_USAGE_WORKBUDDY_DIRS`). Reads usage-bearing completed assistant and `function_call` records, using the routed model identifier exposed as `providerData.requestModelId`. Splits cache reads and reasoning from inclusive input/output totals, deduplicates copied record IDs, and extracts local session timing without uploading message content. |
|
|
92
|
+
| CodeBuddy | Tencent's CodeBuddy Code CLI (`@tencent-ai/codebuddy-code`). Home is `$CODEBUDDY_CONFIG_DIR` or `~/.codebuddy` (fixture/relocation override: `VIBE_USAGE_CODEBUDDY_DIRS`); transcripts at `projects/<compressed-cwd>/<sessionId>.jsonl`, including nested subagent directories. Only the API-message records' `message.usage` is read: `input_tokens` + `cache_creation_input_tokens` fold into input (the store writes no per-TTL breakdown), `cache_read_input_tokens` stays separate, `output_tokens` is the full completion. Retries and copies dedupe onto one logical call using `message.id`, `providerData.messageId`, or the record id (the CLI leaves `message.id` empty on some builds; `conversationRequestId` is a turn id, never a dedup key). The model comes from `message.model` and falls back to `providerData.requestModelId`/`model`, because successful calls can write `message.model: null`; routing-tier labels are namespaced (`codebuddy-auto`) so they can never match another vendor's price. Prompt text, thinking signatures, and tool payloads are never read. |
|
|
92
93
|
| 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
94
|
| 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
95
|
| 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` |
|
|
96
|
+
| 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
97
|
|
|
96
98
|
## How It Works
|
|
97
99
|
|
package/package.json
CHANGED
package/src/parsers/aggregate.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
261
|
+
const baseModel = rawModel && rawModel !== '<synthetic>'
|
|
229
262
|
? rawModel
|
|
230
263
|
: lastModel || 'claude-unknown';
|
|
231
|
-
const
|
|
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
|
|
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,202 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from 'node:fs';
|
|
2
|
+
import { createInterface } from 'node:readline';
|
|
3
|
+
import { createReadStream } from 'node:fs';
|
|
4
|
+
import { basename, join, sep } from 'node:path';
|
|
5
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
6
|
+
import { projectFromCwd, toCount } from './fs-utils.js';
|
|
7
|
+
import { getCodebuddyRoots } from '../tools.js';
|
|
8
|
+
|
|
9
|
+
// CodeBuddy Code (Tencent's terminal agent, `@tencent-ai/codebuddy-code`). The
|
|
10
|
+
// store follows Claude Code's layout:
|
|
11
|
+
// <home>/projects/<compressed-cwd>/<sessionId>.jsonl (+ nested subagent dirs)
|
|
12
|
+
// where <home> is `$CODEBUDDY_CONFIG_DIR` or ~/.codebuddy.
|
|
13
|
+
//
|
|
14
|
+
// Two record shapes live in those transcripts (verified against 2.151.0's own
|
|
15
|
+
// writer and a real store): local turns are `{type:"message", role:"user"|…,
|
|
16
|
+
// content, sessionId, cwd}`, while every successful model call is the API
|
|
17
|
+
// message shape — `{message:{id, model, role:"assistant",
|
|
18
|
+
// usage:{input_tokens, output_tokens, cache_read_input_tokens,
|
|
19
|
+
// cache_creation_input_tokens}}}` — so token accounting reads `message.usage`
|
|
20
|
+
// only. `usage.cache_creation` (the per-TTL breakdown) is written as `null`, so
|
|
21
|
+
// cache writes fold into input and cannot be priced per TTL, matching every
|
|
22
|
+
// parser that has no split.
|
|
23
|
+
const SOURCE = 'codebuddy';
|
|
24
|
+
const PROJECTS = 'projects';
|
|
25
|
+
|
|
26
|
+
export function resolveCodebuddyRoots(env = process.env, home) {
|
|
27
|
+
return getCodebuddyRoots(env, home);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Session id of a transcript file, and the project fallback from its folder. */
|
|
31
|
+
function fileIdentity(filePath, projectsDir) {
|
|
32
|
+
const sessionId = basename(filePath, '.jsonl');
|
|
33
|
+
const relative = filePath.startsWith(projectsDir + sep) ? filePath.slice(projectsDir.length + 1) : '';
|
|
34
|
+
const folder = relative.split(sep)[0] || '';
|
|
35
|
+
// Compressed cwd folders look like `private-tmp-my-project`; the last segment
|
|
36
|
+
// is the best guess when a record carries no cwd.
|
|
37
|
+
const fallback = folder.split('-').filter(Boolean).at(-1) || 'unknown';
|
|
38
|
+
return { sessionId, fallback };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function findTranscripts(root, onWarning) {
|
|
42
|
+
const projectsDir = join(root, PROJECTS);
|
|
43
|
+
if (!existsSync(projectsDir)) return { projectsDir, files: [] };
|
|
44
|
+
const files = [];
|
|
45
|
+
const walk = dir => {
|
|
46
|
+
let entries;
|
|
47
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); }
|
|
48
|
+
catch (err) { onWarning(`codebuddy: 无法读取目录 ${dir}: ${err.message}`); return; }
|
|
49
|
+
for (const entry of entries) {
|
|
50
|
+
const path = join(dir, entry.name);
|
|
51
|
+
if (entry.isDirectory()) walk(path);
|
|
52
|
+
else if (entry.isFile() && entry.name.endsWith('.jsonl')) files.push(path);
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
walk(projectsDir);
|
|
56
|
+
return { projectsDir, files };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Provider routing/tier labels ('auto', 'default', …) are not model ids, and
|
|
61
|
+
* server-side pricing matches the model string alone: a bare `auto` would be
|
|
62
|
+
* billed at Cursor's `auto` rate — the collision PR #83 fixed for Qoder by
|
|
63
|
+
* renaming it `qoder-auto`. Namespace the tier labels with the tool prefix;
|
|
64
|
+
* concrete ids (`claude-sonnet-4-6`, …) pass through untouched.
|
|
65
|
+
*/
|
|
66
|
+
const ROUTING_TIER_IDS = new Set([
|
|
67
|
+
'auto', 'default', 'default-model', 'fast', 'turbo', 'lite', 'ultimate', 'performance', 'efficient',
|
|
68
|
+
]);
|
|
69
|
+
|
|
70
|
+
function normalizeModel(model) {
|
|
71
|
+
const lower = model.toLowerCase();
|
|
72
|
+
return ROUTING_TIER_IDS.has(lower) ? `${SOURCE}-${lower}` : model;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One usage-bearing assistant message, keyed so retries/copies collapse. */
|
|
76
|
+
function usageEntry(obj, projectFallback, sessionId) {
|
|
77
|
+
const usage = obj.message?.usage;
|
|
78
|
+
if (!usage) return null;
|
|
79
|
+
const timestampMs = Number(obj.timestamp) || Date.parse(obj.message?.timestamp) || null;
|
|
80
|
+
const timestamp = Number.isFinite(timestampMs) ? new Date(timestampMs) : null;
|
|
81
|
+
if (!timestamp || isNaN(timestamp.getTime())) return null;
|
|
82
|
+
|
|
83
|
+
const cacheWrite = toCount(usage.cache_creation_input_tokens);
|
|
84
|
+
const inputTokens = toCount(usage.input_tokens) + cacheWrite;
|
|
85
|
+
const outputTokens = toCount(usage.output_tokens);
|
|
86
|
+
const cachedInputTokens = toCount(usage.cache_read_input_tokens);
|
|
87
|
+
if (!inputTokens && !outputTokens && !cachedInputTokens) return null;
|
|
88
|
+
|
|
89
|
+
const messageId = typeof obj.message?.id === 'string' ? obj.message.id.trim() : '';
|
|
90
|
+
const providerMessageId = typeof obj.providerData?.messageId === 'string' ? obj.providerData.messageId.trim() : '';
|
|
91
|
+
const ownId = typeof obj.id === 'string' ? obj.id.trim() : '';
|
|
92
|
+
// The CLI leaves `message.id` empty on some builds and keeps the per-message id
|
|
93
|
+
// in providerData; `conversationRequestId` is a *turn* id (one turn can hold
|
|
94
|
+
// several billable calls), so it is explicitly not a dedup key. Without this
|
|
95
|
+
// chain every call collapses onto one empty key and the session under-counts.
|
|
96
|
+
const identity = messageId || providerMessageId || ownId;
|
|
97
|
+
const dedupeKey = identity ? `call:${identity}` : null;
|
|
98
|
+
|
|
99
|
+
const model = normalizeModel([
|
|
100
|
+
obj.message?.model,
|
|
101
|
+
obj.providerData?.requestModelId,
|
|
102
|
+
obj.providerData?.model,
|
|
103
|
+
].find(value => typeof value === 'string' && value.trim()) || 'unknown');
|
|
104
|
+
|
|
105
|
+
return {
|
|
106
|
+
dedupeKey,
|
|
107
|
+
usageScore: inputTokens + outputTokens + cachedInputTokens,
|
|
108
|
+
entry: {
|
|
109
|
+
source: SOURCE,
|
|
110
|
+
model,
|
|
111
|
+
project: projectFromCwd(obj.cwd, projectFallback),
|
|
112
|
+
timestamp,
|
|
113
|
+
inputTokens,
|
|
114
|
+
outputTokens,
|
|
115
|
+
cachedInputTokens,
|
|
116
|
+
reasoningOutputTokens: 0,
|
|
117
|
+
},
|
|
118
|
+
sessionId: typeof obj.sessionId === 'string' && obj.sessionId ? obj.sessionId : sessionId,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Human prompt? Local user turns only — the CLI marks injected/system text. */
|
|
123
|
+
function isHumanPrompt(obj) {
|
|
124
|
+
if (obj?.role !== 'user' && obj?.message?.role !== 'user') return false;
|
|
125
|
+
if (obj.providerData?.isMeta || obj.providerData?.skipRun) return false;
|
|
126
|
+
if (obj.providerData?.isSessionSeparator || obj.providerData?.isCompactSummary) return false;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readTimestamp(obj) {
|
|
131
|
+
const ms = Number(obj?.timestamp);
|
|
132
|
+
return Number.isFinite(ms) ? new Date(ms) : null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function parse() {
|
|
136
|
+
const warnings = [];
|
|
137
|
+
const onWarning = message => warnings.push(message);
|
|
138
|
+
const entries = [];
|
|
139
|
+
const events = [];
|
|
140
|
+
const byKey = new Map();
|
|
141
|
+
const anonymous = [];
|
|
142
|
+
let skipped = false;
|
|
143
|
+
|
|
144
|
+
for (const root of resolveCodebuddyRoots()) {
|
|
145
|
+
const { projectsDir, files } = findTranscripts(root, onWarning);
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
const { sessionId, fallback } = fileIdentity(file, projectsDir);
|
|
148
|
+
let stream;
|
|
149
|
+
try {
|
|
150
|
+
stream = createReadStream(file);
|
|
151
|
+
const lines = createInterface({ input: stream, crlfDelay: Infinity });
|
|
152
|
+
for await (const line of lines) {
|
|
153
|
+
if (!line.trim()) continue;
|
|
154
|
+
let obj;
|
|
155
|
+
try { obj = JSON.parse(line); } catch { continue; }
|
|
156
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
157
|
+
|
|
158
|
+
const entry = usageEntry(obj, fallback, sessionId);
|
|
159
|
+
if (entry) {
|
|
160
|
+
if (!entry.dedupeKey) anonymous.push(entry);
|
|
161
|
+
else {
|
|
162
|
+
const current = byKey.get(entry.dedupeKey);
|
|
163
|
+
if (!current || entry.usageScore > current.usageScore) byKey.set(entry.dedupeKey, entry);
|
|
164
|
+
}
|
|
165
|
+
const timestamp = readTimestamp(obj);
|
|
166
|
+
if (timestamp) events.push({ sessionId: entry.sessionId, source: SOURCE, project: entry.entry.project, timestamp, role: 'assistant' });
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (isHumanPrompt(obj)) {
|
|
171
|
+
const timestamp = readTimestamp(obj);
|
|
172
|
+
if (timestamp) {
|
|
173
|
+
events.push({
|
|
174
|
+
sessionId: typeof obj.sessionId === 'string' && obj.sessionId ? obj.sessionId : sessionId,
|
|
175
|
+
source: SOURCE,
|
|
176
|
+
project: projectFromCwd(obj.cwd) || fallback,
|
|
177
|
+
timestamp,
|
|
178
|
+
role: 'user',
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
} catch (err) {
|
|
184
|
+
// A transcript that cannot be read means this source's snapshot is
|
|
185
|
+
// incomplete: skip the source so its previous upload state survives.
|
|
186
|
+
skipped = true;
|
|
187
|
+
onWarning(`codebuddy: 无法读取会话 ${file}: ${err.message}`);
|
|
188
|
+
} finally {
|
|
189
|
+
stream?.close();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
for (const entry of anonymous) entries.push(entry.entry);
|
|
195
|
+
for (const entry of byKey.values()) entries.push(entry.entry);
|
|
196
|
+
|
|
197
|
+
const buckets = aggregateToBuckets(entries);
|
|
198
|
+
const sessions = extractSessions(events);
|
|
199
|
+
return skipped
|
|
200
|
+
? { buckets: [], sessions: [], skipped: true, warnings }
|
|
201
|
+
: { buckets, sessions, warnings };
|
|
202
|
+
}
|
|
@@ -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
|
+
}
|
package/src/parsers/index.js
CHANGED
|
@@ -28,6 +28,8 @@ 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';
|
|
32
|
+
import { parse as parseCodebuddy } from './codebuddy.js';
|
|
31
33
|
|
|
32
34
|
export const parsers = {
|
|
33
35
|
'claude-code': parseClaudeCode,
|
|
@@ -61,6 +63,8 @@ export const parsers = {
|
|
|
61
63
|
'roo-code': parseRooCode,
|
|
62
64
|
'workbuddy': parseWorkbuddy,
|
|
63
65
|
'zcode': parseZcode,
|
|
66
|
+
'devin': parseDevin,
|
|
67
|
+
'codebuddy': parseCodebuddy,
|
|
64
68
|
};
|
|
65
69
|
|
|
66
70
|
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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
-
import { dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path';
|
|
2
|
+
import { delimiter, dirname, isAbsolute, join, posix, resolve, win32 } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { getOpenCodeStores } from './opencode-roots.js';
|
|
5
5
|
import { findClaudeCodeDataDirs } from './claude-roots.js';
|
|
@@ -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)}`);
|
|
@@ -172,6 +182,12 @@ export function getMimocodeDbPath(env = process.env) {
|
|
|
172
182
|
return isAbsolute(env.MIMOCODE_DB) ? env.MIMOCODE_DB : join(dataDir, env.MIMOCODE_DB);
|
|
173
183
|
}
|
|
174
184
|
|
|
185
|
+
export function getCodebuddyRoots(env = process.env, home = homedir()) {
|
|
186
|
+
const override = env.VIBE_USAGE_CODEBUDDY_DIRS?.trim();
|
|
187
|
+
if (override) return override.split(delimiter).map(value => value.trim()).filter(Boolean);
|
|
188
|
+
return [env.CODEBUDDY_CONFIG_DIR?.trim() || join(home, '.codebuddy')];
|
|
189
|
+
}
|
|
190
|
+
|
|
175
191
|
export function getZcodeDbPath(env = process.env, home = homedir()) {
|
|
176
192
|
const override = env.VIBE_USAGE_ZCODE_DB?.trim();
|
|
177
193
|
if (override) return isAbsolute(override) ? override : resolve(override);
|
|
@@ -441,6 +457,18 @@ export const TOOLS = [
|
|
|
441
457
|
id: 'zcode',
|
|
442
458
|
dataDir: getZcodeDbPath(),
|
|
443
459
|
},
|
|
460
|
+
{
|
|
461
|
+
name: 'CodeBuddy',
|
|
462
|
+
id: 'codebuddy',
|
|
463
|
+
dataDir: join(getCodebuddyRoots()[0], 'projects'),
|
|
464
|
+
detectDataDirs: () => getCodebuddyRoots().map(root => join(root, 'projects')).filter(existsSync),
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
name: 'Devin',
|
|
468
|
+
id: 'devin',
|
|
469
|
+
dataDir: getDevinDbPath(),
|
|
470
|
+
detectDataDirs: () => [getDevinDbPath()].filter(existsSync),
|
|
471
|
+
},
|
|
444
472
|
];
|
|
445
473
|
|
|
446
474
|
export function detectInstalledTools(options = {}) {
|