@vibe-cafe/vibe-usage 0.14.0 → 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.0",
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": {
package/src/config.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, writeFileSync, chmodSync, mkdirSync, existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
+ import { moveDirectoryOutOfFilePath } from './path-repair.js';
4
5
 
5
6
  // VIBE_USAGE_CONFIG_DIR overrides the dir (test hook).
6
7
  const CONFIG_DIR = process.env.VIBE_USAGE_CONFIG_DIR?.trim() || join(homedir(), '.vibe-usage');
@@ -22,6 +23,9 @@ export function loadConfig() {
22
23
 
23
24
  export function saveConfig(config) {
24
25
  mkdirSync(CONFIG_DIR, { recursive: true });
26
+ // A directory sitting where the config file belongs would make this write
27
+ // throw EISDIR forever; move it aside so the run can save.
28
+ moveDirectoryOutOfFilePath(CONFIG_FILE);
25
29
  // The file holds the vbu_ API key — never leave it group/world-readable.
26
30
  // mode only applies at file creation, so chmod explicitly for pre-existing
27
31
  // files written before this hardening.
package/src/init.js CHANGED
@@ -21,7 +21,16 @@ function prompt(question) {
21
21
  }
22
22
 
23
23
  function openBrowser(url) {
24
- const cmds = { darwin: 'open', linux: 'xdg-open', win32: 'start' };
24
+ // `start` is a cmd.exe builtin, not an executable: execFile('start', …) fails
25
+ // outright on Windows, so the device-flow login never opened a tab and the
26
+ // user was left staring at the code with no browser to type it into. Go
27
+ // through cmd, and escape a bare `&` (present in verificationUriComplete's
28
+ // query string) or cmd would read it as a command separator.
29
+ if (platform() === 'win32') {
30
+ execFile('cmd', ['/c', 'start', '', url.replace(/&/g, '^&')], { windowsHide: true }, () => {});
31
+ return;
32
+ }
33
+ const cmds = { darwin: 'open', linux: 'xdg-open' };
25
34
  const cmd = cmds[platform()] || cmds.linux;
26
35
  // Use execFile with args array to avoid shell injection via VIBE_USAGE_API_URL
27
36
  execFile(cmd, [url], () => {});
@@ -24,10 +24,21 @@ export function openCodeStore(root) {
24
24
  return null;
25
25
  }
26
26
 
27
+ // XDG first, then the Windows per-user store. A root that does not resolve is
28
+ // skipped by the caller, so probing costs nothing when it is absent.
29
+ function defaultOpenCodeRoots() {
30
+ const xdg = join(homedir(), '.local', 'share', 'opencode');
31
+ const localAppData = process.env.LOCALAPPDATA?.trim();
32
+ if (process.platform === 'win32' && localAppData) {
33
+ return [xdg, join(localAppData, 'opencode')];
34
+ }
35
+ return [xdg];
36
+ }
37
+
27
38
  export function getOpenCodeStores({ extraRoots = [], onWarning = () => {} } = {}) {
28
39
  const override = process.env.VIBE_USAGE_OPENCODE_DIRS?.trim();
29
40
  const defaults = override ? override.split(delimiter).map(p => p.trim()).filter(Boolean)
30
- : [join(homedir(), '.local', 'share', 'opencode')];
41
+ : defaultOpenCodeRoots();
31
42
  const seen = new Set(), stores = [];
32
43
  for (const root of [...defaults, ...extraRoots]) {
33
44
  try {
@@ -3,9 +3,17 @@ import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { aggregateToBuckets, extractSessions } from './aggregate.js';
5
5
 
6
- function resolveThreadsDir() {
6
+ /** Threads directory, XDG first and %LOCALAPPDATA% on Windows. Exported so the
7
+ * Windows branch can be exercised from a POSIX test host. */
8
+ export function resolveThreadsDir() {
7
9
  if (process.env.AMP_DATA_DIR) return process.env.AMP_DATA_DIR;
8
10
  if (process.env.XDG_DATA_HOME) return join(process.env.XDG_DATA_HOME, 'amp', 'threads');
11
+ // Amp on Windows keeps its threads under %LOCALAPPDATA%; without this the
12
+ // source silently collected nothing there.
13
+ if (process.platform === 'win32' && process.env.LOCALAPPDATA) {
14
+ const winDir = join(process.env.LOCALAPPDATA, 'amp', 'threads');
15
+ if (existsSync(winDir)) return winDir;
16
+ }
9
17
  return join(homedir(), '.local', 'share', 'amp', 'threads');
10
18
  }
11
19
 
@@ -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,
@@ -104,7 +105,9 @@ function extractProject(meta) {
104
105
  const match = meta.git.repository_url.match(/([^/]+\/[^/]+?)(?:\.git)?$/);
105
106
  if (match) return match[1];
106
107
  }
107
- if (meta.cwd) return meta.cwd.split('/').pop() || 'unknown';
108
+ // Windows reports cwd with backslashes, so splitting on '/' alone yields the
109
+ // whole path as the "project".
110
+ if (meta.cwd) return meta.cwd.split(/[\\/]/).pop() || 'unknown';
108
111
  return 'unknown';
109
112
  }
110
113
 
@@ -137,7 +140,7 @@ async function readSessionHeader(filePath, snapshotSize) {
137
140
  for await (const line of readLines(filePath, snapshotSize)) {
138
141
  if (!line.trim()) continue;
139
142
  try {
140
- const obj = JSON.parse(line);
143
+ const obj = normalizeUsageRecord(JSON.parse(line));
141
144
  if (obj.type !== 'session_meta' || !obj.payload) continue;
142
145
  const meta = obj.payload;
143
146
  return {
@@ -336,7 +339,7 @@ async function indexSessionFile(filePath, snapshotSize, lines = null) {
336
339
  for await (const line of (lines ?? readLines(filePath, snapshotSize))) {
337
340
  if (!line.trim()) continue;
338
341
  try {
339
- const obj = JSON.parse(line);
342
+ const obj = normalizeUsageRecord(JSON.parse(line));
340
343
  parsedRecordCount++;
341
344
 
342
345
  const recordTimestamp = timestampMs(obj.timestamp);
@@ -633,12 +636,20 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
633
636
  let serviceTier = previousTail?.serviceTier || null;
634
637
  let prevTotal = previousTail?.prevTotal || null;
635
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;
636
643
  const start = previousTail?.parsedBytes || 0;
637
644
  for await (const line of (lines ?? readLines(filePath, snapshotSize, start))) {
638
645
  if (!line.trim()) continue;
639
646
  try {
640
- const obj = JSON.parse(line);
647
+ const obj = normalizeUsageRecord(JSON.parse(line));
641
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;
642
653
 
643
654
  // A direct child task boundary covers every copied record, including
644
655
  // timing/meta events. The raw-token ordinal covers full-history and
@@ -715,11 +726,21 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
715
726
  // compaction — and must count as zero, not a second copy of
716
727
  // last_token_usage. Guarded to positive totals so builds that leave
717
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;
718
736
  const cumulativeTotal = info.total_token_usage?.total_tokens;
719
737
  const isDuplicateEmission = typeof cumulativeTotal === 'number'
720
738
  && cumulativeTotal > 0
721
- && cumulativeTotal === prevCumulativeTotal;
722
- 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
+ }
723
744
 
724
745
  // Prefer incremental per-request usage; compute delta from cumulative
725
746
  // totals as fallback. Always advance the cumulative baseline, even
@@ -747,7 +768,7 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
747
768
  // avoids counting the full cumulative total again after a model switch.
748
769
  if (curr) prevTotal = { ...curr };
749
770
  if (!usage) continue;
750
- if (isReplayedHistory || isDuplicateEmission) continue;
771
+ if (isReplayedHistory || isDuplicateEmission || isMirror) continue;
751
772
 
752
773
  const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
753
774
  if (!timestamp || isNaN(timestamp.getTime())) continue;
@@ -814,6 +835,8 @@ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
814
835
  serviceTier,
815
836
  prevTotal,
816
837
  prevCumulativeTotal,
838
+ prevRecordTotal,
839
+ pendingUsageMirror,
817
840
  buckets,
818
841
  sessionAccumulator,
819
842
  guardHash: guard.hash,
@@ -1169,6 +1192,12 @@ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
1169
1192
  return { ...mergeFileResults(results), cache: cacheStats };
1170
1193
  }
1171
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
+
1172
1201
  export async function parse(options = {}) {
1173
1202
  const nativeResult = await parseNativeCodex(options);
1174
1203
  return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('codex'));
@@ -40,7 +40,9 @@ function findSessionFiles(baseDir) {
40
40
 
41
41
  function extractProject(cwd, filePath) {
42
42
  if (cwd) {
43
- const parts = cwd.split('/').filter(Boolean);
43
+ // Windows reports cwd with backslashes; split on both separators so the
44
+ // project is the last path segment rather than the whole path.
45
+ const parts = cwd.split(/[\\/]/).filter(Boolean);
44
46
  if (parts.length > 0) return parts[parts.length - 1];
45
47
  }
46
48
  const tmpPrefix = QWEN_TMP_DIR + sep;
@@ -0,0 +1,27 @@
1
+ import { statSync, renameSync } from 'node:fs';
2
+
3
+ /**
4
+ * Move a directory that is occupying a path we need as a file.
5
+ *
6
+ * Both the config file and the state file are written by this CLI, and a
7
+ * customer hit a layout where the *file* path had become a directory (the store
8
+ * was created by a different tool, or an earlier run was interrupted between
9
+ * mkdir and open). `writeFileSync` then fails with EISDIR and the CLI is stuck:
10
+ * every later run reads nothing, and the save that would fix it is the one that
11
+ * throws.
12
+ *
13
+ * Renaming it aside keeps whatever the user's data was — never delete it, the
14
+ * directory may hold real content — while letting the current run write a fresh
15
+ * file. Absence is not a repair, so ENOENT stays silent; anything else (a
16
+ * permission error, a read-only volume) is the caller's to report rather than
17
+ * something to swallow.
18
+ */
19
+ export function moveDirectoryOutOfFilePath(path) {
20
+ try {
21
+ if (statSync(path).isDirectory()) {
22
+ renameSync(path, `${path}.directory-backup-${Date.now()}`);
23
+ }
24
+ } catch (err) {
25
+ if (err?.code !== 'ENOENT') throw err;
26
+ }
27
+ }
package/src/state.js CHANGED
@@ -2,6 +2,7 @@ import { readFileSync, writeFileSync, unlinkSync, mkdirSync, existsSync, renameS
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
  import { createHash, randomBytes } from 'node:crypto';
5
+ import { moveDirectoryOutOfFilePath } from './path-repair.js';
5
6
 
6
7
  // Persisted sync state, kept next to config.js's files (same dir + dev split).
7
8
  // Maps a stable item key -> hash of its mutable fields, recording what we have
@@ -79,6 +80,9 @@ export function loadState(identity) {
79
80
 
80
81
  export function saveState(state, identity) {
81
82
  mkdirSync(STATE_DIR, { recursive: true });
83
+ // Same EISDIR repair as config: a directory where the state file belongs
84
+ // would block every save, and with it every incremental sync.
85
+ moveDirectoryOutOfFilePath(STATE_FILE);
82
86
  // Only the durable fields are written: `identityChanged` is loadState()'s
83
87
  // one-run signal, not state. A CLI older than 0.11.1 reads just
84
88
  // buckets/sessions, so the extra top-level `identity` key is ignored there —