@vibe-cafe/vibe-usage 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/index.js +1 -1
- package/src/init.js +1 -1
- package/src/parsers/codex.js +8 -4
- package/src/parsers/gemini-cli.js +14 -10
- package/src/parsers/index.js +5 -1
- package/src/parsers/kimi-code.js +125 -0
- package/src/parsers/qwen-code.js +112 -0
- package/src/{hooks.js → tools.js} +10 -0
package/README.md
CHANGED
|
@@ -34,6 +34,8 @@ npx vibe-usage status # Show config & detected tools
|
|
|
34
34
|
| Gemini CLI | `~/.gemini/tmp/` |
|
|
35
35
|
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite) |
|
|
36
36
|
| OpenClaw | `~/.openclaw/agents/` |
|
|
37
|
+
| Qwen Code | `~/.qwen/tmp/` |
|
|
38
|
+
| Kimi Code | `~/.kimi/sessions/` |
|
|
37
39
|
|
|
38
40
|
## How It Works
|
|
39
41
|
|
package/package.json
CHANGED
package/src/index.js
CHANGED
package/src/init.js
CHANGED
|
@@ -4,7 +4,7 @@ import { platform } from 'node:os';
|
|
|
4
4
|
import { loadConfig, saveConfig } from './config.js';
|
|
5
5
|
import { ingest } from './api.js';
|
|
6
6
|
import { runSync } from './sync.js';
|
|
7
|
-
import { detectInstalledTools } from './
|
|
7
|
+
import { detectInstalledTools } from './tools.js';
|
|
8
8
|
|
|
9
9
|
function prompt(question) {
|
|
10
10
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
package/src/parsers/codex.js
CHANGED
|
@@ -115,15 +115,19 @@ export async function parse() {
|
|
|
115
115
|
|
|
116
116
|
const model = info.model || payload.model || turnContextModel || sessionModel;
|
|
117
117
|
|
|
118
|
+
// OpenAI API: input_tokens INCLUDES cached, output_tokens INCLUDES reasoning.
|
|
119
|
+
// Normalize to Anthropic-style semantics where each field is non-overlapping.
|
|
120
|
+
const cachedInput = usage.cached_input_tokens || usage.cache_read_input_tokens || 0;
|
|
121
|
+
const reasoningOutput = usage.reasoning_output_tokens || 0;
|
|
118
122
|
entries.push({
|
|
119
123
|
source: 'codex',
|
|
120
124
|
model,
|
|
121
125
|
project: sessionProject,
|
|
122
126
|
timestamp,
|
|
123
|
-
inputTokens: usage.input_tokens || 0,
|
|
124
|
-
outputTokens: usage.output_tokens || 0,
|
|
125
|
-
cachedInputTokens:
|
|
126
|
-
reasoningOutputTokens:
|
|
127
|
+
inputTokens: (usage.input_tokens || 0) - cachedInput,
|
|
128
|
+
outputTokens: (usage.output_tokens || 0) - reasoningOutput,
|
|
129
|
+
cachedInputTokens: cachedInput,
|
|
130
|
+
reasoningOutputTokens: reasoningOutput,
|
|
127
131
|
});
|
|
128
132
|
} catch {
|
|
129
133
|
continue;
|
|
@@ -59,28 +59,32 @@ export async function parse() {
|
|
|
59
59
|
if (isNaN(ts.getTime())) continue;
|
|
60
60
|
|
|
61
61
|
if (tokens) {
|
|
62
|
-
//
|
|
62
|
+
// Gemini API: input INCLUDES cached, output INCLUDES thoughts. Normalize to non-overlapping.
|
|
63
|
+
const cached = tokens.cached || 0;
|
|
64
|
+
const thoughts = tokens.thoughts || 0;
|
|
63
65
|
entries.push({
|
|
64
66
|
source: 'gemini-cli',
|
|
65
67
|
model: msg.model || data.model || 'unknown',
|
|
66
68
|
project: 'unknown',
|
|
67
69
|
timestamp: ts,
|
|
68
|
-
inputTokens: tokens.input || 0,
|
|
69
|
-
outputTokens: tokens.output || 0,
|
|
70
|
-
cachedInputTokens:
|
|
71
|
-
reasoningOutputTokens:
|
|
70
|
+
inputTokens: (tokens.input || 0) - cached,
|
|
71
|
+
outputTokens: (tokens.output || 0) - thoughts,
|
|
72
|
+
cachedInputTokens: cached,
|
|
73
|
+
reasoningOutputTokens: thoughts,
|
|
72
74
|
});
|
|
73
75
|
} else {
|
|
74
|
-
//
|
|
76
|
+
// Gemini API: promptTokenCount INCLUDES cachedContentTokenCount. Normalize to non-overlapping.
|
|
77
|
+
const cached = usage.cachedContentTokenCount || 0;
|
|
78
|
+
const thoughts = usage.thoughtsTokenCount || 0;
|
|
75
79
|
entries.push({
|
|
76
80
|
source: 'gemini-cli',
|
|
77
81
|
model: msg.model || data.model || 'unknown',
|
|
78
82
|
project: 'unknown',
|
|
79
83
|
timestamp: ts,
|
|
80
|
-
inputTokens: usage.promptTokenCount || usage.input_tokens || 0,
|
|
81
|
-
outputTokens: usage.candidatesTokenCount || usage.output_tokens || 0,
|
|
82
|
-
cachedInputTokens:
|
|
83
|
-
reasoningOutputTokens:
|
|
84
|
+
inputTokens: (usage.promptTokenCount || usage.input_tokens || 0) - cached,
|
|
85
|
+
outputTokens: (usage.candidatesTokenCount || usage.output_tokens || 0) - thoughts,
|
|
86
|
+
cachedInputTokens: cached,
|
|
87
|
+
reasoningOutputTokens: thoughts,
|
|
84
88
|
});
|
|
85
89
|
}
|
|
86
90
|
}
|
package/src/parsers/index.js
CHANGED
|
@@ -3,6 +3,8 @@ import { parse as parseCodex } from './codex.js';
|
|
|
3
3
|
import { parse as parseGeminiCli } from './gemini-cli.js';
|
|
4
4
|
import { parse as parseOpencode } from './opencode.js';
|
|
5
5
|
import { parse as parseOpenclaw } from './openclaw.js';
|
|
6
|
+
import { parse as parseQwenCode } from './qwen-code.js';
|
|
7
|
+
import { parse as parseKimiCode } from './kimi-code.js';
|
|
6
8
|
|
|
7
9
|
export const parsers = {
|
|
8
10
|
'claude-code': parseClaudeCode,
|
|
@@ -10,6 +12,8 @@ export const parsers = {
|
|
|
10
12
|
'gemini-cli': parseGeminiCli,
|
|
11
13
|
'opencode': parseOpencode,
|
|
12
14
|
'openclaw': parseOpenclaw,
|
|
15
|
+
'qwen-code': parseQwenCode,
|
|
16
|
+
'kimi-code': parseKimiCode,
|
|
13
17
|
};
|
|
14
18
|
|
|
15
19
|
|
|
@@ -45,7 +49,7 @@ export function aggregateToBuckets(entries) {
|
|
|
45
49
|
b.outputTokens += e.outputTokens || 0;
|
|
46
50
|
b.cachedInputTokens += e.cachedInputTokens || 0;
|
|
47
51
|
b.reasoningOutputTokens += e.reasoningOutputTokens || 0;
|
|
48
|
-
b.totalTokens += (e.inputTokens || 0) + (e.outputTokens || 0);
|
|
52
|
+
b.totalTokens += (e.inputTokens || 0) + (e.outputTokens || 0) + (e.reasoningOutputTokens || 0);
|
|
49
53
|
}
|
|
50
54
|
|
|
51
55
|
return Array.from(map.values());
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, sep } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { aggregateToBuckets } from './index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Kimi Code CLI parser.
|
|
8
|
+
* Wire protocol JSONL at ~/.kimi/sessions/<work-dir-hash>/<session-id>/wire.jsonl
|
|
9
|
+
* Token data from StatusUpdate events: payload.token_usage.{input_other, output,
|
|
10
|
+
* input_cache_read, input_cache_creation}
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const KIMI_SESSIONS_DIR = join(homedir(), '.kimi', 'sessions');
|
|
14
|
+
const KIMI_CONFIG = join(homedir(), '.kimi', 'kimi.json');
|
|
15
|
+
|
|
16
|
+
function findWireFiles(baseDir) {
|
|
17
|
+
const results = [];
|
|
18
|
+
if (!existsSync(baseDir)) return results;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
for (const workDir of readdirSync(baseDir, { withFileTypes: true })) {
|
|
22
|
+
if (!workDir.isDirectory()) continue;
|
|
23
|
+
const workDirPath = join(baseDir, workDir.name);
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
for (const session of readdirSync(workDirPath, { withFileTypes: true })) {
|
|
27
|
+
if (!session.isDirectory()) continue;
|
|
28
|
+
const wireFile = join(workDirPath, session.name, 'wire.jsonl');
|
|
29
|
+
if (existsSync(wireFile)) {
|
|
30
|
+
results.push({ filePath: wireFile, workDirHash: workDir.name });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
} catch {
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
return results;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function loadProjectMap() {
|
|
44
|
+
const map = new Map();
|
|
45
|
+
if (!existsSync(KIMI_CONFIG)) return map;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const config = JSON.parse(readFileSync(KIMI_CONFIG, 'utf-8'));
|
|
49
|
+
const workspaces = config.workspaces || config.projects || {};
|
|
50
|
+
for (const [hash, info] of Object.entries(workspaces)) {
|
|
51
|
+
const path = typeof info === 'string' ? info : (info?.path || info?.dir);
|
|
52
|
+
if (path) {
|
|
53
|
+
const parts = path.split('/').filter(Boolean);
|
|
54
|
+
map.set(hash, parts[parts.length - 1] || hash);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
} catch {
|
|
58
|
+
// config unreadable
|
|
59
|
+
}
|
|
60
|
+
return map;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function parse() {
|
|
64
|
+
const wireFiles = findWireFiles(KIMI_SESSIONS_DIR);
|
|
65
|
+
if (wireFiles.length === 0) return [];
|
|
66
|
+
|
|
67
|
+
const projectMap = loadProjectMap();
|
|
68
|
+
const entries = [];
|
|
69
|
+
const seenMessageIds = new Set();
|
|
70
|
+
|
|
71
|
+
for (const { filePath, workDirHash } of wireFiles) {
|
|
72
|
+
let content;
|
|
73
|
+
try {
|
|
74
|
+
content = readFileSync(filePath, 'utf-8');
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const project = projectMap.get(workDirHash) || workDirHash;
|
|
80
|
+
let currentModel = 'unknown';
|
|
81
|
+
let lastTimestamp = null;
|
|
82
|
+
|
|
83
|
+
for (const line of content.split('\n')) {
|
|
84
|
+
if (!line.trim()) continue;
|
|
85
|
+
try {
|
|
86
|
+
const obj = JSON.parse(line);
|
|
87
|
+
const type = obj.type;
|
|
88
|
+
const payload = obj.payload;
|
|
89
|
+
if (!payload) continue;
|
|
90
|
+
|
|
91
|
+
if (payload.timestamp) lastTimestamp = payload.timestamp;
|
|
92
|
+
if (payload.model) currentModel = payload.model;
|
|
93
|
+
|
|
94
|
+
if (type !== 'StatusUpdate') continue;
|
|
95
|
+
|
|
96
|
+
const tokenUsage = payload.token_usage;
|
|
97
|
+
if (!tokenUsage) continue;
|
|
98
|
+
if (!tokenUsage.input_other && !tokenUsage.output) continue;
|
|
99
|
+
|
|
100
|
+
const messageId = payload.message_id;
|
|
101
|
+
if (messageId) {
|
|
102
|
+
if (seenMessageIds.has(messageId)) continue;
|
|
103
|
+
seenMessageIds.add(messageId);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const ts = lastTimestamp ? new Date(lastTimestamp) : new Date();
|
|
107
|
+
|
|
108
|
+
entries.push({
|
|
109
|
+
source: 'kimi-code',
|
|
110
|
+
model: currentModel,
|
|
111
|
+
project,
|
|
112
|
+
timestamp: ts,
|
|
113
|
+
inputTokens: tokenUsage.input_other || 0,
|
|
114
|
+
outputTokens: tokenUsage.output || 0,
|
|
115
|
+
cachedInputTokens: tokenUsage.input_cache_read || 0,
|
|
116
|
+
reasoningOutputTokens: 0,
|
|
117
|
+
});
|
|
118
|
+
} catch {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return aggregateToBuckets(entries);
|
|
125
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, basename, sep } from 'node:path';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { aggregateToBuckets } from './index.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Qwen Code parser (Gemini CLI fork).
|
|
8
|
+
* JSONL at ~/.qwen/tmp/<project_id>/chats/<sessionId>.jsonl
|
|
9
|
+
* Token fields: usageMetadata.{promptTokenCount, candidatesTokenCount,
|
|
10
|
+
* cachedContentTokenCount, thoughtsTokenCount}
|
|
11
|
+
* Note: promptTokenCount INCLUDES cachedContentTokenCount (needs normalization).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const QWEN_TMP_DIR = join(homedir(), '.qwen', 'tmp');
|
|
15
|
+
|
|
16
|
+
function findSessionFiles(baseDir) {
|
|
17
|
+
const results = [];
|
|
18
|
+
if (!existsSync(baseDir)) return results;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
|
|
22
|
+
if (!entry.isDirectory()) continue;
|
|
23
|
+
const chatsDir = join(baseDir, entry.name, 'chats');
|
|
24
|
+
if (!existsSync(chatsDir)) continue;
|
|
25
|
+
try {
|
|
26
|
+
for (const f of readdirSync(chatsDir)) {
|
|
27
|
+
if (f.endsWith('.jsonl')) {
|
|
28
|
+
results.push(join(chatsDir, f));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
} catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
return results;
|
|
37
|
+
}
|
|
38
|
+
return results;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function extractProject(cwd, filePath) {
|
|
42
|
+
if (cwd) {
|
|
43
|
+
const parts = cwd.split('/').filter(Boolean);
|
|
44
|
+
if (parts.length > 0) return parts[parts.length - 1];
|
|
45
|
+
}
|
|
46
|
+
const tmpPrefix = QWEN_TMP_DIR + sep;
|
|
47
|
+
if (filePath.startsWith(tmpPrefix)) {
|
|
48
|
+
const relative = filePath.slice(tmpPrefix.length);
|
|
49
|
+
const projectId = relative.split(sep)[0];
|
|
50
|
+
if (projectId) return projectId;
|
|
51
|
+
}
|
|
52
|
+
return 'unknown';
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function parse() {
|
|
56
|
+
const sessionFiles = findSessionFiles(QWEN_TMP_DIR);
|
|
57
|
+
if (sessionFiles.length === 0) return [];
|
|
58
|
+
|
|
59
|
+
const entries = [];
|
|
60
|
+
const seenUuids = new Set();
|
|
61
|
+
|
|
62
|
+
for (const filePath of sessionFiles) {
|
|
63
|
+
let content;
|
|
64
|
+
try {
|
|
65
|
+
content = readFileSync(filePath, 'utf-8');
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const line of content.split('\n')) {
|
|
71
|
+
if (!line.trim()) continue;
|
|
72
|
+
try {
|
|
73
|
+
const obj = JSON.parse(line);
|
|
74
|
+
|
|
75
|
+
if (obj.type !== 'assistant') continue;
|
|
76
|
+
const usage = obj.usageMetadata;
|
|
77
|
+
if (!usage) continue;
|
|
78
|
+
if (usage.promptTokenCount == null && usage.candidatesTokenCount == null) continue;
|
|
79
|
+
|
|
80
|
+
const uuid = obj.uuid;
|
|
81
|
+
if (uuid) {
|
|
82
|
+
if (seenUuids.has(uuid)) continue;
|
|
83
|
+
seenUuids.add(uuid);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const timestamp = obj.timestamp;
|
|
87
|
+
if (!timestamp) continue;
|
|
88
|
+
const ts = new Date(timestamp);
|
|
89
|
+
if (isNaN(ts.getTime())) continue;
|
|
90
|
+
|
|
91
|
+
// promptTokenCount INCLUDES cachedContentTokenCount — normalize to non-overlapping
|
|
92
|
+
const cached = usage.cachedContentTokenCount || 0;
|
|
93
|
+
const thoughts = usage.thoughtsTokenCount || 0;
|
|
94
|
+
|
|
95
|
+
entries.push({
|
|
96
|
+
source: 'qwen-code',
|
|
97
|
+
model: obj.model || 'unknown',
|
|
98
|
+
project: extractProject(obj.cwd, filePath),
|
|
99
|
+
timestamp: ts,
|
|
100
|
+
inputTokens: (usage.promptTokenCount || 0) - cached,
|
|
101
|
+
outputTokens: (usage.candidatesTokenCount || 0) - thoughts,
|
|
102
|
+
cachedInputTokens: cached,
|
|
103
|
+
reasoningOutputTokens: thoughts,
|
|
104
|
+
});
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return aggregateToBuckets(entries);
|
|
112
|
+
}
|
|
@@ -28,6 +28,16 @@ export const TOOLS = [
|
|
|
28
28
|
id: 'openclaw',
|
|
29
29
|
dataDir: join(homedir(), '.openclaw', 'agents'),
|
|
30
30
|
},
|
|
31
|
+
{
|
|
32
|
+
name: 'Qwen Code',
|
|
33
|
+
id: 'qwen-code',
|
|
34
|
+
dataDir: join(homedir(), '.qwen', 'tmp'),
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'Kimi Code',
|
|
38
|
+
id: 'kimi-code',
|
|
39
|
+
dataDir: join(homedir(), '.kimi', 'sessions'),
|
|
40
|
+
},
|
|
31
41
|
];
|
|
32
42
|
|
|
33
43
|
export function detectInstalledTools() {
|