@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 +1 -1
- package/src/config.js +4 -0
- package/src/init.js +10 -1
- package/src/opencode-roots.js +12 -1
- package/src/parsers/amp.js +9 -1
- package/src/parsers/codex-cache.js +1 -1
- package/src/parsers/codex-segments.js +3 -1
- package/src/parsers/codex-usage-record.js +29 -0
- package/src/parsers/codex.js +36 -7
- package/src/parsers/qwen-code.js +3 -1
- package/src/path-repair.js +27 -0
- package/src/state.js +4 -0
package/package.json
CHANGED
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
|
-
|
|
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], () => {});
|
package/src/opencode-roots.js
CHANGED
|
@@ -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
|
-
:
|
|
41
|
+
: defaultOpenCodeRoots();
|
|
31
42
|
const seen = new Set(), stores = [];
|
|
32
43
|
for (const root of [...defaults, ...extraRoots]) {
|
|
33
44
|
try {
|
package/src/parsers/amp.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
+
}
|
package/src/parsers/codex.js
CHANGED
|
@@ -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
|
-
|
|
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')
|
|
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'));
|
package/src/parsers/qwen-code.js
CHANGED
|
@@ -40,7 +40,9 @@ function findSessionFiles(baseDir) {
|
|
|
40
40
|
|
|
41
41
|
function extractProject(cwd, filePath) {
|
|
42
42
|
if (cwd) {
|
|
43
|
-
|
|
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 —
|