@vibe-cafe/vibe-usage 0.8.1 → 0.8.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 +1 -1
- package/package.json +1 -1
- package/src/parsers/gemini-cli.js +147 -57
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
|
|
|
50
50
|
| Codex CLI | `~/.codex/sessions/` and `~/.codex/archived_sessions/` |
|
|
51
51
|
| GitHub Copilot CLI | `~/.copilot/session-state/*/events.jsonl` |
|
|
52
52
|
| Cursor | `state.vscdb` (SQLite, reads `cursorAuth/accessToken`, fetches CSV from `cursor.com`); cloud data is stamped with a fixed `cursor-cloud` hostname so multi-machine setups don't double-count |
|
|
53
|
-
| Gemini CLI | `~/.gemini/tmp
|
|
53
|
+
| Gemini CLI | `~/.gemini/tmp/<project_hash>/chats/session-*.jsonl` (current line-delimited format) and legacy `session-*.json`; recurses into nested subagent sessions |
|
|
54
54
|
| OpenCode | `~/.local/share/opencode/opencode.db` (SQLite, `json_extract` query) |
|
|
55
55
|
| OpenClaw | `~/.openclaw/agents/`, `~/.openclaw-<profile>/agents/` (profile deployments) |
|
|
56
56
|
| pi | `~/.pi/agent/sessions/` |
|
package/package.json
CHANGED
|
@@ -1,33 +1,145 @@
|
|
|
1
|
-
import { readdirSync, readFileSync,
|
|
2
|
-
import { join } from 'node:path';
|
|
1
|
+
import { readdirSync, readFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, basename } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { aggregateToBuckets, extractSessions } from './index.js';
|
|
5
5
|
|
|
6
6
|
const TMP_DIR = join(homedir(), '.gemini', 'tmp');
|
|
7
7
|
|
|
8
|
+
// Gemini CLI session storage:
|
|
9
|
+
// ~/.gemini/tmp/<project_hash>/chats/session-<ts>-<id>.jsonl (current, v0.39+)
|
|
10
|
+
// ~/.gemini/tmp/<project_hash>/chats/session-<ts>-<id>.json (legacy, single JSON object)
|
|
11
|
+
// ~/.gemini/tmp/<project_hash>/chats/<parent_id>/<sub_id>.jsonl (subagent sessions, nested)
|
|
12
|
+
// The .jsonl migration (PR #23749, ~v0.39.0) made the old .json-only glob miss every new
|
|
13
|
+
// session — collect both extensions, and recurse one level for nested subagent files.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Walk each project's chats/ directory and collect every session file
|
|
17
|
+
* (both .json and .jsonl), descending into subagent subdirectories.
|
|
18
|
+
*/
|
|
8
19
|
function findSessionFiles(baseDir) {
|
|
9
20
|
const results = [];
|
|
10
21
|
if (!existsSync(baseDir)) return results;
|
|
11
22
|
|
|
23
|
+
let projectDirs;
|
|
24
|
+
try {
|
|
25
|
+
projectDirs = readdirSync(baseDir, { withFileTypes: true });
|
|
26
|
+
} catch {
|
|
27
|
+
return results;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
for (const entry of projectDirs) {
|
|
31
|
+
if (!entry.isDirectory()) continue;
|
|
32
|
+
collectChatFiles(join(baseDir, entry.name, 'chats'), results, 0);
|
|
33
|
+
}
|
|
34
|
+
return results;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function collectChatFiles(dir, out, depth) {
|
|
38
|
+
if (depth > 2) return; // chats/ + nested subagent dirs is as deep as it goes
|
|
39
|
+
let entries;
|
|
12
40
|
try {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
41
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
42
|
+
} catch {
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
for (const e of entries) {
|
|
46
|
+
const full = join(dir, e.name);
|
|
47
|
+
if (e.isDirectory()) {
|
|
48
|
+
collectChatFiles(full, out, depth + 1);
|
|
49
|
+
} else if (e.name.endsWith('.jsonl') || e.name.endsWith('.json')) {
|
|
50
|
+
out.push(full);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Read a session file into a uniform { messages, directories } shape.
|
|
57
|
+
* .jsonl: line 1 is session metadata, each following line is one record.
|
|
58
|
+
* .json: a single ConversationRecord object with a messages[] array.
|
|
59
|
+
*/
|
|
60
|
+
function readRecords(filePath) {
|
|
61
|
+
let raw;
|
|
62
|
+
try {
|
|
63
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
64
|
+
} catch {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (filePath.endsWith('.jsonl')) {
|
|
69
|
+
const messages = [];
|
|
70
|
+
let directories = null;
|
|
71
|
+
for (const line of raw.split('\n')) {
|
|
72
|
+
const trimmed = line.trim();
|
|
73
|
+
if (!trimmed) continue;
|
|
74
|
+
let obj;
|
|
17
75
|
try {
|
|
18
|
-
|
|
19
|
-
if (f.startsWith('session-') && f.endsWith('.json')) {
|
|
20
|
-
results.push(join(chatsDir, f));
|
|
21
|
-
}
|
|
22
|
-
}
|
|
76
|
+
obj = JSON.parse(trimmed);
|
|
23
77
|
} catch {
|
|
24
78
|
continue;
|
|
25
79
|
}
|
|
80
|
+
// The metadata line carries directories; message lines carry a `type`.
|
|
81
|
+
if (!directories && Array.isArray(obj.directories)) directories = obj.directories;
|
|
82
|
+
if (typeof obj.type === 'string' || typeof obj.role === 'string') messages.push(obj);
|
|
26
83
|
}
|
|
84
|
+
return { messages, directories };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let data;
|
|
88
|
+
try {
|
|
89
|
+
data = JSON.parse(raw);
|
|
27
90
|
} catch {
|
|
28
|
-
return
|
|
91
|
+
return null;
|
|
29
92
|
}
|
|
30
|
-
return
|
|
93
|
+
return {
|
|
94
|
+
messages: data.messages || data.history || [],
|
|
95
|
+
directories: Array.isArray(data.directories) ? data.directories : null,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Model/assistant messages are recorded as type 'gemini'; user turns as 'user'.
|
|
100
|
+
// info/error/warning are system noise and skipped. `role` is accepted as a
|
|
101
|
+
// fallback for any older format that used it.
|
|
102
|
+
function classifyRole(msg) {
|
|
103
|
+
const t = msg.type ?? msg.role;
|
|
104
|
+
if (t === 'user') return 'user';
|
|
105
|
+
if (t === 'gemini' || t === 'model' || t === 'assistant') return 'assistant';
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Tokens live in msg.tokens.{input,output,cached,thoughts} (TokensSummary, where
|
|
110
|
+
// `input` already includes cached). Fall back to the raw Gemini API usageMetadata
|
|
111
|
+
// shape for any legacy record that stored it.
|
|
112
|
+
function extractTokens(msg) {
|
|
113
|
+
const t = msg.tokens;
|
|
114
|
+
if (t) {
|
|
115
|
+
const cached = t.cached || 0;
|
|
116
|
+
const thoughts = t.thoughts || 0;
|
|
117
|
+
return {
|
|
118
|
+
inputTokens: (t.input || 0) - cached,
|
|
119
|
+
outputTokens: (t.output || 0) - thoughts,
|
|
120
|
+
cachedInputTokens: cached,
|
|
121
|
+
reasoningOutputTokens: thoughts,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const u = msg.usageMetadata || msg.usage;
|
|
125
|
+
if (u) {
|
|
126
|
+
const cached = u.cachedContentTokenCount || 0;
|
|
127
|
+
const thoughts = u.thoughtsTokenCount || 0;
|
|
128
|
+
return {
|
|
129
|
+
inputTokens: (u.promptTokenCount || u.input_tokens || 0) - cached,
|
|
130
|
+
outputTokens: (u.candidatesTokenCount || u.output_tokens || 0) - thoughts,
|
|
131
|
+
cachedInputTokens: cached,
|
|
132
|
+
reasoningOutputTokens: thoughts,
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function projectFromDirectories(directories) {
|
|
139
|
+
if (!directories || directories.length === 0) return 'unknown';
|
|
140
|
+
const first = directories[0];
|
|
141
|
+
if (!first) return 'unknown';
|
|
142
|
+
return basename(String(first).replace(/[\\/]+$/, '')) || 'unknown';
|
|
31
143
|
}
|
|
32
144
|
|
|
33
145
|
export async function parse() {
|
|
@@ -38,61 +150,39 @@ export async function parse() {
|
|
|
38
150
|
const sessionEvents = [];
|
|
39
151
|
|
|
40
152
|
for (const filePath of sessionFiles) {
|
|
153
|
+
const record = readRecords(filePath);
|
|
154
|
+
if (!record) continue;
|
|
41
155
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
continue;
|
|
47
|
-
}
|
|
156
|
+
const project = projectFromDirectories(record.directories);
|
|
157
|
+
|
|
158
|
+
for (const msg of record.messages) {
|
|
159
|
+
const role = classifyRole(msg);
|
|
160
|
+
if (!role) continue;
|
|
48
161
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
if (!timestamp) continue;
|
|
53
|
-
const ts = new Date(timestamp);
|
|
162
|
+
const stamp = msg.timestamp || msg.createTime;
|
|
163
|
+
if (!stamp) continue;
|
|
164
|
+
const ts = new Date(stamp);
|
|
54
165
|
if (isNaN(ts.getTime())) continue;
|
|
55
166
|
|
|
56
|
-
const role = (msg.role === 'user') ? 'user' : 'assistant';
|
|
57
167
|
sessionEvents.push({
|
|
58
168
|
sessionId: filePath,
|
|
59
169
|
source: 'gemini-cli',
|
|
60
|
-
project
|
|
170
|
+
project,
|
|
61
171
|
timestamp: ts,
|
|
62
172
|
role,
|
|
63
173
|
});
|
|
64
174
|
|
|
65
|
-
|
|
66
|
-
const
|
|
67
|
-
if (!tokens
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
timestamp: ts,
|
|
77
|
-
inputTokens: (tokens.input || 0) - cached,
|
|
78
|
-
outputTokens: (tokens.output || 0) - thoughts,
|
|
79
|
-
cachedInputTokens: cached,
|
|
80
|
-
reasoningOutputTokens: thoughts,
|
|
81
|
-
});
|
|
82
|
-
} else {
|
|
83
|
-
const cached = usage.cachedContentTokenCount || 0;
|
|
84
|
-
const thoughts = usage.thoughtsTokenCount || 0;
|
|
85
|
-
entries.push({
|
|
86
|
-
source: 'gemini-cli',
|
|
87
|
-
model: msg.model || data.model || 'unknown',
|
|
88
|
-
project: 'unknown',
|
|
89
|
-
timestamp: ts,
|
|
90
|
-
inputTokens: (usage.promptTokenCount || usage.input_tokens || 0) - cached,
|
|
91
|
-
outputTokens: (usage.candidatesTokenCount || usage.output_tokens || 0) - thoughts,
|
|
92
|
-
cachedInputTokens: cached,
|
|
93
|
-
reasoningOutputTokens: thoughts,
|
|
94
|
-
});
|
|
95
|
-
}
|
|
175
|
+
if (role !== 'assistant') continue;
|
|
176
|
+
const tokens = extractTokens(msg);
|
|
177
|
+
if (!tokens) continue;
|
|
178
|
+
|
|
179
|
+
entries.push({
|
|
180
|
+
source: 'gemini-cli',
|
|
181
|
+
model: msg.model || 'unknown',
|
|
182
|
+
project,
|
|
183
|
+
timestamp: ts,
|
|
184
|
+
...tokens,
|
|
185
|
+
});
|
|
96
186
|
}
|
|
97
187
|
}
|
|
98
188
|
|