@vibe-cafe/vibe-usage 0.7.17 → 0.7.19

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
@@ -47,7 +47,7 @@ npx @vibe-cafe/vibe-usage status # Show config & detected tools
47
47
  | Tool | Data Location |
48
48
  |------|---------------|
49
49
  | Claude Code | `~/.claude/projects/` (tokens + sessions), `~/.claude/transcripts/` (sessions only) |
50
- | Codex CLI | `~/.codex/sessions/` |
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`) |
53
53
  | Gemini CLI | `~/.gemini/tmp/` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.7.17",
3
+ "version": "0.7.19",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,9 +1,21 @@
1
- import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
1
+ import { createReadStream, readdirSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { createInterface } from 'node:readline';
4
5
  import { aggregateToBuckets, extractSessions } from './index.js';
5
6
 
6
- const SESSIONS_DIR = join(homedir(), '.codex', 'sessions');
7
+ // Codex stores live sessions in ~/.codex/sessions and, once a session is
8
+ // "completed", moves its rollout file verbatim into ~/.codex/archived_sessions.
9
+ // A session can be archived between two syncs, so scanning only the live dir
10
+ // loses that session's usage forever. We scan both: the parser is stateless
11
+ // and the server dedups on (source, sessionHash/bucket), so re-reading an
12
+ // archived file that was already synced from sessions/ is idempotent. Indexing
13
+ // both together also keeps fork replay-skip correct when a fork and its parent
14
+ // end up split across the two directories.
15
+ const SESSIONS_DIRS = [
16
+ join(homedir(), '.codex', 'sessions'),
17
+ join(homedir(), '.codex', 'archived_sessions'),
18
+ ];
7
19
 
8
20
  /**
9
21
  * Recursively find all .jsonl files under a directory.
@@ -27,32 +39,63 @@ function findJsonlFiles(dir) {
27
39
  return results;
28
40
  }
29
41
 
42
+ function readLines(filePath) {
43
+ return createInterface({
44
+ input: createReadStream(filePath, { encoding: 'utf-8' }),
45
+ crlfDelay: Infinity,
46
+ });
47
+ }
48
+
49
+ function extractProject(meta) {
50
+ if (meta.git?.repository_url) {
51
+ // e.g. https://github.com/org/repo.git → org/repo
52
+ const match = meta.git.repository_url.match(/([^/]+\/[^/]+?)(?:\.git)?$/);
53
+ if (match) return match[1];
54
+ }
55
+ if (meta.cwd) return meta.cwd.split('/').pop() || 'unknown';
56
+ return 'unknown';
57
+ }
58
+
30
59
  /**
31
- * Count the leading `event_msg/token_count` records in a session file.
32
- * Used to size the replayed-history block of a forked session: a fork
33
- * copies the original conversation verbatim, so it begins with exactly as
34
- * many token_count records as the original session has in total.
60
+ * Stream a session file once and extract its index metadata: the session
61
+ * id, the forked-from id, the project name, and the total count of
62
+ * `event_msg/token_count` records. The token_count total is used to size
63
+ * the replayed-history block of a forked session a fork copies the
64
+ * original conversation verbatim, so it begins with exactly as many
65
+ * token_count records as the source session has in total.
35
66
  */
36
- function countTokenCountRecords(content) {
37
- let n = 0;
38
- for (const line of content.split('\n')) {
67
+ async function indexSessionFile(filePath) {
68
+ let sessionId = null;
69
+ let forkedFromId = null;
70
+ let sessionProject = 'unknown';
71
+ let tokenCountRecords = 0;
72
+
73
+ for await (const line of readLines(filePath)) {
39
74
  if (!line.trim()) continue;
40
75
  try {
41
76
  const obj = JSON.parse(line);
42
- if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') n++;
77
+ if (obj.type === 'session_meta' && obj.payload) {
78
+ const meta = obj.payload;
79
+ sessionId = meta.id || sessionId;
80
+ forkedFromId = meta.forked_from_id || null;
81
+ sessionProject = extractProject(meta);
82
+ } else if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
83
+ tokenCountRecords++;
84
+ }
43
85
  } catch {
44
86
  continue;
45
87
  }
46
88
  }
47
- return n;
89
+
90
+ return { sessionId, forkedFromId, sessionProject, tokenCountRecords };
48
91
  }
49
92
 
50
93
  export async function parse() {
51
- if (!existsSync(SESSIONS_DIR)) return { buckets: [], sessions: [] };
94
+ if (!SESSIONS_DIRS.some(existsSync)) return { buckets: [], sessions: [] };
52
95
 
53
96
  const entries = [];
54
97
  const sessionEvents = [];
55
- const files = findJsonlFiles(SESSIONS_DIR);
98
+ const files = SESSIONS_DIRS.flatMap(findJsonlFiles);
56
99
  if (files.length === 0) return { buckets: [], sessions: [] };
57
100
 
58
101
  // Pass 1: index every session by its UUID and count its token_count
@@ -66,30 +109,17 @@ export async function parse() {
66
109
  // instant, within the same 1–3s window), so we instead skip exactly the
67
110
  // original session's token_count count from the start of each fork.
68
111
  const tokenCountById = new Map(); // sessionId → number of token_count records
69
- const fileMeta = new Map(); // filePath { content, forkedFromId }
112
+ const fileMeta = new Map(); // filePath -> { forkedFromId, sessionProject }
70
113
  for (const filePath of files) {
71
- let content;
114
+ let meta;
72
115
  try {
73
- content = readFileSync(filePath, 'utf-8');
116
+ meta = await indexSessionFile(filePath);
74
117
  } catch {
75
118
  continue;
76
119
  }
77
- let sessionId = null;
78
- let forkedFromId = null;
79
- for (const line of content.split('\n')) {
80
- if (!line.trim()) continue;
81
- try {
82
- const obj = JSON.parse(line);
83
- if (obj.type === 'session_meta' && obj.payload) {
84
- sessionId = obj.payload.id || null;
85
- forkedFromId = obj.payload.forked_from_id || null;
86
- }
87
- } catch { /* ignore */ }
88
- break; // session_meta is always the first line
89
- }
90
- fileMeta.set(filePath, { content, forkedFromId });
91
- if (sessionId) {
92
- tokenCountById.set(sessionId, countTokenCountRecords(content));
120
+ fileMeta.set(filePath, meta);
121
+ if (meta.sessionId) {
122
+ tokenCountById.set(meta.sessionId, meta.tokenCountRecords);
93
123
  }
94
124
  }
95
125
 
@@ -97,9 +127,7 @@ export async function parse() {
97
127
  for (const filePath of files) {
98
128
  const fm = fileMeta.get(filePath);
99
129
  if (!fm) continue;
100
- const { content, forkedFromId } = fm;
101
-
102
- const lines = content.split('\n');
130
+ const { forkedFromId } = fm;
103
131
 
104
132
  // How many leading token_count records are copied history. A fork's file
105
133
  // begins with the *entire* source file replayed verbatim, so the count
@@ -116,31 +144,17 @@ export async function parse() {
116
144
  }
117
145
  let tokenCountSeen = 0;
118
146
 
119
- // Extract project name from session_meta.
120
- let sessionProject = 'unknown';
121
- let sessionModel = 'unknown';
122
- for (const line of lines) {
123
- if (!line.trim()) continue;
124
- try {
125
- const obj = JSON.parse(line);
126
- if (obj.type === 'session_meta' && obj.payload) {
127
- const meta = obj.payload;
128
- if (meta.cwd) {
129
- sessionProject = meta.cwd.split('/').pop() || 'unknown';
130
- }
131
- if (meta.git?.repository_url) {
132
- // e.g. https://github.com/org/repo.git → org/repo
133
- const match = meta.git.repository_url.match(/([^/]+\/[^/]+?)(?:\.git)?$/);
134
- if (match) sessionProject = match[1];
135
- }
136
- }
137
- } catch { /* ignore */ }
138
- break; // session_meta is always the first line
139
- }
147
+ const sessionProject = fm.sessionProject;
148
+ // Group timing events by the real Codex session id, not the file path: the
149
+ // same session can briefly exist in both sessions/ and archived_sessions/
150
+ // (mid-archive, or a re-synced archive). Path-keyed grouping would emit it
151
+ // as two different sessionHashes and double-count its session stats. Fall
152
+ // back to the path only when the id is unknown (corrupt/missing meta).
153
+ const sessionKey = fm.sessionId || filePath;
140
154
 
141
155
  let turnContextModel = 'unknown';
142
156
  const prevTotal = new Map();
143
- for (const line of lines) {
157
+ for await (const line of readLines(filePath)) {
144
158
  if (!line.trim()) continue;
145
159
  try {
146
160
  const obj = JSON.parse(line);
@@ -164,7 +178,7 @@ export async function parse() {
164
178
  if (!isReplay) {
165
179
  const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
166
180
  sessionEvents.push({
167
- sessionId: filePath,
181
+ sessionId: sessionKey,
168
182
  source: 'codex',
169
183
  project: sessionProject,
170
184
  timestamp: evTs,
@@ -225,7 +239,7 @@ export async function parse() {
225
239
  if (!usage) continue;
226
240
  if (isReplayedHistory) continue;
227
241
 
228
- const model = info.model || payload.model || turnContextModel || sessionModel;
242
+ const model = info.model || payload.model || turnContextModel || 'unknown';
229
243
 
230
244
  // OpenAI API: input_tokens INCLUDES cached, output_tokens INCLUDES reasoning.
231
245
  // Normalize to Anthropic-style semantics where each field is non-overlapping.
package/src/tools.js CHANGED
@@ -80,6 +80,16 @@ function findOpenclawDataDirs() {
80
80
  return dirs;
81
81
  }
82
82
 
83
+ // Codex keeps live sessions in ~/.codex/sessions and moves completed ones to
84
+ // ~/.codex/archived_sessions. Detect Codex if either dir exists, so a user
85
+ // whose sessions have all been archived is still recognized.
86
+ function findCodexDataDirs() {
87
+ return [
88
+ join(homedir(), '.codex', 'sessions'),
89
+ join(homedir(), '.codex', 'archived_sessions'),
90
+ ].filter(existsSync);
91
+ }
92
+
83
93
  export const TOOLS = [
84
94
  {
85
95
  name: 'Antigravity',
@@ -101,6 +111,7 @@ export const TOOLS = [
101
111
  name: 'Codex CLI',
102
112
  id: 'codex',
103
113
  dataDir: join(homedir(), '.codex', 'sessions'),
114
+ detectDataDirs: findCodexDataDirs,
104
115
  },
105
116
  {
106
117
  name: 'GitHub Copilot CLI',