@vibe-cafe/vibe-usage 0.9.3 → 0.9.4

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,33 +1,209 @@
1
1
  import { readdirSync, readFileSync, existsSync } from 'node:fs';
2
- import { join } from 'node:path';
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 './index.js';
6
6
 
7
7
  /**
8
- * Kimi CLI parser (a.k.a. "Kimi Code"). MoonshotAI/kimi-cli.
8
+ * Kimi Code CLI parser. MoonshotAI/kimi-cli (a.k.a. "Kimi Code").
9
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)
10
+ * Two on-disk layouts are supported:
14
11
  *
15
- * Token data: StatusUpdate.payload.token_usage
16
- * = {input_other, output, input_cache_read, input_cache_creation}
12
+ * 1. Current ("~/.kimi-code", protocol >= 1.4). Sessions live at
13
+ * ~/.kimi-code/sessions/wd_<slug>_<hash>/session_<id>/agents/<agentId>/wire.jsonl
14
+ * Each line is a self-describing event with a top-level integer-ms `time`:
15
+ * {"type":"usage.record","model":"kimi-code/kimi-for-coding",
16
+ * "usage":{"inputOther","output","inputCacheRead","inputCacheCreation"},
17
+ * "usageScope":"turn","time":<ms>}
18
+ * `usage.record` events are per-step deltas (one per assistant step), so they
19
+ * sum to the session total. The model name rides on each record — no config
20
+ * lookup needed. User turns are `turn.prompt` with origin.kind === "user".
21
+ * The real working directory per session is recorded in
22
+ * ~/.kimi-code/session_index.jsonl -> {sessionId, sessionDir, workDir}
23
+ * which gives an accurate project name (last path component of workDir).
17
24
  *
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).
25
+ * 2. Legacy ("~/.kimi", protocol 1.1 / 1.9). Sessions live at
26
+ * ~/.kimi/sessions/<md5(workdir)>/<session-id>/wire.jsonl
27
+ * with a different envelope (StatusUpdate.payload.token_usage, float-second
28
+ * `timestamp`) and the model in ~/.kimi/config.toml. Kept for users who have
29
+ * not migrated; see parseLegacyKimi() below.
30
+ */
31
+
32
+ // ---------------------------------------------------------------------------
33
+ // Current format: ~/.kimi-code
34
+ // ---------------------------------------------------------------------------
35
+
36
+ const KIMI_CODE_DIR = join(homedir(), '.kimi-code');
37
+ const KIMI_CODE_SESSIONS_DIR = join(KIMI_CODE_DIR, 'sessions');
38
+ const KIMI_CODE_SESSION_INDEX = join(KIMI_CODE_DIR, 'session_index.jsonl');
39
+
40
+ function projectNameFromPath(path) {
41
+ if (typeof path !== 'string' || !path) return null;
42
+ return basename(path.replace(/[/\\]+$/, '')) || path;
43
+ }
44
+
45
+ /**
46
+ * Map each session directory (absolute path) to a project name, read from
47
+ * ~/.kimi-code/session_index.jsonl. Falls back gracefully if the file is
48
+ * missing or malformed — callers default to the wd_ bucket name.
23
49
  */
50
+ function loadSessionIndex() {
51
+ const map = new Map();
52
+ if (!existsSync(KIMI_CODE_SESSION_INDEX)) return map;
53
+
54
+ let content;
55
+ try {
56
+ content = readFileSync(KIMI_CODE_SESSION_INDEX, 'utf-8');
57
+ } catch {
58
+ return map;
59
+ }
60
+
61
+ for (const line of content.split('\n')) {
62
+ if (!line.trim()) continue;
63
+ let entry;
64
+ try { entry = JSON.parse(line); } catch { continue; }
65
+ const dir = entry?.sessionDir;
66
+ const project = projectNameFromPath(entry?.workDir);
67
+ if (typeof dir === 'string' && dir && project) map.set(dir, project);
68
+ }
69
+ return map;
70
+ }
71
+
72
+ // Strip the trailing _<hash> from a "wd_<slug>_<hash>" bucket name so the slug
73
+ // can serve as a last-resort project label when session_index has no entry.
74
+ function projectFromBucketName(name) {
75
+ const m = /^wd_(.+)_[0-9a-f]+$/.exec(name);
76
+ return m ? m[1] : name;
77
+ }
78
+
79
+ // Collect every agents/<id>/wire.jsonl under sessions/wd_<...>/session_<...>/.
80
+ function findKimiCodeWireFiles(baseDir) {
81
+ const results = [];
82
+ if (!existsSync(baseDir)) return results;
83
+
84
+ let workDirs;
85
+ try {
86
+ workDirs = readdirSync(baseDir, { withFileTypes: true });
87
+ } catch {
88
+ return results;
89
+ }
90
+
91
+ for (const workDir of workDirs) {
92
+ if (!workDir.isDirectory()) continue;
93
+ const workDirPath = join(baseDir, workDir.name);
94
+ const bucketProject = projectFromBucketName(workDir.name);
95
+
96
+ let sessions;
97
+ try {
98
+ sessions = readdirSync(workDirPath, { withFileTypes: true });
99
+ } catch {
100
+ continue;
101
+ }
102
+
103
+ for (const session of sessions) {
104
+ if (!session.isDirectory()) continue;
105
+ const sessionDir = join(workDirPath, session.name);
106
+ const agentsDir = join(sessionDir, 'agents');
107
+
108
+ let agents;
109
+ try {
110
+ agents = readdirSync(agentsDir, { withFileTypes: true });
111
+ } catch {
112
+ continue;
113
+ }
114
+
115
+ for (const agent of agents) {
116
+ if (!agent.isDirectory()) continue;
117
+ const wireFile = join(agentsDir, agent.name, 'wire.jsonl');
118
+ if (existsSync(wireFile)) {
119
+ results.push({ wireFile, sessionDir, bucketProject });
120
+ }
121
+ }
122
+ }
123
+ }
124
+ return results;
125
+ }
126
+
127
+ function parseKimiCode() {
128
+ const wireFiles = findKimiCodeWireFiles(KIMI_CODE_SESSIONS_DIR);
129
+ if (wireFiles.length === 0) return null;
130
+
131
+ const sessionIndex = loadSessionIndex();
132
+ const entries = [];
133
+ const sessionEvents = [];
134
+
135
+ for (const { wireFile, sessionDir, bucketProject } of wireFiles) {
136
+ let content;
137
+ try {
138
+ content = readFileSync(wireFile, 'utf-8');
139
+ } catch {
140
+ continue;
141
+ }
142
+
143
+ const project = sessionIndex.get(sessionDir) || bucketProject || 'unknown';
144
+
145
+ for (const line of content.split('\n')) {
146
+ if (!line.trim()) continue;
147
+ let evt;
148
+ try { evt = JSON.parse(line); } catch { continue; }
149
+
150
+ const type = evt.type;
151
+ // Top-level `time` is integer milliseconds since epoch.
152
+ const time = typeof evt.time === 'number' ? evt.time : null;
153
+
154
+ // Session timing: a user turn vs. anything the model emits.
155
+ if (type === 'turn.prompt' && evt.origin?.kind === 'user' && time) {
156
+ const ts = new Date(time);
157
+ if (!isNaN(ts.getTime())) {
158
+ sessionEvents.push({ sessionId: wireFile, source: 'kimi-code', project, timestamp: ts, role: 'user' });
159
+ }
160
+ continue;
161
+ }
162
+
163
+ if (type !== 'usage.record') continue;
164
+
165
+ const usage = evt.usage;
166
+ if (!usage) continue;
167
+
168
+ const inputTokens = usage.inputOther || 0;
169
+ const outputTokens = usage.output || 0;
170
+ const cachedInputTokens = usage.inputCacheRead || 0;
171
+ if (!inputTokens && !outputTokens && !cachedInputTokens) continue;
172
+
173
+ const ts = time ? new Date(time) : new Date();
174
+
175
+ entries.push({
176
+ source: 'kimi-code',
177
+ model: evt.model || 'unknown',
178
+ project,
179
+ timestamp: ts,
180
+ inputTokens,
181
+ outputTokens,
182
+ cachedInputTokens,
183
+ reasoningOutputTokens: 0,
184
+ });
185
+
186
+ // Each usage.record marks an assistant step completing — use it as an
187
+ // assistant timing event so active-time math has both sides of a turn.
188
+ if (time && !isNaN(ts.getTime())) {
189
+ sessionEvents.push({ sessionId: wireFile, source: 'kimi-code', project, timestamp: ts, role: 'assistant' });
190
+ }
191
+ }
192
+ }
193
+
194
+ return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // Legacy format: ~/.kimi (kept for users who haven't migrated to ~/.kimi-code)
199
+ // ---------------------------------------------------------------------------
24
200
 
25
201
  const KIMI_DIR = join(homedir(), '.kimi');
26
202
  const KIMI_SESSIONS_DIR = join(KIMI_DIR, 'sessions');
27
203
  const KIMI_WORKDIRS_JSON = join(KIMI_DIR, 'kimi.json');
28
204
  const KIMI_CONFIG_TOML = join(KIMI_DIR, 'config.toml');
29
205
 
30
- function findWireFiles(baseDir) {
206
+ function findLegacyWireFiles(baseDir) {
31
207
  const results = [];
32
208
  if (!existsSync(baseDir)) return results;
33
209
 
@@ -54,12 +230,7 @@ function findWireFiles(baseDir) {
54
230
  return results;
55
231
  }
56
232
 
57
- function projectNameFromPath(path) {
58
- const parts = path.split('/').filter(Boolean);
59
- return parts[parts.length - 1] || path;
60
- }
61
-
62
- function loadProjectMap() {
233
+ function loadLegacyProjectMap() {
63
234
  const map = new Map();
64
235
  if (!existsSync(KIMI_WORKDIRS_JSON)) return map;
65
236
 
@@ -70,7 +241,6 @@ function loadProjectMap() {
70
241
  return map;
71
242
  }
72
243
 
73
- // 1.9 schema: { work_dirs: [{ path, kaos, last_session_id }] }
74
244
  if (Array.isArray(config.work_dirs)) {
75
245
  for (const entry of config.work_dirs) {
76
246
  const path = entry?.path;
@@ -80,7 +250,6 @@ function loadProjectMap() {
80
250
  }
81
251
  }
82
252
 
83
- // Legacy schemas keyed by hash
84
253
  for (const key of ['workspaces', 'projects']) {
85
254
  const obj = config[key];
86
255
  if (!obj || typeof obj !== 'object') continue;
@@ -93,13 +262,10 @@ function loadProjectMap() {
93
262
  return map;
94
263
  }
95
264
 
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
265
  const TOML_MODEL_SECTION_RE = /^\s*\[models\.(?:"([^"]+)"|([A-Za-z0-9_-]+))\]/m;
100
266
  const TOML_DEFAULT_MODEL_RE = /^\s*default_model\s*=\s*["']([^"']+)["']/m;
101
267
 
102
- function loadModelFromConfig() {
268
+ function loadLegacyModelFromConfig() {
103
269
  if (!existsSync(KIMI_CONFIG_TOML)) return 'unknown';
104
270
 
105
271
  let content;
@@ -118,14 +284,14 @@ function loadModelFromConfig() {
118
284
  return 'unknown';
119
285
  }
120
286
 
121
- const USER_EVENT_TYPES = new Set(['TurnBegin', 'UserMessage', 'user_message', 'Input']);
287
+ const LEGACY_USER_EVENT_TYPES = new Set(['TurnBegin', 'UserMessage', 'user_message', 'Input']);
122
288
 
123
- export async function parse() {
124
- const wireFiles = findWireFiles(KIMI_SESSIONS_DIR);
289
+ function parseLegacyKimi() {
290
+ const wireFiles = findLegacyWireFiles(KIMI_SESSIONS_DIR);
125
291
  if (wireFiles.length === 0) return { buckets: [], sessions: [] };
126
292
 
127
- const projectMap = loadProjectMap();
128
- const defaultModel = loadModelFromConfig();
293
+ const projectMap = loadLegacyProjectMap();
294
+ const defaultModel = loadLegacyModelFromConfig();
129
295
  const entries = [];
130
296
  const sessionEvents = [];
131
297
  const seenMessageIds = new Set();
@@ -147,15 +313,11 @@ export async function parse() {
147
313
  let raw;
148
314
  try { raw = JSON.parse(line); } catch { continue; }
149
315
 
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
316
  const envelope = raw.message || raw;
153
317
  const type = envelope.type || raw.type;
154
318
  const payload = envelope.payload || raw.payload;
155
319
  if (!payload) continue;
156
320
 
157
- // 1.9 puts timestamp at the outer level (Unix seconds, float).
158
- // Legacy 1.1 sometimes puts it inside payload.
159
321
  if (typeof raw.timestamp === 'number') {
160
322
  lastTimestamp = raw.timestamp * 1000;
161
323
  } else if (typeof payload.timestamp === 'number') {
@@ -171,7 +333,7 @@ export async function parse() {
171
333
  source: 'kimi-code',
172
334
  project,
173
335
  timestamp: evTs,
174
- role: USER_EVENT_TYPES.has(type) ? 'user' : 'assistant',
336
+ role: LEGACY_USER_EVENT_TYPES.has(type) ? 'user' : 'assistant',
175
337
  });
176
338
  }
177
339
  }
@@ -205,3 +367,11 @@ export async function parse() {
205
367
 
206
368
  return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
207
369
  }
370
+
371
+ export async function parse() {
372
+ // Prefer the current ~/.kimi-code layout; fall back to legacy ~/.kimi only
373
+ // when no kimi-code sessions exist, so migrated users aren't double-counted.
374
+ const current = parseKimiCode();
375
+ if (current) return current;
376
+ return parseLegacyKimi();
377
+ }
package/src/tools.js CHANGED
@@ -104,6 +104,15 @@ function findCodexDataDirs() {
104
104
  ].filter(existsSync);
105
105
  }
106
106
 
107
+ // Kimi Code moved its store from ~/.kimi to ~/.kimi-code; recognize either so
108
+ // users on either version are detected. The parser prefers ~/.kimi-code.
109
+ function findKimiCodeDataDirs() {
110
+ return [
111
+ join(homedir(), '.kimi-code', 'sessions'),
112
+ join(homedir(), '.kimi', 'sessions'),
113
+ ].filter(existsSync);
114
+ }
115
+
107
116
  export const TOOLS = [
108
117
  {
109
118
  name: 'Antigravity',
@@ -167,7 +176,10 @@ export const TOOLS = [
167
176
  {
168
177
  name: 'Kimi Code',
169
178
  id: 'kimi-code',
170
- dataDir: join(homedir(), '.kimi', 'sessions'),
179
+ // Current layout is ~/.kimi-code/sessions; ~/.kimi/sessions is the legacy
180
+ // path. The parser reads whichever exists (preferring ~/.kimi-code).
181
+ dataDir: join(homedir(), '.kimi-code', 'sessions'),
182
+ detectDataDirs: findKimiCodeDataDirs,
171
183
  },
172
184
  {
173
185
  name: 'Amp',