@vibe-cafe/vibe-usage 0.7.17 → 0.7.18

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/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.18",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,7 @@
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
7
  const SESSIONS_DIR = join(homedir(), '.codex', 'sessions');
@@ -27,24 +28,55 @@ function findJsonlFiles(dir) {
27
28
  return results;
28
29
  }
29
30
 
31
+ function readLines(filePath) {
32
+ return createInterface({
33
+ input: createReadStream(filePath, { encoding: 'utf-8' }),
34
+ crlfDelay: Infinity,
35
+ });
36
+ }
37
+
38
+ function extractProject(meta) {
39
+ if (meta.git?.repository_url) {
40
+ // e.g. https://github.com/org/repo.git → org/repo
41
+ const match = meta.git.repository_url.match(/([^/]+\/[^/]+?)(?:\.git)?$/);
42
+ if (match) return match[1];
43
+ }
44
+ if (meta.cwd) return meta.cwd.split('/').pop() || 'unknown';
45
+ return 'unknown';
46
+ }
47
+
30
48
  /**
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.
49
+ * Stream a session file once and extract its index metadata: the session
50
+ * id, the forked-from id, the project name, and the total count of
51
+ * `event_msg/token_count` records. The token_count total is used to size
52
+ * the replayed-history block of a forked session a fork copies the
53
+ * original conversation verbatim, so it begins with exactly as many
54
+ * token_count records as the source session has in total.
35
55
  */
36
- function countTokenCountRecords(content) {
37
- let n = 0;
38
- for (const line of content.split('\n')) {
56
+ async function indexSessionFile(filePath) {
57
+ let sessionId = null;
58
+ let forkedFromId = null;
59
+ let sessionProject = 'unknown';
60
+ let tokenCountRecords = 0;
61
+
62
+ for await (const line of readLines(filePath)) {
39
63
  if (!line.trim()) continue;
40
64
  try {
41
65
  const obj = JSON.parse(line);
42
- if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') n++;
66
+ if (obj.type === 'session_meta' && obj.payload) {
67
+ const meta = obj.payload;
68
+ sessionId = meta.id || sessionId;
69
+ forkedFromId = meta.forked_from_id || null;
70
+ sessionProject = extractProject(meta);
71
+ } else if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
72
+ tokenCountRecords++;
73
+ }
43
74
  } catch {
44
75
  continue;
45
76
  }
46
77
  }
47
- return n;
78
+
79
+ return { sessionId, forkedFromId, sessionProject, tokenCountRecords };
48
80
  }
49
81
 
50
82
  export async function parse() {
@@ -66,30 +98,17 @@ export async function parse() {
66
98
  // instant, within the same 1–3s window), so we instead skip exactly the
67
99
  // original session's token_count count from the start of each fork.
68
100
  const tokenCountById = new Map(); // sessionId → number of token_count records
69
- const fileMeta = new Map(); // filePath { content, forkedFromId }
101
+ const fileMeta = new Map(); // filePath -> { forkedFromId, sessionProject }
70
102
  for (const filePath of files) {
71
- let content;
103
+ let meta;
72
104
  try {
73
- content = readFileSync(filePath, 'utf-8');
105
+ meta = await indexSessionFile(filePath);
74
106
  } catch {
75
107
  continue;
76
108
  }
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));
109
+ fileMeta.set(filePath, meta);
110
+ if (meta.sessionId) {
111
+ tokenCountById.set(meta.sessionId, meta.tokenCountRecords);
93
112
  }
94
113
  }
95
114
 
@@ -97,9 +116,7 @@ export async function parse() {
97
116
  for (const filePath of files) {
98
117
  const fm = fileMeta.get(filePath);
99
118
  if (!fm) continue;
100
- const { content, forkedFromId } = fm;
101
-
102
- const lines = content.split('\n');
119
+ const { forkedFromId } = fm;
103
120
 
104
121
  // How many leading token_count records are copied history. A fork's file
105
122
  // begins with the *entire* source file replayed verbatim, so the count
@@ -116,31 +133,11 @@ export async function parse() {
116
133
  }
117
134
  let tokenCountSeen = 0;
118
135
 
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
- }
136
+ const sessionProject = fm.sessionProject;
140
137
 
141
138
  let turnContextModel = 'unknown';
142
139
  const prevTotal = new Map();
143
- for (const line of lines) {
140
+ for await (const line of readLines(filePath)) {
144
141
  if (!line.trim()) continue;
145
142
  try {
146
143
  const obj = JSON.parse(line);
@@ -225,7 +222,7 @@ export async function parse() {
225
222
  if (!usage) continue;
226
223
  if (isReplayedHistory) continue;
227
224
 
228
- const model = info.model || payload.model || turnContextModel || sessionModel;
225
+ const model = info.model || payload.model || turnContextModel || 'unknown';
229
226
 
230
227
  // OpenAI API: input_tokens INCLUDES cached, output_tokens INCLUDES reasoning.
231
228
  // Normalize to Anthropic-style semantics where each field is non-overlapping.