cchubber 0.5.7 → 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.
@@ -0,0 +1,154 @@
1
+ import { readFileSync } from 'fs';
2
+ import { join, dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ import { FRONTIER_MODELS } from '../data/frontier-models.js';
5
+ import { PRICE_OVERRIDES } from '../data/price-overrides.js';
6
+
7
+ const __dirname = dirname(fileURLToPath(import.meta.url));
8
+
9
+ // Bundled offline prices (30 Sep 2026). Read lazily so a missing file only costs the offline path.
10
+ let snapshotCache = null;
11
+ export function loadSnapshot() {
12
+ if (!snapshotCache) snapshotCache = JSON.parse(readFileSync(join(__dirname, '..', 'data', 'price-snapshot.json'), 'utf-8'));
13
+ return snapshotCache;
14
+ }
15
+
16
+ const PARTS = ['input', 'output', 'cacheRead', 'cacheWrite'];
17
+
18
+ // LiteLLM lists dollars per token; 1.32e-6 * 1e6 is 1.3199999999999998 in floating point, so round to 10 significant digits.
19
+ const perMillion = (perToken) => Number((perToken * 1e6).toPrecision(10));
20
+
21
+ /** One LiteLLM entry to per-million prices exactly as listed. A price LiteLLM does not list is null. */
22
+ export function priceFromLiteLLM(entry) {
23
+ const f = (v) => (v == null ? null : perMillion(v));
24
+ return {
25
+ input: f(entry.input_cost_per_token),
26
+ output: f(entry.output_cost_per_token),
27
+ cacheRead: f(entry.cache_read_input_token_cost),
28
+ cacheWrite: f(entry.cache_creation_input_token_cost),
29
+ };
30
+ }
31
+
32
+ /**
33
+ * Apply the two fill rules and record which fields they touched.
34
+ * cache read missing -> the input price (no discount assumed)
35
+ * cache write missing or zero -> the input price (the provider bills cache writes as ordinary input)
36
+ */
37
+ export function resolvePrice(p) {
38
+ const filled = [];
39
+ let cacheRead = p.cacheRead;
40
+ let cacheWrite = p.cacheWrite;
41
+ if (cacheRead == null) { cacheRead = p.input; filled.push('cacheRead'); }
42
+ if (cacheWrite == null || cacheWrite === 0) { cacheWrite = p.input; filled.push('cacheWrite'); }
43
+ return { price: { input: p.input, output: p.output, cacheRead, cacheWrite }, filled };
44
+ }
45
+
46
+ const usable = (p) => p && p.input > 0 && p.output > 0;
47
+
48
+ function costParts(tokens, price) {
49
+ const parts = {
50
+ input: tokens.input / 1e6 * price.input,
51
+ output: tokens.output / 1e6 * price.output,
52
+ cacheRead: tokens.cacheRead / 1e6 * price.cacheRead,
53
+ cacheWrite: tokens.cacheWrite / 1e6 * price.cacheWrite,
54
+ };
55
+ return { parts, total: parts.input + parts.output + parts.cacheRead + parts.cacheWrite };
56
+ }
57
+
58
+ /**
59
+ * Find a model's raw per-million prices. LiteLLM live data wins; a manual override is used only when LiteLLM has no
60
+ * entry for the id; the bundled snapshot stands in when the fetch failed (and as the last resort online).
61
+ */
62
+ function findPrice(id, { raw, snapshot, overrides }) {
63
+ const live = raw && raw[id] ? priceFromLiteLLM(raw[id]) : null;
64
+ if (usable(live)) return { source: 'litellm', raw: live };
65
+ const snap = snapshot?.models?.[id];
66
+ const ovr = overrides?.[id];
67
+ const fromOverride = ovr?.intro ? { source: 'override', raw: { cacheRead: null, cacheWrite: null, ...ovr.intro }, ovr } : null;
68
+ const fromSnapshot = usable(snap) ? { source: 'snapshot', raw: { cacheRead: null, cacheWrite: null, ...snap } } : null;
69
+ const order = raw ? [fromOverride, fromSnapshot] : [fromSnapshot, fromOverride];
70
+ return order.find(Boolean) || null;
71
+ }
72
+
73
+ /**
74
+ * Reprice the user's own token totals on each listed model's list price.
75
+ * `opts` exists so tests can pass a fixed price table: { raw (LiteLLM JSON or null = offline), fetchedAt, snapshot, overrides, models }.
76
+ */
77
+ export function reprice(costAnalysis, opts = {}) {
78
+ const raw = opts.raw || null;
79
+ const models = opts.models || FRONTIER_MODELS;
80
+ const overrides = opts.overrides || PRICE_OVERRIDES;
81
+ const lookup = { raw, overrides, snapshot: opts.snapshot || safeSnapshot() };
82
+
83
+ const t = costAnalysis.totals || {};
84
+ const tokens = {
85
+ input: t.inputTokens || 0,
86
+ output: t.outputTokens || 0,
87
+ cacheRead: t.cacheReadTokens || 0,
88
+ cacheWrite: t.cacheWriteTokens || 0,
89
+ };
90
+
91
+ const rows = [];
92
+ const dailyDays = costAnalysis.dailyCosts || [];
93
+ for (const m of models) {
94
+ const found = findPrice(m.id, lookup);
95
+ if (!found) continue;
96
+ const { price, filled } = resolvePrice(found.raw);
97
+ const { parts, total } = costParts(tokens, price);
98
+ const row = {
99
+ id: m.id, label: m.label, maker: m.maker,
100
+ price, parts, total,
101
+ cacheShare: total > 0 ? (parts.cacheRead + parts.cacheWrite) / total : 0,
102
+ source: found.source,
103
+ filled,
104
+ };
105
+ if (found.ovr) {
106
+ row.sourceUrl = found.ovr.sourceUrl;
107
+ row.sourceName = found.ovr.sourceName;
108
+ row.checked = found.ovr.checked;
109
+ row.note = found.ovr.note;
110
+ row.assumption = found.ovr.assumption;
111
+ if (found.ovr.after) {
112
+ const a = resolvePrice({ cacheRead: null, cacheWrite: null, ...found.ovr.after });
113
+ const c = costParts(tokens, a.price);
114
+ row.after = { price: a.price, parts: c.parts, total: c.total, filled: a.filled };
115
+ }
116
+ }
117
+ rows.push(row);
118
+ }
119
+ rows.sort((a, b) => b.total - a.total);
120
+
121
+ // Running total per day, from each day's per-model token counts priced at this model's rates.
122
+ const dates = dailyDays.map(d => d.date);
123
+ const perModel = {};
124
+ for (const r of rows) {
125
+ let cum = 0;
126
+ perModel[r.id] = dailyDays.map(d => {
127
+ for (const dm of d.models || []) {
128
+ const tk = dm.tokens;
129
+ if (!tk || typeof tk !== 'object') continue;
130
+ cum += costParts({ input: tk.input || 0, output: tk.output || 0, cacheRead: tk.cacheRead || 0, cacheWrite: tk.cacheWrite || 0 }, r.price).total;
131
+ }
132
+ return round2(cum);
133
+ });
134
+ }
135
+ let actualCum = 0;
136
+ const actual = dailyDays.map(d => { actualCum += d.cost || 0; return round2(actualCum); });
137
+
138
+ return {
139
+ models: rows,
140
+ actualListCost: costAnalysis.totalCost || 0,
141
+ tokens,
142
+ readPerWrite: tokens.output > 0 ? (tokens.input + tokens.cacheRead + tokens.cacheWrite) / tokens.output : 0,
143
+ dailyCum: { dates, perModel, actual },
144
+ fetchedAt: opts.fetchedAt ?? null,
145
+ offline: !raw,
146
+ ...(raw ? {} : { snapshotDate: (lookup.snapshot || {}).date || null }),
147
+ };
148
+ }
149
+
150
+ function safeSnapshot() {
151
+ try { return loadSnapshot(); } catch { return null; }
152
+ }
153
+
154
+ const round2 = (n) => Math.round(n * 100) / 100;
package/src/cli/index.js CHANGED
@@ -5,7 +5,7 @@ import { existsSync, writeFileSync } from 'fs';
5
5
  import { homedir, platform } from 'os';
6
6
  import { exec } from 'child_process';
7
7
  import { readFileSync } from 'fs';
8
- import { fileURLToPath } from 'url';
8
+ import { fileURLToPath, pathToFileURL } from 'url';
9
9
  import { dirname } from 'path';
10
10
 
11
11
  const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -18,7 +18,8 @@ import { readSessionMeta } from '../readers/session-meta.js';
18
18
  import { readCacheBreaks } from '../readers/cache-breaks.js';
19
19
  import { readClaudeMdStack } from '../readers/claude-md.js';
20
20
  import { readOAuthUsage } from '../readers/oauth-usage.js';
21
- import { analyzeUsage, fetchPricing } from '../analyzers/cost-calculator.js';
21
+ import { analyzeUsage, fetchPricing, getLiteLLMRaw } from '../analyzers/cost-calculator.js';
22
+ import { reprice } from '../analyzers/reprice.js';
22
23
  import { analyzeCacheHealth } from '../analyzers/cache-health.js';
23
24
  import { detectAnomalies } from '../analyzers/anomaly-detector.js';
24
25
  import { generateRecommendations } from '../analyzers/recommendations.js';
@@ -27,8 +28,13 @@ import { analyzeSessionIntelligence } from '../analyzers/session-intelligence.js
27
28
  import { analyzeModelRouting } from '../analyzers/model-routing.js';
28
29
  import { analyzeValueTrend } from '../analyzers/value-tracker.js';
29
30
  import { renderHTML } from '../renderers/html-report.js';
30
- import { renderTerminal } from '../renderers/terminal-summary.js';
31
- import { shouldSendTelemetry, sendTelemetry } from '../telemetry.js';
31
+ import { renderTerminal, vsOneLiner } from '../renderers/terminal-summary.js';
32
+ import { renderS1 } from '../renderers/s1-report.js';
33
+ import { readPlan, monthsBilled } from '../readers/plan.js';
34
+ import { runDrift } from '../analyzers/drift-run.js';
35
+ import { renderDrift } from '../renderers/drift-report.js';
36
+ import { shouldSendTelemetry, sendTelemetry, beaconConfig } from '../telemetry.js';
37
+ import { spentWellLine } from '../renderers/spent-well.js';
32
38
  import { saveRun, getDelta, getHistory } from '../history.js';
33
39
 
34
40
  const args = process.argv.slice(2);
@@ -37,6 +43,13 @@ const flags = {
37
43
  json: args.includes('--json'),
38
44
  noTelemetry: args.includes('--no-telemetry'),
39
45
  noOpen: args.includes('--no-open'),
46
+ s1: args.includes('--s1'),
47
+ drift: args.includes('--drift'),
48
+ vs: args.includes('--vs'),
49
+ redact: args.includes('--redact'),
50
+ showProject: args.includes('--show-project'),
51
+ name: (() => { const i = args.indexOf('--name'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })(),
52
+ plan: (() => { const i = args.indexOf('--plan'); return i !== -1 && args[i + 1] ? args[i + 1] : null; })(),
40
53
  output: (() => {
41
54
  const idx = args.indexOf('--output') !== -1 ? args.indexOf('--output') : args.indexOf('-o');
42
55
  return idx !== -1 && args[idx + 1] ? args[idx + 1] : null;
@@ -62,7 +75,13 @@ if (flags.help) {
62
75
  --days, -d <n> Analyze last N days (default: 30)
63
76
  --output, -o <path> Output HTML report to custom path
64
77
  --no-open Don't auto-open the report in browser
65
- --json Output raw analysis as JSON
78
+ --json Output raw analysis as JSON (includes reprice: your usage on other models)
79
+ --s1 Your usage as an IPO prospectus (parody)
80
+ --name <name> Company name on the S-1 (default: You)
81
+ --plan <usd> Monthly plan price, if it can't be detected
82
+ --show-project Name your top project on the S-1
83
+ --drift Your week vs what you said you'd do (a shareable card)
84
+ --redact On --drift, replace detour names with categories
66
85
  -h, --help Show this help
67
86
 
68
87
  Examples:
@@ -70,6 +89,19 @@ if (flags.help) {
70
89
  cchubber --days 7 Last 7 days only
71
90
  cchubber -o report.html Custom output path
72
91
  cchubber --json Machine-readable output
92
+ cchubber --drift How much of your week went to the plan
93
+ cchubber --drift --redact Same, with detour names hidden
94
+
95
+ Your usage on other models needs no flag. The report that cchubber opens has
96
+ a section for it: your own tokens repriced on each frontier model's list
97
+ price, a race built from your real days, and buttons to download a card, copy
98
+ the text or post it on X. Prices come live from LiteLLM, plus a manual entry
99
+ for a listed model LiteLLM does not carry yet (used only until LiteLLM has
100
+ it). Offline it uses bundled prices dated 30 Sep 2026. When a plan you wrote
101
+ down is found (plan.md or similar) the section also shows how much of your
102
+ last week went to it, computed locally.
103
+ Same tokens on each model's list price, cache included. Another model would
104
+ use a different number of tokens, so this compares prices, not outcomes.
73
105
 
74
106
  Shipped with Mover OS at speed.
75
107
  https://moveros.dev
@@ -86,6 +118,13 @@ async function main() {
86
118
  process.exit(1);
87
119
  }
88
120
 
121
+ // Hidden, for scripts: `--vs --json` prints only the repricing (no report work). Plain `--vs` is an alias of the normal
122
+ // run that opens the report scrolled to its "Your usage on other models" section. Nothing needs the flag.
123
+ if (flags.vs && flags.json) return vsJsonMode(claudeDir);
124
+
125
+ // Drift report: its own local pipeline (last week of sessions vs your stated plan). No cost reading, no network.
126
+ if (flags.drift) return driftMode(claudeDir);
127
+
89
128
  console.log(`
90
129
  /\\ _ /\\
91
130
  / \\(_)/ \\ CC Hubber v${VERSION}
@@ -157,6 +196,14 @@ async function main() {
157
196
  if (communityStats) console.log(` ✓ Community data: ${communityStats.totalReports} users from ${Object.keys(communityStats.countries || {}).length} countries`);
158
197
  else console.log(' ○ Community data unavailable (offline)');
159
198
 
199
+ // Your usage on other models. Never allowed to break the normal run.
200
+ let vsData = null;
201
+ try {
202
+ const live = getLiteLLMRaw();
203
+ vsData = reprice(costAnalysis, { raw: live?.data, fetchedAt: live?.fetchedAt });
204
+ console.log(vsData.offline ? ' ○ Other-model prices offline: bundled prices as of 30 Sep 2026' : ' ✓ Other-model prices: LiteLLM');
205
+ } catch { vsData = null; }
206
+
160
207
  const report = {
161
208
  generatedAt: new Date().toISOString(),
162
209
  periodDays: flags.days,
@@ -172,6 +219,7 @@ async function main() {
172
219
  recommendations,
173
220
  valueTrend,
174
221
  communityStats,
222
+ reprice: vsData,
175
223
  history: getHistory(),
176
224
  };
177
225
 
@@ -198,6 +246,12 @@ async function main() {
198
246
 
199
247
  renderTerminal(report);
200
248
 
249
+ // One line on what the same tokens would cost elsewhere; the rest is in the report.
250
+ try { const line = vsOneLiner(vsData); if (line) console.log(`\n ${line}`); } catch {}
251
+
252
+ // The report's "Did you spend them well?" strip, pointed at from the terminal.
253
+ try { const line = spentWellLine(report); if (line) console.log(`\n ${line}`); } catch {}
254
+
201
255
  // Anonymous telemetry (opt out: --no-telemetry or CC_HUBBER_TELEMETRY=0)
202
256
  if (shouldSendTelemetry(flags)) {
203
257
  console.log(' ○ Sharing anonymous stats...');
@@ -205,27 +259,121 @@ async function main() {
205
259
  console.log(' ✓ Stats shared (opt out: --no-telemetry)');
206
260
  }
207
261
 
262
+ if (flags.s1) {
263
+ const plan = readPlan(flags.plan);
264
+ const s1Path = flags.output || join(process.cwd(), 'cchubber-s1.html');
265
+ writeFileSync(s1Path, renderS1(report, { plan, name: flags.name, showProject: flags.showProject }), 'utf-8');
266
+ console.log(`\n ✓ Your S-1 saved to: ${s1Path}`);
267
+ if (!plan) console.log(' ○ Plan not detected. Add --plan 100 (or 20, 200) to show what you paid.');
268
+ if (!flags.noOpen) { openInBrowser(s1Path); console.log(' ✓ Opened in browser\n'); }
269
+ return;
270
+ }
271
+
208
272
  const outputPath = flags.output || join(process.cwd(), 'cchubber-report.html');
209
- const html = renderHTML(report);
273
+ const vsCtx = vsData ? vsContext(claudeDir, vsData) : null;
274
+ const html = renderHTML(report, { vs: vsCtx, telemetry: beaconConfig(flags) });
210
275
  writeFileSync(outputPath, html, 'utf-8');
211
276
  console.log(`\n ✓ Report saved to: ${outputPath}`);
212
277
 
213
278
  if (!flags.noOpen) {
214
- openInBrowser(outputPath);
279
+ openInBrowser(outputPath, flags.vs ? 'vs' : '');
215
280
  console.log(' ✓ Opened in browser\n');
216
281
  }
217
282
  }
218
283
 
284
+ // What the "other models" section needs beyond the repricing: the plan paid for (if detected), billing months over the
285
+ // logged window, and the drift headline when a stated plan is found and computes locally (about 2 seconds).
286
+ function vsContext(claudeDir, rp) {
287
+ const plan = readPlan(flags.plan);
288
+ const dates = rp.dailyCum.dates;
289
+ const months = dates.length ? monthsBilled(dates[0], dates[dates.length - 1], plan?.since) : 1;
290
+ let driftPct = null;
291
+ try {
292
+ const t0 = Date.now();
293
+ const days = flags.days === 30 ? 7 : flags.days;
294
+ const drift = runDrift(claudeDir, { days });
295
+ if (drift.available && drift.source?.planNames?.length) driftPct = Math.round(drift.share * 100);
296
+ console.log(driftPct != null ? ` ✓ Drift: ${driftPct}% of your last ${days} days on what you said you'd do (${((Date.now() - t0) / 1000).toFixed(1)}s)` : ' ○ No stated plan found, so no drift line');
297
+ } catch { driftPct = null; }
298
+ return { plan, months, driftPct };
299
+ }
300
+
219
301
  function getClaudeDir() {
220
302
  const home = homedir();
221
303
  return join(home, '.claude');
222
304
  }
223
305
 
224
- function openInBrowser(filePath) {
306
+ async function vsJsonMode(claudeDir) {
307
+ // Progress goes to stderr so stdout is clean JSON
308
+ const log = (m) => process.stderr.write(m + '\n');
309
+ log(' Reading local Claude Code data...');
310
+
311
+ const jsonlEntries = readAllJSONL(claudeDir);
312
+ const statsCache = readStatsCache(claudeDir);
313
+ if (jsonlEntries.length === 0 && !statsCache) {
314
+ console.error(' ✗ No usage data found. Use Claude Code first, then run CC Hubber.\n');
315
+ process.exit(1);
316
+ }
317
+ const sessionMeta = readSessionMeta(claudeDir);
318
+ const dailyFromJSONL = aggregateDaily(jsonlEntries);
319
+ const modelFromJSONL = aggregateByModel(jsonlEntries);
320
+
321
+ await fetchPricing();
322
+ const live = getLiteLLMRaw();
323
+ const costAnalysis = analyzeUsage(statsCache, sessionMeta, 99999, dailyFromJSONL, modelFromJSONL);
324
+ const rp = reprice(costAnalysis, { raw: live?.data, fetchedAt: live?.fetchedAt });
325
+ log(rp.offline ? ' ○ Offline: prices as of 30 Sep 2026 (bundled)' : ' ✓ Live prices from LiteLLM');
326
+ process.stdout.write(JSON.stringify(rp, null, 2) + '\n');
327
+ }
328
+
329
+ async function driftMode(claudeDir) {
330
+ const days = flags.days === 30 ? 7 : flags.days; // 7-day week by default; --days overrides
331
+ // Progress goes to stderr so `--drift --json` emits clean JSON on stdout
332
+ const log = flags.json ? (m) => process.stderr.write(m + '\n') : (m) => console.log(m);
333
+
334
+ log(`
335
+ /\\ _ /\\
336
+ / \\(_)/ \\ CC Hubber v${VERSION} — Drift Report
337
+ \\ / ~ \\ / How much of your week went to what you said you'd do.
338
+ \\/ · \\/
339
+ `);
340
+ log(` Reading the last ${days} days of local sessions...\n`);
341
+
342
+ const drift = runDrift(claudeDir, { days });
343
+
344
+ if (!drift.available) {
345
+ console.error(' ✗ No agent work found in the window. Use Claude Code (or Codex) this week, then try again.\n');
346
+ process.exit(1);
347
+ }
348
+
349
+ const pct = Math.round(drift.share * 100);
350
+ log(` ✓ ${drift.totals.prompts} prompts across ${drift.totals.sessions} sessions (${drift.inputs.claude} Claude${drift.inputs.codex ? `, ${drift.inputs.codex} Codex` : ''})`);
351
+ log(` ✓ What you said you'd do: ${drift.source.planNames.length ? drift.source.planNames.join(' + ') : 'your first ask of each session'}`);
352
+ if (!drift.inputs.codexPresent) log(' ○ No Codex sessions found (skipped)');
353
+ log(`\n ${pct}% of your agent time went to what you said you'd do.`);
354
+ log(` ${Math.round(drift.totals.onMs / 3600000)}h on plan · ${Math.round(drift.totals.detourMs / 3600000)}h on detours\n`);
355
+ for (const d of drift.detours.slice(0, 5)) {
356
+ const label = flags.redact ? d.category : (d.name || d.category);
357
+ log(` · ${label} ${Math.max(1, Math.round(d.ms / 60000))}m`);
358
+ }
359
+ log('');
360
+
361
+ if (flags.json) { process.stdout.write(JSON.stringify(drift, null, 2) + '\n'); return; }
362
+
363
+ const outPath = flags.output || join(process.cwd(), 'cchubber-drift.html');
364
+ writeFileSync(outPath, renderDrift(drift, { redact: flags.redact }), 'utf-8');
365
+ log(` ✓ Card saved to: ${outPath}`);
366
+ log(' ○ It stayed on this machine. Nothing was uploaded.' + (flags.redact ? '' : ' Use --redact to hide detour names.'));
367
+ if (!flags.noOpen) { openInBrowser(outPath); log(' ✓ Opened in browser\n'); }
368
+ }
369
+
370
+ function openInBrowser(filePath, hash = '') {
225
371
  const p = platform();
226
- const cmd = p === 'win32' ? `start "" "${filePath}"`
227
- : p === 'darwin' ? `open "${filePath}"`
228
- : `xdg-open "${filePath}"`;
372
+ // A file URL lets the browser scroll to a #section; a bare path cannot carry one.
373
+ const target = hash ? `${pathToFileURL(filePath).href}#${hash}` : filePath;
374
+ const cmd = p === 'win32' ? `start "" "${target}"`
375
+ : p === 'darwin' ? `open "${target}"`
376
+ : `xdg-open "${target}"`;
229
377
  exec(cmd, (err) => { if (err) console.log(' ○ Could not auto-open browser. Open the file manually.'); });
230
378
  }
231
379
 
@@ -0,0 +1,12 @@
1
+ // The explicit comparison list for --vs. `id` is the LiteLLM key (model_prices_and_context_window.json).
2
+ // Edit this list to add or drop a model; nothing else needs to change.
3
+ export const FRONTIER_MODELS = [
4
+ { id: 'claude-fable-5-1', label: 'Claude Fable 5.1', maker: 'Anthropic' },
5
+ { id: 'claude-opus-5-5', label: 'Claude Opus 5.5', maker: 'Anthropic' },
6
+ { id: 'claude-sonnet-5-5', label: 'Claude Sonnet 5.5', maker: 'Anthropic' },
7
+ { id: 'gpt-6-astra', label: 'GPT-6 Astra', maker: 'OpenAI' },
8
+ { id: 'gpt-6-sol', label: 'GPT-6 Sol', maker: 'OpenAI' },
9
+ { id: 'gemini-4-argon', label: 'Gemini 4 Argon', maker: 'Google' },
10
+ { id: 'deepseek-v4-pro', label: 'DeepSeek V4 Pro', maker: 'DeepSeek' },
11
+ { id: 'moonshot/kimi-k3', label: 'Kimi K3', maker: 'Moonshot' },
12
+ ];
@@ -0,0 +1,16 @@
1
+ // Manual prices for listed models that LiteLLM does not carry yet. Used ONLY when LiteLLM has no entry for the id,
2
+ // so live data wins the day LiteLLM adds the model. All prices are USD per million tokens.
3
+ //
4
+ // A missing cacheWrite is deliberate: Google lists no cache-write premium, so reprice.js bills writes at the input
5
+ // rate and records that in `filled` (an assumption, not a published price).
6
+ export const PRICE_OVERRIDES = {
7
+ 'gemini-4-argon': {
8
+ intro: { input: 2, output: 10, cacheRead: 0.10 }, // cached input is 95% off the input price
9
+ after: { input: 4, output: 20, cacheRead: 0.20 }, // standard price once the introductory period ends
10
+ sourceUrl: 'https://blog.google/innovation-and-ai/models-and-research/gemini-models/gemini-4-argon/',
11
+ sourceName: 'blog.google',
12
+ checked: '2026-09-30',
13
+ note: 'introductory price',
14
+ assumption: 'Google lists no cache-write premium, so cache writes are priced at the input rate.',
15
+ },
16
+ };
@@ -0,0 +1,49 @@
1
+ {
2
+ "date": "2026-09-30",
3
+ "source": "LiteLLM model_prices_and_context_window.json",
4
+ "note": "USD per million tokens as LiteLLM lists them; null means the field is absent (reprice.js fills it from the input price).",
5
+ "models": {
6
+ "claude-fable-5-1": {
7
+ "input": 10,
8
+ "output": 50,
9
+ "cacheRead": 0.25,
10
+ "cacheWrite": 12.5
11
+ },
12
+ "claude-opus-5-5": {
13
+ "input": 4,
14
+ "output": 20,
15
+ "cacheRead": 0.2,
16
+ "cacheWrite": 5
17
+ },
18
+ "claude-sonnet-5-5": {
19
+ "input": 2,
20
+ "output": 10,
21
+ "cacheRead": 0.2,
22
+ "cacheWrite": 2.5
23
+ },
24
+ "gpt-6-astra": {
25
+ "input": 10,
26
+ "output": 50,
27
+ "cacheRead": 1,
28
+ "cacheWrite": 12.5
29
+ },
30
+ "gpt-6-sol": {
31
+ "input": 2,
32
+ "output": 10,
33
+ "cacheRead": 0.2,
34
+ "cacheWrite": 2.5
35
+ },
36
+ "deepseek-v4-pro": {
37
+ "input": 1.32,
38
+ "output": 3.96,
39
+ "cacheRead": 0.044,
40
+ "cacheWrite": 0
41
+ },
42
+ "moonshot/kimi-k3": {
43
+ "input": 3,
44
+ "output": 15,
45
+ "cacheRead": 0.3,
46
+ "cacheWrite": null
47
+ }
48
+ }
49
+ }
@@ -8,24 +8,21 @@ import { homedir } from 'os';
8
8
  * Claude Code stores full conversation transcripts with token usage per message.
9
9
  */
10
10
  export function readAllJSONL(claudeDir) {
11
- const projectsDir = join(claudeDir, 'projects');
12
- const xdgDir = join(homedir(), '.config', 'claude', 'projects'); // XDG fallback for Linux
13
-
14
11
  const entries = [];
15
-
16
- // Read from primary location
17
- if (existsSync(projectsDir)) {
18
- readProjectsDir(projectsDir, entries);
19
- }
20
-
21
- // XDG fallback (Linux with newer Claude Code)
22
- if (existsSync(xdgDir) && xdgDir !== projectsDir) {
23
- readProjectsDir(xdgDir, entries);
24
- }
25
-
12
+ for (const dir of projectRoots(claudeDir)) readProjectsDir(dir, entries);
26
13
  return entries.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
27
14
  }
28
15
 
16
+ /**
17
+ * Where Claude Code keeps its transcripts: ~/.claude/projects, plus the XDG path newer Linux builds use.
18
+ * Shared with the drift reader so both read the same sessions.
19
+ */
20
+ export function projectRoots(claudeDir) {
21
+ const projectsDir = join(claudeDir, 'projects');
22
+ const xdgDir = join(homedir(), '.config', 'claude', 'projects');
23
+ return [projectsDir, xdgDir].filter((d, i, all) => all.indexOf(d) === i && existsSync(d));
24
+ }
25
+
29
26
  function readProjectsDir(dir, entries) {
30
27
  try {
31
28
  const projectHashes = readdirSync(dir).filter(f => {
@@ -0,0 +1,56 @@
1
+ import { readFileSync, existsSync } from 'fs';
2
+ import { join } from 'path';
3
+ import { homedir } from 'os';
4
+
5
+ // Monthly list prices from claude.com/pricing and the Max plan help page, checked 29 Sep 2026 (US web prices, before tax).
6
+ const PLAN_PRICES = { pro: 20, max_5x: 100, max_20x: 200 };
7
+ const PLAN_NAMES = { pro: 'Claude Pro', max_5x: 'Claude Max 5x', max_20x: 'Claude Max 20x' };
8
+
9
+ // Reads only the plan tier and start date from ~/.claude.json. Tokens and keys in that file are never touched.
10
+ export function readPlan(override) {
11
+ if (override) {
12
+ const usd = Number(override);
13
+ if (Number.isFinite(usd) && usd > 0) return { key: 'custom', name: 'your plan', monthlyUSD: usd, since: null };
14
+ }
15
+ const file = join(homedir(), '.claude.json');
16
+ if (!existsSync(file)) return null;
17
+ try {
18
+ const acct = JSON.parse(readFileSync(file, 'utf-8')).oauthAccount || {};
19
+ const tier = String(acct.organizationRateLimitTier || acct.userRateLimitTier || '').toLowerCase();
20
+ const key = tier.includes('max_20x') ? 'max_20x' : tier.includes('max_5x') ? 'max_5x' : tier.includes('pro') ? 'pro' : null;
21
+ if (!key) return null;
22
+ return { key, name: PLAN_NAMES[key], monthlyUSD: PLAN_PRICES[key], since: acct.subscriptionCreatedAt || null };
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ // `d` plus `k` months, clamped to the last day of a shorter month: a plan billed on the 31st renews 28 Feb, 31 Mar, 30 Apr.
29
+ // (setUTCMonth alone rolls 31 Jan + 1 month over to 3 Mar, which silently drops February.)
30
+ function addMonths(d, k) {
31
+ const y = d.getUTCFullYear();
32
+ const m = d.getUTCMonth() + k;
33
+ const lastDay = new Date(Date.UTC(y, m + 1, 0)).getUTCDate();
34
+ return new Date(Date.UTC(y, m, Math.min(d.getUTCDate(), lastDay)));
35
+ }
36
+
37
+ // Billing months inside the window the logs cover: every charge whose month overlaps it. Counting only the logged window
38
+ // keeps "paid" and "consumed" about the same days, since Claude Code prunes old transcripts by default.
39
+ // Charges land on the subscription's own anniversary (`since`, when ~/.claude.json has it), not on the first logged day;
40
+ // without it the first logged day stands in as the billing day.
41
+ export function monthsBilled(firstDate, lastDate, since) {
42
+ const a = new Date(firstDate + 'T00:00:00Z');
43
+ const b = new Date(lastDate + 'T00:00:00Z');
44
+ if (isNaN(a) || isNaN(b) || b < a) return 1;
45
+ let anchor = a;
46
+ if (since) {
47
+ const s = new Date(String(since).slice(0, 10) + 'T00:00:00Z');
48
+ if (!isNaN(s) && s <= b) anchor = s;
49
+ }
50
+ let n = 0;
51
+ for (let k = 0; ; k++) {
52
+ if (addMonths(anchor, k) > b) break;
53
+ if (addMonths(anchor, k + 1) > a) n++;
54
+ }
55
+ return Math.max(1, n);
56
+ }