@vibe-cafe/vibe-usage 0.10.16 → 0.10.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-cafe/vibe-usage",
3
- "version": "0.10.16",
3
+ "version": "0.10.17",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -128,7 +128,9 @@ export function getClaudeRoots({ onWarning = () => {} } = {}) {
128
128
  // The default/configured roots remain usable if home discovery fails.
129
129
  }
130
130
 
131
- roots.push(...findClaudeDesktopRoots(getClaudeDesktopDataDirs(), onWarning));
131
+ for (const root of findClaudeDesktopRoots(getClaudeDesktopDataDirs(), onWarning)) {
132
+ roots.push(root);
133
+ }
132
134
  }
133
135
 
134
136
  const seen = new Set();
@@ -80,6 +80,93 @@ export function aggregateToBuckets(entries) {
80
80
  });
81
81
  }
82
82
 
83
+ /**
84
+ * Incremental session aggregation for parsers whose event stream is already
85
+ * chronological. `extractSessions()` below keeps the sorting fallback for
86
+ * parsers that emit mixed or out-of-order sessions.
87
+ */
88
+ export function createSessionAccumulator() {
89
+ return {
90
+ ordered: true,
91
+ first: null,
92
+ last: null,
93
+ lastTimestampMs: null,
94
+ activeSeconds: 0,
95
+ turnStartMs: null,
96
+ turnEndMs: null,
97
+ waitingForFirstResponse: false,
98
+ messageCount: 0,
99
+ userMessageCount: 0,
100
+ userPromptHours: new Array(24).fill(0),
101
+ };
102
+ }
103
+
104
+ function commitTurn(accumulator) {
105
+ const { turnStartMs, turnEndMs } = accumulator;
106
+ if (turnStartMs !== null && turnEndMs !== null && turnEndMs > turnStartMs) {
107
+ accumulator.activeSeconds += Math.round((turnEndMs - turnStartMs) / 1000);
108
+ }
109
+ }
110
+
111
+ export function accumulateSessionEvent(accumulator, event) {
112
+ const timestampMs = event.timestamp.getTime();
113
+ if (accumulator.lastTimestampMs !== null && timestampMs < accumulator.lastTimestampMs) {
114
+ accumulator.ordered = false;
115
+ }
116
+ if (accumulator.first === null) accumulator.first = event;
117
+ accumulator.last = event;
118
+ accumulator.lastTimestampMs = timestampMs;
119
+ accumulator.messageCount++;
120
+
121
+ if (event.role === 'user') {
122
+ commitTurn(accumulator);
123
+ accumulator.turnStartMs = null;
124
+ accumulator.turnEndMs = null;
125
+ accumulator.waitingForFirstResponse = true;
126
+ accumulator.userMessageCount++;
127
+ accumulator.userPromptHours[event.timestamp.getUTCHours()]++;
128
+ } else if (accumulator.waitingForFirstResponse) {
129
+ accumulator.turnStartMs = timestampMs;
130
+ accumulator.turnEndMs = timestampMs;
131
+ accumulator.waitingForFirstResponse = false;
132
+ } else if (accumulator.turnStartMs !== null) {
133
+ accumulator.turnEndMs = timestampMs;
134
+ }
135
+ }
136
+
137
+ export function sessionAccumulatorIsOrdered(accumulator) {
138
+ return accumulator.ordered;
139
+ }
140
+
141
+ export function finalizeSessionAccumulator(accumulator, sessionId, projectOverride) {
142
+ if (accumulator.first === null || accumulator.last === null) return null;
143
+ if (!accumulator.ordered) {
144
+ throw new TypeError('Session accumulator received out-of-order events');
145
+ }
146
+
147
+ let activeSeconds = accumulator.activeSeconds;
148
+ const { turnStartMs, turnEndMs } = accumulator;
149
+ if (turnStartMs !== null && turnEndMs !== null && turnEndMs > turnStartMs) {
150
+ activeSeconds += Math.round((turnEndMs - turnStartMs) / 1000);
151
+ }
152
+
153
+ const first = accumulator.first;
154
+ const last = accumulator.last;
155
+ const sessionHash = createHash('sha256').update(sessionId).digest('hex').slice(0, 16);
156
+ return {
157
+ source: first.source,
158
+ project: projectOverride || first.project || 'unknown',
159
+ sessionHash,
160
+ firstMessageAt: first.timestamp.toISOString(),
161
+ lastMessageAt: last.timestamp.toISOString(),
162
+ durationSeconds: Math.round((last.timestamp - first.timestamp) / 1000),
163
+ activeSeconds,
164
+ messageCount: accumulator.messageCount,
165
+ userMessageCount: accumulator.userMessageCount,
166
+ userPromptHours: accumulator.userPromptHours,
167
+ };
168
+ }
169
+
83
170
  /**
84
171
  * Extract session metadata from timing events.
85
172
  * Each event: { sessionId, source, project, timestamp: Date, role: 'user'|'assistant' }
@@ -90,68 +177,18 @@ export function aggregateToBuckets(entries) {
90
177
  */
91
178
  export function extractSessions(events) {
92
179
  const groups = new Map();
93
- for (const e of events) {
94
- if (!groups.has(e.sessionId)) groups.set(e.sessionId, []);
95
- groups.get(e.sessionId).push(e);
180
+ for (const event of events) {
181
+ if (!groups.has(event.sessionId)) groups.set(event.sessionId, []);
182
+ groups.get(event.sessionId).push(event);
96
183
  }
97
184
 
98
185
  const sessions = [];
99
186
  for (const [sessionId, sessionEvents] of groups) {
100
187
  sessionEvents.sort((a, b) => a.timestamp - b.timestamp);
101
-
102
- const first = sessionEvents[0];
103
- const last = sessionEvents[sessionEvents.length - 1];
104
- const durationSeconds = Math.round((last.timestamp - first.timestamp) / 1000);
105
-
106
- let activeSeconds = 0;
107
- let turnStart = null;
108
- let turnEnd = null;
109
- let waitingForFirstResponse = false;
110
-
111
- for (const event of sessionEvents) {
112
- if (event.role === 'user') {
113
- if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
114
- activeSeconds += Math.round((turnEnd - turnStart) / 1000);
115
- }
116
- turnStart = null;
117
- turnEnd = null;
118
- waitingForFirstResponse = true;
119
- } else if (waitingForFirstResponse) {
120
- turnStart = event.timestamp;
121
- turnEnd = event.timestamp;
122
- waitingForFirstResponse = false;
123
- } else if (turnStart !== null) {
124
- turnEnd = event.timestamp;
125
- }
126
- }
127
- if (turnStart !== null && turnEnd !== null && turnEnd > turnStart) {
128
- activeSeconds += Math.round((turnEnd - turnStart) / 1000);
129
- }
130
-
131
- const userPromptHours = new Array(24).fill(0);
132
- let userMessageCount = 0;
133
- for (const event of sessionEvents) {
134
- if (event.role === 'user') {
135
- userMessageCount++;
136
- userPromptHours[event.timestamp.getUTCHours()]++;
137
- }
138
- }
139
-
140
- const sessionHash = createHash('sha256').update(sessionId).digest('hex').slice(0, 16);
141
-
142
- sessions.push({
143
- source: first.source,
144
- project: first.project || 'unknown',
145
- sessionHash,
146
- firstMessageAt: first.timestamp.toISOString(),
147
- lastMessageAt: last.timestamp.toISOString(),
148
- durationSeconds,
149
- activeSeconds,
150
- messageCount: sessionEvents.length,
151
- userMessageCount,
152
- userPromptHours,
153
- });
188
+ const accumulator = createSessionAccumulator();
189
+ for (const event of sessionEvents) accumulateSessionEvent(accumulator, event);
190
+ const session = finalizeSessionAccumulator(accumulator, sessionId);
191
+ if (session) sessions.push(session);
154
192
  }
155
-
156
193
  return sessions;
157
194
  }
@@ -17,7 +17,7 @@ function findThreadFiles(dir) {
17
17
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
18
18
  const fullPath = join(dir, entry.name);
19
19
  if (entry.isDirectory()) {
20
- results.push(...findThreadFiles(fullPath));
20
+ for (const nested of findThreadFiles(fullPath)) results.push(nested);
21
21
  } else if (entry.isFile() && entry.name.startsWith('T-') && entry.name.endsWith('.json')) {
22
22
  results.push(fullPath);
23
23
  }
@@ -75,9 +75,10 @@ export function readCindyHarnessUsage(agentKind) {
75
75
  const rows = [];
76
76
  for (const dbPath of dbPaths) {
77
77
  try {
78
- rows.push(...queryDbJsonSnapshot(dbPath, CINDY_USAGE_SQL, {
78
+ const dbRows = queryDbJsonSnapshot(dbPath, CINDY_USAGE_SQL, {
79
79
  tempPrefix: 'vibe-usage-cindy-',
80
- }));
80
+ });
81
+ for (const row of dbRows) rows.push(row);
81
82
  } catch (error) {
82
83
  if (isSqliteUnavailableError(error)) throw sqliteUnavailableError('Cindy');
83
84
  // Cindy versions before the daily ledger was introduced have no usage
@@ -1,7 +1,14 @@
1
1
  import { createReadStream, readdirSync, statSync } from 'node:fs';
2
2
  import { createInterface } from 'node:readline';
3
3
  import { join, basename, sep } from 'node:path';
4
- import { aggregateToBuckets, extractSessions } from './aggregate.js';
4
+ import {
5
+ accumulateSessionEvent,
6
+ aggregateToBuckets,
7
+ createSessionAccumulator,
8
+ extractSessions,
9
+ finalizeSessionAccumulator,
10
+ sessionAccumulatorIsOrdered,
11
+ } from './aggregate.js';
5
12
  import { projectFromCwd, toCount } from './fs-utils.js';
6
13
  import { getClaudeRoots } from '../claude-roots.js';
7
14
 
@@ -28,7 +35,7 @@ function findJsonlFiles(dir, ctx) {
28
35
  for (const entry of entries) {
29
36
  const fullPath = join(dir, entry.name);
30
37
  if (entry.isDirectory()) {
31
- results.push(...findJsonlFiles(fullPath, ctx));
38
+ for (const nested of findJsonlFiles(fullPath, ctx)) results.push(nested);
32
39
  } else if (entry.name.endsWith('.jsonl')) {
33
40
  results.push(fullPath);
34
41
  }
@@ -155,10 +162,46 @@ function timingEvent(obj, sessionId, project) {
155
162
  role: obj.type === 'user' ? 'user' : 'assistant',
156
163
  };
157
164
  }
165
+ async function collectTimingEvents(candidate, projectForObject) {
166
+ const events = [];
167
+ await readJsonl(candidate, (obj) => {
168
+ const event = timingEvent(
169
+ obj,
170
+ candidate.sessionId,
171
+ projectForObject(obj),
172
+ );
173
+ if (event) events.push(event);
174
+ });
175
+ return events;
176
+ }
177
+
178
+ async function finalizeCandidateSession(
179
+ candidate,
180
+ accumulator,
181
+ projectForObject,
182
+ projectOverride,
183
+ ) {
184
+ if (sessionAccumulatorIsOrdered(accumulator)) {
185
+ return finalizeSessionAccumulator(
186
+ accumulator,
187
+ candidate.sessionId,
188
+ projectOverride,
189
+ );
190
+ }
191
+
192
+ // JSONL is normally append-ordered. Preserve the old sort semantics for an
193
+ // unusual copied/rewritten file without retaining every event on the common
194
+ // path: re-read only that candidate and let extractSessions() sort it.
195
+ const events = await collectTimingEvents(candidate, projectForObject);
196
+ return extractSessions(events)[0] || null;
197
+ }
158
198
 
159
199
  async function scanProjectCandidate(candidate) {
160
- const entries = [];
161
- const events = [];
200
+ const usageEntries = {
201
+ entriesByKey: new Map(),
202
+ anonymousEntries: [],
203
+ };
204
+ const sessionAccumulator = createSessionAccumulator();
162
205
  let lastModel = null;
163
206
  let sessionProject = candidate.fallbackProject;
164
207
  let foundSessionCwd = false;
@@ -171,7 +214,7 @@ async function scanProjectCandidate(candidate) {
171
214
  foundSessionCwd = true;
172
215
  }
173
216
  const event = timingEvent(obj, candidate.sessionId, sessionProject);
174
- if (event) events.push(event);
217
+ if (event) accumulateSessionEvent(sessionAccumulator, event);
175
218
 
176
219
  if (obj.type !== 'assistant' || !obj.message?.usage || !obj.timestamp) return;
177
220
  const timestamp = new Date(obj.timestamp);
@@ -194,7 +237,7 @@ async function scanProjectCandidate(candidate) {
194
237
  // inflate the CLI's bucket count with rows the server will discard anyway.
195
238
  if (usageScore === 0) return;
196
239
 
197
- entries.push({
240
+ mergeUsageEntry(usageEntries, {
198
241
  dedupeKey: usageDedupeKey(obj),
199
242
  usageScore,
200
243
  source: 'claude-code',
@@ -210,22 +253,34 @@ async function scanProjectCandidate(candidate) {
210
253
 
211
254
  // A cwd can appear after initial metadata/messages. Normalize the completed
212
255
  // session in one place so early records receive the same project label.
213
- for (const entry of entries) entry.project = sessionProject;
214
- for (const event of events) event.project = sessionProject;
215
- return { entries, events };
256
+ for (const entry of usageEntries.anonymousEntries) entry.project = sessionProject;
257
+ for (const entry of usageEntries.entriesByKey.values()) entry.project = sessionProject;
258
+ const session = await finalizeCandidateSession(
259
+ candidate,
260
+ sessionAccumulator,
261
+ () => sessionProject,
262
+ sessionProject,
263
+ );
264
+ return { usageEntries, session };
216
265
  }
217
266
 
218
267
  async function scanTranscriptCandidate(candidate) {
219
- const events = [];
268
+ const sessionAccumulator = createSessionAccumulator();
269
+ const projectForObject = (obj) => projectFromCwd(obj.cwd, 'unknown');
220
270
  await readJsonl(candidate, (obj) => {
221
271
  const event = timingEvent(
222
272
  obj,
223
273
  candidate.sessionId,
224
- projectFromCwd(obj.cwd, 'unknown'),
274
+ projectForObject(obj),
225
275
  );
226
- if (event) events.push(event);
276
+ if (event) accumulateSessionEvent(sessionAccumulator, event);
227
277
  });
228
- return { entries: [], events };
278
+ const session = await finalizeCandidateSession(
279
+ candidate,
280
+ sessionAccumulator,
281
+ projectForObject,
282
+ );
283
+ return { session };
229
284
  }
230
285
 
231
286
  async function scanBestCandidate(candidates, scanner, ctx) {
@@ -266,11 +321,21 @@ function mergeUsageEntry(ctx, entry) {
266
321
  }
267
322
  }
268
323
 
324
+ function mergeUsageEntries(target, source) {
325
+ for (const entry of source.anonymousEntries) mergeUsageEntry(target, entry);
326
+ for (const entry of source.entriesByKey.values()) mergeUsageEntry(target, entry);
327
+ }
328
+
329
+ function* iterateUsageEntries(ctx) {
330
+ for (const entry of ctx.anonymousEntries) yield entry;
331
+ for (const entry of ctx.entriesByKey.values()) yield entry;
332
+ }
333
+
269
334
  export async function parse() {
270
335
  const ctx = {
271
336
  entriesByKey: new Map(),
272
337
  anonymousEntries: [],
273
- sessionEvents: [],
338
+ sessions: [],
274
339
  warnings: [],
275
340
  incomplete: false,
276
341
  };
@@ -284,25 +349,20 @@ export async function parse() {
284
349
  const parsed = await scanBestCandidate(candidates, scanProjectCandidate, ctx);
285
350
  if (!parsed) continue;
286
351
  projectSessionIds.add(sessionId);
287
- ctx.sessionEvents.push(...parsed.events);
288
- for (const entry of parsed.entries) mergeUsageEntry(ctx, entry);
352
+ if (parsed.session) ctx.sessions.push(parsed.session);
353
+ mergeUsageEntries(ctx, parsed.usageEntries);
289
354
  }
290
355
 
291
356
  const transcriptGroups = collectCandidates(roots, 'transcripts', ctx);
292
357
  for (const [sessionId, candidates] of transcriptGroups) {
293
358
  if (projectSessionIds.has(sessionId)) continue;
294
359
  const parsed = await scanBestCandidate(candidates, scanTranscriptCandidate, ctx);
295
- if (parsed) ctx.sessionEvents.push(...parsed.events);
360
+ if (parsed?.session) ctx.sessions.push(parsed.session);
296
361
  }
297
362
 
298
- const entries = [
299
- ...ctx.anonymousEntries,
300
- ...ctx.entriesByKey.values(),
301
- ].map(({ dedupeKey: _dedupeKey, usageScore: _usageScore, ...entry }) => entry);
302
-
303
363
  return {
304
- buckets: aggregateToBuckets(entries),
305
- sessions: extractSessions(ctx.sessionEvents),
364
+ buckets: aggregateToBuckets(iterateUsageEntries(ctx)),
365
+ sessions: ctx.sessions,
306
366
  ...(ctx.incomplete ? { skipped: true } : {}),
307
367
  ...(ctx.warnings.length > 0 ? { warnings: ctx.warnings } : {}),
308
368
  };
@@ -44,7 +44,7 @@ function findJsonlFiles(dir) {
44
44
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
45
45
  const fullPath = join(dir, entry.name);
46
46
  if (entry.isDirectory()) {
47
- results.push(...findJsonlFiles(fullPath));
47
+ for (const nested of findJsonlFiles(fullPath)) results.push(nested);
48
48
  } else if (entry.name.endsWith('.jsonl')) {
49
49
  results.push(fullPath);
50
50
  }
@@ -792,7 +792,7 @@ function mergeFileResults(results) {
792
792
  reasoningOutputTokens: bucket.reasoningOutputTokens,
793
793
  });
794
794
  }
795
- sessions.push(...(result.sessions || []));
795
+ for (const session of result.sessions || []) sessions.push(session);
796
796
  }
797
797
  return { buckets: aggregateToBuckets(entries), sessions };
798
798
  }
@@ -13,7 +13,7 @@ function findJsonlFiles(dir) {
13
13
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
14
14
  const fullPath = join(dir, entry.name);
15
15
  if (entry.isDirectory()) {
16
- results.push(...findJsonlFiles(fullPath));
16
+ for (const nested of findJsonlFiles(fullPath)) results.push(nested);
17
17
  } else if (entry.name.endsWith('.jsonl') && !entry.name.endsWith('.settings.json')) {
18
18
  results.push(fullPath);
19
19
  }
@@ -535,7 +535,7 @@ export async function parse() {
535
535
  model.parentSessionId == null ? null : perSession.get(model.parentSessionId);
536
536
  const skip = parent ? replaySkipCount(model, parent.model) : 0;
537
537
  const { entries: fileEntries, events: fileEvents } = modelToResult(model, skip);
538
- entries.push(...fileEntries);
538
+ for (const entry of fileEntries) entries.push(entry);
539
539
  for (const event of fileEvents) {
540
540
  if (!eventsBySession.has(event.sessionId)) eventsBySession.set(event.sessionId, []);
541
541
  eventsBySession.get(event.sessionId).push(event);
@@ -547,7 +547,7 @@ export async function parse() {
547
547
  const events = [];
548
548
  for (const sessionEvents of eventsBySession.values()) {
549
549
  if (sessionEvents.some((event) => event.role === 'user')) {
550
- events.push(...sessionEvents);
550
+ for (const event of sessionEvents) events.push(event);
551
551
  }
552
552
  }
553
553
 
@@ -326,7 +326,7 @@ export function conversationsToEstimateEntries(conversations) {
326
326
  }
327
327
  }
328
328
  for (const conversation of byId.values()) {
329
- entries.push(...conversationToEntries(conversation));
329
+ for (const entry of conversationToEntries(conversation)) entries.push(entry);
330
330
  }
331
331
  return entries;
332
332
  }
@@ -557,7 +557,7 @@ async function readCliSessionStreamEntries() {
557
557
  const entries = [];
558
558
  for (const file of files) {
559
559
  try {
560
- entries.push(...await readCliSessionEntries(dir, file));
560
+ for (const entry of await readCliSessionEntries(dir, file)) entries.push(entry);
561
561
  } catch {
562
562
  // skip unreadable / concurrently rotated session
563
563
  }
@@ -677,7 +677,7 @@ async function readUsageSnapshots(userPath) {
677
677
  const snapshots = [];
678
678
  for (const file of files) {
679
679
  try {
680
- snapshots.push(...await readLogSnapshots(file));
680
+ for (const snapshot of await readLogSnapshots(file)) snapshots.push(snapshot);
681
681
  } catch {
682
682
  // skip unreadable / concurrently rotated logs
683
683
  }
@@ -23,8 +23,11 @@ function findJsonlFiles(dir, includeFile, ctx) {
23
23
  const files = [];
24
24
  for (const child of children) {
25
25
  const filePath = join(dir, child.name);
26
- if (child.isDirectory()) files.push(...findJsonlFiles(filePath, includeFile, ctx));
27
- else if (child.name.endsWith('.jsonl') && includeFile(filePath)) files.push(filePath);
26
+ if (child.isDirectory()) {
27
+ for (const nested of findJsonlFiles(filePath, includeFile, ctx)) files.push(nested);
28
+ } else if (child.name.endsWith('.jsonl') && includeFile(filePath)) {
29
+ files.push(filePath);
30
+ }
28
31
  }
29
32
  return files;
30
33
  }
@@ -62,8 +62,11 @@ function findJsonlFiles(dir, ctx) {
62
62
  const files = [];
63
63
  for (const child of children) {
64
64
  const filePath = join(dir, child.name);
65
- if (child.isDirectory()) files.push(...findJsonlFiles(filePath, ctx));
66
- else if (child.isFile() && child.name.endsWith('.jsonl')) files.push(filePath);
65
+ if (child.isDirectory()) {
66
+ for (const nested of findJsonlFiles(filePath, ctx)) files.push(nested);
67
+ } else if (child.isFile() && child.name.endsWith('.jsonl')) {
68
+ files.push(filePath);
69
+ }
67
70
  }
68
71
  return files;
69
72
  }
package/src/pi-roots.js CHANGED
@@ -63,7 +63,9 @@ export function getOmpSessionDirs() {
63
63
  const configName = process.env.PI_CONFIG_DIR?.trim() || '.omp';
64
64
  const configRoot = join(homedir(), configName);
65
65
  dirs.push(join(configRoot, 'agent', 'sessions'));
66
- dirs.push(...profileSessionDirs(join(configRoot, 'profiles'), true));
66
+ for (const dir of profileSessionDirs(join(configRoot, 'profiles'), true)) {
67
+ dirs.push(dir);
68
+ }
67
69
 
68
70
  const agentOverride = process.env.PI_CODING_AGENT_DIR?.trim();
69
71
  if (agentOverride) {
@@ -78,7 +80,9 @@ export function getOmpSessionDirs() {
78
80
  if (xdgDataHome) {
79
81
  const xdgRoot = join(expandHome(xdgDataHome), 'omp');
80
82
  dirs.push(join(xdgRoot, 'sessions'));
81
- dirs.push(...profileSessionDirs(join(xdgRoot, 'profiles'), false));
83
+ for (const dir of profileSessionDirs(join(xdgRoot, 'profiles'), false)) {
84
+ dirs.push(dir);
85
+ }
82
86
  }
83
87
  }
84
88
 
package/src/sync.js CHANGED
@@ -181,8 +181,8 @@ export async function runSync({
181
181
  // timeout) to keep daemon logs quiet. Its empty result is not proof that
182
182
  // its prior data disappeared, so it must not be pruned this run.
183
183
  if (!skipped) okSources.add(source);
184
- if (buckets.length > 0) allBuckets.push(...buckets);
185
- if (sessions.length > 0) allSessions.push(...sessions);
184
+ for (const bucket of buckets) allBuckets.push(bucket);
185
+ for (const session of sessions) allSessions.push(session);
186
186
  if (buckets.length > 0 || sessions.length > 0) {
187
187
  parserResults.push({ source, buckets: buckets.length, sessions: sessions.length });
188
188
  }