vibeusage 0.5.0 → 0.6.1

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": "vibeusage",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "Codex CLI token usage tracker (macOS-first, notify-driven).",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -6,6 +6,13 @@ const { resolveTrackerPaths } = require("../lib/tracker-paths");
6
6
  const { collectTrackerDiagnostics } = require("../lib/diagnostics");
7
7
  const { resolveRuntimeConfig } = require("../lib/runtime-config");
8
8
  const { buildDoctorReport } = require("../lib/doctor");
9
+ const {
10
+ DEFAULT_DAYS: AUDIT_DEFAULT_DAYS,
11
+ DEFAULT_THRESHOLD_PCT: AUDIT_DEFAULT_THRESHOLD,
12
+ runSourceAudit,
13
+ getStrategy: getAuditStrategy,
14
+ listRegisteredSources,
15
+ } = require("../lib/ops/audit-source");
9
16
 
10
17
  async function cmdDoctor(argv = []) {
11
18
  const opts = parseArgs(argv);
@@ -16,6 +23,11 @@ async function cmdDoctor(argv = []) {
16
23
  const configStatus = await readJsonStrict(configPath);
17
24
  const config =
18
25
  configStatus.status === "ok" && isPlainObject(configStatus.value) ? configStatus.value : {};
26
+
27
+ if (opts.auditTokens) {
28
+ return runAuditTokens({ opts, config });
29
+ }
30
+
19
31
  const runtime = resolveRuntimeConfig({
20
32
  cli: { baseUrl: opts.baseUrl },
21
33
  config,
@@ -56,14 +68,192 @@ async function cmdDoctor(argv = []) {
56
68
  }
57
69
  }
58
70
 
71
+ function runAuditTokens({ opts, config }) {
72
+ const sourceId = opts.auditSource || "claude";
73
+ if (sourceId === "all") {
74
+ return runAuditTokensAll({ opts, config });
75
+ }
76
+ const strategy = getAuditStrategy(sourceId);
77
+ if (!strategy) {
78
+ const registered = ["all", ...listRegisteredSources()].join(", ");
79
+ const message = `unknown --source '${sourceId}'. Registered: ${registered}.`;
80
+ if (opts.json) {
81
+ process.stdout.write(`${JSON.stringify({ ok: false, error: "unknown-source", message })}\n`);
82
+ } else {
83
+ process.stderr.write(`doctor --audit-tokens: ${message}\n`);
84
+ }
85
+ process.exitCode = 2;
86
+ return;
87
+ }
88
+
89
+ let result;
90
+ try {
91
+ result = runSourceAudit({
92
+ strategy,
93
+ days: opts.auditDays,
94
+ threshold: opts.auditThreshold,
95
+ deviceId: config.deviceId || null,
96
+ dbJsonPath: opts.auditDbJson || null,
97
+ });
98
+ } catch (err) {
99
+ const message = err?.message || String(err);
100
+ if (opts.json) {
101
+ process.stdout.write(`${JSON.stringify({ ok: false, error: "audit-error", message })}\n`);
102
+ } else {
103
+ process.stderr.write(`doctor --audit-tokens: ${message}\n`);
104
+ }
105
+ process.exitCode = 2;
106
+ return;
107
+ }
108
+
109
+ if (opts.json) {
110
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
111
+ if (!result.ok) process.exitCode = 2;
112
+ else if (result.exceedsThreshold) process.exitCode = 1;
113
+ return;
114
+ }
115
+
116
+ if (!result.ok) {
117
+ process.stderr.write(`doctor --audit-tokens: ${result.message || result.error}\n`);
118
+ process.exitCode = 2;
119
+ return;
120
+ }
121
+
122
+ process.stdout.write(
123
+ `${result.displayName || result.source} token audit (last ${result.days} days, window >= ${result.windowStartIso})\n`,
124
+ );
125
+ process.stdout.write(
126
+ `Scanned ${result.filesScanned} .jsonl files, ${formatNumber(result.usageLines)} usage lines, ` +
127
+ `${formatNumber(result.uniqueMessages)} unique message.ids, ` +
128
+ `${formatNumber(result.duplicatesSkipped)} duplicates skipped.\n\n`,
129
+ );
130
+ process.stdout.write(
131
+ `${"day".padEnd(12)} ${"truth".padStart(15)} ${"db".padStart(15)} ${"ratio".padStart(8)} ${"drift".padStart(8)}\n`,
132
+ );
133
+ process.stdout.write(`${"-".repeat(66)}\n`);
134
+ for (const r of result.rows) {
135
+ const ratio = r.ratio == null ? "—" : `${r.ratio.toFixed(3)}x`;
136
+ const drift = r.driftPct == null ? "—" : `${r.driftPct.toFixed(1)}%`;
137
+ process.stdout.write(
138
+ `${r.day.padEnd(12)} ${formatNumber(r.truth).padStart(15)} ` +
139
+ `${formatNumber(r.db).padStart(15)} ${ratio.padStart(8)} ${drift.padStart(8)}\n`,
140
+ );
141
+ }
142
+ process.stdout.write(
143
+ `\nMax drift: ${result.maxDriftPct.toFixed(2)}% (threshold ${result.thresholdPct}%).\n`,
144
+ );
145
+
146
+ if (result.exceedsThreshold) {
147
+ process.stderr.write(
148
+ `\nFAIL drift ${result.maxDriftPct.toFixed(2)}% exceeds threshold ${result.thresholdPct}%.\n` +
149
+ `If vibeusage >= 0.5.0, scrub the Claude/OpenCode cursor block in\n` +
150
+ `~/.vibeusage/tracker/cursors.json and rerun \`vibeusage sync --drain\`.\n`,
151
+ );
152
+ process.exitCode = 1;
153
+ }
154
+ }
155
+
156
+ function runAuditTokensAll({ opts, config }) {
157
+ const ids = listRegisteredSources();
158
+ const perSource = [];
159
+ let anyExceeds = false;
160
+ let anyHardError = false;
161
+
162
+ for (const id of ids) {
163
+ const strategy = getAuditStrategy(id);
164
+ let result;
165
+ try {
166
+ result = runSourceAudit({
167
+ strategy,
168
+ days: opts.auditDays,
169
+ threshold: opts.auditThreshold,
170
+ deviceId: config.deviceId || null,
171
+ // --db-json is source-specific so we skip it under --source=all; the
172
+ // only sane paths are insforge auto-mode or no-local-sessions.
173
+ });
174
+ } catch (err) {
175
+ result = { ok: false, source: id, error: "audit-error", message: err?.message || String(err) };
176
+ anyHardError = true;
177
+ }
178
+ if (result.ok && result.exceedsThreshold) anyExceeds = true;
179
+ // no-local-sessions is informational, not a hard error; other non-ok states
180
+ // (cannot-resolve-user-id, insforge-db-query-failed, etc.) count as errors.
181
+ if (!result.ok && result.error !== "no-local-sessions") anyHardError = true;
182
+ perSource.push(result);
183
+ }
184
+
185
+ if (opts.json) {
186
+ process.stdout.write(
187
+ `${JSON.stringify({ ok: !anyHardError, thresholdPct: opts.auditThreshold, days: opts.auditDays, sources: perSource }, null, 2)}\n`,
188
+ );
189
+ } else {
190
+ process.stdout.write(
191
+ `Token audit across all registered sources (last ${opts.auditDays} days)\n\n`,
192
+ );
193
+ process.stdout.write(
194
+ `${"source".padEnd(12)} ${"status".padEnd(22)} ${"max drift".padStart(10)} ${"files".padStart(6)} ${"events".padStart(6)}\n`,
195
+ );
196
+ process.stdout.write(`${"-".repeat(70)}\n`);
197
+ for (const r of perSource) {
198
+ if (r.ok) {
199
+ const statusText = r.exceedsThreshold
200
+ ? `FAIL > ${opts.auditThreshold}%`
201
+ : `ok`;
202
+ const drift = `${r.maxDriftPct.toFixed(1)}%`;
203
+ process.stdout.write(
204
+ `${r.source.padEnd(12)} ${statusText.padEnd(22)} ${drift.padStart(10)} ${String(r.filesScanned).padStart(6)} ${String(r.usageLines).padStart(6)}\n`,
205
+ );
206
+ } else {
207
+ const statusText =
208
+ r.error === "no-local-sessions" ? "no local sessions" : `ERR ${r.error}`;
209
+ process.stdout.write(
210
+ `${r.source.padEnd(12)} ${statusText.padEnd(22)} ${"—".padStart(10)} ${"—".padStart(6)} ${"—".padStart(6)}\n`,
211
+ );
212
+ }
213
+ }
214
+ process.stdout.write(
215
+ `\nThreshold ${opts.auditThreshold}%. ` +
216
+ (anyExceeds
217
+ ? `At least one source exceeds threshold — rerun \`vibeusage doctor --audit-tokens --source <id>\` for details.\n`
218
+ : `All sources within threshold.\n`),
219
+ );
220
+ }
221
+
222
+ if (anyHardError) process.exitCode = 2;
223
+ else if (anyExceeds) process.exitCode = 1;
224
+ }
225
+
59
226
  function parseArgs(argv) {
60
- const out = { json: false, out: null, baseUrl: null };
227
+ const out = {
228
+ json: false,
229
+ out: null,
230
+ baseUrl: null,
231
+ auditTokens: false,
232
+ auditDays: AUDIT_DEFAULT_DAYS,
233
+ auditThreshold: AUDIT_DEFAULT_THRESHOLD,
234
+ auditDbJson: null,
235
+ auditSource: "claude",
236
+ };
61
237
  for (let i = 0; i < argv.length; i += 1) {
62
238
  const arg = argv[i];
63
239
  if (arg === "--json") out.json = true;
64
240
  else if (arg === "--out") out.out = argv[++i] || null;
65
241
  else if (arg === "--base-url") out.baseUrl = argv[++i] || null;
66
- else throw new Error(`Unknown option: ${arg}`);
242
+ else if (arg === "--audit-tokens") out.auditTokens = true;
243
+ else if (arg === "--days") {
244
+ const n = Number(argv[++i]);
245
+ if (!Number.isFinite(n) || n <= 0) throw new Error(`--days expects a positive number`);
246
+ out.auditDays = Math.floor(n);
247
+ } else if (arg === "--threshold") {
248
+ const n = Number(argv[++i]);
249
+ if (!Number.isFinite(n) || n < 0) throw new Error(`--threshold expects a non-negative number`);
250
+ out.auditThreshold = n;
251
+ } else if (arg === "--db-json") out.auditDbJson = argv[++i] || null;
252
+ else if (arg === "--source") {
253
+ const raw = argv[++i];
254
+ if (!raw) throw new Error(`--source expects a value`);
255
+ out.auditSource = String(raw).trim().toLowerCase();
256
+ } else throw new Error(`Unknown option: ${arg}`);
67
257
  }
68
258
  return out;
69
259
  }
@@ -89,6 +279,10 @@ function formatCheckLine(check = {}) {
89
279
  return `- [${status}] ${check.id || "unknown"}${detail}`;
90
280
  }
91
281
 
282
+ function formatNumber(n) {
283
+ return Number(n).toLocaleString("en-US");
284
+ }
285
+
92
286
  function isPlainObject(value) {
93
287
  return Boolean(value && typeof value === "object" && !Array.isArray(value));
94
288
  }
@@ -132,7 +132,7 @@ async function cmdSync(argv) {
132
132
  : { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
133
133
 
134
134
  const claudeFiles = await listClaudeProjectFiles(claudeProjectsDir);
135
- let claudeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
135
+ let claudeResult = { filesProcessed: 0, eventsAggregated: 0, bucketsQueued: 0, dedupSkipped: 0 };
136
136
  if (claudeFiles.length > 0) {
137
137
  if (progress?.enabled) {
138
138
  progress.start(
@@ -155,6 +155,7 @@ async function cmdSync(argv) {
155
155
  },
156
156
  source: "claude",
157
157
  });
158
+ await maybeWarnClaudeDedupSpike({ trackerDir, claudeResult });
158
159
  }
159
160
 
160
161
  const geminiFiles = await listGeminiSessionFiles(geminiTmpDir);
@@ -592,6 +593,33 @@ async function parseOpenclawSanitizedLedger({ trackerDir, cursors, queuePath })
592
593
  };
593
594
  }
594
595
 
596
+ // Claude Code occasionally writes the same assistant message multiple times to
597
+ // its session .jsonl (same message.id, different outer uuid). Parser dedup
598
+ // silently skips the dup rows, but a sudden spike in the dedup-skipped ratio
599
+ // usually means an upstream change that deserves human attention. Emit a
600
+ // debug.jsonl line when the ratio crosses 50% so vibeusage doctor / retro
601
+ // reviewers can find it; intentionally non-blocking.
602
+ async function maybeWarnClaudeDedupSpike({ trackerDir, claudeResult }) {
603
+ const events = Number(claudeResult?.eventsAggregated || 0);
604
+ const skipped = Number(claudeResult?.dedupSkipped || 0);
605
+ const total = events + skipped;
606
+ if (total === 0) return;
607
+ const ratio = skipped / total;
608
+ if (ratio <= 0.5) return;
609
+ const line =
610
+ JSON.stringify({
611
+ ts: new Date().toISOString(),
612
+ event: "claude_dedup_spike",
613
+ source: "claude",
614
+ events_aggregated: events,
615
+ dedup_skipped: skipped,
616
+ ratio: Number(ratio.toFixed(3)),
617
+ hint: "Unusual duplicate rate from ~/.claude/projects jsonl; run `vibeusage doctor --audit-tokens` to compare local vs DB totals.",
618
+ }) + "\n";
619
+ const logPath = path.join(trackerDir, "notify.debug.jsonl");
620
+ await fs.appendFile(logPath, line, "utf8").catch(() => {});
621
+ }
622
+
595
623
  async function safeStatSize(p) {
596
624
  try {
597
625
  const st = await fs.stat(p);
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * audit-claude.js
5
+ *
6
+ * Thin backward-compatible shim over audit-source.js. Retained so existing
7
+ * consumers (`scripts/ops/compare-claude-ground-truth.cjs`, older doctor
8
+ * imports) keep working. New code should import audit-source directly and
9
+ * pass the claude strategy.
10
+ */
11
+
12
+ const {
13
+ DEFAULT_DAYS,
14
+ DEFAULT_THRESHOLD_PCT,
15
+ runSourceAudit,
16
+ } = require("./audit-source");
17
+ const claudeStrategy = require("./sources/claude");
18
+
19
+ function runAudit(opts = {}) {
20
+ const { projectsDir, ...rest } = opts;
21
+ return runSourceAudit({
22
+ ...rest,
23
+ strategy: claudeStrategy,
24
+ // Preserve the legacy `projectsDir` option so existing callers and tests
25
+ // (test/doctor-audit-tokens.test.js) keep working without knowing about
26
+ // the new sessionRootOverride plumbing.
27
+ sessionRootOverride: projectsDir || rest.sessionRootOverride || null,
28
+ });
29
+ }
30
+
31
+ module.exports = {
32
+ DEFAULT_DAYS,
33
+ DEFAULT_THRESHOLD_PCT,
34
+ runAudit,
35
+ };