@vibe-cafe/vibe-usage 0.9.15 → 0.9.16

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.9.15",
3
+ "version": "0.9.16",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -1,4 +1,4 @@
1
- import { createReadStream, readdirSync, existsSync } from 'node:fs';
1
+ import { createReadStream, readdirSync, existsSync, statSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createInterface } from 'node:readline';
@@ -42,9 +42,15 @@ function findJsonlFiles(dir) {
42
42
  return results;
43
43
  }
44
44
 
45
- function readLines(filePath) {
45
+ function readLines(filePath, snapshotSize) {
46
46
  return createInterface({
47
- input: createReadStream(filePath, { encoding: 'utf-8' }),
47
+ input: createReadStream(filePath, {
48
+ encoding: 'utf-8',
49
+ // Rollouts are append-only while Codex is working. Bound both parser
50
+ // passes to the size captured before pass 1 so they see the same prefix
51
+ // even when the live file grows between reads.
52
+ ...(snapshotSize == null ? {} : { end: snapshotSize - 1 }),
53
+ }),
48
54
  crlfDelay: Infinity,
49
55
  });
50
56
  }
@@ -143,6 +149,34 @@ function longestReplayPrefix(child, parent) {
143
149
  return matched;
144
150
  }
145
151
 
152
+ /**
153
+ * Return the longest prefix of `child` found contiguously anywhere in
154
+ * `parent`. A live sub-agent rollout can be observed while Codex is still
155
+ * copying the parent block, before that copy reaches the parent snapshot's
156
+ * end. In that state the exact records are inherited history even though the
157
+ * stricter completed-replay suffix match above deliberately rejects them.
158
+ */
159
+ function longestPartialReplayPrefix(child, parent) {
160
+ if (child.length === 0 || parent.length === 0) return 0;
161
+
162
+ const prefix = new Array(child.length).fill(0);
163
+ for (let i = 1, matched = 0; i < child.length; i++) {
164
+ while (matched > 0 && child[i] !== child[matched]) matched = prefix[matched - 1];
165
+ if (child[i] === child[matched]) matched++;
166
+ prefix[i] = matched;
167
+ }
168
+
169
+ let matched = 0;
170
+ let longest = 0;
171
+ for (const fingerprint of parent) {
172
+ while (matched > 0 && fingerprint !== child[matched]) matched = prefix[matched - 1];
173
+ if (fingerprint === child[matched]) matched++;
174
+ longest = Math.max(longest, matched);
175
+ if (matched === child.length) matched = prefix[matched - 1];
176
+ }
177
+ return longest;
178
+ }
179
+
146
180
  // `task_started.started_at` is stored at one-second precision while the
147
181
  // canonical session timestamp has milliseconds. Real Codex Desktop rollouts
148
182
  // start the child task within a few seconds of creating the child session.
@@ -160,7 +194,7 @@ const OWN_TASK_START_WINDOW_MS = 5_000;
160
194
  * full prefix. Together they bound matching to source records that existed at
161
195
  * spawn without over-skipping child work when the parent later grows.
162
196
  */
163
- async function indexSessionFile(filePath) {
197
+ async function indexSessionFile(filePath, snapshotSize) {
164
198
  let sessionId = null;
165
199
  let forkedFromId = null;
166
200
  let parentThreadId = null;
@@ -178,7 +212,7 @@ async function indexSessionFile(filePath) {
178
212
  let firstTaskBoundary = null;
179
213
  let ownTaskBoundary = null;
180
214
 
181
- for await (const line of readLines(filePath)) {
215
+ for await (const line of readLines(filePath, snapshotSize)) {
182
216
  if (!line.trim()) continue;
183
217
  try {
184
218
  const obj = JSON.parse(line);
@@ -261,12 +295,13 @@ function replayBoundary(meta, sessionById) {
261
295
  const parentAtSpawn = parent && meta.sessionStartedAtMs != null
262
296
  ? upperBound(parent.tokenTimes, meta.sessionStartedAtMs)
263
297
  : null;
264
- const replayTokenCount = parentAtSpawn == null
265
- ? 0
266
- : longestReplayPrefix(
267
- meta.tokenFingerprints,
268
- parent.tokenFingerprints.slice(0, parentAtSpawn)
269
- );
298
+ const parentSnapshot = parentAtSpawn == null
299
+ ? []
300
+ : parent.tokenFingerprints.slice(0, parentAtSpawn);
301
+ const replayTokenCount = longestReplayPrefix(meta.tokenFingerprints, parentSnapshot);
302
+ const partialReplayTokenCount = meta.isSubagent
303
+ ? longestPartialReplayPrefix(meta.tokenFingerprints, parentSnapshot)
304
+ : 0;
270
305
 
271
306
  if (meta.isSubagent) {
272
307
  // Direct evidence inside the child wins. Legacy single-meta rollouts did
@@ -292,11 +327,25 @@ function replayBoundary(meta, sessionById) {
292
327
  : null);
293
328
  if (direct) {
294
329
  return {
295
- rawTokenCount: Math.max(replayTokenCount, direct.rawTokenCount),
330
+ rawTokenCount: Math.max(
331
+ replayTokenCount,
332
+ partialReplayTokenCount,
333
+ direct.rawTokenCount
334
+ ),
296
335
  recordIndex: direct.recordIndex,
297
336
  };
298
337
  }
299
- return { rawTokenCount: replayTokenCount, recordIndex: null };
338
+
339
+ // A recognized sub-agent can be synced while Codex is only partway
340
+ // through appending the copied parent block. The completed-replay matcher
341
+ // correctly rejects that interior slice, but counting it would create a
342
+ // temporary spike that disappears on the next sync. Exact payload overlap
343
+ // with the known parent is sufficient evidence to defer those leading
344
+ // records until the rollout reaches a stable suffix or task boundary.
345
+ return {
346
+ rawTokenCount: Math.max(replayTokenCount, partialReplayTokenCount),
347
+ recordIndex: null,
348
+ };
300
349
  }
301
350
 
302
351
  if (meta.forkedFromId) {
@@ -311,7 +360,16 @@ export async function parse() {
311
360
 
312
361
  const entries = [];
313
362
  const sessionEvents = [];
314
- const files = dirs.flatMap(findJsonlFiles);
363
+ const files = [];
364
+ for (const filePath of dirs.flatMap(findJsonlFiles)) {
365
+ try {
366
+ const snapshotSize = statSync(filePath).size;
367
+ if (snapshotSize > 0) files.push({ filePath, snapshotSize });
368
+ } catch {
369
+ // The file may move to archived_sessions between discovery and stat.
370
+ // Its archived copy will be picked up on the next sync.
371
+ }
372
+ }
315
373
  if (files.length === 0) return { buckets: [], sessions: [] };
316
374
 
317
375
  // Pass 1: build a compact per-file index. Keep the most complete physical
@@ -319,10 +377,10 @@ export async function parse() {
319
377
  // sessions/ and archived_sessions/ cannot double its token buckets.
320
378
  const sessionById = new Map();
321
379
  const fileMeta = new Map();
322
- for (const filePath of files) {
380
+ for (const { filePath, snapshotSize } of files) {
323
381
  let meta;
324
382
  try {
325
- meta = await indexSessionFile(filePath);
383
+ meta = await indexSessionFile(filePath, snapshotSize);
326
384
  } catch {
327
385
  continue;
328
386
  }
@@ -336,7 +394,7 @@ export async function parse() {
336
394
  }
337
395
 
338
396
  // Pass 2: parse usage while skipping the replay prefix resolved above.
339
- for (const filePath of files) {
397
+ for (const { filePath, snapshotSize } of files) {
340
398
  const fm = fileMeta.get(filePath);
341
399
  if (!fm) continue;
342
400
  if (fm.sessionId && sessionById.get(fm.sessionId)?.filePath !== filePath) continue;
@@ -357,7 +415,7 @@ export async function parse() {
357
415
  let turnContextModel = 'unknown';
358
416
  let prevTotal = null;
359
417
  let prevCumulativeTotal = null;
360
- for await (const line of readLines(filePath)) {
418
+ for await (const line of readLines(filePath, snapshotSize)) {
361
419
  if (!line.trim()) continue;
362
420
  try {
363
421
  const obj = JSON.parse(line);
@@ -483,6 +541,13 @@ export async function parse() {
483
541
  continue;
484
542
  }
485
543
  }
544
+
545
+ // The byte bound makes append-only growth invisible to both passes. If a
546
+ // rollout was instead truncated or replaced in place, fail the Codex
547
+ // parser rather than upload totals produced from two different snapshots.
548
+ if (parsedRecordIndex !== fm.parsedRecordCount || rawTokenSeen !== fm.rawTokenCount) {
549
+ throw new Error('Codex rollout changed while syncing; retry on the next sync');
550
+ }
486
551
  }
487
552
 
488
553
  return { buckets: aggregateToBuckets(entries), sessions: extractSessions(sessionEvents) };