@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 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,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
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,145 @@
1
- import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
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
- for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
14
- if (!entry.isDirectory()) continue;
15
- const chatsDir = join(baseDir, entry.name, 'chats');
16
- if (!existsSync(chatsDir)) continue;
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
- for (const f of readdirSync(chatsDir)) {
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 results;
91
+ return null;
29
92
  }
30
- return results;
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
- let data;
43
- try {
44
- data = JSON.parse(readFileSync(filePath, 'utf-8'));
45
- } catch {
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
- const messages = data.messages || data.history || [];
50
- for (const msg of messages) {
51
- const timestamp = msg.timestamp || msg.createTime || data.createTime;
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: 'unknown',
170
+ project,
61
171
  timestamp: ts,
62
172
  role,
63
173
  });
64
174
 
65
- const tokens = msg.tokens;
66
- const usage = msg.usage || msg.usageMetadata || msg.token_count;
67
- if (!tokens && !usage) continue;
68
-
69
- if (tokens) {
70
- const cached = tokens.cached || 0;
71
- const thoughts = tokens.thoughts || 0;
72
- entries.push({
73
- source: 'gemini-cli',
74
- model: msg.model || data.model || 'unknown',
75
- project: 'unknown',
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