@vibe-cafe/vibe-usage 0.8.1 → 0.8.3

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
@@ -46,11 +46,11 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
46
46
 
47
47
  | Tool | Data Location |
48
48
  |------|---------------|
49
- | Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only) |
49
+ | Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only); also scans `$CLAUDE_CONFIG_DIR` when set (deduped), so relocated configs and GUI/CLI env mismatches are both covered |
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.3",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,18 +1,54 @@
1
- import { readdirSync, readFileSync, existsSync } from 'node:fs';
1
+ import { readdirSync, readFileSync, existsSync, realpathSync } from 'node:fs';
2
2
  import { join, basename, sep } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { aggregateToBuckets, extractSessions } from './index.js';
5
5
 
6
6
  /**
7
7
  * Stateless Claude Code parser.
8
- * Reads ALL *.jsonl files under ~/.claude/projects/ and extracts per-message
8
+ * Reads ALL *.jsonl files under <root>/projects/ and extracts per-message
9
9
  * token usage from assistant messages. No state file needed — every sync
10
10
  * computes the full bucket totals from raw data, making server-side
11
11
  * ON CONFLICT ... DO UPDATE SET idempotent.
12
+ *
13
+ * Roots: always ~/.claude, plus $CLAUDE_CONFIG_DIR when set to a different
14
+ * path. Claude Code itself relocates its whole tree (incl. projects/) to
15
+ * $CLAUDE_CONFIG_DIR and uses only that dir — but a GUI launched from the
16
+ * Dock may not inherit the shell's env, so usage can be split across both
17
+ * roots. We scan both and dedup so neither source is missed or double-counted.
12
18
  */
13
19
 
14
- const CLAUDE_PROJECTS_DIR = join(homedir(), '.claude', 'projects');
15
- const CLAUDE_TRANSCRIPTS_DIR = join(homedir(), '.claude', 'transcripts');
20
+ /**
21
+ * Resolve the set of Claude config roots to scan.
22
+ * Always includes ~/.claude; adds $CLAUDE_CONFIG_DIR when set and it resolves
23
+ * to a different real path. Deduped by canonical path.
24
+ */
25
+ function getClaudeRoots() {
26
+ const roots = [join(homedir(), '.claude')];
27
+
28
+ const cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
29
+ if (cfg) {
30
+ let custom = cfg;
31
+ if (custom.startsWith('~')) custom = join(homedir(), custom.slice(1));
32
+ custom = custom.replace(/[/\\]+$/, '') || custom;
33
+ roots.push(custom);
34
+ }
35
+
36
+ // Dedup by canonical path (realpath when the dir exists, else the raw string).
37
+ const seen = new Set();
38
+ const unique = [];
39
+ for (const r of roots) {
40
+ let key = r;
41
+ try {
42
+ key = realpathSync(r);
43
+ } catch {
44
+ // dir may not exist yet — fall back to the literal path
45
+ }
46
+ if (seen.has(key)) continue;
47
+ seen.add(key);
48
+ unique.push(r);
49
+ }
50
+ return unique;
51
+ }
16
52
 
17
53
  /**
18
54
  * Recursively find all .jsonl files under a directory.
@@ -39,15 +75,22 @@ function findJsonlFiles(dir) {
39
75
  }
40
76
 
41
77
  /**
42
- * Extract project name from file path.
43
- * Path format: ~/.claude/projects/{encodedProjectPath}/{sessionId}.jsonl
44
- * The encodedProjectPath uses dashes for separators (e.g. -Users-foo-myproject).
45
- * We extract the last path segment as the project name.
78
+ * Path of a project file relative to its root's projects/ dir, e.g.
79
+ * "<root>/projects/-Users-foo-app/abc.jsonl" "-Users-foo-app/abc.jsonl".
80
+ * Used both for project-name extraction and cross-root dedup.
81
+ */
82
+ function projectRelativePath(filePath, projectsDir) {
83
+ const prefix = projectsDir + sep;
84
+ return filePath.startsWith(prefix) ? filePath.slice(prefix.length) : null;
85
+ }
86
+
87
+ /**
88
+ * Extract project name from a projects-relative path.
89
+ * The first segment is the dash-encoded project path (e.g. -Users-foo-myproject);
90
+ * we take its last component as the project name.
46
91
  */
47
- function extractProject(filePath) {
48
- const projectsPrefix = CLAUDE_PROJECTS_DIR + sep;
49
- if (!filePath.startsWith(projectsPrefix)) return 'unknown';
50
- const relative = filePath.slice(projectsPrefix.length);
92
+ function extractProject(relative) {
93
+ if (!relative) return 'unknown';
51
94
  const firstSeg = relative.split(sep)[0];
52
95
  if (!firstSeg) return 'unknown';
53
96
  const parts = firstSeg.split('-').filter(Boolean);
@@ -58,16 +101,21 @@ function extractSessionId(filePath) {
58
101
  return basename(filePath, '.jsonl');
59
102
  }
60
103
 
61
- export async function parse() {
62
- const entries = [];
63
- const sessionEvents = [];
64
- const seenUuids = new Set();
65
- const seenSessionIds = new Set();
66
-
67
- // --- projects/ directory: extract BOTH token buckets AND session events ---
68
- const projectFiles = findJsonlFiles(CLAUDE_PROJECTS_DIR);
104
+ /**
105
+ * Scan one root's projects/ dir → token entries + session events (mutates ctx).
106
+ */
107
+ function scanProjectsRoot(root, ctx) {
108
+ const projectsDir = join(root, 'projects');
109
+
110
+ for (const filePath of findJsonlFiles(projectsDir)) {
111
+ const relative = projectRelativePath(filePath, projectsDir);
112
+ // Same session present under two roots (e.g. data copied between them):
113
+ // process it once so session message counts aren't inflated.
114
+ if (relative !== null) {
115
+ if (ctx.seenProjectFiles.has(relative)) continue;
116
+ ctx.seenProjectFiles.add(relative);
117
+ }
69
118
 
70
- for (const filePath of projectFiles) {
71
119
  let content;
72
120
  try {
73
121
  content = readFileSync(filePath, 'utf-8');
@@ -75,9 +123,9 @@ export async function parse() {
75
123
  continue;
76
124
  }
77
125
 
78
- const project = extractProject(filePath);
126
+ const project = extractProject(relative);
79
127
  const sessionId = extractSessionId(filePath);
80
- seenSessionIds.add(sessionId);
128
+ ctx.seenSessionIds.add(sessionId);
81
129
 
82
130
  for (const line of content.split('\n')) {
83
131
  if (!line.trim()) continue;
@@ -90,7 +138,7 @@ export async function parse() {
90
138
  if (isNaN(ts.getTime())) continue;
91
139
 
92
140
  if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
93
- sessionEvents.push({
141
+ ctx.sessionEvents.push({
94
142
  sessionId,
95
143
  source: 'claude-code',
96
144
  project,
@@ -108,11 +156,11 @@ export async function parse() {
108
156
 
109
157
  const uuid = obj.uuid;
110
158
  if (uuid) {
111
- if (seenUuids.has(uuid)) continue;
112
- seenUuids.add(uuid);
159
+ if (ctx.seenUuids.has(uuid)) continue;
160
+ ctx.seenUuids.add(uuid);
113
161
  }
114
162
 
115
- entries.push({
163
+ ctx.entries.push({
116
164
  source: 'claude-code',
117
165
  model: msg.model || 'unknown',
118
166
  project,
@@ -127,13 +175,17 @@ export async function parse() {
127
175
  }
128
176
  }
129
177
  }
178
+ }
130
179
 
131
- // --- transcripts/ directory: extract session events ONLY (no token data) ---
132
- const transcriptFiles = findJsonlFiles(CLAUDE_TRANSCRIPTS_DIR);
133
-
134
- for (const filePath of transcriptFiles) {
180
+ /**
181
+ * Scan one root's transcripts/ dir → session events only (no token data).
182
+ * Skips sessions already covered by a projects/ or transcripts/ scan.
183
+ */
184
+ function scanTranscriptsRoot(root, ctx) {
185
+ for (const filePath of findJsonlFiles(join(root, 'transcripts'))) {
135
186
  const sessionId = extractSessionId(filePath);
136
- if (seenSessionIds.has(sessionId)) continue;
187
+ if (ctx.seenSessionIds.has(sessionId)) continue;
188
+ ctx.seenSessionIds.add(sessionId);
137
189
 
138
190
  let content;
139
191
  try {
@@ -153,7 +205,7 @@ export async function parse() {
153
205
  if (isNaN(ts.getTime())) continue;
154
206
 
155
207
  if (obj.type === 'user' || obj.type === 'assistant' || obj.type === 'tool_use' || obj.type === 'tool_result') {
156
- sessionEvents.push({
208
+ ctx.sessionEvents.push({
157
209
  sessionId,
158
210
  source: 'claude-code',
159
211
  project: 'unknown',
@@ -166,6 +218,26 @@ export async function parse() {
166
218
  }
167
219
  }
168
220
  }
221
+ }
169
222
 
170
- return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };
223
+ export async function parse() {
224
+ const ctx = {
225
+ entries: [],
226
+ sessionEvents: [],
227
+ seenUuids: new Set(),
228
+ seenSessionIds: new Set(),
229
+ seenProjectFiles: new Set(), // projects-relative path → dedup same session across roots
230
+ };
231
+
232
+ const roots = getClaudeRoots();
233
+
234
+ // projects/ yields BOTH token buckets and session events.
235
+ for (const root of roots) scanProjectsRoot(root, ctx);
236
+ // transcripts/ yields session events only, for sessions not already covered.
237
+ for (const root of roots) scanTranscriptsRoot(root, ctx);
238
+
239
+ return {
240
+ buckets: aggregateToBuckets(ctx.entries),
241
+ sessions: extractSessions(ctx.sessionEvents),
242
+ };
171
243
  }
@@ -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
 
package/src/tools.js CHANGED
@@ -80,6 +80,20 @@ function findOpenclawDataDirs() {
80
80
  return dirs;
81
81
  }
82
82
 
83
+ // Claude Code lives in ~/.claude/projects, but $CLAUDE_CONFIG_DIR relocates its
84
+ // whole tree. Detect either so a user who only set CLAUDE_CONFIG_DIR is still
85
+ // recognized (the parser scans both roots; see parsers/claude-code.js).
86
+ function findClaudeCodeDataDirs() {
87
+ const dirs = [join(homedir(), '.claude', 'projects')];
88
+ const cfg = process.env.CLAUDE_CONFIG_DIR?.trim();
89
+ if (cfg) {
90
+ let custom = cfg.startsWith('~') ? join(homedir(), cfg.slice(1)) : cfg;
91
+ custom = custom.replace(/[/\\]+$/, '') || custom;
92
+ dirs.push(join(custom, 'projects'));
93
+ }
94
+ return dirs.filter(existsSync);
95
+ }
96
+
83
97
  // Codex keeps live sessions in ~/.codex/sessions and moves completed ones to
84
98
  // ~/.codex/archived_sessions. Detect Codex if either dir exists, so a user
85
99
  // whose sessions have all been archived is still recognized.
@@ -100,6 +114,7 @@ export const TOOLS = [
100
114
  name: 'Claude Code',
101
115
  id: 'claude-code',
102
116
  dataDir: join(homedir(), '.claude', 'projects'),
117
+ detectDataDirs: findClaudeCodeDataDirs,
103
118
  },
104
119
  {
105
120
  name: 'Cline',