@vibe-cafe/vibe-usage 0.7.16 → 0.7.17

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/parsers/codex.js +114 -14
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.7.16",
3
+ "version": "0.7.17",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,26 @@ function findJsonlFiles(dir) {
27
27
  return results;
28
28
  }
29
29
 
30
+ /**
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.
35
+ */
36
+ function countTokenCountRecords(content) {
37
+ let n = 0;
38
+ for (const line of content.split('\n')) {
39
+ if (!line.trim()) continue;
40
+ try {
41
+ const obj = JSON.parse(line);
42
+ if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') n++;
43
+ } catch {
44
+ continue;
45
+ }
46
+ }
47
+ return n;
48
+ }
49
+
30
50
  export async function parse() {
31
51
  if (!existsSync(SESSIONS_DIR)) return { buckets: [], sessions: [] };
32
52
 
@@ -34,19 +54,72 @@ export async function parse() {
34
54
  const sessionEvents = [];
35
55
  const files = findJsonlFiles(SESSIONS_DIR);
36
56
  if (files.length === 0) return { buckets: [], sessions: [] };
37
- for (const filePath of files) {
38
57
 
58
+ // Pass 1: index every session by its UUID and count its token_count
59
+ // records. A forked session (session_meta.payload.forked_from_id) starts
60
+ // with the original conversation replayed verbatim — including every
61
+ // token_count, all timestamped in a burst at the fork instant. Those
62
+ // tokens are already counted from the original session's own file, so
63
+ // re-counting them here double-counts usage and produces a spurious
64
+ // token/cost spike at the fork time. Timestamps cannot distinguish the
65
+ // replay from new activity (the replay burst is stamped at/after the fork
66
+ // instant, within the same 1–3s window), so we instead skip exactly the
67
+ // original session's token_count count from the start of each fork.
68
+ const tokenCountById = new Map(); // sessionId → number of token_count records
69
+ const fileMeta = new Map(); // filePath → { content, forkedFromId }
70
+ for (const filePath of files) {
39
71
  let content;
40
72
  try {
41
73
  content = readFileSync(filePath, 'utf-8');
42
74
  } catch {
43
75
  continue;
44
76
  }
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));
93
+ }
94
+ }
45
95
 
46
- // Extract project name and model from session_meta line
96
+ // Pass 2: parse usage, skipping each fork's replayed-history token_counts.
97
+ for (const filePath of files) {
98
+ const fm = fileMeta.get(filePath);
99
+ if (!fm) continue;
100
+ const { content, forkedFromId } = fm;
101
+
102
+ const lines = content.split('\n');
103
+
104
+ // How many leading token_count records are copied history. A fork's file
105
+ // begins with the *entire* source file replayed verbatim, so the count
106
+ // to skip is the source's total token_count count. This is correct even
107
+ // for chained forks: a fork-of-a-fork replays the parent fork's whole
108
+ // file (which itself already contains the grandparent's replay), so
109
+ // skipping the parent's full count skips exactly the duplicated region.
110
+ // If the source file is missing (rotated/deleted) we cannot locate the
111
+ // boundary; skip nothing so incomplete data over-counts rather than
112
+ // silently dropping real usage.
113
+ let replayTokenCountToSkip = 0;
114
+ if (forkedFromId != null) {
115
+ replayTokenCountToSkip = tokenCountById.get(forkedFromId) ?? 0;
116
+ }
117
+ let tokenCountSeen = 0;
118
+
119
+ // Extract project name from session_meta.
47
120
  let sessionProject = 'unknown';
48
121
  let sessionModel = 'unknown';
49
- for (const line of content.split('\n')) {
122
+ for (const line of lines) {
50
123
  if (!line.trim()) continue;
51
124
  try {
52
125
  const obj = JSON.parse(line);
@@ -60,29 +133,44 @@ export async function parse() {
60
133
  const match = meta.git.repository_url.match(/([^/]+\/[^/]+?)(?:\.git)?$/);
61
134
  if (match) sessionProject = match[1];
62
135
  }
63
- break;
64
136
  }
65
- } catch { break; }
137
+ } catch { /* ignore */ }
138
+ break; // session_meta is always the first line
66
139
  }
67
140
 
68
141
  let turnContextModel = 'unknown';
69
142
  const prevTotal = new Map();
70
- for (const line of content.split('\n')) {
143
+ for (const line of lines) {
71
144
  if (!line.trim()) continue;
72
145
  try {
73
146
  const obj = JSON.parse(line);
74
147
 
148
+ // A fork's replayed-history block is the run from the start of the
149
+ // file up to and including the Nth token_count, where N is the source
150
+ // session's total token_count count. We are still inside that block
151
+ // until we have *passed* the Nth token_count. (token_count is the
152
+ // last event of each turn, so the boundary lands cleanly at a turn
153
+ // edge — the new conversation's events come strictly after it.)
154
+ const inReplayBlock = tokenCountSeen < replayTokenCountToSkip;
155
+
75
156
  if (obj.timestamp) {
76
157
  const evTs = new Date(obj.timestamp);
77
158
  if (!isNaN(evTs.getTime())) {
78
- const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
79
- sessionEvents.push({
80
- sessionId: filePath,
81
- source: 'codex',
82
- project: sessionProject,
83
- timestamp: evTs,
84
- role: isUserTurn ? 'user' : 'assistant',
85
- });
159
+ // Skip replayed history events so a forked session's
160
+ // duration/active-time/message counts reflect only the new
161
+ // conversation, not the copied original. session_meta itself is
162
+ // kept: it marks when the fork actually started.
163
+ const isReplay = inReplayBlock && obj.type !== 'session_meta';
164
+ if (!isReplay) {
165
+ const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
166
+ sessionEvents.push({
167
+ sessionId: filePath,
168
+ source: 'codex',
169
+ project: sessionProject,
170
+ timestamp: evTs,
171
+ role: isUserTurn ? 'user' : 'assistant',
172
+ });
173
+ }
86
174
  }
87
175
  }
88
176
 
@@ -104,6 +192,14 @@ export async function parse() {
104
192
  const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
105
193
  if (!timestamp || isNaN(timestamp.getTime())) continue;
106
194
 
195
+ // This is the (tokenCountSeen+1)-th token_count in the file. If it
196
+ // falls inside the fork's replay block it's an exact copy of a record
197
+ // already counted from the source session's own file — skip it (but
198
+ // still advance the cumulative-total baseline below so the first real
199
+ // post-fork delta is measured correctly).
200
+ const isReplayedHistory = tokenCountSeen < replayTokenCountToSkip;
201
+ tokenCountSeen++;
202
+
107
203
  // Prefer incremental per-request usage; compute delta from cumulative total as fallback
108
204
  let usage = info.last_token_usage;
109
205
  if (!usage && info.total_token_usage) {
@@ -121,9 +217,13 @@ export async function parse() {
121
217
  // First cumulative entry — use as-is (it's the first event's total)
122
218
  usage = curr;
123
219
  }
220
+ // Always advance the cumulative baseline, even for replayed history,
221
+ // so the first real post-fork delta is measured against the last
222
+ // replayed total instead of being mistaken for a fresh "first entry".
124
223
  prevTotal.set(totalKey, { ...curr });
125
224
  }
126
225
  if (!usage) continue;
226
+ if (isReplayedHistory) continue;
127
227
 
128
228
  const model = info.model || payload.model || turnContextModel || sessionModel;
129
229