@vibe-cafe/vibe-usage 0.10.31 → 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 +5 -4
- package/package.json +1 -1
- package/src/index.js +34 -1
- package/src/kimi-roots.js +63 -0
- package/src/parsers/aggregate.js +18 -1
- package/src/parsers/claude-code.js +52 -10
- package/src/parsers/cline-sdk.js +26 -6
- package/src/parsers/cline.js +10 -4
- package/src/parsers/devin.js +184 -0
- package/src/parsers/index.js +2 -0
- package/src/parsers/kimi-code.js +43 -22
- package/src/parsers/zcode.js +13 -6
- package/src/state.js +5 -0
- package/src/sync.js +4 -0
- package/src/tools.js +26 -8
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ npx @vibe-cafe/vibe-usage reset # Delete all data and re-upload from loca
|
|
|
50
50
|
npx @vibe-cafe/vibe-usage reset --local # Delete this host's data only and re-upload (`--host` remains a legacy alias)
|
|
51
51
|
npx @vibe-cafe/vibe-usage skill # Install skill for AI coding assistants
|
|
52
52
|
npx @vibe-cafe/vibe-usage skill --remove # Remove installed skills
|
|
53
|
-
npx @vibe-cafe/vibe-usage status #
|
|
53
|
+
npx @vibe-cafe/vibe-usage status # Config, detected tools, and what each tool has uploaded so far
|
|
54
54
|
npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short version)
|
|
55
55
|
```
|
|
56
56
|
|
|
@@ -76,7 +76,7 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
|
|
|
76
76
|
| Oh My Pi | `~/.omp/agent/sessions/`, `~/.omp/profiles/*/agent/sessions/`, and `$XDG_DATA_HOME/omp/{sessions,profiles/*/sessions}`; recognizes OMP's `$PI_CODING_AGENT_DIR`, current v3 title slots and path/hashed session directories, deduplicates copied records, includes cache writes in input, and splits reasoning from OMP's inclusive output count |
|
|
77
77
|
| pi | `~/.pi/agent/sessions/` or `$PI_CODING_AGENT_DIR/sessions/`, plus the session directory Pi itself was pointed at via `PI_CODING_AGENT_SESSION_DIR` or `sessionDir` in `~/.pi/agent/settings.json`, plus explicitly added `pi-coding-agent` roots for stores only reachable through `pi --session <file>` (fixture/relocation override: `VIBE_USAGE_PI_SESSION_DIRS`). Cache writes are included in input usage; reasoning is read from Pi's `usage.reasoning` (legacy `usage.reasoningTokens` still accepted) and split out of the inclusive output total |
|
|
78
78
|
| Qwen Code | `~/.qwen/tmp/` |
|
|
79
|
-
| Kimi Code | Current
|
|
79
|
+
| Kimi Code | Current `<home>/sessions/wd_<slug>_<hash>/session_<id>/agents/<agent>/wire.jsonl` (`usage.record` deltas, including retry/compaction scope and cache creation; main/subagent wires form one session), with project names from `session_index.jsonl`. Every home with that layout is scanned: the CLI home (`$KIMI_CODE_HOME`, else `~/.kimi-code`) plus the Kimi Work desktop app's embedded runtime home (`<Electron userData>/kimi-desktop/daimon-share/daimon/runtime/kimi-code/home`), which the app never writes into the CLI home; legacy `~/.kimi/sessions/` is parsed alongside (`kimi migrate` never carries usage over, so both stores are always merged) |
|
|
80
80
|
| MiniMax Code (mcode) | `$MCODE_HOME/v2/sqlite/runtime-state.sqlite` (default `~/.minimax/v2/sqlite/runtime-state.sqlite`; fixture override: `VIBE_USAGE_MCODE_DB`). Reads only allow-listed token ledger fields and session workspace/project paths, uses basename-only projects, folds cache writes into input, keeps cache reads and reasoning separate, and never selects raw/message JSON payloads. WAL/lock reads use a disposable snapshot; malformed or incompatible databases are skipped to preserve incremental state. |
|
|
81
81
|
| MiMoCode | `$MIMOCODE_HOME/data/mimocode.db`, `$XDG_DATA_HOME/mimocode/mimocode.db`, or `~/.local/share/mimocode/mimocode.db` (SQLite; exact input, output, reasoning, and cache-read tokens from assistant messages; honors `MIMOCODE_DB`; cache-write tokens are included in input usage) |
|
|
82
82
|
| Amp | `~/.local/share/amp/threads/`; cache-creation tokens are included in input usage |
|
|
@@ -84,14 +84,15 @@ npx @vibe-cafe/vibe-usage help --all # Full help (plain `help` shows the short
|
|
|
84
84
|
| DeepSeek Harness | `$DSH_HOME/sessions/` (default `~/.dsh`, fixture/relocation override: `VIBE_USAGE_DSH_SESSIONS`). Reads V0–V3 logs, including `session.v3.jsonl.zstd` from DSH `0.1.5-alpha.2`, with multi-frame Zstandard support (Node ≥ 22.15 built-in, `zstd` CLI fallback) and plain JSONL support. Each session uses its highest `session[.vN].jsonl[.zstd]` generation once, so frozen pre-migration logs are not double-counted. Usage comes from `assistant/message`: cache writes join uncached input, cache reads remain separate, and reasoning is split out of inclusive output. Fork history uses V0/V1 `seedLength` or V2/V3's last `session/end-seed` tagged `inherited: true`, and is skipped only when the parent copy confirms it; missing parents retain the sole local history. Unknown versions warn and protect sync state. |
|
|
85
85
|
| Hermes (CLI / Desktop) | `<home>/state.db` + `<home>/profiles/<name>/state.db` (SQLite, multi-profile). Home: `$HERMES_HOME`, otherwise `~/.hermes` on macOS/Linux or `%LOCALAPPDATA%\hermes` on Windows (falls back to an existing `~/.hermes` only when the Windows native root is absent). Cache writes join input; reasoning is separated from inclusive output. Usage is currently a cumulative session total attributed to session start: a session spanning several days does **not** yet provide an accurate daily breakdown. |
|
|
86
86
|
| Kiro | Kiro CLI native event streams `~/.kiro/sessions/cli/*.jsonl` (estimated tokens from message text: input = prompt + tool results, output = reply + tool calls, reasoning = thinking, cacheRead = re-sent context; thinking-block signatures excluded). Falls back to `~/Library/Application Support/kiro-cli/data.sqlite3` / `~/.local/share/kiro-cli/data.sqlite3` + optional `~/.kiro_sessions/*.json` archives, then IDE `q-client.log` whole-credit deltas as `kiro-credits` (floored cumulative diff — the server stores token counts as bigint); legacy IDE `dev_data/devdata.sqlite` token telemetry is opt-in with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
|
|
87
|
-
| Cline
|
|
87
|
+
| Cline (CLI / SDK / Desktop app) | All variants write the same `~/.cline/data/sessions/*/*.messages.json` per-call metrics (the desktop app's manifest says `source: "desktop"`; its Electron userData holds only WebView caches), plus legacy `~/.cline/{,data/}state/taskHistory.json` and editor extension stores. Honors `CLINE_DIR`, `CLINE_DATA_DIR`, and `CLINE_SESSION_DATA_DIR`; copied history is deduplicated |
|
|
88
88
|
| Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
|
|
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
|
-
| ZCode | `~/.zcode/cli/db/db.sqlite` (SQLite; reads the `message` table for per-message tokens, model, and project `cwd`/`root`, joined to `session.directory`) |
|
|
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
package/src/index.js
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
validateExtraRoot,
|
|
10
10
|
} from './extra-roots.js';
|
|
11
11
|
import { dim as dimText, failure, hint, smallHeader, warn } from './output.js';
|
|
12
|
+
import { loadState } from './state.js';
|
|
12
13
|
import { fetchAccount } from './api.js';
|
|
13
14
|
|
|
14
15
|
function printSmallHeader() {
|
|
@@ -17,6 +18,25 @@ function printSmallHeader() {
|
|
|
17
18
|
console.log();
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Per-source counts of everything `state.json` records as uploaded. Keys are
|
|
23
|
+
* `${source}|…` (see state.js), so the source is the first segment.
|
|
24
|
+
*/
|
|
25
|
+
function uploadedItemCounts(state) {
|
|
26
|
+
const countBySource = keys => {
|
|
27
|
+
const counts = new Map();
|
|
28
|
+
for (const key of keys) {
|
|
29
|
+
const source = key.split('|')[0];
|
|
30
|
+
counts.set(source, (counts.get(source) || 0) + 1);
|
|
31
|
+
}
|
|
32
|
+
return counts;
|
|
33
|
+
};
|
|
34
|
+
return {
|
|
35
|
+
buckets: countBySource(Object.keys(state.buckets || {})),
|
|
36
|
+
sessions: countBySource(Object.keys(state.sessions || {})),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
20
40
|
async function showStatus() {
|
|
21
41
|
const config = loadConfig();
|
|
22
42
|
console.log('\nvibe-usage status\n');
|
|
@@ -49,8 +69,21 @@ async function showStatus() {
|
|
|
49
69
|
if (detected.length === 0) {
|
|
50
70
|
console.log(' (none)\n');
|
|
51
71
|
} else {
|
|
72
|
+
// "installed" alone cannot separate "syncing fine" from "never uploaded":
|
|
73
|
+
// a tool whose data dir exists but whose buckets never reached the server
|
|
74
|
+
// reads exactly like a healthy one, which is how a stale bundled CLI stayed
|
|
75
|
+
// invisible (issue #100). state.json is the only local record of what was
|
|
76
|
+
// actually uploaded, so count it per source.
|
|
77
|
+
const uploaded = uploadedItemCounts(loadState());
|
|
52
78
|
for (const tool of detected) {
|
|
53
|
-
|
|
79
|
+
const buckets = uploaded.buckets.get(tool.id) || 0;
|
|
80
|
+
const sessions = uploaded.sessions.get(tool.id) || 0;
|
|
81
|
+
if (buckets || sessions) {
|
|
82
|
+
console.log(` ${tool.name} · 已上传 ${buckets} buckets / ${sessions} sessions`);
|
|
83
|
+
} else {
|
|
84
|
+
console.log(` ${tool.name} · 尚未上传过数据`);
|
|
85
|
+
console.log(dimText(' 本机检测到数据,但本地没有这条工具的上传记录:运行 `npx @vibe-cafe/vibe-usage` 同步'));
|
|
86
|
+
}
|
|
54
87
|
}
|
|
55
88
|
console.log();
|
|
56
89
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { join, posix, win32 } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
|
|
5
|
+
// Root discovery for Kimi Code. The CLI keeps one data home, and the Kimi Work
|
|
6
|
+
// desktop app runs an embedded Kimi Code runtime whose home has the CLI's exact
|
|
7
|
+
// layout (issue #85), so both homes are scanned additively.
|
|
8
|
+
|
|
9
|
+
// Kimi Work's embedded runtime home, relative to the app's Electron userData.
|
|
10
|
+
const DAIMON_RUNTIME = ['daimon-share', 'daimon', 'runtime', 'kimi-code', 'home'];
|
|
11
|
+
|
|
12
|
+
/** Kimi Work's embedded Kimi Code home for this platform. */
|
|
13
|
+
export function kimiWorkCodeHome(env = process.env, platform = process.platform, home = homedir()) {
|
|
14
|
+
const pathImpl = platform === 'win32' ? win32 : posix;
|
|
15
|
+
const userData = platform === 'darwin'
|
|
16
|
+
? pathImpl.join(home, 'Library', 'Application Support')
|
|
17
|
+
: platform === 'win32'
|
|
18
|
+
? (env.APPDATA?.trim() || pathImpl.join(home, 'AppData', 'Roaming'))
|
|
19
|
+
: (env.XDG_CONFIG_HOME?.trim() || pathImpl.join(home, '.config'));
|
|
20
|
+
return pathImpl.join(userData, 'kimi-desktop', ...DAIMON_RUNTIME);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Two roots can name the same directory (symlinks, a relocated home that still
|
|
24
|
+
// resolves to the same store); scanning both would double-count every record.
|
|
25
|
+
function uniquePaths(paths) {
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
const result = [];
|
|
28
|
+
for (const path of paths) {
|
|
29
|
+
let key = path;
|
|
30
|
+
try { key = realpathSync(path); } catch { /* not created yet — compare as given */ }
|
|
31
|
+
if (seen.has(key)) continue;
|
|
32
|
+
seen.add(key);
|
|
33
|
+
result.push(path);
|
|
34
|
+
}
|
|
35
|
+
return result;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Kimi Code data homes to scan, primary first.
|
|
40
|
+
*
|
|
41
|
+
* `VIBE_USAGE_KIMI_CODE_DIR` (test/relocation hook) replaces discovery entirely,
|
|
42
|
+
* so a fixture never picks up the machine's real stores. Otherwise the CLI home
|
|
43
|
+
* — `$KIMI_CODE_HOME` when set, exactly like the CLI itself — is scanned
|
|
44
|
+
* alongside the Kimi Work desktop home; the desktop app never writes into the
|
|
45
|
+
* CLI home, so the two stores are independent.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveKimiCodeRoots(env = process.env, platform = process.platform, home = homedir()) {
|
|
48
|
+
const override = env.VIBE_USAGE_KIMI_CODE_DIR?.trim();
|
|
49
|
+
if (override) return [override];
|
|
50
|
+
const pathImpl = platform === 'win32' ? win32 : posix;
|
|
51
|
+
return uniquePaths([
|
|
52
|
+
env.KIMI_CODE_HOME?.trim() || pathImpl.join(home, '.kimi-code'),
|
|
53
|
+
kimiWorkCodeHome(env, platform, home),
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Existing Kimi Code session stores, for tool detection (`status`). */
|
|
58
|
+
export function findKimiCodeDataDirs() {
|
|
59
|
+
return uniquePaths([
|
|
60
|
+
...resolveKimiCodeRoots().map(root => join(root, 'sessions')),
|
|
61
|
+
join(homedir(), '.kimi', 'sessions'), // legacy store, always parsed alongside
|
|
62
|
+
]).filter(existsSync);
|
|
63
|
+
}
|
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
|
|
package/src/parsers/cline-sdk.js
CHANGED
|
@@ -5,12 +5,30 @@ import { projectFromCwd, toCount } from './fs-utils.js';
|
|
|
5
5
|
// Verified with the shipped cline 3.0.61 / @cline/core 0.0.82. SQLite is only
|
|
6
6
|
// the session index: per-call accounting lives in version-1 messages artifacts.
|
|
7
7
|
// Read canonical artifacts, not DB prompt/metadata columns or provider settings.
|
|
8
|
-
|
|
8
|
+
//
|
|
9
|
+
// Two failure sizes, because they mean different things (see AGENTS.md):
|
|
10
|
+
// a *format* mismatch means the store moved on and any number we produce would
|
|
11
|
+
// be wrong, so the caller must skip the source and keep its previous upload
|
|
12
|
+
// state; an *IO* failure on one artifact (the desktop app rewrites these files
|
|
13
|
+
// in place, so half-written JSON is normal) drops only that artifact, while the
|
|
14
|
+
// rest of the store still syncs. Transient losses re-upload on the next sync.
|
|
15
|
+
function unsupported(message) {
|
|
16
|
+
const error = new Error(message);
|
|
17
|
+
error.clineUnsupported = true;
|
|
18
|
+
return error;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function readClineSdk(sessionDirs, { onWarning = () => {}, onFatal = () => {} } = {}) {
|
|
9
22
|
const copies = [];
|
|
10
23
|
for (const dir of sessionDirs) {
|
|
11
24
|
let children;
|
|
12
25
|
try { children = readdirSync(dir, { withFileTypes: true }); }
|
|
13
|
-
catch (err) {
|
|
26
|
+
catch (err) {
|
|
27
|
+
// Cannot enumerate this root: the snapshot may be missing sessions we
|
|
28
|
+
// could not even list, so the whole source is unsafe this run.
|
|
29
|
+
onFatal(`cline: 无法读取会话目录 ${dir}: ${err.message}`);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
14
32
|
for (const child of children) {
|
|
15
33
|
if (!child.isDirectory()) continue;
|
|
16
34
|
const sessionDir = join(dir, child.name);
|
|
@@ -21,10 +39,11 @@ export function readClineSdk(sessionDirs, onWarning) {
|
|
|
21
39
|
if (!files.length) continue;
|
|
22
40
|
manifest = JSON.parse(readFileSync(join(sessionDir, `${child.name}.json`), 'utf8'));
|
|
23
41
|
if (manifest?.version !== 1 || manifest.session_id !== child.name) {
|
|
24
|
-
throw
|
|
42
|
+
throw unsupported('unsupported or inconsistent Cline session manifest');
|
|
25
43
|
}
|
|
26
44
|
} catch (err) {
|
|
27
|
-
|
|
45
|
+
if (err?.clineUnsupported) onFatal(`cline: 不支持或不一致的会话清单 ${sessionDir}: ${err.message}`);
|
|
46
|
+
else onWarning(`cline: 跳过无法读取的会话目录 ${sessionDir}: ${err.message}`);
|
|
28
47
|
continue;
|
|
29
48
|
}
|
|
30
49
|
for (const file of files) {
|
|
@@ -34,7 +53,7 @@ export function readClineSdk(sessionDirs, onWarning) {
|
|
|
34
53
|
if (payload?.version !== 1 || !Array.isArray(payload.messages)
|
|
35
54
|
|| typeof payload.sessionId !== 'string'
|
|
36
55
|
|| (payload.sessionId !== child.name && payload.origin?.parentThreadId !== child.name)) {
|
|
37
|
-
throw
|
|
56
|
+
throw unsupported('unsupported or inconsistent Cline session artifact');
|
|
38
57
|
}
|
|
39
58
|
const project = projectFromCwd(manifest.workspace_root || manifest.cwd);
|
|
40
59
|
// Reduce immediately to accounting/timing metadata. No prompt, response,
|
|
@@ -68,7 +87,8 @@ export function readClineSdk(sessionDirs, onWarning) {
|
|
|
68
87
|
// shared history to the earliest original session deterministically.
|
|
69
88
|
started: Date.parse(manifest.started_at) || 0, messagesPath });
|
|
70
89
|
} catch (err) {
|
|
71
|
-
|
|
90
|
+
if (err?.clineUnsupported) onFatal(`cline: 不支持或不一致的会话文件 ${messagesPath}: ${err.message}`);
|
|
91
|
+
else onWarning(`cline: 跳过无法读取的会话文件 ${messagesPath}: ${err.message}`);
|
|
72
92
|
}
|
|
73
93
|
}
|
|
74
94
|
}
|
package/src/parsers/cline.js
CHANGED
|
@@ -7,8 +7,13 @@ import { readClineSdk } from './cline-sdk.js';
|
|
|
7
7
|
|
|
8
8
|
export async function parse() {
|
|
9
9
|
const warnings = [];
|
|
10
|
+
// Format drift or an unreadable root means we cannot describe the store:
|
|
11
|
+
// skip the source so its previous upload state survives. A single unreadable
|
|
12
|
+
// artifact only drops that artifact (it re-uploads on the next sync).
|
|
13
|
+
let fatal = false;
|
|
10
14
|
const onWarning = message => warnings.push(message);
|
|
11
|
-
const
|
|
15
|
+
const onFatal = message => { fatal = true; warnings.push(message); };
|
|
16
|
+
const { legacyRoots: extDirs, sdkSessionDirs } = findClineStores({ onWarning: onFatal });
|
|
12
17
|
|
|
13
18
|
const entries = [];
|
|
14
19
|
const events = [];
|
|
@@ -90,8 +95,9 @@ export async function parse() {
|
|
|
90
95
|
}
|
|
91
96
|
}
|
|
92
97
|
|
|
93
|
-
const sdk = readClineSdk(sdkSessionDirs, onWarning);
|
|
94
|
-
if (
|
|
98
|
+
const sdk = readClineSdk(sdkSessionDirs, { onWarning, onFatal });
|
|
99
|
+
if (fatal) return { buckets: [], sessions: [], skipped: true, warnings };
|
|
95
100
|
return { buckets: aggregateToBuckets([...entries, ...sdk.entries]),
|
|
96
|
-
sessions: extractSessions([...events, ...sdk.events])
|
|
101
|
+
sessions: extractSessions([...events, ...sdk.events]),
|
|
102
|
+
warnings };
|
|
97
103
|
}
|
|
@@ -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,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/parsers/kimi-code.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
1
|
+
import { readdirSync, readFileSync, existsSync, realpathSync } from 'node:fs';
|
|
2
2
|
import { join, basename } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
6
|
+
import { resolveKimiCodeRoots } from '../kimi-roots.js';
|
|
6
7
|
|
|
7
8
|
/**
|
|
8
9
|
* Kimi Code CLI parser. MoonshotAI/kimi-cli (a.k.a. "Kimi Code").
|
|
@@ -19,8 +20,13 @@ import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
|
19
20
|
* sum to the session total. The model name rides on each record — no config
|
|
20
21
|
* lookup needed. User turns are `turn.prompt` with origin.kind === "user".
|
|
21
22
|
* The real working directory per session is recorded in
|
|
22
|
-
*
|
|
23
|
+
* <home>/session_index.jsonl -> {sessionId, sessionDir, workDir}
|
|
23
24
|
* which gives an accurate project name (last path component of workDir).
|
|
25
|
+
* Several homes share this layout and are all scanned (see kimi-roots.js):
|
|
26
|
+
* the CLI home ($KIMI_CODE_HOME, else ~/.kimi-code) and the Kimi Work
|
|
27
|
+
* desktop app's embedded runtime home. The desktop app never writes into the
|
|
28
|
+
* CLI home, so the stores are independent and merging them cannot
|
|
29
|
+
* double-count.
|
|
24
30
|
*
|
|
25
31
|
* 2. Legacy ("~/.kimi", protocol 1.1 / 1.9). Sessions live at
|
|
26
32
|
* ~/.kimi/sessions/<md5(workdir)>/<session-id>/wire.jsonl
|
|
@@ -39,15 +45,12 @@ import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
|
39
45
|
// Current format: ~/.kimi-code
|
|
40
46
|
// ---------------------------------------------------------------------------
|
|
41
47
|
|
|
42
|
-
// VIBE_USAGE_KIMI_CODE_DIR overrides the root (test hook). Otherwise
|
|
43
|
-
// the
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|| join(homedir(), '.kimi-code');
|
|
49
|
-
const KIMI_CODE_SESSIONS_DIR = join(KIMI_CODE_DIR, 'sessions');
|
|
50
|
-
const KIMI_CODE_SESSION_INDEX = join(KIMI_CODE_DIR, 'session_index.jsonl');
|
|
48
|
+
// VIBE_USAGE_KIMI_CODE_DIR overrides the root (test hook). Otherwise every root
|
|
49
|
+
// from kimi-roots.js is scanned — the CLI home resolved the same way the CLI
|
|
50
|
+
// itself resolves it ($KIMI_CODE_HOME, then ~/.kimi-code) plus the Kimi Work
|
|
51
|
+
// desktop app's embedded runtime home (issue #85), which uses this exact
|
|
52
|
+
// layout. Ignoring KIMI_CODE_HOME means users with a custom home get zero usage
|
|
53
|
+
// parsed; ignoring the desktop home means Kimi Work users get zero usage parsed.
|
|
51
54
|
|
|
52
55
|
function projectNameFromPath(path) {
|
|
53
56
|
if (typeof path !== 'string' || !path) return null;
|
|
@@ -55,17 +58,17 @@ function projectNameFromPath(path) {
|
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
/**
|
|
58
|
-
* Map each session directory (absolute path) to a project name, read from
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
+
* Map each session directory (absolute path) to a project name, read from the
|
|
62
|
+
* home's session_index.jsonl. Falls back gracefully if the file is missing or
|
|
63
|
+
* malformed — callers default to the wd_ bucket name.
|
|
61
64
|
*/
|
|
62
|
-
function loadSessionIndex() {
|
|
65
|
+
function loadSessionIndex(indexPath) {
|
|
63
66
|
const map = new Map();
|
|
64
|
-
if (!existsSync(
|
|
67
|
+
if (!existsSync(indexPath)) return map;
|
|
65
68
|
|
|
66
69
|
let content;
|
|
67
70
|
try {
|
|
68
|
-
content = readFileSync(
|
|
71
|
+
content = readFileSync(indexPath, 'utf-8');
|
|
69
72
|
} catch {
|
|
70
73
|
return map;
|
|
71
74
|
}
|
|
@@ -141,15 +144,35 @@ function findKimiCodeWireFiles(baseDir) {
|
|
|
141
144
|
return results;
|
|
142
145
|
}
|
|
143
146
|
|
|
147
|
+
/**
|
|
148
|
+
* Every wire file to parse, with its project name, across all Kimi Code homes.
|
|
149
|
+
* Two roots can resolve to the same store (symlink, relocated home), so the
|
|
150
|
+
* same physical wire file is returned once.
|
|
151
|
+
*/
|
|
152
|
+
function findKimiCodeWireFilesInAllRoots() {
|
|
153
|
+
const results = [];
|
|
154
|
+
const seen = new Set();
|
|
155
|
+
for (const root of resolveKimiCodeRoots()) {
|
|
156
|
+
const sessionIndex = loadSessionIndex(join(root, 'session_index.jsonl'));
|
|
157
|
+
for (const { wireFile, sessionDir, bucketProject } of findKimiCodeWireFiles(join(root, 'sessions'))) {
|
|
158
|
+
let identity = wireFile;
|
|
159
|
+
try { identity = realpathSync(wireFile); } catch { /* keep the literal path */ }
|
|
160
|
+
if (seen.has(identity)) continue;
|
|
161
|
+
seen.add(identity);
|
|
162
|
+
results.push({ wireFile, sessionDir, project: sessionIndex.get(sessionDir) || bucketProject || 'unknown' });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return results;
|
|
166
|
+
}
|
|
167
|
+
|
|
144
168
|
function parseKimiCode() {
|
|
145
|
-
const wireFiles =
|
|
169
|
+
const wireFiles = findKimiCodeWireFilesInAllRoots();
|
|
146
170
|
if (wireFiles.length === 0) return null;
|
|
147
171
|
|
|
148
|
-
const sessionIndex = loadSessionIndex();
|
|
149
172
|
const entries = [];
|
|
150
173
|
const sessionEvents = [];
|
|
151
174
|
|
|
152
|
-
for (const { wireFile, sessionDir,
|
|
175
|
+
for (const { wireFile, sessionDir, project } of wireFiles) {
|
|
153
176
|
let content;
|
|
154
177
|
try {
|
|
155
178
|
content = readFileSync(wireFile, 'utf-8');
|
|
@@ -157,8 +180,6 @@ function parseKimiCode() {
|
|
|
157
180
|
continue;
|
|
158
181
|
}
|
|
159
182
|
|
|
160
|
-
const project = sessionIndex.get(sessionDir) || bucketProject || 'unknown';
|
|
161
|
-
|
|
162
183
|
for (const line of content.split('\n')) {
|
|
163
184
|
if (!line.trim()) continue;
|
|
164
185
|
let evt;
|
package/src/parsers/zcode.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
|
-
import {
|
|
3
|
-
import { homedir } from 'node:os';
|
|
2
|
+
import { basename } from 'node:path';
|
|
4
3
|
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
4
|
import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
|
|
5
|
+
import { getZcodeDbPath } from '../tools.js';
|
|
6
6
|
|
|
7
7
|
// ZCode (z.ai / Zhipu's coding agent) stores everything in a SQLite database
|
|
8
8
|
// at ~/.zcode/cli/db/db.sqlite. The `message` table is the canonical source:
|
|
@@ -11,7 +11,9 @@ import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from '.
|
|
|
11
11
|
// than the parallel `model_usage` ledger because `message` gives us BOTH session
|
|
12
12
|
// timing (user + assistant rows) and token usage in one pass, with the project
|
|
13
13
|
// path attached to each message.
|
|
14
|
-
|
|
14
|
+
export function resolveZcodeDbPath(env = process.env) {
|
|
15
|
+
return getZcodeDbPath(env);
|
|
16
|
+
}
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Project name from a ZCode message's path. ZCode records both `cwd` and `root`;
|
|
@@ -26,15 +28,20 @@ function projectName(root, cwd, sessionDir) {
|
|
|
26
28
|
}
|
|
27
29
|
|
|
28
30
|
export async function parse() {
|
|
29
|
-
|
|
31
|
+
const dbPath = resolveZcodeDbPath();
|
|
32
|
+
if (!existsSync(dbPath)) return { buckets: [], sessions: [] };
|
|
30
33
|
|
|
31
34
|
// Join each message to its session so we can fall back to the session's
|
|
32
35
|
// directory when an individual message has no path (older rows, lite agents).
|
|
36
|
+
// ZCode renamed the assistant message's model keys from `modelID` /
|
|
37
|
+
// `providerID` to `modelId` / `providerId`; read both spellings so neither
|
|
38
|
+
// build reports every bucket as `unknown`.
|
|
33
39
|
const query = `SELECT
|
|
34
40
|
m.session_id AS sessionId,
|
|
35
41
|
m.time_created AS created,
|
|
36
42
|
json_extract(m.data, '$.role') AS role,
|
|
37
43
|
json_extract(m.data, '$.modelID') AS modelID,
|
|
44
|
+
json_extract(m.data, '$.modelId') AS modelId,
|
|
38
45
|
json_extract(m.data, '$.tokens') AS tokens,
|
|
39
46
|
json_extract(m.data, '$.path.root') AS pathRoot,
|
|
40
47
|
json_extract(m.data, '$.path.cwd') AS pathCwd,
|
|
@@ -44,7 +51,7 @@ export async function parse() {
|
|
|
44
51
|
|
|
45
52
|
let rows;
|
|
46
53
|
try {
|
|
47
|
-
rows = queryDbJson(
|
|
54
|
+
rows = queryDbJson(dbPath, query);
|
|
48
55
|
} catch (err) {
|
|
49
56
|
if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('ZCode');
|
|
50
57
|
throw err;
|
|
@@ -88,7 +95,7 @@ export async function parse() {
|
|
|
88
95
|
|
|
89
96
|
entries.push({
|
|
90
97
|
source: 'zcode',
|
|
91
|
-
model: row.modelID || 'unknown',
|
|
98
|
+
model: row.modelID || row.modelId || 'unknown',
|
|
92
99
|
project,
|
|
93
100
|
timestamp,
|
|
94
101
|
inputTokens: (tokens.input || 0) - cachedInput,
|
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
|
@@ -15,6 +15,7 @@ import { findClineDataDirs } from './cline-roots.js';
|
|
|
15
15
|
import { findColaDataDirs, getColaSessionsDir } from './cola-roots.js';
|
|
16
16
|
import { findCraftDataDirs } from './craft-roots.js';
|
|
17
17
|
import { findHermesDataDirs, getHermesHome } from './hermes-roots.js';
|
|
18
|
+
import { findKimiCodeDataDirs } from './kimi-roots.js';
|
|
18
19
|
import { findOmpDataDirs, findPiDataDirs } from './pi-roots.js';
|
|
19
20
|
import { findQoderDataDirs, getQoderProjectsDir } from './qoder-roots.js';
|
|
20
21
|
import { findWorkbuddyDataDirs } from './workbuddy-roots.js';
|
|
@@ -124,13 +125,8 @@ export function findCodexDataDirs(codexExtraHome, extraRoots = []) {
|
|
|
124
125
|
}
|
|
125
126
|
|
|
126
127
|
// Kimi Code moved its store from ~/.kimi to ~/.kimi-code; recognize either so
|
|
127
|
-
// users on either version are detected.
|
|
128
|
-
|
|
129
|
-
return [
|
|
130
|
-
join(homedir(), '.kimi-code', 'sessions'),
|
|
131
|
-
join(homedir(), '.kimi', 'sessions'),
|
|
132
|
-
].filter(existsSync);
|
|
133
|
-
}
|
|
128
|
+
// users on either version are detected. Kimi Work's embedded runtime home is
|
|
129
|
+
// recognized too — see kimi-roots.js, which the parser uses as well.
|
|
134
130
|
|
|
135
131
|
/** DeepSeek Harness home: DSH_HOME env (same as the dsh CLI) or ~/.dsh. */
|
|
136
132
|
export function getDshHome(env = process.env) {
|
|
@@ -165,6 +161,16 @@ export function getMcodeDbPath(env = process.env, home = homedir()) {
|
|
|
165
161
|
return join(root, 'v2', 'sqlite', 'runtime-state.sqlite');
|
|
166
162
|
}
|
|
167
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
|
+
|
|
168
174
|
export function getMimocodeDbPath(env = process.env) {
|
|
169
175
|
if (env.MIMOCODE_HOME && !isAbsolute(env.MIMOCODE_HOME)) {
|
|
170
176
|
throw new Error(`MIMOCODE_HOME must be an absolute path, got: ${JSON.stringify(env.MIMOCODE_HOME)}`);
|
|
@@ -176,6 +182,12 @@ export function getMimocodeDbPath(env = process.env) {
|
|
|
176
182
|
return isAbsolute(env.MIMOCODE_DB) ? env.MIMOCODE_DB : join(dataDir, env.MIMOCODE_DB);
|
|
177
183
|
}
|
|
178
184
|
|
|
185
|
+
export function getZcodeDbPath(env = process.env, home = homedir()) {
|
|
186
|
+
const override = env.VIBE_USAGE_ZCODE_DB?.trim();
|
|
187
|
+
if (override) return isAbsolute(override) ? override : resolve(override);
|
|
188
|
+
return join(home, '.zcode', 'cli', 'db', 'db.sqlite');
|
|
189
|
+
}
|
|
190
|
+
|
|
179
191
|
export function findAntigravityDataDirs(extraRoots = []) {
|
|
180
192
|
return [...new Set([
|
|
181
193
|
...antigravityConversationDirs(homedir()).map(dirname),
|
|
@@ -437,7 +449,13 @@ export const TOOLS = [
|
|
|
437
449
|
{
|
|
438
450
|
name: 'ZCode',
|
|
439
451
|
id: 'zcode',
|
|
440
|
-
dataDir:
|
|
452
|
+
dataDir: getZcodeDbPath(),
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
name: 'Devin',
|
|
456
|
+
id: 'devin',
|
|
457
|
+
dataDir: getDevinDbPath(),
|
|
458
|
+
detectDataDirs: () => [getDevinDbPath()].filter(existsSync),
|
|
441
459
|
},
|
|
442
460
|
];
|
|
443
461
|
|