@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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
package/src/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { loadConfig, saveConfig, getConfigPath } from './config.js';
2
- import { detectInstalledTools, TOOLS } from './hooks.js';
2
+ import { detectInstalledTools, TOOLS } from './tools.js';
3
3
  import { existsSync } from 'node:fs';
4
4
 
5
5
  async function showStatus() {
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 './hooks.js';
7
+ import { detectInstalledTools } from './tools.js';
8
8
 
9
9
  function prompt(question) {
10
10
  const rl = createInterface({ input: process.stdin, output: process.stdout });
@@ -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: usage.cached_input_tokens || usage.cache_read_input_tokens || 0,
126
- reasoningOutputTokens: usage.reasoning_output_tokens || 0,
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
- // New format: { input, output, cached, thoughts, tool, total }
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: tokens.cached || 0,
71
- reasoningOutputTokens: tokens.thoughts || 0,
70
+ inputTokens: (tokens.input || 0) - cached,
71
+ outputTokens: (tokens.output || 0) - thoughts,
72
+ cachedInputTokens: cached,
73
+ reasoningOutputTokens: thoughts,
72
74
  });
73
75
  } else {
74
- // Old format: { promptTokenCount, candidatesTokenCount, ... }
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: usage.cachedContentTokenCount || 0,
83
- reasoningOutputTokens: usage.thoughtsTokenCount || 0,
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
  }
@@ -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() {