@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 +1 -1
- package/package.json +1 -1
- package/src/parsers/codex.js +73 -59
- package/src/tools.js +11 -0
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
package/src/parsers/codex.js
CHANGED
|
@@ -1,9 +1,21 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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
|
|
37
|
-
let
|
|
38
|
-
|
|
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 === '
|
|
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
|
-
|
|
89
|
+
|
|
90
|
+
return { sessionId, forkedFromId, sessionProject, tokenCountRecords };
|
|
48
91
|
}
|
|
49
92
|
|
|
50
93
|
export async function parse() {
|
|
51
|
-
if (!existsSync
|
|
94
|
+
if (!SESSIONS_DIRS.some(existsSync)) return { buckets: [], sessions: [] };
|
|
52
95
|
|
|
53
96
|
const entries = [];
|
|
54
97
|
const sessionEvents = [];
|
|
55
|
-
const files = findJsonlFiles
|
|
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
|
|
112
|
+
const fileMeta = new Map(); // filePath -> { forkedFromId, sessionProject }
|
|
70
113
|
for (const filePath of files) {
|
|
71
|
-
let
|
|
114
|
+
let meta;
|
|
72
115
|
try {
|
|
73
|
-
|
|
116
|
+
meta = await indexSessionFile(filePath);
|
|
74
117
|
} catch {
|
|
75
118
|
continue;
|
|
76
119
|
}
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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 {
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
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:
|
|
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 ||
|
|
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',
|