@vibe-cafe/vibe-usage 0.14.1 → 0.14.2

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.14.1",
3
+ "version": "0.14.2",
4
4
  "description": "Track your AI coding tool token usage and sync to vibecafe.ai",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -14,7 +14,7 @@ import { join } from 'node:path';
14
14
  // separate from ~/.vibe-usage/state.json, whose hashes are the authoritative
15
15
  // record of successful uploads and must remain backward-compatible.
16
16
  export const CODEX_CACHE_SCHEMA_VERSION = 1;
17
- export const CODEX_PARSER_ALGORITHM_VERSION = 4;
17
+ export const CODEX_PARSER_ALGORITHM_VERSION = 5;
18
18
 
19
19
  function hash(value, length = 24) {
20
20
  return createHash('sha256').update(value).digest('hex').slice(0, length);
@@ -1,4 +1,5 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { normalizeUsageRecord } from './codex-usage-record.js';
2
3
 
3
4
  const hash = value => createHash('sha256').update(JSON.stringify(value)).digest('base64url');
4
5
  function pick(value, keys) {
@@ -10,6 +11,7 @@ const usageKeys = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'cach
10
11
  // Retain only fields consumed by the existing token/timing parser. Full JSON is
11
12
  // hashed transiently to identify exact copies; chat/tool text is never retained.
12
13
  function accountingRecord(obj, context) {
14
+ obj = normalizeUsageRecord(obj);
13
15
  const next = { type: obj.type, timestamp: obj.timestamp };
14
16
  const p = obj.payload;
15
17
  if (obj.type === 'session_meta' && p) {
@@ -22,7 +24,7 @@ function accountingRecord(obj, context) {
22
24
  } else if (obj.type === 'turn_context') {
23
25
  next.payload = pick(p, ['model', 'service_tier']);
24
26
  } else if (obj.type === 'event_msg' && p) {
25
- next.payload = pick(p, ['type', 'started_at', 'model']);
27
+ next.payload = pick(p, ['type', 'started_at', 'model', 'usage_record']);
26
28
  if (p.type === 'token_count') {
27
29
  next._tokenFingerprint = hash(p).slice(0, 16);
28
30
  next._segmentContext = { ...context };
@@ -0,0 +1,29 @@
1
+ // Codex writes usage twice for the same request: once as the UI-facing
2
+ // `event_msg` / `token_count`, and once as a durable `token_usage_record`.
3
+ // The UI event can be missing entirely — an interrupted or crashed call never
4
+ // reaches the point where it is emitted — while the durable record still lands,
5
+ // so counting only the UI form under-reports real usage.
6
+ //
7
+ // Rewrite the durable form into the shape the parser already understands, and
8
+ // keep a `usage_record` marker so the UI event that mirrors the same request is
9
+ // not counted a second time when its cumulative total lags.
10
+ export function normalizeUsageRecord(obj) {
11
+ if (obj?.type !== 'token_usage_record') return obj;
12
+ const p = obj.payload;
13
+ const usage = p?.usage;
14
+ const total = p?.thread_token_usage;
15
+ if (!usage || !total || !Number.isFinite(total.total_tokens) || total.total_tokens <= 0) return obj;
16
+ for (const value of [usage.input_tokens, usage.output_tokens, usage.cached_input_tokens ?? 0, usage.reasoning_output_tokens ?? 0]) {
17
+ if (!Number.isFinite(value) || value < 0) return obj;
18
+ }
19
+ return { ...obj, type: 'event_msg', payload: {
20
+ type: 'token_count', usage_record: true,
21
+ info: { last_token_usage: pickUsage(usage), total_token_usage: pickUsage(total) },
22
+ }};
23
+ }
24
+
25
+ function pickUsage(value) {
26
+ const keys = ['input_tokens', 'output_tokens', 'cached_input_tokens', 'cache_read_input_tokens',
27
+ 'cache_write_input_tokens', 'reasoning_output_tokens', 'total_tokens'];
28
+ return Object.fromEntries(keys.filter(key => Number.isFinite(value[key])).map(key => [key, value[key]]));
29
+ }
@@ -1,3 +1,4 @@
1
+ import { normalizeUsageRecord } from './codex-usage-record.js';
1
2
  import {
2
3
  closeSync,
3
4
  createReadStream,
@@ -139,7 +140,7 @@ async function readSessionHeader(filePath, snapshotSize) {
139
140
  for await (const line of readLines(filePath, snapshotSize)) {
140
141
  if (!line.trim()) continue;
141
142
  try {
142
- const obj = JSON.parse(line);
143
+ const obj = normalizeUsageRecord(JSON.parse(line));
143
144
  if (obj.type !== 'session_meta' || !obj.payload) continue;
144
145
  const meta = obj.payload;
145
146
  return {
@@ -338,7 +339,7 @@ async function indexSessionFile(filePath, snapshotSize, lines = null) {
338
339
  for await (const line of (lines ?? readLines(filePath, snapshotSize))) {
339
340
  if (!line.trim()) continue;
340
341
  try {
341
- const obj = JSON.parse(line);
342
+ const obj = normalizeUsageRecord(JSON.parse(line));
342
343
  parsedRecordCount++;
343
344
 
344
345
  const recordTimestamp = timestampMs(obj.timestamp);
@@ -635,12 +636,20 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
635
636
  let serviceTier = previousTail?.serviceTier || null;
636
637
  let prevTotal = previousTail?.prevTotal || null;
637
638
  let prevCumulativeTotal = previousTail?.prevCumulativeTotal ?? null;
639
+ // Durable records and UI counters are separate series whose cumulative totals
640
+ // can diverge after an interrupted call, so each keeps its own baseline.
641
+ let prevRecordTotal = previousTail?.prevRecordTotal ?? null;
642
+ let pendingUsageMirror = previousTail?.pendingUsageMirror ?? null;
638
643
  const start = previousTail?.parsedBytes || 0;
639
644
  for await (const line of (lines ?? readLines(filePath, snapshotSize, start))) {
640
645
  if (!line.trim()) continue;
641
646
  try {
642
- const obj = JSON.parse(line);
647
+ const obj = normalizeUsageRecord(JSON.parse(line));
643
648
  parsedRecordIndex++;
649
+ // A resumed or new turn cannot be the delayed mirror of the preceding
650
+ // request, so the marker must not survive a turn boundary.
651
+ if (obj.type === 'session_meta' || obj.type === 'turn_context' || obj.type === 'token_usage_record'
652
+ || (obj.type === 'event_msg' && isTaskStarted(obj.payload))) pendingUsageMirror = null;
644
653
 
645
654
  // A direct child task boundary covers every copied record, including
646
655
  // timing/meta events. The raw-token ordinal covers full-history and
@@ -717,11 +726,21 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
717
726
  // compaction — and must count as zero, not a second copy of
718
727
  // last_token_usage. Guarded to positive totals so builds that leave
719
728
  // total_token_usage all-zero can't suppress real usage.
729
+ const isUsageRecord = payload.usage_record === true;
730
+ // The durable record and the UI event describe the same request, but their
731
+ // cumulative counters can diverge after an interrupted call, so the match
732
+ // is on per-request usage. The marker survives appends because the mirror
733
+ // can arrive in a later read.
734
+ const isMirror = !isUsageRecord && pendingUsageMirror && sameRequestUsage(pendingUsageMirror, info.last_token_usage);
735
+ pendingUsageMirror = isUsageRecord ? info.last_token_usage : null;
720
736
  const cumulativeTotal = info.total_token_usage?.total_tokens;
721
737
  const isDuplicateEmission = typeof cumulativeTotal === 'number'
722
738
  && cumulativeTotal > 0
723
- && cumulativeTotal === prevCumulativeTotal;
724
- if (typeof cumulativeTotal === 'number') prevCumulativeTotal = cumulativeTotal;
739
+ && cumulativeTotal === (isUsageRecord ? prevRecordTotal : prevCumulativeTotal);
740
+ if (typeof cumulativeTotal === 'number') {
741
+ if (isUsageRecord) prevRecordTotal = cumulativeTotal;
742
+ else prevCumulativeTotal = cumulativeTotal;
743
+ }
725
744
 
726
745
  // Prefer incremental per-request usage; compute delta from cumulative
727
746
  // totals as fallback. Always advance the cumulative baseline, even
@@ -749,7 +768,7 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
749
768
  // avoids counting the full cumulative total again after a model switch.
750
769
  if (curr) prevTotal = { ...curr };
751
770
  if (!usage) continue;
752
- if (isReplayedHistory || isDuplicateEmission) continue;
771
+ if (isReplayedHistory || isDuplicateEmission || isMirror) continue;
753
772
 
754
773
  const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
755
774
  if (!timestamp || isNaN(timestamp.getTime())) continue;
@@ -816,6 +835,8 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
816
835
  serviceTier,
817
836
  prevTotal,
818
837
  prevCumulativeTotal,
838
+ prevRecordTotal,
839
+ pendingUsageMirror,
819
840
  buckets,
820
841
  sessionAccumulator,
821
842
  guardHash: guard.hash,
@@ -1171,6 +1192,12 @@ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
1171
1192
  return { ...mergeFileResults(results), cache: cacheStats };
1172
1193
  }
1173
1194
 
1195
+ function sameRequestUsage(left, right) {
1196
+ if (!left || !right) return false;
1197
+ return ['input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens']
1198
+ .every(key => (left[key] || 0) === (right[key] || 0));
1199
+ }
1200
+
1174
1201
  export async function parse(options = {}) {
1175
1202
  const nativeResult = await parseNativeCodex(options);
1176
1203
  return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('codex'));