@geoqiao/pi-usage 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +222 -0
- package/bin/pi-usage.js +69 -0
- package/data/models.dev-LICENSE +21 -0
- package/data/prices.json +2678 -0
- package/extensions/usage-report.js +36 -0
- package/package.json +51 -0
- package/src/analytics.js +189 -0
- package/src/collect.js +34 -0
- package/src/network.js +25 -0
- package/src/report.js +43 -0
- package/vendor/vibe-usage/NOTICE.md +58 -0
- package/vendor/vibe-usage/src/cindy-roots.js +85 -0
- package/vendor/vibe-usage/src/claude-roots.js +165 -0
- package/vendor/vibe-usage/src/cline-roots.js +40 -0
- package/vendor/vibe-usage/src/codex-roots.js +46 -0
- package/vendor/vibe-usage/src/craft-roots.js +15 -0
- package/vendor/vibe-usage/src/extra-roots.js +312 -0
- package/vendor/vibe-usage/src/parsers/aggregate.js +196 -0
- package/vendor/vibe-usage/src/parsers/alma.js +94 -0
- package/vendor/vibe-usage/src/parsers/amp.js +156 -0
- package/vendor/vibe-usage/src/parsers/antigravity-db.js +359 -0
- package/vendor/vibe-usage/src/parsers/antigravity.js +530 -0
- package/vendor/vibe-usage/src/parsers/cindy-ledger.js +157 -0
- package/vendor/vibe-usage/src/parsers/claude-code.js +372 -0
- package/vendor/vibe-usage/src/parsers/cline.js +92 -0
- package/vendor/vibe-usage/src/parsers/codex-cache.js +138 -0
- package/vendor/vibe-usage/src/parsers/codex.js +1198 -0
- package/vendor/vibe-usage/src/parsers/contract.js +55 -0
- package/vendor/vibe-usage/src/parsers/copilot-cli.js +128 -0
- package/vendor/vibe-usage/src/parsers/craft-agent.js +21 -0
- package/vendor/vibe-usage/src/parsers/cursor.js +262 -0
- package/vendor/vibe-usage/src/parsers/dimagent.js +127 -0
- package/vendor/vibe-usage/src/parsers/droid.js +113 -0
- package/vendor/vibe-usage/src/parsers/dsh.js +563 -0
- package/vendor/vibe-usage/src/parsers/fs-utils.js +36 -0
- package/vendor/vibe-usage/src/parsers/gemini-cli.js +190 -0
- package/vendor/vibe-usage/src/parsers/grok.js +395 -0
- package/vendor/vibe-usage/src/parsers/hermes.js +123 -0
- package/vendor/vibe-usage/src/parsers/index.js +61 -0
- package/vendor/vibe-usage/src/parsers/kimi-code.js +467 -0
- package/vendor/vibe-usage/src/parsers/kiro.js +788 -0
- package/vendor/vibe-usage/src/parsers/mcode.js +182 -0
- package/vendor/vibe-usage/src/parsers/mimocode.js +88 -0
- package/vendor/vibe-usage/src/parsers/omp.js +10 -0
- package/vendor/vibe-usage/src/parsers/openclaw.js +142 -0
- package/vendor/vibe-usage/src/parsers/opencode.js +151 -0
- package/vendor/vibe-usage/src/parsers/pi-coding-agent.js +27 -0
- package/vendor/vibe-usage/src/parsers/pi-session-jsonl.js +166 -0
- package/vendor/vibe-usage/src/parsers/qwen-code.js +122 -0
- package/vendor/vibe-usage/src/parsers/roo-code.js +123 -0
- package/vendor/vibe-usage/src/parsers/sqlite.js +148 -0
- package/vendor/vibe-usage/src/parsers/trae-cli.js +171 -0
- package/vendor/vibe-usage/src/parsers/workbuddy.js +322 -0
- package/vendor/vibe-usage/src/parsers/zcode.js +115 -0
- package/vendor/vibe-usage/src/pi-roots.js +125 -0
- package/vendor/vibe-usage/src/tools.js +422 -0
- package/vendor/vibe-usage/src/workbuddy-roots.js +22 -0
- package/vendor/vibe-usage/upstream-files.json +48 -0
- package/web/report.css +10 -0
- package/web/report.html +81 -0
- package/web/report.js +310 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
5
|
+
import { queryDbJson, sqliteUnavailableError, isSqliteUnavailableError } from './sqlite.js';
|
|
6
|
+
|
|
7
|
+
const HERMES_HOME = process.env.HERMES_HOME || join(homedir(), '.hermes');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse Hermes Agent usage data from its SQLite databases.
|
|
11
|
+
*
|
|
12
|
+
* Hermes supports multiple profiles — the default profile lives at
|
|
13
|
+
* ~/.hermes/state.db, while named profiles live at ~/.hermes/profiles/<name>/state.db.
|
|
14
|
+
* Each profile is an independent HERMES_HOME with its own state.db, so we scan all of them.
|
|
15
|
+
*
|
|
16
|
+
* Token buckets come from the sessions table (cumulative per-session totals).
|
|
17
|
+
* Session timing comes from the messages table (per-message role + timestamp).
|
|
18
|
+
*/
|
|
19
|
+
export async function parse() {
|
|
20
|
+
const dbs = discoverDbPaths(HERMES_HOME);
|
|
21
|
+
if (dbs.length === 0) return { buckets: [], sessions: [] };
|
|
22
|
+
|
|
23
|
+
const entries = [];
|
|
24
|
+
const sessionEvents = [];
|
|
25
|
+
|
|
26
|
+
for (const { path: dbPath, profile } of dbs) {
|
|
27
|
+
let sessionRows;
|
|
28
|
+
try {
|
|
29
|
+
sessionRows = queryDb(dbPath, `SELECT
|
|
30
|
+
id,
|
|
31
|
+
model,
|
|
32
|
+
started_at as startedAt,
|
|
33
|
+
input_tokens as inputTokens,
|
|
34
|
+
output_tokens as outputTokens,
|
|
35
|
+
cache_read_tokens as cacheReadTokens,
|
|
36
|
+
reasoning_tokens as reasoningTokens
|
|
37
|
+
FROM sessions
|
|
38
|
+
WHERE input_tokens > 0 OR output_tokens > 0`);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
if (isSqliteUnavailableError(err)) throw sqliteUnavailableError('Hermes');
|
|
41
|
+
throw err;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
for (const row of sessionRows) {
|
|
45
|
+
// started_at is a Unix timestamp (float)
|
|
46
|
+
const timestamp = new Date(row.startedAt * 1000);
|
|
47
|
+
if (isNaN(timestamp.getTime())) continue;
|
|
48
|
+
|
|
49
|
+
// Hermes stores input_tokens exclusive of cache (Anthropic-style semantics)
|
|
50
|
+
entries.push({
|
|
51
|
+
source: 'hermes',
|
|
52
|
+
model: row.model || 'unknown',
|
|
53
|
+
project: profile,
|
|
54
|
+
timestamp,
|
|
55
|
+
inputTokens: row.inputTokens || 0,
|
|
56
|
+
outputTokens: row.outputTokens || 0,
|
|
57
|
+
cachedInputTokens: row.cacheReadTokens || 0,
|
|
58
|
+
reasoningOutputTokens: row.reasoningTokens || 0,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let messageRows;
|
|
63
|
+
try {
|
|
64
|
+
messageRows = queryDb(dbPath, `SELECT
|
|
65
|
+
session_id as sessionId,
|
|
66
|
+
role,
|
|
67
|
+
timestamp
|
|
68
|
+
FROM messages
|
|
69
|
+
WHERE role IN ('user', 'assistant')
|
|
70
|
+
ORDER BY timestamp`);
|
|
71
|
+
} catch {
|
|
72
|
+
// Messages query failed for this profile — skip its session events
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
for (const row of messageRows) {
|
|
77
|
+
const timestamp = new Date(row.timestamp * 1000);
|
|
78
|
+
if (isNaN(timestamp.getTime())) continue;
|
|
79
|
+
|
|
80
|
+
sessionEvents.push({
|
|
81
|
+
sessionId: row.sessionId,
|
|
82
|
+
source: 'hermes',
|
|
83
|
+
project: profile,
|
|
84
|
+
timestamp,
|
|
85
|
+
role: row.role === 'user' ? 'user' : 'assistant',
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function discoverDbPaths(home) {
|
|
94
|
+
const dbs = [];
|
|
95
|
+
|
|
96
|
+
const defaultDb = join(home, 'state.db');
|
|
97
|
+
if (existsSync(defaultDb)) dbs.push({ path: defaultDb, profile: 'default' });
|
|
98
|
+
|
|
99
|
+
const profilesDir = join(home, 'profiles');
|
|
100
|
+
if (existsSync(profilesDir)) {
|
|
101
|
+
let entries;
|
|
102
|
+
try {
|
|
103
|
+
entries = readdirSync(profilesDir, { withFileTypes: true });
|
|
104
|
+
} catch {
|
|
105
|
+
return dbs;
|
|
106
|
+
}
|
|
107
|
+
for (const entry of entries) {
|
|
108
|
+
if (!entry.isDirectory()) continue;
|
|
109
|
+
const profileDb = join(profilesDir, entry.name, 'state.db');
|
|
110
|
+
try {
|
|
111
|
+
if (statSync(profileDb).isFile()) dbs.push({ path: profileDb, profile: entry.name });
|
|
112
|
+
} catch {
|
|
113
|
+
// missing or unreadable — skip
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return dbs;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function queryDb(dbPath, sql) {
|
|
122
|
+
return queryDbJson(dbPath, sql);
|
|
123
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { parse as parseClaudeCode } from './claude-code.js';
|
|
2
|
+
import { parse as parseCline } from './cline.js';
|
|
3
|
+
import { parse as parseCodex } from './codex.js';
|
|
4
|
+
import { parse as parseCopilotCli } from './copilot-cli.js';
|
|
5
|
+
import { parse as parseCraftAgent } from './craft-agent.js';
|
|
6
|
+
import { parse as parseCursor } from './cursor.js';
|
|
7
|
+
import { parse as parseDimAgent } from './dimagent.js';
|
|
8
|
+
import { parse as parseRooCode } from './roo-code.js';
|
|
9
|
+
import { parse as parseGeminiCli } from './gemini-cli.js';
|
|
10
|
+
import { parse as parseGrok } from './grok.js';
|
|
11
|
+
import { parse as parseOpencode } from './opencode.js';
|
|
12
|
+
import { parse as parseOpenclaw } from './openclaw.js';
|
|
13
|
+
import { parse as parseOmp } from './omp.js';
|
|
14
|
+
import { parse as parseQwenCode } from './qwen-code.js';
|
|
15
|
+
import { parse as parseKimiCode } from './kimi-code.js';
|
|
16
|
+
import { parse as parseAmp } from './amp.js';
|
|
17
|
+
import { parse as parseAlma } from './alma.js';
|
|
18
|
+
import { parse as parseDroid } from './droid.js';
|
|
19
|
+
import { parse as parseDsh } from './dsh.js';
|
|
20
|
+
import { parse as parseAntigravity } from './antigravity.js';
|
|
21
|
+
import { parse as parseHermes } from './hermes.js';
|
|
22
|
+
import { parse as parseKiro } from './kiro.js';
|
|
23
|
+
import { parse as parseMcode } from './mcode.js';
|
|
24
|
+
import { parse as parseMimocode } from './mimocode.js';
|
|
25
|
+
import { parse as parsePiCodingAgent } from './pi-coding-agent.js';
|
|
26
|
+
import { parse as parseZcode } from './zcode.js';
|
|
27
|
+
import { parse as parseTraeCli } from './trae-cli.js';
|
|
28
|
+
import { parse as parseWorkbuddy } from './workbuddy.js';
|
|
29
|
+
|
|
30
|
+
export const parsers = {
|
|
31
|
+
'claude-code': parseClaudeCode,
|
|
32
|
+
'codex': parseCodex,
|
|
33
|
+
'grok': parseGrok,
|
|
34
|
+
'copilot-cli': parseCopilotCli,
|
|
35
|
+
'craft-agent': parseCraftAgent,
|
|
36
|
+
'cursor': parseCursor,
|
|
37
|
+
'dimagent': parseDimAgent,
|
|
38
|
+
'gemini-cli': parseGeminiCli,
|
|
39
|
+
'opencode': parseOpencode,
|
|
40
|
+
'openclaw': parseOpenclaw,
|
|
41
|
+
'omp': parseOmp,
|
|
42
|
+
'pi-coding-agent': parsePiCodingAgent,
|
|
43
|
+
'qwen-code': parseQwenCode,
|
|
44
|
+
'kimi-code': parseKimiCode,
|
|
45
|
+
'amp': parseAmp,
|
|
46
|
+
'alma': parseAlma,
|
|
47
|
+
'droid': parseDroid,
|
|
48
|
+
'dsh': parseDsh,
|
|
49
|
+
'antigravity': parseAntigravity,
|
|
50
|
+
'trae-cli': parseTraeCli,
|
|
51
|
+
'hermes': parseHermes,
|
|
52
|
+
'kiro': parseKiro,
|
|
53
|
+
'mcode': parseMcode,
|
|
54
|
+
'mimocode': parseMimocode,
|
|
55
|
+
'cline': parseCline,
|
|
56
|
+
'roo-code': parseRooCode,
|
|
57
|
+
'workbuddy': parseWorkbuddy,
|
|
58
|
+
'zcode': parseZcode,
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
export { roundToHalfHour, aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { aggregateToBuckets, extractSessions } from './aggregate.js';
|
|
6
|
+
import { mergeRequestTypes } from '../../../../src/analytics.js';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Kimi Code CLI parser. MoonshotAI/kimi-cli (a.k.a. "Kimi Code").
|
|
10
|
+
*
|
|
11
|
+
* Two on-disk layouts are supported:
|
|
12
|
+
*
|
|
13
|
+
* 1. Current ("~/.kimi-code", protocol >= 1.4). Sessions live at
|
|
14
|
+
* ~/.kimi-code/sessions/wd_<slug>_<hash>/session_<id>/agents/<agentId>/wire.jsonl
|
|
15
|
+
* Each line is a self-describing event with a top-level integer-ms `time`:
|
|
16
|
+
* {"type":"usage.record","model":"kimi-code/kimi-for-coding",
|
|
17
|
+
* "usage":{"inputOther","output","inputCacheRead","inputCacheCreation"},
|
|
18
|
+
* "usageScope":"turn","time":<ms>}
|
|
19
|
+
* `usage.record` events are per-step deltas (one per assistant step), so they
|
|
20
|
+
* sum to the session total. The model name rides on each record — no config
|
|
21
|
+
* lookup needed. User turns are `turn.prompt` with origin.kind === "user".
|
|
22
|
+
* The real working directory per session is recorded in
|
|
23
|
+
* ~/.kimi-code/session_index.jsonl -> {sessionId, sessionDir, workDir}
|
|
24
|
+
* which gives an accurate project name (last path component of workDir).
|
|
25
|
+
*
|
|
26
|
+
* 2. Legacy ("~/.kimi", protocol 1.1 / 1.9). Sessions live at
|
|
27
|
+
* ~/.kimi/sessions/<md5(workdir)>/<session-id>/wire.jsonl
|
|
28
|
+
* with a different envelope (StatusUpdate.payload.token_usage, float-second
|
|
29
|
+
* `timestamp`) and the model in ~/.kimi/config.toml. Kept for users who have
|
|
30
|
+
* not migrated; see parseLegacyKimi() below.
|
|
31
|
+
*
|
|
32
|
+
* Both stores are always parsed and merged. `kimi migrate` translates legacy
|
|
33
|
+
* context.jsonl into the new wire format but DROPS the `_usage` records, so
|
|
34
|
+
* historical token usage only ever exists in the legacy store — parsing both
|
|
35
|
+
* cannot double-count, while skipping legacy whenever ~/.kimi-code has any
|
|
36
|
+
* session silently loses a migrated user's entire history.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Current format: ~/.kimi-code
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
// VIBE_USAGE_KIMI_CODE_DIR overrides the root (test hook). Otherwise resolve
|
|
44
|
+
// the data root the same way the CLI itself does: $KIMI_CODE_HOME, then
|
|
45
|
+
// ~/.kimi-code. Ignoring KIMI_CODE_HOME means users with a custom home get
|
|
46
|
+
// zero usage parsed.
|
|
47
|
+
const KIMI_CODE_DIR = process.env.VIBE_USAGE_KIMI_CODE_DIR?.trim()
|
|
48
|
+
|| process.env.KIMI_CODE_HOME?.trim()
|
|
49
|
+
|| join(homedir(), '.kimi-code');
|
|
50
|
+
const KIMI_CODE_SESSIONS_DIR = join(KIMI_CODE_DIR, 'sessions');
|
|
51
|
+
const KIMI_CODE_SESSION_INDEX = join(KIMI_CODE_DIR, 'session_index.jsonl');
|
|
52
|
+
|
|
53
|
+
function projectNameFromPath(path) {
|
|
54
|
+
if (typeof path !== 'string' || !path) return null;
|
|
55
|
+
return basename(path.replace(/[/\\]+$/, '')) || path;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Map each session directory (absolute path) to a project name, read from
|
|
60
|
+
* ~/.kimi-code/session_index.jsonl. Falls back gracefully if the file is
|
|
61
|
+
* missing or malformed — callers default to the wd_ bucket name.
|
|
62
|
+
*/
|
|
63
|
+
function loadSessionIndex() {
|
|
64
|
+
const map = new Map();
|
|
65
|
+
if (!existsSync(KIMI_CODE_SESSION_INDEX)) return map;
|
|
66
|
+
|
|
67
|
+
let content;
|
|
68
|
+
try {
|
|
69
|
+
content = readFileSync(KIMI_CODE_SESSION_INDEX, 'utf-8');
|
|
70
|
+
} catch {
|
|
71
|
+
return map;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
for (const line of content.split('\n')) {
|
|
75
|
+
if (!line.trim()) continue;
|
|
76
|
+
let entry;
|
|
77
|
+
try { entry = JSON.parse(line); } catch { continue; }
|
|
78
|
+
const dir = entry?.sessionDir;
|
|
79
|
+
const project = projectNameFromPath(entry?.workDir);
|
|
80
|
+
if (typeof dir === 'string' && dir && project) map.set(dir, project);
|
|
81
|
+
}
|
|
82
|
+
return map;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Strip the trailing _<hash> from a "wd_<slug>_<hash>" bucket name so the slug
|
|
86
|
+
// can serve as a last-resort project label when session_index has no entry.
|
|
87
|
+
function projectFromBucketName(name) {
|
|
88
|
+
const m = /^wd_(.+)_[0-9a-f]+$/.exec(name);
|
|
89
|
+
return m ? m[1] : name;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function usageTokens(value) {
|
|
93
|
+
const n = Number(value);
|
|
94
|
+
return Number.isFinite(n) && n > 0 ? n : 0;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Collect every agents/<id>/wire.jsonl under sessions/wd_<...>/session_<...>/.
|
|
98
|
+
function findKimiCodeWireFiles(baseDir) {
|
|
99
|
+
const results = [];
|
|
100
|
+
if (!existsSync(baseDir)) return results;
|
|
101
|
+
|
|
102
|
+
let workDirs;
|
|
103
|
+
try {
|
|
104
|
+
workDirs = readdirSync(baseDir, { withFileTypes: true });
|
|
105
|
+
} catch {
|
|
106
|
+
return results;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const workDir of workDirs) {
|
|
110
|
+
if (!workDir.isDirectory()) continue;
|
|
111
|
+
const workDirPath = join(baseDir, workDir.name);
|
|
112
|
+
const bucketProject = projectFromBucketName(workDir.name);
|
|
113
|
+
|
|
114
|
+
let sessions;
|
|
115
|
+
try {
|
|
116
|
+
sessions = readdirSync(workDirPath, { withFileTypes: true });
|
|
117
|
+
} catch {
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const session of sessions) {
|
|
122
|
+
if (!session.isDirectory()) continue;
|
|
123
|
+
const sessionDir = join(workDirPath, session.name);
|
|
124
|
+
const agentsDir = join(sessionDir, 'agents');
|
|
125
|
+
|
|
126
|
+
let agents;
|
|
127
|
+
try {
|
|
128
|
+
agents = readdirSync(agentsDir, { withFileTypes: true });
|
|
129
|
+
} catch {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const agent of agents) {
|
|
134
|
+
if (!agent.isDirectory()) continue;
|
|
135
|
+
const wireFile = join(agentsDir, agent.name, 'wire.jsonl');
|
|
136
|
+
if (existsSync(wireFile)) {
|
|
137
|
+
results.push({ wireFile, sessionDir, bucketProject });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return results;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Current wire: step.end repeats the step.begin UUID and carries the exact
|
|
146
|
+
// usage later emitted by usage.record. Scope alone is NOT request evidence.
|
|
147
|
+
function currentStepEvidence(step, evt) {
|
|
148
|
+
const e = evt.type === 'context.append_loop_event' ? evt.event : null;
|
|
149
|
+
if (['turn.prompt', 'turn.cancel', 'full_compaction.begin', 'full_compaction.complete'].includes(evt.type)) return null;
|
|
150
|
+
if (!e || typeof e !== 'object' || typeof e.type !== 'string') return evt.type === 'context.append_loop_event' ? null : step;
|
|
151
|
+
if (['step.interrupted', 'step.retry'].includes(e.type)) return null;
|
|
152
|
+
if (e.type === 'step.begin') return { uuid: e.uuid, turnId: e.turnId, step: e.step, tool: false, invalid: !!step };
|
|
153
|
+
if (!step) return null;
|
|
154
|
+
if (['tool.call', 'content.part', 'step.end'].includes(e.type)) {
|
|
155
|
+
if (!step.uuid || e.turnId !== step.turnId || e.step !== step.step
|
|
156
|
+
|| (e.type === 'step.end' ? e.uuid : e.stepUuid) !== step.uuid) step.invalid = true;
|
|
157
|
+
}
|
|
158
|
+
if (step.usage && ['tool.call', 'content.part'].includes(e.type)) step.invalid = true;
|
|
159
|
+
if (e.type === 'tool.call') step.tool = true;
|
|
160
|
+
if (e.type === 'step.end') {
|
|
161
|
+
step.usage = e.usage;
|
|
162
|
+
step.requestType = step.tool || e.finishReason === 'tool_use' ? 'tool'
|
|
163
|
+
: ['stop', 'end_turn'].includes(e.finishReason) ? 'non_tool' : 'other';
|
|
164
|
+
}
|
|
165
|
+
return step;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function parseKimiCode() {
|
|
169
|
+
const wireFiles = findKimiCodeWireFiles(KIMI_CODE_SESSIONS_DIR);
|
|
170
|
+
if (wireFiles.length === 0) return null;
|
|
171
|
+
|
|
172
|
+
const sessionIndex = loadSessionIndex();
|
|
173
|
+
const entries = [];
|
|
174
|
+
const sessionEvents = [];
|
|
175
|
+
|
|
176
|
+
for (const { wireFile, sessionDir, bucketProject } of wireFiles) {
|
|
177
|
+
let content;
|
|
178
|
+
try {
|
|
179
|
+
content = readFileSync(wireFile, 'utf-8');
|
|
180
|
+
} catch {
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const project = sessionIndex.get(sessionDir) || bucketProject || 'unknown';
|
|
185
|
+
let step = null;
|
|
186
|
+
|
|
187
|
+
for (const line of content.split('\n')) {
|
|
188
|
+
if (!line.trim()) continue;
|
|
189
|
+
let evt;
|
|
190
|
+
try { evt = JSON.parse(line); } catch { step = null; continue; }
|
|
191
|
+
if (!evt || typeof evt !== 'object') { step = null; continue; }
|
|
192
|
+
step = currentStepEvidence(step, evt);
|
|
193
|
+
const requestType = evt.type === 'usage.record' && evt.usageScope === 'turn' && step?.usage && evt.usage && !step.invalid
|
|
194
|
+
&& ['inputOther', 'output', 'inputCacheRead', 'inputCacheCreation'].every(key => (step.usage[key] ?? 0) === (evt.usage[key] ?? 0))
|
|
195
|
+
? step.requestType : 'other';
|
|
196
|
+
if (evt.type === 'usage.record') step = null;
|
|
197
|
+
|
|
198
|
+
const type = evt.type;
|
|
199
|
+
// Top-level `time` is integer milliseconds since epoch. Validate it:
|
|
200
|
+
// JSON numbers can overflow to Infinity (e.g. 1e400 parses fine), and
|
|
201
|
+
// any out-of-range value yields an Invalid Date that later crashes
|
|
202
|
+
// aggregateToBuckets() (RangeError from toISOString) — one corrupt line
|
|
203
|
+
// would take down this tool's entire parse.
|
|
204
|
+
const time = typeof evt.time === 'number' && Number.isFinite(evt.time) ? evt.time : null;
|
|
205
|
+
const ts = time !== null ? new Date(time) : null;
|
|
206
|
+
const tsValid = ts !== null && !isNaN(ts.getTime());
|
|
207
|
+
|
|
208
|
+
// Session timing: a user turn vs. anything the model emits. All agent
|
|
209
|
+
// wires under one sessionDir belong to the same logical user session;
|
|
210
|
+
// grouping by wireFile would create separate zero-user sessions for
|
|
211
|
+
// subagents instead of attributing their work to the parent turn.
|
|
212
|
+
if (type === 'turn.prompt' && evt.origin?.kind === 'user') {
|
|
213
|
+
if (tsValid) {
|
|
214
|
+
sessionEvents.push({ sessionId: sessionDir, source: 'kimi-code', project, timestamp: ts, role: 'user' });
|
|
215
|
+
}
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (type !== 'usage.record') continue;
|
|
220
|
+
// Every usage.record is a delta. `turn` is the normal successful-step
|
|
221
|
+
// scope; `session` is used for other real model calls (for example
|
|
222
|
+
// retry/compaction work), not for a cumulative summary. Count both.
|
|
223
|
+
// No valid timestamp → can't bucket accurately. Skip instead of stamping
|
|
224
|
+
// "now": this parser is stateless, so a "now" fallback would re-key the
|
|
225
|
+
// same record into a fresh 30-min bucket on every sync (duplicates).
|
|
226
|
+
if (!tsValid) continue;
|
|
227
|
+
|
|
228
|
+
const usage = evt.usage;
|
|
229
|
+
if (!usage) continue;
|
|
230
|
+
|
|
231
|
+
// Cache creation is billed non-cached input, matching the common bucket
|
|
232
|
+
// model used by the other parsers; cache reads stay in their own field.
|
|
233
|
+
const inputTokens = usageTokens(usage.inputOther) + usageTokens(usage.inputCacheCreation);
|
|
234
|
+
const outputTokens = usageTokens(usage.output);
|
|
235
|
+
const cachedInputTokens = usageTokens(usage.inputCacheRead);
|
|
236
|
+
if (!inputTokens && !outputTokens && !cachedInputTokens) continue;
|
|
237
|
+
|
|
238
|
+
entries.push({
|
|
239
|
+
source: 'kimi-code',
|
|
240
|
+
model: evt.model || 'unknown',
|
|
241
|
+
requestType,
|
|
242
|
+
project,
|
|
243
|
+
timestamp: ts,
|
|
244
|
+
inputTokens,
|
|
245
|
+
outputTokens,
|
|
246
|
+
cachedInputTokens,
|
|
247
|
+
reasoningOutputTokens: 0,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// Each usage.record marks an assistant step completing — use it as an
|
|
251
|
+
// assistant timing event so active-time math has both sides of a turn.
|
|
252
|
+
sessionEvents.push({ sessionId: sessionDir, source: 'kimi-code', project, timestamp: ts, role: 'assistant' });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Legacy format: ~/.kimi (kept for users who haven't migrated to ~/.kimi-code)
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
// VIBE_USAGE_KIMI_DIR overrides the legacy root (test hook).
|
|
264
|
+
const KIMI_DIR = process.env.VIBE_USAGE_KIMI_DIR?.trim() || join(homedir(), '.kimi');
|
|
265
|
+
const KIMI_SESSIONS_DIR = join(KIMI_DIR, 'sessions');
|
|
266
|
+
const KIMI_WORKDIRS_JSON = join(KIMI_DIR, 'kimi.json');
|
|
267
|
+
const KIMI_CONFIG_TOML = join(KIMI_DIR, 'config.toml');
|
|
268
|
+
|
|
269
|
+
function findLegacyWireFiles(baseDir) {
|
|
270
|
+
const results = [];
|
|
271
|
+
if (!existsSync(baseDir)) return results;
|
|
272
|
+
|
|
273
|
+
try {
|
|
274
|
+
for (const workDir of readdirSync(baseDir, { withFileTypes: true })) {
|
|
275
|
+
if (!workDir.isDirectory()) continue;
|
|
276
|
+
const workDirPath = join(baseDir, workDir.name);
|
|
277
|
+
|
|
278
|
+
try {
|
|
279
|
+
for (const session of readdirSync(workDirPath, { withFileTypes: true })) {
|
|
280
|
+
if (!session.isDirectory()) continue;
|
|
281
|
+
const wireFile = join(workDirPath, session.name, 'wire.jsonl');
|
|
282
|
+
if (existsSync(wireFile)) {
|
|
283
|
+
results.push({ filePath: wireFile, workDirHash: workDir.name });
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
} catch {
|
|
287
|
+
continue;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
} catch {
|
|
291
|
+
return results;
|
|
292
|
+
}
|
|
293
|
+
return results;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function loadLegacyProjectMap() {
|
|
297
|
+
const map = new Map();
|
|
298
|
+
if (!existsSync(KIMI_WORKDIRS_JSON)) return map;
|
|
299
|
+
|
|
300
|
+
let config;
|
|
301
|
+
try {
|
|
302
|
+
config = JSON.parse(readFileSync(KIMI_WORKDIRS_JSON, 'utf-8'));
|
|
303
|
+
} catch {
|
|
304
|
+
return map;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (Array.isArray(config.work_dirs)) {
|
|
308
|
+
for (const entry of config.work_dirs) {
|
|
309
|
+
const path = entry?.path;
|
|
310
|
+
if (typeof path !== 'string' || !path) continue;
|
|
311
|
+
const hash = createHash('md5').update(path).digest('hex');
|
|
312
|
+
map.set(hash, projectNameFromPath(path));
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
for (const key of ['workspaces', 'projects']) {
|
|
317
|
+
const obj = config[key];
|
|
318
|
+
if (!obj || typeof obj !== 'object') continue;
|
|
319
|
+
for (const [hash, info] of Object.entries(obj)) {
|
|
320
|
+
const path = typeof info === 'string' ? info : (info?.path || info?.dir);
|
|
321
|
+
if (typeof path === 'string' && path) map.set(hash, projectNameFromPath(path));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return map;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const TOML_MODEL_SECTION_RE = /^\s*\[models\.(?:"([^"]+)"|([A-Za-z0-9_-]+))\]/m;
|
|
329
|
+
const TOML_DEFAULT_MODEL_RE = /^\s*default_model\s*=\s*["']([^"']+)["']/m;
|
|
330
|
+
|
|
331
|
+
function loadLegacyModelFromConfig() {
|
|
332
|
+
if (!existsSync(KIMI_CONFIG_TOML)) return 'unknown';
|
|
333
|
+
|
|
334
|
+
let content;
|
|
335
|
+
try {
|
|
336
|
+
content = readFileSync(KIMI_CONFIG_TOML, 'utf-8');
|
|
337
|
+
} catch {
|
|
338
|
+
return 'unknown';
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const defaultMatch = content.match(TOML_DEFAULT_MODEL_RE);
|
|
342
|
+
if (defaultMatch) return defaultMatch[1];
|
|
343
|
+
|
|
344
|
+
const sectionMatch = content.match(TOML_MODEL_SECTION_RE);
|
|
345
|
+
if (sectionMatch) return sectionMatch[1] || sectionMatch[2];
|
|
346
|
+
|
|
347
|
+
return 'unknown';
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const LEGACY_USER_EVENT_TYPES = new Set(['TurnBegin', 'UserMessage', 'user_message', 'Input']);
|
|
351
|
+
|
|
352
|
+
function parseLegacyKimi() {
|
|
353
|
+
const wireFiles = findLegacyWireFiles(KIMI_SESSIONS_DIR);
|
|
354
|
+
if (wireFiles.length === 0) return { buckets: [], sessions: [] };
|
|
355
|
+
|
|
356
|
+
const projectMap = loadLegacyProjectMap();
|
|
357
|
+
const defaultModel = loadLegacyModelFromConfig();
|
|
358
|
+
const entries = [];
|
|
359
|
+
const sessionEvents = [];
|
|
360
|
+
const seenMessageIds = new Map();
|
|
361
|
+
|
|
362
|
+
for (const { filePath, workDirHash } of wireFiles) {
|
|
363
|
+
let content;
|
|
364
|
+
try {
|
|
365
|
+
content = readFileSync(filePath, 'utf-8');
|
|
366
|
+
} catch {
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const project = projectMap.get(workDirHash) || workDirHash;
|
|
371
|
+
let currentModel = defaultModel;
|
|
372
|
+
let lastTimestamp = null;
|
|
373
|
+
let step = null;
|
|
374
|
+
|
|
375
|
+
for (const line of content.split('\n')) {
|
|
376
|
+
if (!line.trim()) continue;
|
|
377
|
+
let raw;
|
|
378
|
+
try { raw = JSON.parse(line); } catch { step = null; continue; }
|
|
379
|
+
if (!raw || typeof raw !== 'object') { step = null; continue; }
|
|
380
|
+
|
|
381
|
+
const envelope = raw.message || raw;
|
|
382
|
+
const type = envelope.type || raw.type;
|
|
383
|
+
const payload = envelope.payload || raw.payload;
|
|
384
|
+
if (['StepBegin', 'StepRetry', 'CompactionEnd'].includes(type)) step = { tool: false, content: false };
|
|
385
|
+
if (['TurnBegin', 'TurnEnd', 'StepInterrupted', 'CompactionBegin'].includes(type)) step = null;
|
|
386
|
+
if (step && type === 'ToolCall') step.tool = true;
|
|
387
|
+
if (step && type === 'ContentPart') step.content = true;
|
|
388
|
+
if (!payload) { step = null; continue; }
|
|
389
|
+
|
|
390
|
+
if (typeof raw.timestamp === 'number') {
|
|
391
|
+
lastTimestamp = raw.timestamp * 1000;
|
|
392
|
+
} else if (typeof payload.timestamp === 'number') {
|
|
393
|
+
lastTimestamp = payload.timestamp * 1000;
|
|
394
|
+
}
|
|
395
|
+
if (payload.model) currentModel = payload.model;
|
|
396
|
+
|
|
397
|
+
if (lastTimestamp) {
|
|
398
|
+
const evTs = new Date(lastTimestamp);
|
|
399
|
+
if (!isNaN(evTs.getTime())) {
|
|
400
|
+
sessionEvents.push({
|
|
401
|
+
sessionId: filePath,
|
|
402
|
+
source: 'kimi-code',
|
|
403
|
+
project,
|
|
404
|
+
timestamp: evTs,
|
|
405
|
+
role: LEGACY_USER_EVENT_TYPES.has(type) ? 'user' : 'assistant',
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (type !== 'StatusUpdate') continue;
|
|
411
|
+
|
|
412
|
+
const tokenUsage = payload.token_usage;
|
|
413
|
+
if (!tokenUsage) continue;
|
|
414
|
+
|
|
415
|
+
// StatusUpdate.token_usage is sent after kosong.step returns the full
|
|
416
|
+
// response, before waiting for tools. Retries/compaction reset evidence;
|
|
417
|
+
// nested SubagentEvent output never belongs to this assistant step.
|
|
418
|
+
const requestType = step?.tool ? 'tool' : step?.content ? 'non_tool' : 'other';
|
|
419
|
+
step = null;
|
|
420
|
+
if (!tokenUsage.input_other && !tokenUsage.output
|
|
421
|
+
&& !tokenUsage.input_cache_read && !tokenUsage.input_cache_creation) continue;
|
|
422
|
+
const messageId = payload.message_id;
|
|
423
|
+
if (messageId && seenMessageIds.has(messageId)) {
|
|
424
|
+
const prior = seenMessageIds.get(messageId);
|
|
425
|
+
if (prior) prior.requestType = mergeRequestTypes(prior.requestType, requestType);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (messageId) seenMessageIds.set(messageId, null);
|
|
429
|
+
|
|
430
|
+
// No valid timestamp → skip instead of stamping "now": this parser is
|
|
431
|
+
// stateless, so a "now" fallback would re-key the same record into a
|
|
432
|
+
// fresh 30-min bucket on every sync (duplicates).
|
|
433
|
+
if (!lastTimestamp) continue;
|
|
434
|
+
const ts = new Date(lastTimestamp);
|
|
435
|
+
if (isNaN(ts.getTime())) continue;
|
|
436
|
+
|
|
437
|
+
entries.push({
|
|
438
|
+
source: 'kimi-code',
|
|
439
|
+
model: currentModel,
|
|
440
|
+
requestType,
|
|
441
|
+
project,
|
|
442
|
+
timestamp: ts,
|
|
443
|
+
// Cache creation is billed non-cached input, matching the current
|
|
444
|
+
// parser and the common bucket model used by the other parsers.
|
|
445
|
+
inputTokens: (tokenUsage.input_other || 0) + (tokenUsage.input_cache_creation || 0),
|
|
446
|
+
outputTokens: tokenUsage.output || 0,
|
|
447
|
+
cachedInputTokens: tokenUsage.input_cache_read || 0,
|
|
448
|
+
reasoningOutputTokens: 0,
|
|
449
|
+
});
|
|
450
|
+
if (messageId) seenMessageIds.set(messageId, entries.at(-1));
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export async function parse() {
|
|
458
|
+
// Always parse both stores and merge (see the header comment): legacy usage
|
|
459
|
+
// is never carried into ~/.kimi-code by `kimi migrate`, so a migrated user's
|
|
460
|
+
// history exists only in ~/.kimi.
|
|
461
|
+
const current = parseKimiCode();
|
|
462
|
+
const legacy = parseLegacyKimi();
|
|
463
|
+
return {
|
|
464
|
+
buckets: [...(current?.buckets ?? []), ...legacy.buckets],
|
|
465
|
+
sessions: [...(current?.sessions ?? []), ...legacy.sessions],
|
|
466
|
+
};
|
|
467
|
+
}
|