vibeusage 0.5.0 → 0.6.0
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/commands/doctor.js +196 -2
- package/src/commands/sync.js +29 -1
- package/src/lib/ops/audit-claude.js +35 -0
- package/src/lib/ops/audit-source.js +364 -0
- package/src/lib/ops/sources/_rollout-base.js +203 -0
- package/src/lib/ops/sources/claude.js +52 -0
- package/src/lib/ops/sources/codex.js +10 -0
- package/src/lib/ops/sources/every-code.js +10 -0
- package/src/lib/ops/sources/gemini.js +154 -0
- package/src/lib/ops/sources/hermes.js +69 -0
- package/src/lib/ops/sources/kimi.js +105 -0
- package/src/lib/ops/sources/openclaw.js +64 -0
- package/src/lib/ops/sources/opencode.js +100 -0
- package/src/lib/rollout.js +27 -5
package/package.json
CHANGED
package/src/commands/doctor.js
CHANGED
|
@@ -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 = {
|
|
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
|
|
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
|
}
|
package/src/commands/sync.js
CHANGED
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,364 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* audit-source.js — generic ground-truth auditor.
|
|
5
|
+
*
|
|
6
|
+
* Every AI CLI source (claude, opencode, codex, gemini, kimi, ...) carries a
|
|
7
|
+
* different session-log layout and token schema, but the audit shape is the
|
|
8
|
+
* same: walk local sessions, dedup by upstream id, sum all channels per day,
|
|
9
|
+
* then compare against DB totals.
|
|
10
|
+
*
|
|
11
|
+
* This module captures that shape. Source-specific knowledge lives in
|
|
12
|
+
* src/lib/ops/sources/<id>.js as a `strategy` object (see CONTRACT below).
|
|
13
|
+
*
|
|
14
|
+
* CONTRACT (strategy shape):
|
|
15
|
+
* {
|
|
16
|
+
* id: "claude" | "opencode" | ...,
|
|
17
|
+
* displayName: "Claude Code",
|
|
18
|
+
* sessionRoot({ home, env }) -> absolute path,
|
|
19
|
+
* walkSessions({ root }) -> string[] // list of files/dbs to read
|
|
20
|
+
* extractUsage(line, context) -> null | {
|
|
21
|
+
* timestamp: "<ISO8601>",
|
|
22
|
+
* dedupeId: "<stable id>" | null,
|
|
23
|
+
* channels: { input, cache_creation, cache_read, output, reasoning }
|
|
24
|
+
* }
|
|
25
|
+
* // optional: skip the jsonl line-by-line reader if the source uses sqlite
|
|
26
|
+
* // and must iterate rows differently
|
|
27
|
+
* iterateRecords(filePath) -> iterable<{ line, context }>
|
|
28
|
+
* }
|
|
29
|
+
*
|
|
30
|
+
* Consumers:
|
|
31
|
+
* - doctor --audit-tokens --source <id>
|
|
32
|
+
* - scripts/ops/compare-<source>-ground-truth.cjs (thin CLI wrappers)
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const fs = require("node:fs");
|
|
36
|
+
const os = require("node:os");
|
|
37
|
+
const { spawnSync } = require("node:child_process");
|
|
38
|
+
|
|
39
|
+
const DEFAULT_DAYS = 14;
|
|
40
|
+
const DEFAULT_THRESHOLD_PCT = 25;
|
|
41
|
+
|
|
42
|
+
function runSourceAudit({
|
|
43
|
+
strategy,
|
|
44
|
+
days = DEFAULT_DAYS,
|
|
45
|
+
threshold = DEFAULT_THRESHOLD_PCT,
|
|
46
|
+
userId = null,
|
|
47
|
+
deviceId = null,
|
|
48
|
+
dbJsonPath = null,
|
|
49
|
+
dbJson = null,
|
|
50
|
+
home = os.homedir(),
|
|
51
|
+
env = process.env,
|
|
52
|
+
sessionRootOverride = null,
|
|
53
|
+
} = {}) {
|
|
54
|
+
if (!strategy || typeof strategy !== "object") {
|
|
55
|
+
throw new Error("runSourceAudit requires a strategy object");
|
|
56
|
+
}
|
|
57
|
+
for (const key of ["id", "sessionRoot", "walkSessions", "extractUsage"]) {
|
|
58
|
+
if (typeof strategy[key] !== "function" && typeof strategy[key] !== "string") {
|
|
59
|
+
throw new Error(`strategy.${key} is required`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!Number.isFinite(days) || days <= 0) {
|
|
63
|
+
throw new Error(`days must be a positive number, got ${days}`);
|
|
64
|
+
}
|
|
65
|
+
if (!Number.isFinite(threshold) || threshold < 0) {
|
|
66
|
+
throw new Error(`threshold must be non-negative, got ${threshold}`);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const root = sessionRootOverride || strategy.sessionRoot({ home, env });
|
|
70
|
+
const now = new Date();
|
|
71
|
+
const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
72
|
+
start.setUTCDate(start.getUTCDate() - (days - 1));
|
|
73
|
+
const windowStartIso = start.toISOString();
|
|
74
|
+
const files = strategy.walkSessions({ root, windowStartIso });
|
|
75
|
+
if (!files || files.length === 0) {
|
|
76
|
+
return {
|
|
77
|
+
ok: false,
|
|
78
|
+
error: "no-local-sessions",
|
|
79
|
+
source: strategy.id,
|
|
80
|
+
message: `no local sessions for source=${strategy.id} under ${root}`,
|
|
81
|
+
rows: [],
|
|
82
|
+
maxDriftPct: 0,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const local = computeLocalTotals({ files, windowStartIso, strategy });
|
|
87
|
+
|
|
88
|
+
let backend;
|
|
89
|
+
if (dbJson) {
|
|
90
|
+
try {
|
|
91
|
+
backend = parseDbJson(dbJson);
|
|
92
|
+
} catch (err) {
|
|
93
|
+
return { ok: false, error: "db-json-parse", source: strategy.id, message: err.message, rows: [], maxDriftPct: 0 };
|
|
94
|
+
}
|
|
95
|
+
} else if (dbJsonPath) {
|
|
96
|
+
let blob;
|
|
97
|
+
try {
|
|
98
|
+
blob = fs.readFileSync(dbJsonPath, "utf8");
|
|
99
|
+
} catch (err) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
error: "db-json-read-failed",
|
|
103
|
+
source: strategy.id,
|
|
104
|
+
message: `cannot read ${dbJsonPath}: ${err?.message || err}`,
|
|
105
|
+
rows: [],
|
|
106
|
+
maxDriftPct: 0,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
backend = parseDbJson(blob);
|
|
110
|
+
} else {
|
|
111
|
+
const resolvedUserId = userId || resolveUserIdViaInsforge({ deviceId });
|
|
112
|
+
if (!resolvedUserId) {
|
|
113
|
+
return {
|
|
114
|
+
ok: false,
|
|
115
|
+
error: "cannot-resolve-user-id",
|
|
116
|
+
source: strategy.id,
|
|
117
|
+
message:
|
|
118
|
+
"cannot resolve user_id; pass userId explicitly, supply dbJson, or make sure `insforge` CLI is linked to the vibeusage workspace and config.deviceId is set",
|
|
119
|
+
rows: [],
|
|
120
|
+
maxDriftPct: 0,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
const queryRes = queryDbTotalsViaInsforge({
|
|
124
|
+
userId: resolvedUserId,
|
|
125
|
+
source: strategy.id,
|
|
126
|
+
windowStartIso,
|
|
127
|
+
});
|
|
128
|
+
if (!queryRes.ok) {
|
|
129
|
+
return { ...queryRes, source: strategy.id, rows: [], maxDriftPct: 0 };
|
|
130
|
+
}
|
|
131
|
+
backend = queryRes.byDay;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const dayKeys = Array.from(new Set([...local.byDay.keys(), ...backend.keys()])).sort();
|
|
135
|
+
const rows = [];
|
|
136
|
+
let maxDriftPct = 0;
|
|
137
|
+
for (const day of dayKeys) {
|
|
138
|
+
const truth = (local.byDay.get(day) || { total: 0 }).total;
|
|
139
|
+
const dbTotal = backend.get(day) || 0;
|
|
140
|
+
const ratio = truth > 0 ? dbTotal / truth : null;
|
|
141
|
+
const drift = ratio == null ? null : Math.abs(ratio - 1) * 100;
|
|
142
|
+
if (drift != null && drift > maxDriftPct) maxDriftPct = drift;
|
|
143
|
+
rows.push({ day, truth, db: dbTotal, ratio, driftPct: drift });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
source: strategy.id,
|
|
149
|
+
displayName: strategy.displayName || strategy.id,
|
|
150
|
+
windowStartIso,
|
|
151
|
+
days,
|
|
152
|
+
thresholdPct: threshold,
|
|
153
|
+
filesScanned: files.length,
|
|
154
|
+
usageLines: local.scanned,
|
|
155
|
+
uniqueMessages: local.uniqueIds,
|
|
156
|
+
duplicatesSkipped: local.skippedDup,
|
|
157
|
+
rows,
|
|
158
|
+
maxDriftPct: Number(maxDriftPct.toFixed(2)),
|
|
159
|
+
exceedsThreshold: maxDriftPct > threshold,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function computeLocalTotals({ files, windowStartIso, strategy }) {
|
|
164
|
+
const byDay = new Map();
|
|
165
|
+
const seen = new Set();
|
|
166
|
+
let scanned = 0;
|
|
167
|
+
let skippedDup = 0;
|
|
168
|
+
|
|
169
|
+
const records = typeof strategy.iterateRecords === "function"
|
|
170
|
+
? strategy.iterateRecords
|
|
171
|
+
: defaultIterateRecords;
|
|
172
|
+
|
|
173
|
+
for (const filePath of files) {
|
|
174
|
+
for (const { line, context } of records(filePath)) {
|
|
175
|
+
const extracted = strategy.extractUsage(line, context);
|
|
176
|
+
if (!extracted) continue;
|
|
177
|
+
const { timestamp, dedupeId, channels } = extracted;
|
|
178
|
+
if (!timestamp || timestamp < windowStartIso) continue;
|
|
179
|
+
const day = isoDay(timestamp);
|
|
180
|
+
if (!day) continue;
|
|
181
|
+
|
|
182
|
+
scanned += 1;
|
|
183
|
+
if (dedupeId && seen.has(dedupeId)) {
|
|
184
|
+
skippedDup += 1;
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
if (dedupeId) seen.add(dedupeId);
|
|
188
|
+
|
|
189
|
+
const input = nonneg(channels.input);
|
|
190
|
+
const cacheCreation = nonneg(channels.cache_creation);
|
|
191
|
+
const cacheRead = nonneg(channels.cache_read);
|
|
192
|
+
const output = nonneg(channels.output);
|
|
193
|
+
const reasoning = nonneg(channels.reasoning);
|
|
194
|
+
const total = input + cacheCreation + cacheRead + output + reasoning;
|
|
195
|
+
if (total === 0) continue;
|
|
196
|
+
|
|
197
|
+
let row = byDay.get(day);
|
|
198
|
+
if (!row) {
|
|
199
|
+
row = {
|
|
200
|
+
total: 0,
|
|
201
|
+
input: 0,
|
|
202
|
+
cache_creation: 0,
|
|
203
|
+
cache_read: 0,
|
|
204
|
+
output: 0,
|
|
205
|
+
reasoning: 0,
|
|
206
|
+
messages: 0,
|
|
207
|
+
};
|
|
208
|
+
byDay.set(day, row);
|
|
209
|
+
}
|
|
210
|
+
row.total += total;
|
|
211
|
+
row.input += input;
|
|
212
|
+
row.cache_creation += cacheCreation;
|
|
213
|
+
row.cache_read += cacheRead;
|
|
214
|
+
row.output += output;
|
|
215
|
+
row.reasoning += reasoning;
|
|
216
|
+
row.messages += 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { byDay, scanned, skippedDup, uniqueIds: seen.size };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function* defaultIterateRecords(filePath) {
|
|
224
|
+
let text;
|
|
225
|
+
try {
|
|
226
|
+
text = fs.readFileSync(filePath, "utf8");
|
|
227
|
+
} catch (_err) {
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
for (const line of text.split("\n")) {
|
|
231
|
+
if (!line) continue;
|
|
232
|
+
yield { line, context: { filePath } };
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isoDay(ts) {
|
|
237
|
+
if (typeof ts !== "string") return null;
|
|
238
|
+
const m = ts.match(/^(\d{4}-\d{2}-\d{2})/);
|
|
239
|
+
return m ? m[1] : null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function nonneg(v) {
|
|
243
|
+
const n = Number(v);
|
|
244
|
+
if (!Number.isFinite(n) || n < 0) return 0;
|
|
245
|
+
return Math.floor(n);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function resolveUserIdViaInsforge({ deviceId }) {
|
|
249
|
+
if (!deviceId) return null;
|
|
250
|
+
const r = spawnSync(
|
|
251
|
+
"insforge",
|
|
252
|
+
[
|
|
253
|
+
"db",
|
|
254
|
+
"query",
|
|
255
|
+
`SELECT user_id FROM vibeusage_tracker_devices WHERE id='${deviceId}' LIMIT 1`,
|
|
256
|
+
],
|
|
257
|
+
{ encoding: "utf8" },
|
|
258
|
+
);
|
|
259
|
+
if (r.status !== 0) return null;
|
|
260
|
+
const m = (r.stdout || "").match(
|
|
261
|
+
/\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b/i,
|
|
262
|
+
);
|
|
263
|
+
return m ? m[1] : null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function queryDbTotalsViaInsforge({ userId, source, windowStartIso }) {
|
|
267
|
+
const sql =
|
|
268
|
+
`SELECT DATE(hour_start) AS day, SUM(total_tokens) AS tokens ` +
|
|
269
|
+
`FROM vibeusage_tracker_hourly ` +
|
|
270
|
+
`WHERE source='${source}' AND user_id='${userId}' AND hour_start >= '${windowStartIso}' ` +
|
|
271
|
+
`GROUP BY DATE(hour_start) ORDER BY day`;
|
|
272
|
+
const r = spawnSync("insforge", ["--json", "db", "query", sql], { encoding: "utf8" });
|
|
273
|
+
if (r.status !== 0) {
|
|
274
|
+
return {
|
|
275
|
+
ok: false,
|
|
276
|
+
error: "insforge-db-query-failed",
|
|
277
|
+
message:
|
|
278
|
+
`\`insforge db query\` failed (${r.status}). Run \`insforge current\` to confirm ` +
|
|
279
|
+
`the CLI is linked to the vibeusage workspace, or pass dbJson directly.`,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return { ok: true, byDay: parseDbJson(r.stdout) };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function parseDbJson(blob) {
|
|
286
|
+
let parsed;
|
|
287
|
+
if (typeof blob === "object" && blob !== null) {
|
|
288
|
+
parsed = blob;
|
|
289
|
+
} else {
|
|
290
|
+
try {
|
|
291
|
+
parsed = JSON.parse(blob);
|
|
292
|
+
} catch (err) {
|
|
293
|
+
throw new Error(`cannot parse DB JSON: ${err?.message || err}`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
const rows = Array.isArray(parsed)
|
|
297
|
+
? parsed
|
|
298
|
+
: Array.isArray(parsed?.rows)
|
|
299
|
+
? parsed.rows
|
|
300
|
+
: Array.isArray(parsed?.data)
|
|
301
|
+
? parsed.data
|
|
302
|
+
: null;
|
|
303
|
+
if (!rows) {
|
|
304
|
+
throw new Error(
|
|
305
|
+
`DB JSON shape unexpected (need array of {day, tokens}); got ${
|
|
306
|
+
Object.keys(parsed || {}).join(",") || "(empty)"
|
|
307
|
+
}`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
const byDay = new Map();
|
|
311
|
+
for (const row of rows) {
|
|
312
|
+
const rawDay = row?.day ?? row?.date ?? row?.bucket_day;
|
|
313
|
+
const day = typeof rawDay === "string" ? rawDay.slice(0, 10) : null;
|
|
314
|
+
if (!day) continue;
|
|
315
|
+
const total = nonneg(row.tokens ?? row.total_tokens ?? row.total);
|
|
316
|
+
byDay.set(day, total);
|
|
317
|
+
}
|
|
318
|
+
return byDay;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// Registry for doctor --audit-tokens --source routing.
|
|
322
|
+
// Register new strategies as they land in sources/<id>.js.
|
|
323
|
+
function getStrategy(id) {
|
|
324
|
+
switch (id) {
|
|
325
|
+
case "claude":
|
|
326
|
+
// eslint-disable-next-line global-require
|
|
327
|
+
return require("./sources/claude");
|
|
328
|
+
case "opencode":
|
|
329
|
+
// eslint-disable-next-line global-require
|
|
330
|
+
return require("./sources/opencode");
|
|
331
|
+
case "codex":
|
|
332
|
+
// eslint-disable-next-line global-require
|
|
333
|
+
return require("./sources/codex");
|
|
334
|
+
case "every-code":
|
|
335
|
+
// eslint-disable-next-line global-require
|
|
336
|
+
return require("./sources/every-code");
|
|
337
|
+
case "gemini":
|
|
338
|
+
// eslint-disable-next-line global-require
|
|
339
|
+
return require("./sources/gemini");
|
|
340
|
+
case "kimi":
|
|
341
|
+
// eslint-disable-next-line global-require
|
|
342
|
+
return require("./sources/kimi");
|
|
343
|
+
case "hermes":
|
|
344
|
+
// eslint-disable-next-line global-require
|
|
345
|
+
return require("./sources/hermes");
|
|
346
|
+
case "openclaw":
|
|
347
|
+
// eslint-disable-next-line global-require
|
|
348
|
+
return require("./sources/openclaw");
|
|
349
|
+
default:
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function listRegisteredSources() {
|
|
355
|
+
return ["claude", "opencode", "codex", "every-code", "gemini", "kimi", "hermes", "openclaw"];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
module.exports = {
|
|
359
|
+
DEFAULT_DAYS,
|
|
360
|
+
DEFAULT_THRESHOLD_PCT,
|
|
361
|
+
runSourceAudit,
|
|
362
|
+
getStrategy,
|
|
363
|
+
listRegisteredSources,
|
|
364
|
+
};
|