@vibe-cafe/vibe-usage 0.7.13 → 0.7.14

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
@@ -55,7 +55,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
55
55
  | OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments) |
56
56
  | pi | `~/.pi/agent/sessions/` |
57
57
  | Qwen Code | `~/.qwen/tmp/` |
58
- | Kimi Code | `~/.kimi/sessions/` |
58
+ | Kimi Code | `~/.kimi/sessions/<md5(workdir)>/<session-id>/wire.jsonl` (wire protocol 1.9, model from `~/.kimi/config.toml`, project from `~/.kimi/kimi.json`) |
59
59
  | Amp | `~/.local/share/amp/threads/` |
60
60
  | Droid | `~/.factory/sessions/` |
61
61
  | Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.7.13",
3
+ "version": "0.7.14",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,17 +1,31 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
- import { join, sep } from 'node:path';
2
+ import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { createHash } from 'node:crypto';
4
5
  import { aggregateToBuckets, extractSessions } from './index.js';
5
6
 
6
7
  /**
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}
8
+ * Kimi CLI parser (a.k.a. "Kimi Code"). MoonshotAI/kimi-cli.
9
+ *
10
+ * Wire protocol JSONL at ~/.kimi/sessions/<md5(workdir)>/<session-id>/wire.jsonl
11
+ * - First line is a metadata header: {"type":"metadata","protocol_version":"1.9"}
12
+ * - Each subsequent line (1.9): {"timestamp": <float seconds>, "message": {"type", "payload"}}
13
+ * - Legacy 1.1 line: {"type", "payload"} (no message wrapper, ts may live in payload)
14
+ *
15
+ * Token data: StatusUpdate.payload.token_usage
16
+ * = {input_other, output, input_cache_read, input_cache_creation}
17
+ *
18
+ * Model name is NOT present on StatusUpdate events; we read it from
19
+ * ~/.kimi/config.toml (default_model, falling back to first [models.X] table).
20
+ *
21
+ * Project name comes from ~/.kimi/kimi.json -> work_dirs[].path; the dir name
22
+ * under sessions/ is md5(path).
11
23
  */
12
24
 
13
- const KIMI_SESSIONS_DIR = join(homedir(), '.kimi', 'sessions');
14
- const KIMI_CONFIG = join(homedir(), '.kimi', 'kimi.json');
25
+ const KIMI_DIR = join(homedir(), '.kimi');
26
+ const KIMI_SESSIONS_DIR = join(KIMI_DIR, 'sessions');
27
+ const KIMI_WORKDIRS_JSON = join(KIMI_DIR, 'kimi.json');
28
+ const KIMI_CONFIG_TOML = join(KIMI_DIR, 'config.toml');
15
29
 
16
30
  function findWireFiles(baseDir) {
17
31
  const results = [];
@@ -40,31 +54,78 @@ function findWireFiles(baseDir) {
40
54
  return results;
41
55
  }
42
56
 
57
+ function projectNameFromPath(path) {
58
+ const parts = path.split('/').filter(Boolean);
59
+ return parts[parts.length - 1] || path;
60
+ }
61
+
43
62
  function loadProjectMap() {
44
63
  const map = new Map();
45
- if (!existsSync(KIMI_CONFIG)) return map;
64
+ if (!existsSync(KIMI_WORKDIRS_JSON)) return map;
46
65
 
66
+ let config;
47
67
  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)) {
68
+ config = JSON.parse(readFileSync(KIMI_WORKDIRS_JSON, 'utf-8'));
69
+ } catch {
70
+ return map;
71
+ }
72
+
73
+ // 1.9 schema: { work_dirs: [{ path, kaos, last_session_id }] }
74
+ if (Array.isArray(config.work_dirs)) {
75
+ for (const entry of config.work_dirs) {
76
+ const path = entry?.path;
77
+ if (typeof path !== 'string' || !path) continue;
78
+ const hash = createHash('md5').update(path).digest('hex');
79
+ map.set(hash, projectNameFromPath(path));
80
+ }
81
+ }
82
+
83
+ // Legacy schemas keyed by hash
84
+ for (const key of ['workspaces', 'projects']) {
85
+ const obj = config[key];
86
+ if (!obj || typeof obj !== 'object') continue;
87
+ for (const [hash, info] of Object.entries(obj)) {
51
88
  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
- }
89
+ if (typeof path === 'string' && path) map.set(hash, projectNameFromPath(path));
56
90
  }
57
- } catch {
58
- // config unreadable
59
91
  }
92
+
60
93
  return map;
61
94
  }
62
95
 
96
+ // Matches both bare-key `[models.kimi-for-coding]` and quoted
97
+ // `[models."kimi-code/kimi-for-coding"]` forms (TOML bare keys can't
98
+ // contain `/`, so quoting is mandatory for hierarchical names).
99
+ const TOML_MODEL_SECTION_RE = /^\s*\[models\.(?:"([^"]+)"|([A-Za-z0-9_-]+))\]/m;
100
+ const TOML_DEFAULT_MODEL_RE = /^\s*default_model\s*=\s*["']([^"']+)["']/m;
101
+
102
+ function loadModelFromConfig() {
103
+ if (!existsSync(KIMI_CONFIG_TOML)) return 'unknown';
104
+
105
+ let content;
106
+ try {
107
+ content = readFileSync(KIMI_CONFIG_TOML, 'utf-8');
108
+ } catch {
109
+ return 'unknown';
110
+ }
111
+
112
+ const defaultMatch = content.match(TOML_DEFAULT_MODEL_RE);
113
+ if (defaultMatch) return defaultMatch[1];
114
+
115
+ const sectionMatch = content.match(TOML_MODEL_SECTION_RE);
116
+ if (sectionMatch) return sectionMatch[1] || sectionMatch[2];
117
+
118
+ return 'unknown';
119
+ }
120
+
121
+ const USER_EVENT_TYPES = new Set(['TurnBegin', 'UserMessage', 'user_message', 'Input']);
122
+
63
123
  export async function parse() {
64
124
  const wireFiles = findWireFiles(KIMI_SESSIONS_DIR);
65
125
  if (wireFiles.length === 0) return { buckets: [], sessions: [] };
66
126
 
67
127
  const projectMap = loadProjectMap();
128
+ const defaultModel = loadModelFromConfig();
68
129
  const entries = [];
69
130
  const sessionEvents = [];
70
131
  const seenMessageIds = new Set();
@@ -78,61 +139,67 @@ export async function parse() {
78
139
  }
79
140
 
80
141
  const project = projectMap.get(workDirHash) || workDirHash;
81
- let currentModel = 'unknown';
142
+ let currentModel = defaultModel;
82
143
  let lastTimestamp = null;
83
144
 
84
145
  for (const line of content.split('\n')) {
85
146
  if (!line.trim()) continue;
86
- try {
87
- const obj = JSON.parse(line);
88
- const type = obj.type;
89
- const payload = obj.payload;
90
- if (!payload) continue;
91
-
92
- if (payload.timestamp) lastTimestamp = payload.timestamp;
93
- if (payload.model) currentModel = payload.model;
94
-
95
- if (lastTimestamp) {
96
- const evTs = new Date(lastTimestamp);
97
- if (!isNaN(evTs.getTime())) {
98
- const isUser = type === 'UserMessage' || type === 'user_message' || type === 'Input';
99
- sessionEvents.push({
100
- sessionId: filePath,
101
- source: 'kimi-code',
102
- project,
103
- timestamp: evTs,
104
- role: isUser ? 'user' : 'assistant',
105
- });
106
- }
147
+ let raw;
148
+ try { raw = JSON.parse(line); } catch { continue; }
149
+
150
+ // Unwrap 1.9 envelope; fall through to top-level for legacy 1.1.
151
+ // Metadata header line has no payload and is filtered by the next check.
152
+ const envelope = raw.message || raw;
153
+ const type = envelope.type || raw.type;
154
+ const payload = envelope.payload || raw.payload;
155
+ if (!payload) continue;
156
+
157
+ // 1.9 puts timestamp at the outer level (Unix seconds, float).
158
+ // Legacy 1.1 sometimes puts it inside payload.
159
+ if (typeof raw.timestamp === 'number') {
160
+ lastTimestamp = raw.timestamp * 1000;
161
+ } else if (typeof payload.timestamp === 'number') {
162
+ lastTimestamp = payload.timestamp * 1000;
163
+ }
164
+ if (payload.model) currentModel = payload.model;
165
+
166
+ if (lastTimestamp) {
167
+ const evTs = new Date(lastTimestamp);
168
+ if (!isNaN(evTs.getTime())) {
169
+ sessionEvents.push({
170
+ sessionId: filePath,
171
+ source: 'kimi-code',
172
+ project,
173
+ timestamp: evTs,
174
+ role: USER_EVENT_TYPES.has(type) ? 'user' : 'assistant',
175
+ });
107
176
  }
177
+ }
108
178
 
109
- if (type !== 'StatusUpdate') continue;
110
-
111
- const tokenUsage = payload.token_usage;
112
- if (!tokenUsage) continue;
113
- if (!tokenUsage.input_other && !tokenUsage.output) continue;
179
+ if (type !== 'StatusUpdate') continue;
114
180
 
115
- const messageId = payload.message_id;
116
- if (messageId) {
117
- if (seenMessageIds.has(messageId)) continue;
118
- seenMessageIds.add(messageId);
119
- }
181
+ const tokenUsage = payload.token_usage;
182
+ if (!tokenUsage) continue;
183
+ if (!tokenUsage.input_other && !tokenUsage.output) continue;
120
184
 
121
- const ts = lastTimestamp ? new Date(lastTimestamp) : new Date();
122
-
123
- entries.push({
124
- source: 'kimi-code',
125
- model: currentModel,
126
- project,
127
- timestamp: ts,
128
- inputTokens: tokenUsage.input_other || 0,
129
- outputTokens: tokenUsage.output || 0,
130
- cachedInputTokens: tokenUsage.input_cache_read || 0,
131
- reasoningOutputTokens: 0,
132
- });
133
- } catch {
134
- continue;
185
+ const messageId = payload.message_id;
186
+ if (messageId) {
187
+ if (seenMessageIds.has(messageId)) continue;
188
+ seenMessageIds.add(messageId);
135
189
  }
190
+
191
+ const ts = lastTimestamp ? new Date(lastTimestamp) : new Date();
192
+
193
+ entries.push({
194
+ source: 'kimi-code',
195
+ model: currentModel,
196
+ project,
197
+ timestamp: ts,
198
+ inputTokens: tokenUsage.input_other || 0,
199
+ outputTokens: tokenUsage.output || 0,
200
+ cachedInputTokens: tokenUsage.input_cache_read || 0,
201
+ reasoningOutputTokens: 0,
202
+ });
136
203
  }
137
204
  }
138
205