@vibe-cafe/vibe-usage 0.9.3 → 0.9.5
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 +1 -1
- package/package.json +4 -1
- package/src/parsers/kimi-code.js +206 -36
- package/src/parsers/kiro.js +251 -117
- package/src/tools.js +13 -1
package/README.md
CHANGED
|
@@ -63,7 +63,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
63
63
|
| Amp | `~/.local/share/amp/threads/` |
|
|
64
64
|
| Droid | `~/.factory/sessions/` |
|
|
65
65
|
| Hermes | `~/.hermes/state.db` + `~/.hermes/profiles/<name>/state.db` (SQLite, multi-profile) |
|
|
66
|
-
| Kiro | `~/Library/Application Support/Kiro/
|
|
66
|
+
| Kiro | `~/Library/Application Support/Kiro/logs/**/kiro.kiroAgent/q-client.log` (`GetUsageLimitsCommand` credit snapshots; emits incremental `kiro-credits`, because Kiro bills by Credits / `INVOCATIONS`, not local model tokens). Legacy `dev_data/devdata.sqlite` token telemetry is available only with `VIBE_USAGE_KIRO_LEGACY_TOKENS=1` |
|
|
67
67
|
| Cline | `<host>/User/globalStorage/saoudrizwan.claude-dev/{state/taskHistory.json,tasks/<id>/ui_messages.json}` (walks all VSCode-fork hosts: Code, Cursor, Windsurf, VSCodium, Trae, ...) |
|
|
68
68
|
| Roo Code | `<host>/User/globalStorage/rooveterinaryinc.roo-cline/{tasks/_index.json,tasks/<id>/{history_item,ui_messages}.json}` (walks all VSCode-fork hosts) |
|
|
69
69
|
| Antigravity | `~/.gemini/antigravity/conversations/*.pb` to discover cascades, then reads token usage + sessions from the running language server via Connect RPC (process discovered with `ps`/`lsof` on macOS/Linux, PowerShell CIM with a `wmic` fallback on Windows) |
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vibe-cafe/vibe-usage",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.5",
|
|
4
4
|
"description": "Track your AI coding tool token usage and sync to vibecafe.ai",
|
|
5
5
|
"type": "module",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"test": "node --test"
|
|
8
|
+
},
|
|
6
9
|
"bin": {
|
|
7
10
|
"vibe-usage": "bin/vibe-usage.js"
|
|
8
11
|
},
|
package/src/parsers/kimi-code.js
CHANGED
|
@@ -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").
|
|
8
|
+
* Kimi Code CLI parser. MoonshotAI/kimi-cli (a.k.a. "Kimi Code").
|
|
9
9
|
*
|
|
10
|
-
*
|
|
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
|
-
*
|
|
16
|
-
*
|
|
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
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
287
|
+
const LEGACY_USER_EVENT_TYPES = new Set(['TurnBegin', 'UserMessage', 'user_message', 'Input']);
|
|
122
288
|
|
|
123
|
-
|
|
124
|
-
const wireFiles =
|
|
289
|
+
function parseLegacyKimi() {
|
|
290
|
+
const wireFiles = findLegacyWireFiles(KIMI_SESSIONS_DIR);
|
|
125
291
|
if (wireFiles.length === 0) return { buckets: [], sessions: [] };
|
|
126
292
|
|
|
127
|
-
const projectMap =
|
|
128
|
-
const defaultModel =
|
|
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:
|
|
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/parsers/kiro.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
copyFileSync,
|
|
3
|
+
createReadStream,
|
|
3
4
|
existsSync,
|
|
4
5
|
mkdtempSync,
|
|
5
6
|
readFileSync,
|
|
@@ -7,23 +8,30 @@ import {
|
|
|
7
8
|
rmSync,
|
|
8
9
|
statSync,
|
|
9
10
|
} from 'node:fs';
|
|
10
|
-
import {
|
|
11
|
+
import { createInterface } from 'node:readline';
|
|
12
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
13
|
import { homedir, tmpdir } from 'node:os';
|
|
12
14
|
import { aggregateToBuckets } from './index.js';
|
|
13
15
|
import { queryDbJson } from './sqlite.js';
|
|
14
16
|
|
|
15
|
-
const
|
|
17
|
+
const KIRO_AGENT_RELATIVE = join('User', 'globalStorage', 'kiro.kiroagent');
|
|
18
|
+
const KIRO_USER_RELATIVE = 'User';
|
|
19
|
+
const CREDIT_MODEL = 'kiro-credits';
|
|
16
20
|
|
|
17
|
-
function
|
|
21
|
+
function getDefaultAppPath() {
|
|
18
22
|
if (process.platform === 'darwin') {
|
|
19
|
-
return join(homedir(), 'Library', 'Application Support', 'Kiro'
|
|
23
|
+
return join(homedir(), 'Library', 'Application Support', 'Kiro');
|
|
20
24
|
}
|
|
21
25
|
if (process.platform === 'win32') {
|
|
22
26
|
const appData = process.env.APPDATA?.trim() || join(homedir(), 'AppData', 'Roaming');
|
|
23
|
-
return join(appData, 'Kiro'
|
|
27
|
+
return join(appData, 'Kiro');
|
|
24
28
|
}
|
|
25
29
|
const xdgConfigHome = process.env.XDG_CONFIG_HOME?.trim() || join(homedir(), '.config');
|
|
26
|
-
return join(xdgConfigHome, 'Kiro'
|
|
30
|
+
return join(xdgConfigHome, 'Kiro');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function getDefaultUserPath() {
|
|
34
|
+
return join(getDefaultAppPath(), KIRO_USER_RELATIVE);
|
|
27
35
|
}
|
|
28
36
|
|
|
29
37
|
export function getKiroBasePath() {
|
|
@@ -32,7 +40,25 @@ export function getKiroBasePath() {
|
|
|
32
40
|
const r = resolve(explicit);
|
|
33
41
|
return existsSync(r) ? r : null;
|
|
34
42
|
}
|
|
35
|
-
const def =
|
|
43
|
+
const def = join(getDefaultAppPath(), KIRO_AGENT_RELATIVE);
|
|
44
|
+
return existsSync(def) ? def : null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function getKiroUserPath() {
|
|
48
|
+
const explicitUser = process.env.KIRO_USER_PATH?.trim();
|
|
49
|
+
if (explicitUser) {
|
|
50
|
+
const r = resolve(explicitUser);
|
|
51
|
+
return existsSync(r) ? r : null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const explicitBase = process.env.KIRO_BASE_PATH?.trim();
|
|
55
|
+
if (explicitBase) {
|
|
56
|
+
const base = resolve(explicitBase);
|
|
57
|
+
const userPath = resolve(base, '..', '..');
|
|
58
|
+
return existsSync(userPath) ? userPath : null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const def = getDefaultUserPath();
|
|
36
62
|
return existsSync(def) ? def : null;
|
|
37
63
|
}
|
|
38
64
|
|
|
@@ -50,12 +76,11 @@ const TOKENS_SQL =
|
|
|
50
76
|
'WHERE tokens_prompt > 0 OR tokens_generated > 0 ' +
|
|
51
77
|
'ORDER BY id ASC';
|
|
52
78
|
|
|
53
|
-
function
|
|
79
|
+
function readLegacyDb(dbPath) {
|
|
54
80
|
try {
|
|
55
81
|
return queryDb(dbPath, TOKENS_SQL);
|
|
56
82
|
} catch (err) {
|
|
57
83
|
if (!isLockError(err)) throw err;
|
|
58
|
-
// Kiro app holds a write lock; snapshot WAL set to a temp dir and retry.
|
|
59
84
|
const snapshotDir = mkdtempSync(join(tmpdir(), 'vibe-usage-kiro-'));
|
|
60
85
|
const queryPath = join(snapshotDir, 'devdata.sqlite');
|
|
61
86
|
copyFileSync(dbPath, queryPath);
|
|
@@ -71,10 +96,10 @@ function readDb(dbPath) {
|
|
|
71
96
|
}
|
|
72
97
|
}
|
|
73
98
|
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
function
|
|
99
|
+
// Legacy Kiro dev telemetry fallback. This is opt-in because recent Kiro builds
|
|
100
|
+
// bill by server-side credits, while this table is often empty, estimated, or
|
|
101
|
+
// populated with placeholder model names such as "agent".
|
|
102
|
+
function readLegacyJsonl(jsonlPath) {
|
|
78
103
|
let raw;
|
|
79
104
|
try { raw = readFileSync(jsonlPath, 'utf-8'); } catch { return []; }
|
|
80
105
|
const lines = raw.split('\n').map(l => l.trim()).filter(Boolean);
|
|
@@ -88,7 +113,7 @@ function readJsonl(jsonlPath) {
|
|
|
88
113
|
const obj = JSON.parse(lines[i]);
|
|
89
114
|
rows.push({
|
|
90
115
|
id: i + 1,
|
|
91
|
-
model: obj.model || '
|
|
116
|
+
model: obj.model || 'kiro-token-estimate',
|
|
92
117
|
tokens_prompt: obj.promptTokens || 0,
|
|
93
118
|
tokens_generated: obj.generatedTokens || 0,
|
|
94
119
|
timestamp: ts,
|
|
@@ -100,102 +125,26 @@ function readJsonl(jsonlPath) {
|
|
|
100
125
|
return rows;
|
|
101
126
|
}
|
|
102
127
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
try { entries = readdirSync(base, { withFileTypes: true }); } catch { return timeline; }
|
|
110
|
-
for (const entry of entries) {
|
|
111
|
-
if (!entry.isDirectory() || entry.name === 'dev_data') continue;
|
|
112
|
-
const dirPath = join(base, entry.name);
|
|
113
|
-
let files;
|
|
114
|
-
try {
|
|
115
|
-
files = readdirSync(dirPath).filter(f => f.endsWith('.chat'));
|
|
116
|
-
} catch {
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
for (const file of files) {
|
|
120
|
-
try {
|
|
121
|
-
const data = JSON.parse(readFileSync(join(dirPath, file), 'utf-8'));
|
|
122
|
-
const meta = data?.metadata;
|
|
123
|
-
if (!meta?.modelId || !meta?.startTime) continue;
|
|
124
|
-
const startMs = Number(meta.startTime);
|
|
125
|
-
const endMs = Number(meta.endTime || meta.startTime);
|
|
126
|
-
if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) continue;
|
|
127
|
-
timeline.push({ startMs, endMs, model: String(meta.modelId) });
|
|
128
|
-
} catch {
|
|
129
|
-
// skip unreadable / malformed chat files
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
timeline.sort((a, b) => a.startMs - b.startMs);
|
|
134
|
-
return timeline;
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function resolveModel(timeline, ts) {
|
|
138
|
-
if (!timeline.length || !ts) return null;
|
|
139
|
-
const t = ts.getTime();
|
|
140
|
-
if (!Number.isFinite(t)) return null;
|
|
141
|
-
let best = null;
|
|
142
|
-
let bestDist = Infinity;
|
|
143
|
-
for (const e of timeline) {
|
|
144
|
-
if (t >= e.startMs && t <= e.endMs) return e.model;
|
|
145
|
-
const d = Math.min(Math.abs(t - e.startMs), Math.abs(t - e.endMs));
|
|
146
|
-
if (d < bestDist) { bestDist = d; best = e.model; }
|
|
147
|
-
}
|
|
148
|
-
// 10-minute tolerance — beyond that, treat as no match.
|
|
149
|
-
return bestDist < 10 * 60 * 1000 ? best : null;
|
|
128
|
+
function parseDbTimestamp(value) {
|
|
129
|
+
if (!value) return null;
|
|
130
|
+
const s = String(value).trim();
|
|
131
|
+
const hasZone = /(?:Z|[+-]\d\d:?\d\d)$/.test(s);
|
|
132
|
+
const d = new Date(hasZone ? s.replace(' ', 'T') : `${s.replace(' ', 'T')}Z`);
|
|
133
|
+
return isNaN(d.getTime()) ? null : d;
|
|
150
134
|
}
|
|
151
135
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if (!
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if (trimmed === trimmed.toLowerCase() && trimmed.includes('-')) return trimmed;
|
|
158
|
-
const cleaned = trimmed
|
|
136
|
+
function normalizeLegacyModel(raw) {
|
|
137
|
+
const model = typeof raw === 'string' ? raw.trim() : '';
|
|
138
|
+
if (!model || model.toLowerCase() === 'agent') return 'kiro-token-estimate';
|
|
139
|
+
if (model === model.toLowerCase() && model.includes('-')) return model;
|
|
140
|
+
return model
|
|
159
141
|
.replace(/_\d{8}_V\d+_\d+$/i, '')
|
|
160
142
|
.replace(/_V\d+$/i, '')
|
|
161
143
|
.toLowerCase()
|
|
162
|
-
.replace(/_/g, '-');
|
|
163
|
-
return cleaned || null;
|
|
144
|
+
.replace(/_/g, '-') || 'kiro-token-estimate';
|
|
164
145
|
}
|
|
165
146
|
|
|
166
|
-
function
|
|
167
|
-
if (!value) return null;
|
|
168
|
-
// SQLite CURRENT_TIMESTAMP: "2026-01-09 15:25:30" (UTC, naive — append Z).
|
|
169
|
-
const d = new Date(String(value).trim().replace(' ', 'T') + 'Z');
|
|
170
|
-
return isNaN(d.getTime()) ? null : d;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export async function parse() {
|
|
174
|
-
const base = getKiroBasePath();
|
|
175
|
-
if (!base) return { buckets: [], sessions: [] };
|
|
176
|
-
|
|
177
|
-
const dbPath = join(base, 'dev_data', 'devdata.sqlite');
|
|
178
|
-
const jsonlPath = join(base, 'dev_data', 'tokens_generated.jsonl');
|
|
179
|
-
|
|
180
|
-
let rows;
|
|
181
|
-
try {
|
|
182
|
-
if (existsSync(dbPath)) {
|
|
183
|
-
rows = readDb(dbPath);
|
|
184
|
-
} else if (existsSync(jsonlPath)) {
|
|
185
|
-
rows = readJsonl(jsonlPath);
|
|
186
|
-
} else {
|
|
187
|
-
return { buckets: [], sessions: [] };
|
|
188
|
-
}
|
|
189
|
-
} catch (err) {
|
|
190
|
-
if (err && typeof err.message === 'string' && err.message.includes('ENOENT')) {
|
|
191
|
-
throw new Error('sqlite3 CLI not found. Install sqlite3 (or use Node >= 22.5) to sync Kiro data.');
|
|
192
|
-
}
|
|
193
|
-
throw err;
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (!rows.length) return { buckets: [], sessions: [] };
|
|
197
|
-
|
|
198
|
-
const timeline = buildModelTimeline(base);
|
|
147
|
+
function rowsToLegacyEntries(rows) {
|
|
199
148
|
const entries = [];
|
|
200
149
|
for (const row of rows) {
|
|
201
150
|
const inputTokens = Math.max(0, Number(row.tokens_prompt) || 0);
|
|
@@ -203,21 +152,9 @@ export async function parse() {
|
|
|
203
152
|
if (inputTokens === 0 && outputTokens === 0) continue;
|
|
204
153
|
const timestamp = parseDbTimestamp(row.timestamp);
|
|
205
154
|
if (!timestamp) continue;
|
|
206
|
-
|
|
207
|
-
// Prefer the .chat timeline; fall back to the row's literal model (skip
|
|
208
|
-
// the placeholder "agent"); then "kiro-agent".
|
|
209
|
-
let model = normalizeModelName(resolveModel(timeline, timestamp));
|
|
210
|
-
if (!model) {
|
|
211
|
-
const literal = (row.model || '').trim();
|
|
212
|
-
if (literal && literal.toLowerCase() !== 'agent') {
|
|
213
|
-
model = normalizeModelName(literal);
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
if (!model) model = 'kiro-agent';
|
|
217
|
-
|
|
218
155
|
entries.push({
|
|
219
156
|
source: 'kiro',
|
|
220
|
-
model,
|
|
157
|
+
model: normalizeLegacyModel(row.model),
|
|
221
158
|
project: 'unknown',
|
|
222
159
|
timestamp,
|
|
223
160
|
inputTokens,
|
|
@@ -226,6 +163,203 @@ export async function parse() {
|
|
|
226
163
|
reasoningOutputTokens: 0,
|
|
227
164
|
});
|
|
228
165
|
}
|
|
166
|
+
return entries;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function parseLogTimestamp(raw) {
|
|
170
|
+
const d = new Date(String(raw).replace(' ', 'T'));
|
|
171
|
+
return isNaN(d.getTime()) ? null : d;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function parseLogLine(line) {
|
|
175
|
+
const match = /^(\d{4}-\d\d-\d\d \d\d:\d\d:\d\d\.\d{3}) \[[^\]]+\] (\{.*\})$/.exec(line);
|
|
176
|
+
if (!match) return null;
|
|
177
|
+
const timestamp = parseLogTimestamp(match[1]);
|
|
178
|
+
if (!timestamp) return null;
|
|
179
|
+
try {
|
|
180
|
+
return { timestamp, obj: JSON.parse(match[2]) };
|
|
181
|
+
} catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function usageBreakdownsFromCommand(obj) {
|
|
187
|
+
if (obj?.commandName !== 'GetUsageLimitsCommand') return [];
|
|
188
|
+
const out = obj.output || {};
|
|
189
|
+
if (Array.isArray(out.usageBreakdownList)) return out.usageBreakdownList;
|
|
190
|
+
if (Array.isArray(out.usageBreakdowns)) return out.usageBreakdowns;
|
|
191
|
+
return [];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function numberFrom(...values) {
|
|
195
|
+
for (const value of values) {
|
|
196
|
+
const n = Number(value);
|
|
197
|
+
if (Number.isFinite(n)) return n;
|
|
198
|
+
}
|
|
199
|
+
return null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function maxNumberFrom(...values) {
|
|
203
|
+
const nums = values
|
|
204
|
+
.map(value => Number(value))
|
|
205
|
+
.filter(Number.isFinite);
|
|
206
|
+
if (nums.length === 0) return null;
|
|
207
|
+
return Math.max(...nums);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function snapshotFromBreakdown(timestamp, breakdown) {
|
|
211
|
+
const type = String(breakdown.resourceType || breakdown.type || '').toUpperCase();
|
|
212
|
+
const unit = String(breakdown.unit || '').toUpperCase();
|
|
213
|
+
if (type !== 'CREDIT' || unit !== 'INVOCATIONS') return null;
|
|
214
|
+
|
|
215
|
+
const currentUsage = maxNumberFrom(
|
|
216
|
+
breakdown.currentUsageWithPrecision,
|
|
217
|
+
breakdown.currentUsage,
|
|
218
|
+
breakdown.freeTrialInfo?.currentUsageWithPrecision,
|
|
219
|
+
breakdown.freeTrialInfo?.currentUsage,
|
|
220
|
+
breakdown.freeTrialUsage?.currentUsage,
|
|
221
|
+
);
|
|
222
|
+
if (currentUsage === null) return null;
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
timestamp,
|
|
226
|
+
currentUsage,
|
|
227
|
+
resetDate: String(breakdown.nextDateReset || breakdown.resetDate || ''),
|
|
228
|
+
usageLimit: numberFrom(
|
|
229
|
+
breakdown.usageLimitWithPrecision,
|
|
230
|
+
breakdown.usageLimit,
|
|
231
|
+
breakdown.freeTrialInfo?.usageLimitWithPrecision,
|
|
232
|
+
breakdown.freeTrialInfo?.usageLimit,
|
|
233
|
+
breakdown.freeTrialUsage?.usageLimit,
|
|
234
|
+
),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function findQClientLogs(logsRoot) {
|
|
239
|
+
const files = [];
|
|
240
|
+
const stack = [logsRoot];
|
|
241
|
+
while (stack.length) {
|
|
242
|
+
const dir = stack.pop();
|
|
243
|
+
let entries;
|
|
244
|
+
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
245
|
+
for (const entry of entries) {
|
|
246
|
+
const p = join(dir, entry.name);
|
|
247
|
+
if (entry.isDirectory()) {
|
|
248
|
+
stack.push(p);
|
|
249
|
+
} else if (entry.isFile() && /^q-client\.log(?:\.\d+)?$/.test(entry.name)) {
|
|
250
|
+
files.push(p);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return files.sort();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
async function readLogSnapshots(logPath) {
|
|
258
|
+
const snapshots = [];
|
|
259
|
+
const rl = createInterface({
|
|
260
|
+
input: createReadStream(logPath, { encoding: 'utf-8' }),
|
|
261
|
+
crlfDelay: Infinity,
|
|
262
|
+
});
|
|
263
|
+
for await (const line of rl) {
|
|
264
|
+
const parsed = parseLogLine(line);
|
|
265
|
+
if (!parsed) continue;
|
|
266
|
+
for (const breakdown of usageBreakdownsFromCommand(parsed.obj)) {
|
|
267
|
+
const snapshot = snapshotFromBreakdown(parsed.timestamp, breakdown);
|
|
268
|
+
if (snapshot) snapshots.push(snapshot);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return snapshots;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function readUsageSnapshots(userPath) {
|
|
275
|
+
const appPath = dirname(userPath);
|
|
276
|
+
const logsRoot = join(appPath, 'logs');
|
|
277
|
+
const files = findQClientLogs(logsRoot);
|
|
278
|
+
const snapshots = [];
|
|
279
|
+
for (const file of files) {
|
|
280
|
+
try {
|
|
281
|
+
snapshots.push(...await readLogSnapshots(file));
|
|
282
|
+
} catch {
|
|
283
|
+
// skip unreadable / concurrently rotated logs
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
return snapshots;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function dedupeSnapshots(snapshots) {
|
|
290
|
+
const map = new Map();
|
|
291
|
+
for (const s of snapshots) {
|
|
292
|
+
const key = `${s.timestamp.toISOString()}|${s.resetDate}|${s.currentUsage}`;
|
|
293
|
+
map.set(key, s);
|
|
294
|
+
}
|
|
295
|
+
return Array.from(map.values()).sort((a, b) => a.timestamp - b.timestamp);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function snapshotsToCreditEntries(snapshots) {
|
|
299
|
+
const ordered = dedupeSnapshots(snapshots);
|
|
300
|
+
const entries = [];
|
|
301
|
+
let prev = null;
|
|
302
|
+
|
|
303
|
+
for (const snapshot of ordered) {
|
|
304
|
+
if (!prev || snapshot.resetDate !== prev.resetDate || snapshot.currentUsage < prev.currentUsage) {
|
|
305
|
+
prev = snapshot;
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const delta = Number((snapshot.currentUsage - prev.currentUsage).toFixed(4));
|
|
310
|
+
if (delta > 0) {
|
|
311
|
+
entries.push({
|
|
312
|
+
source: 'kiro',
|
|
313
|
+
model: CREDIT_MODEL,
|
|
314
|
+
project: 'unknown',
|
|
315
|
+
timestamp: snapshot.timestamp,
|
|
316
|
+
inputTokens: 0,
|
|
317
|
+
outputTokens: delta,
|
|
318
|
+
cachedInputTokens: 0,
|
|
319
|
+
reasoningOutputTokens: 0,
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
prev = snapshot;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return entries;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function parseLegacyTokens(base) {
|
|
329
|
+
const dbPath = join(base, 'dev_data', 'devdata.sqlite');
|
|
330
|
+
const jsonlPath = join(base, 'dev_data', 'tokens_generated.jsonl');
|
|
331
|
+
let rows;
|
|
332
|
+
if (existsSync(dbPath)) {
|
|
333
|
+
rows = readLegacyDb(dbPath);
|
|
334
|
+
} else if (existsSync(jsonlPath)) {
|
|
335
|
+
rows = readLegacyJsonl(jsonlPath);
|
|
336
|
+
} else {
|
|
337
|
+
rows = [];
|
|
338
|
+
}
|
|
339
|
+
return rowsToLegacyEntries(rows);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function parse() {
|
|
343
|
+
const userPath = getKiroUserPath();
|
|
344
|
+
if (!userPath) return { buckets: [], sessions: [] };
|
|
345
|
+
|
|
346
|
+
const snapshots = await readUsageSnapshots(userPath);
|
|
347
|
+
const entries = snapshotsToCreditEntries(snapshots);
|
|
348
|
+
if (entries.length > 0) {
|
|
349
|
+
return { buckets: aggregateToBuckets(entries), sessions: [] };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Keep old token telemetry available for explicit debugging, but do not use
|
|
353
|
+
// it by default: it is not Kiro's billing source and causes false model rows.
|
|
354
|
+
if (process.env.VIBE_USAGE_KIRO_LEGACY_TOKENS === '1') {
|
|
355
|
+
const base = getKiroBasePath();
|
|
356
|
+
if (!base) return { buckets: [], sessions: [] };
|
|
357
|
+
const legacyEntries = parseLegacyTokens(base);
|
|
358
|
+
return { buckets: aggregateToBuckets(legacyEntries), sessions: [] };
|
|
359
|
+
}
|
|
229
360
|
|
|
230
|
-
|
|
361
|
+
// state.vscdb contains only the latest cumulative credit snapshot. The parser
|
|
362
|
+
// stays stateless, so a single cumulative point cannot be uploaded as a bucket
|
|
363
|
+
// without double-counting on later syncs.
|
|
364
|
+
return { buckets: [], sessions: [] };
|
|
231
365
|
}
|
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
|
-
|
|
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',
|