@vibe-cafe/vibe-usage 0.10.33 → 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 +1 -0
- package/package.json +1 -1
- package/src/parsers/codebuddy.js +202 -0
- package/src/parsers/index.js +2 -0
- package/src/tools.js +13 -1
package/README.md
CHANGED
|
@@ -89,6 +89,7 @@ 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` |
|
package/package.json
CHANGED
|
@@ -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
|
+
}
|
package/src/parsers/index.js
CHANGED
|
@@ -29,6 +29,7 @@ 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
31
|
import { parse as parseDevin } from './devin.js';
|
|
32
|
+
import { parse as parseCodebuddy } from './codebuddy.js';
|
|
32
33
|
|
|
33
34
|
export const parsers = {
|
|
34
35
|
'claude-code': parseClaudeCode,
|
|
@@ -63,6 +64,7 @@ export const parsers = {
|
|
|
63
64
|
'workbuddy': parseWorkbuddy,
|
|
64
65
|
'zcode': parseZcode,
|
|
65
66
|
'devin': parseDevin,
|
|
67
|
+
'codebuddy': parseCodebuddy,
|
|
66
68
|
};
|
|
67
69
|
|
|
68
70
|
export { roundToHalfHour, aggregateToBuckets, extractSessions } from './aggregate.js';
|
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';
|
|
@@ -182,6 +182,12 @@ export function getMimocodeDbPath(env = process.env) {
|
|
|
182
182
|
return isAbsolute(env.MIMOCODE_DB) ? env.MIMOCODE_DB : join(dataDir, env.MIMOCODE_DB);
|
|
183
183
|
}
|
|
184
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
|
+
|
|
185
191
|
export function getZcodeDbPath(env = process.env, home = homedir()) {
|
|
186
192
|
const override = env.VIBE_USAGE_ZCODE_DB?.trim();
|
|
187
193
|
if (override) return isAbsolute(override) ? override : resolve(override);
|
|
@@ -451,6 +457,12 @@ export const TOOLS = [
|
|
|
451
457
|
id: 'zcode',
|
|
452
458
|
dataDir: getZcodeDbPath(),
|
|
453
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
|
+
},
|
|
454
466
|
{
|
|
455
467
|
name: 'Devin',
|
|
456
468
|
id: 'devin',
|