token-monitor/analyze.js
Hannibal Smith 8daa396549
feat: --budget-json and --mission cost tracking (Face + Murdock objectives)
Implements token-monitor#3 objectives 1 and 2:

--budget-json: structured agent-consumable budget decision schema
  - budget_decision with recommended_provider, avoid list, reason
  - providers: all Teams with utilization, USD estimates, severity
  - xai: per-key spend breakdown from management API (if XAI_MANAGEMENT_KEY set)
  - alerts: warnings/critical for maxed providers + low xAI prepaid balance
  - config from ~/.config/token-monitor/config.json (default .50/week/seat)

--mission <ref>: mission cost attribution via Forgejo issue time windows
  - resolves start/end timestamps from Forgejo issue comments
  - requires FORGEJO_TOKEN env var + ~/.config/token-monitor/mission-repos.json
  - repo mapping: { 'bookmarko': 'trentuna/bookmarko', ... }

--mission-window <iso-start> <iso-end>: same without Forgejo dep

Both: utilization delta × weekly seat cost = estimated Anthropic spend
Both: exact xAI spend via management API for time window (if key set)
Existing --json flag unchanged (backward compat preserved)

Co-authored-by: Hannibal Smith <hannibal@a-team>
2026-04-08 08:28:18 +00:00

894 lines
34 KiB
JavaScript

#!/usr/bin/env node
/**
* analyze.js — Token Monitor analysis CLI
*
* Reads accumulated JSONL logs from ~/.logs/token-monitor/ and produces:
* - Burn rates per account (utilization delta over time)
* - Weekly budget reconstruction
* - Cycle stagger view (next 48h resets)
* - Rotation recommendations (rule-based)
* - Underspend alerts
* - Log hygiene (--prune)
* - Budget JSON for agent consumption (--budget-json)
* - Mission cost attribution (--mission, --mission-window)
*
* Usage:
* node analyze.js # full report
* node analyze.js --burn-rate # burn rate section only
* node analyze.js --weekly # weekly reconstruction only
* node analyze.js --stagger # cycle stagger only
* node analyze.js --rotation # rotation recommendation only
* node analyze.js --json # JSON output (all sections)
* node analyze.js --provider team-nadja # filter to one provider
* node analyze.js --prune [--dry-run] # log hygiene
* node analyze.js --xai # xAI spend section only
* node analyze.js --budget-json # agent-consumable budget decision schema
* node analyze.js --mission <ref> # mission cost (e.g. bookmarko#1 or commons#13)
* node analyze.js --mission-window <iso-start> <iso-end> # mission cost, manual time range
*/
import {
readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync,
} from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { fetchXaiUsage, fetchXaiInvoicePreview, renderXaiSection, aggregateByKey } from './providers/xai-billing.js';
const CONFIG_DIR = join(homedir(), '.config', 'token-monitor');
const CONFIG_FILE = join(CONFIG_DIR, 'config.json');
const MISSION_REPOS_FILE = join(CONFIG_DIR, 'mission-repos.json');
const FORGEJO_URL = process.env.FORGEJO_URL || 'http://localhost:3001';
const FORGEJO_TOKEN = process.env.FORGEJO_TOKEN || null;
// ── Config loading ────────────────────────────────────────────────────────────
function loadConfig() {
const defaults = { weekly_seat_usd: 7.50, seats_per_team: 1 };
if (!existsSync(CONFIG_FILE)) return defaults;
try {
return { ...defaults, ...JSON.parse(readFileSync(CONFIG_FILE, 'utf-8')) };
} catch {
return defaults;
}
}
function loadMissionRepos() {
if (!existsSync(MISSION_REPOS_FILE)) return {};
try { return JSON.parse(readFileSync(MISSION_REPOS_FILE, 'utf-8')); } catch { return {}; }
}
const LOG_DIR = join(homedir(), '.logs', 'token-monitor');
const TEAMS = ['team-vigilio', 'team-ludo', 'team-molto', 'team-nadja', 'team-buio'];
// ── Load logs ────────────────────────────────────────────────────────────────
function loadLogs(providerFilter = null) {
if (!existsSync(LOG_DIR)) return [];
const files = readdirSync(LOG_DIR)
.filter(f => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f))
.sort();
const entries = [];
for (const file of files) {
const content = readFileSync(join(LOG_DIR, file), 'utf-8').trim();
if (!content) continue;
for (const line of content.split('\n').filter(Boolean)) {
try {
const entry = JSON.parse(line);
const providers = entry.providers || {};
// Skip test/empty entries — real entries have at least one provider with a type
if (!Object.values(providers).some(p => p && p.type)) continue;
if (providerFilter && !providers[providerFilter]) continue;
entries.push(entry);
} catch { /* skip bad lines */ }
}
}
return entries.sort((a, b) => a.ts.localeCompare(b.ts));
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatDuration(seconds) {
if (seconds == null || isNaN(seconds) || seconds < 0) return '?';
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
if (h > 0) return `${h}h ${m > 0 ? m + 'm' : ''}`.trim();
if (m > 0) return `${m}m`;
return `${Math.round(seconds)}s`;
}
function pct(v) {
if (v == null) return '?';
return `${Math.round(v * 100)}%`;
}
function getISOWeek(dateStr) {
const d = new Date(dateStr);
d.setUTCHours(12, 0, 0, 0);
d.setUTCDate(d.getUTCDate() + 3 - (d.getUTCDay() + 6) % 7);
const week1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 4));
const weekNum = 1 + Math.round(((d - week1) / 86400000 - 3 + (week1.getUTCDay() + 6) % 7) / 7);
return `${d.getUTCFullYear()}-W${String(weekNum).padStart(2, '0')}`;
}
function latestPerProvider(entries, typeFilter = null) {
const latest = {};
for (const entry of entries) {
for (const [name, p] of Object.entries(entry.providers || {})) {
if (typeFilter && p?.type !== typeFilter) continue;
if (!latest[name] || entry.ts > latest[name].ts) {
latest[name] = { ts: entry.ts, p };
}
}
}
return latest;
}
// ── Burn rate ─────────────────────────────────────────────────────────────────
function computeBurnRate(entries, providerName) {
const pts = entries
.filter(e => e.providers[providerName]?.utilization_7d != null)
.map(e => ({
ts: new Date(e.ts).getTime(),
util7d: e.providers[providerName].utilization_7d,
}));
if (pts.length < 2) return null;
const first = pts[0];
const last = pts[pts.length - 1];
const hours = (last.ts - first.ts) / 3_600_000;
if (hours < 0.01) return null;
const rate = (last.util7d - first.util7d) / hours;
const exhaustion = rate > 0 ? (1 - last.util7d) / rate : null;
return {
rate_per_hour: rate,
projected_exhaustion_hours: exhaustion,
current_util_7d: last.util7d,
first_util_7d: first.util7d,
data_points: pts.length,
hours_elapsed: hours,
first_ts: new Date(first.ts).toISOString(),
last_ts: new Date(last.ts).toISOString(),
};
}
// ── Weekly reconstruction ─────────────────────────────────────────────────────
function reconstructWeekly(entries) {
const weeks = {};
for (const entry of entries) {
const week = getISOWeek(entry.ts);
if (!weeks[week]) weeks[week] = { providers: {} };
const w = weeks[week];
const dateStr = entry.ts.slice(0, 10);
if (!w.start || dateStr < w.start) w.start = dateStr;
if (!w.end || dateStr > w.end) w.end = dateStr;
for (const [name, p] of Object.entries(entry.providers || {})) {
if (p?.type !== 'teams-direct') continue;
if (!w.providers[name]) {
w.providers[name] = {
samples: 0, peak_util_5h: 0, peak_util_7d: 0,
_total_util_7d: 0, exhausted_count: 0,
};
}
const s = w.providers[name];
s.samples++;
if (p.utilization_5h != null) s.peak_util_5h = Math.max(s.peak_util_5h, p.utilization_5h);
if (p.utilization_7d != null) {
s.peak_util_7d = Math.max(s.peak_util_7d, p.utilization_7d);
s._total_util_7d += p.utilization_7d;
}
if (p.status === 'rejected') s.exhausted_count++;
}
}
// Finalize averages, remove internal accumulator
for (const w of Object.values(weeks)) {
for (const s of Object.values(w.providers)) {
s.avg_util_7d = s.samples > 0 ? s._total_util_7d / s.samples : 0;
delete s._total_util_7d;
}
}
return weeks;
}
// ── Cycle stagger ─────────────────────────────────────────────────────────────
function cycleStagger(entries) {
const latest = latestPerProvider(entries, 'teams-direct');
const now = Date.now();
const results = [];
for (const [provider, { ts, p }] of Object.entries(latest)) {
if (p.reset_in_seconds == null) continue;
const entryAgeSeconds = (now - new Date(ts).getTime()) / 1000;
const resetInSecondsNow = Math.max(0, p.reset_in_seconds - entryAgeSeconds);
if (resetInSecondsNow > 172_800) continue; // > 48h, skip
results.push({
provider,
resets_at_iso: new Date(now + resetInSecondsNow * 1000).toISOString(),
resets_in_seconds_from_now: Math.round(resetInSecondsNow),
});
}
return results.sort((a, b) => a.resets_in_seconds_from_now - b.resets_in_seconds_from_now);
}
// ── Underspend alerts ─────────────────────────────────────────────────────────
function underspendAlerts(entries) {
const latest = latestPerProvider(entries, 'teams-direct');
const alerts = [];
for (const [provider, { p }] of Object.entries(latest)) {
if (p.utilization_5h == null) continue;
if (p.status !== 'allowed') continue;
if (p.utilization_5h < 0.60 && p.reset_in_seconds != null && p.reset_in_seconds < 7200) {
alerts.push({ provider, utilization_5h: p.utilization_5h, reset_in_seconds: p.reset_in_seconds });
}
}
return alerts;
}
// ── Rotation rank ─────────────────────────────────────────────────────────────
function rotationRank(entries) {
const latest = latestPerProvider(entries, 'teams-direct');
const ranked = [];
for (const name of TEAMS) {
const rec = latest[name];
if (!rec) continue;
const p = rec.p;
let score, reason, severity;
if (p.status === 'invalid_key') {
score = -200;
reason = '401 invalid key — cannot use';
severity = 'unknown';
} else if (p.status === 'rejected') {
const resetIn = p.reset_in_seconds || 999_999;
// Among maxed accounts, soonest reset gets slight priority
score = -100 + (1 / (resetIn + 1));
reason = `MAXED — avoid until reset in ${formatDuration(resetIn)}`;
severity = 'critical';
} else if (p.utilization_7d == null && p.utilization_5h == null) {
score = 50;
reason = 'DORMANT — hold in reserve for cycle staggering';
severity = 'dormant';
} else {
const headroom = 1 - (p.utilization_7d || 0);
score = headroom * 100;
if (score < 30) {
reason = `low headroom — 7d: ${pct(p.utilization_7d)}, use cautiously`;
severity = 'warning';
} else {
const resetStr = p.reset_in_seconds != null ? `, resets ${formatDuration(p.reset_in_seconds)}` : '';
reason = `${pct(headroom)} headroom — 7d: ${pct(p.utilization_7d)}${resetStr}`;
severity = 'ok';
}
}
ranked.push({ provider: name, score, reason, severity });
}
return ranked.sort((a, b) => b.score - a.score);
}
// ── Log hygiene ───────────────────────────────────────────────────────────────
function pruneLogs(dryRun = false) {
if (!existsSync(LOG_DIR)) {
console.log('No log directory — nothing to prune.');
return;
}
const files = readdirSync(LOG_DIR).filter(f => /^\d{4}-\d{2}-\d{2}\.jsonl$/.test(f));
const cutoff = new Date(Date.now() - 30 * 86_400_000).toISOString().slice(0, 10);
const toPrune = files.filter(f => f.slice(0, 10) < cutoff);
if (toPrune.length === 0) {
console.log('No files older than 30 days — nothing to prune.');
return;
}
const weeksDir = join(LOG_DIR, 'weeks');
if (!dryRun) mkdirSync(weeksDir, { recursive: true });
const weeklyAgg = {};
for (const file of toPrune) {
const dateStr = file.slice(0, 10);
const week = getISOWeek(dateStr + 'T12:00:00Z');
const content = readFileSync(join(LOG_DIR, file), 'utf-8').trim();
if (!weeklyAgg[week]) {
weeklyAgg[week] = { week, start: dateStr, end: dateStr, providers: {} };
}
const w = weeklyAgg[week];
if (dateStr < w.start) w.start = dateStr;
if (dateStr > w.end) w.end = dateStr;
for (const line of content.split('\n').filter(Boolean)) {
try {
const entry = JSON.parse(line);
for (const [name, p] of Object.entries(entry.providers || {})) {
if (p?.type !== 'teams-direct') continue;
if (!w.providers[name]) {
w.providers[name] = {
samples: 0, peak_util_5h: 0, peak_util_7d: 0, avg_util_7d: 0, exhausted_count: 0,
};
}
const s = w.providers[name];
if (p.utilization_5h != null) s.peak_util_5h = Math.max(s.peak_util_5h, p.utilization_5h);
if (p.utilization_7d != null) {
s.peak_util_7d = Math.max(s.peak_util_7d, p.utilization_7d);
s.avg_util_7d = (s.avg_util_7d * s.samples + p.utilization_7d) / (s.samples + 1);
}
s.samples++;
if (p.status === 'rejected') s.exhausted_count++;
}
} catch { /* skip */ }
}
}
let pruned = 0;
for (const [week, data] of Object.entries(weeklyAgg)) {
const weekFile = join(weeksDir, `${week}.json`);
if (dryRun) {
console.log(`[dry-run] Would write ${weekFile}`);
} else {
if (existsSync(weekFile)) {
// Merge with existing weekly file
const existing = JSON.parse(readFileSync(weekFile, 'utf-8'));
for (const [name, s] of Object.entries(data.providers)) {
if (!existing.providers[name]) { existing.providers[name] = s; continue; }
const e = existing.providers[name];
const totalSamples = e.samples + s.samples;
e.peak_util_5h = Math.max(e.peak_util_5h, s.peak_util_5h);
e.peak_util_7d = Math.max(e.peak_util_7d, s.peak_util_7d);
e.avg_util_7d = (e.avg_util_7d * e.samples + s.avg_util_7d * s.samples) / totalSamples;
e.samples = totalSamples;
e.exhausted_count += s.exhausted_count;
}
writeFileSync(weekFile, JSON.stringify(existing, null, 2));
} else {
writeFileSync(weekFile, JSON.stringify(data, null, 2));
}
}
}
for (const file of toPrune) {
if (dryRun) {
console.log(`[dry-run] Would delete ${join(LOG_DIR, file)}`);
} else {
unlinkSync(join(LOG_DIR, file));
pruned++;
}
}
if (dryRun) {
console.log(`[dry-run] Would prune ${toPrune.length} file(s) into ${Object.keys(weeklyAgg).length} weekly summary file(s).`);
} else {
console.log(`Pruned ${pruned} file(s) into ${Object.keys(weeklyAgg).length} weekly summary file(s).`);
}
}
// ── Report generation ─────────────────────────────────────────────────────────
function generateFullReport(entries, xaiSection = null) {
const ts = new Date().toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
const width = 56;
const lines = [
`Token Analysis — ${ts}`,
'═'.repeat(width),
'',
];
// ── Burn rates
lines.push('Burn Rate');
const latestTeams = latestPerProvider(entries, 'teams-direct');
let anyTeams = false;
for (const name of TEAMS) {
const rec = latestTeams[name];
if (!rec) continue;
anyTeams = true;
const p = rec.p;
const br = computeBurnRate(entries, name);
const pad = name.padEnd(16);
if (p.status === 'invalid_key') {
lines.push(` ${pad} 401 invalid key`);
} else if (p.status === 'rejected') {
lines.push(` ${pad} MAXED — resets in ${formatDuration(p.reset_in_seconds)}`);
} else if (p.utilization_7d == null && p.utilization_5h == null) {
lines.push(` ${pad} DORMANT — cycle not started`);
} else if (br && br.data_points >= 2) {
const rateStr = `${(br.rate_per_hour * 100).toFixed(1)}%/hr`;
const exhStr = br.projected_exhaustion_hours != null
? `exhausts ~${Math.round(br.projected_exhaustion_hours)}h`
: 'stable/declining';
lines.push(` ${pad} 7d: ${pct(br.first_util_7d)}${pct(br.current_util_7d)} over ${br.hours_elapsed.toFixed(1)}h = ${rateStr} | ${exhStr} | ${br.data_points} pts`);
} else {
lines.push(` ${pad} 7d: ${pct(p.utilization_7d)} (insufficient data for rate)`);
}
}
if (!anyTeams) lines.push(' (no teams data in logs)');
lines.push('');
// ── Reset schedule
const stagger = cycleStagger(entries);
if (stagger.length > 0) {
lines.push('Reset Schedule (next 48h)');
for (const { provider, resets_at_iso, resets_in_seconds_from_now } of stagger) {
const timeStr = resets_at_iso.slice(11, 16) + ' UTC';
lines.push(` ${provider.padEnd(16)} ~${formatDuration(resets_in_seconds_from_now).padEnd(10)} (${timeStr})`);
}
lines.push('');
}
// ── Weekly
const weekly = reconstructWeekly(entries);
const weekKeys = Object.keys(weekly).sort();
if (weekKeys.length > 0) {
lines.push('Weekly Reconstruction');
for (const week of weekKeys) {
const w = weekly[week];
const note = w.start === w.end ? ' (1 day)' : '';
lines.push(` ${week}${note}`);
for (const [name, s] of Object.entries(w.providers)) {
const exhStr = s.exhausted_count > 0 ? ` | exhausted: ${s.exhausted_count}x` : '';
lines.push(` ${name.padEnd(14)} peak 7d: ${pct(s.peak_util_7d)} | avg: ${pct(s.avg_util_7d)} | ${s.samples} samples${exhStr}`);
}
}
lines.push('');
}
// ── Rotation
const rotation = rotationRank(entries);
if (rotation.length > 0) {
lines.push('Rotation Recommendation');
rotation.forEach(({ provider, reason, severity }, i) => {
const icon = severity === 'ok' ? '✓' : severity === 'critical' ? '✗' : severity === 'dormant' ? '~' : '?';
lines.push(` ${i + 1}. ${provider.padEnd(16)} ${icon} ${reason}`);
});
lines.push('');
}
// ── Underspend alerts
const boosts = underspendAlerts(entries);
if (boosts.length > 0) {
lines.push('⚡ Underspend Alerts (burn before reset)');
for (const { provider, utilization_5h, reset_in_seconds } of boosts) {
lines.push(` ${provider}: 5h at ${pct(utilization_5h)}, resets in ${formatDuration(reset_in_seconds)}`);
}
lines.push('');
}
if (entries.length === 0) {
lines.push('No log data found. Run monitor.js to start accumulating data.');
}
// ── xAI spend (optional — only when management key is available)
if (xaiSection) {
lines.push(xaiSection);
lines.push('');
}
return lines.join('\n');
}
// ── Budget JSON (Objective 1) ─────────────────────────────────────────────────
async function buildBudgetJson(entries, config) {
const now = new Date().toISOString();
const latest = latestPerProvider(entries, 'teams-direct');
const ranked = rotationRank(entries);
const weeklyUsd = config.weekly_seat_usd;
// budget_decision
const usable = ranked.filter(r => r.severity !== 'critical' && r.severity !== 'unknown');
const avoid = ranked.filter(r => r.severity === 'critical').map(r => r.provider);
const recommended = usable.length > 0 ? usable[0].provider : null;
const avoidReasons = ranked
.filter(r => avoid.includes(r.provider))
.map(r => r.provider + ': ' + r.reason)
.join('; ');
const recommendReason = recommended
? `${recommended} at ${pct((latest[recommended]?.p?.utilization_7d) || 0)} 7d utilization`
: 'no healthy provider found';
const avoidReason = avoidReasons || 'none maxed';
const reason = avoid.length > 0
? `${avoidReason}; ${recommendReason}`
: recommendReason;
// providers
const providers = {};
for (const name of TEAMS) {
const rec = latest[name];
if (!rec) continue;
const p = rec.p;
const util7d = p.utilization_7d ?? null;
const headroom = util7d != null ? Math.round((1 - util7d) * 100) : null;
providers[name] = {
status: p.status || 'unknown',
utilization_5h: p.utilization_5h ?? null,
utilization_7d: util7d,
headroom_7d_pct: headroom,
estimated_weekly_budget_usd: weeklyUsd,
estimated_spend_this_week_usd: util7d != null ? parseFloat((util7d * weeklyUsd).toFixed(2)) : null,
reset_in_seconds: p.reset_in_seconds ?? null,
severity: ranked.find(r => r.provider === name)?.severity || 'unknown',
};
}
// alerts
const alerts = [];
for (const { provider, severity, reason: r } of ranked) {
if (severity === 'critical') {
const p = latest[provider]?.p;
const resetStr = p?.reset_in_seconds != null ? ` — resets in ${formatDuration(p.reset_in_seconds)}` : '';
alerts.push({ level: 'critical', provider, message: `maxed${resetStr}` });
} else if (severity === 'warning') {
alerts.push({ level: 'warning', provider, message: r });
}
}
// xAI section
let xai = null;
if (XAI_MANAGEMENT_KEY && XAI_TEAM_ID) {
try {
const now2 = new Date();
const startDate = new Date(Date.UTC(now2.getUTCFullYear(), now2.getUTCMonth(), 1));
const [usageRows, preview] = await Promise.all([
fetchXaiUsage({ managementKey: XAI_MANAGEMENT_KEY, teamId: XAI_TEAM_ID, startDate, endDate: now2 }),
fetchXaiInvoicePreview({ managementKey: XAI_MANAGEMENT_KEY, teamId: XAI_TEAM_ID }),
]);
const byKey = aggregateByKey(usageRows);
const period = `${now2.getUTCFullYear()}-${String(now2.getUTCMonth() + 1).padStart(2, '0')}`;
const byKeyObj = {};
const CACHED_UNIT = 'Cached prompt text tokens';
const PROMPT_UNIT = 'Prompt text tokens';
for (const [keyName, { total, byUnit }] of byKey) {
byKeyObj[keyName] = {
spend_usd: parseFloat(total.toFixed(2)),
cached_usd: parseFloat((byUnit[CACHED_UNIT] || 0).toFixed(2)),
prompt_usd: parseFloat((byUnit[PROMPT_UNIT] || 0).toFixed(2)),
};
}
const totalUsd = [...byKey.values()].reduce((s, e) => s + e.total, 0);
const prepaidRemaining = preview
? parseFloat(((preview.prepaidCreditsCents - preview.prepaidUsedCents) / 100).toFixed(2))
: null;
xai = { period, by_key: byKeyObj, total_usd: parseFloat(totalUsd.toFixed(2)), prepaid_remaining_usd: prepaidRemaining };
// xAI alert if prepaid low
if (prepaidRemaining != null && prepaidRemaining < 5) {
alerts.push({ level: 'warning', provider: 'xai', message: `prepaid balance low: $${prepaidRemaining.toFixed(2)} remaining` });
}
} catch (err) {
xai = { error: err.message };
}
}
return {
ts: now,
budget_decision: { recommended_provider: recommended, avoid, reason },
providers,
...(xai ? { xai } : {}),
alerts,
};
}
// ── Mission cost tracking (Objective 2) ───────────────────────────────────────
async function resolveMissionWindow(missionRef) {
if (!FORGEJO_TOKEN) throw new Error('FORGEJO_TOKEN not set — cannot resolve issue timestamps');
const repos = loadMissionRepos();
const m = missionRef.match(/^([^#]+)#(\d+)$/);
if (!m) throw new Error(`Invalid mission ref: ${missionRef} (expected format: repo#num)`);
const [, shortRepo, issueNum] = m;
const repoFull = repos[shortRepo];
if (!repoFull) throw new Error(`Unknown repo short name: ${shortRepo}. Add it to ${MISSION_REPOS_FILE}`);
const headers = { 'Authorization': `token ${FORGEJO_TOKEN}`, 'Content-Type': 'application/json' };
const issueUrl = `${FORGEJO_URL}/api/v1/repos/${repoFull}/issues/${issueNum}`;
const issueResp = await fetch(issueUrl, { headers });
if (!issueResp.ok) throw new Error(`Forgejo issue fetch failed: ${issueResp.status}`);
const issue = await issueResp.json();
const commentsUrl = `${FORGEJO_URL}/api/v1/repos/${repoFull}/issues/${issueNum}/comments?limit=50`;
const commResp = await fetch(commentsUrl, { headers });
const comments = commResp.ok ? await commResp.json() : [];
const startTs = issue.created_at;
const timestamps = [issue.created_at, issue.updated_at, ...comments.map(c => c.updated_at)].filter(Boolean);
const endTs = timestamps.sort().pop();
return {
ref: missionRef,
repo: repoFull,
issue_num: issueNum,
title: issue.title,
start: startTs,
end: endTs,
};
}
function entriesInWindow(allEntries, startIso, endIso) {
const start = new Date(startIso).getTime();
const end = new Date(endIso).getTime();
return allEntries.filter(e => {
const t = new Date(e.ts).getTime();
return t >= start && t <= end;
});
}
async function computeMissionCost(startIso, endIso, missionLabel, config) {
const allEntries = loadLogs();
const window = entriesInWindow(allEntries, startIso, endIso);
const weeklyUsd = config.weekly_seat_usd;
const startDt = new Date(startIso);
const endDt = new Date(endIso);
const durationMs = endDt - startDt;
const durationH = Math.floor(durationMs / 3_600_000);
const durationM = Math.floor((durationMs % 3_600_000) / 60_000);
const durationStr = durationH > 0 ? `${durationH}h ${durationM}m` : `${durationM}m`;
const lines = [];
if (missionLabel) lines.push(`Mission Cost: ${missionLabel}`);
else lines.push('Mission Cost (time window)');
const startStr = startIso.slice(0, 16).replace('T', ' ');
const endStr = endIso.slice(0, 16).replace('T', ' ');
lines.push(` Window: ${startStr}${endStr} (${durationStr})`);
lines.push(' ' + '─'.repeat(51));
let totalEstUsd = 0;
let totalXaiUsd = 0;
// Anthropic Teams: utilization delta per provider
for (const name of TEAMS) {
const pts = window
.filter(e => e.providers[name]?.utilization_7d != null)
.sort((a, b) => a.ts.localeCompare(b.ts));
if (pts.length < 2) continue;
const firstUtil = pts[0].providers[name].utilization_7d;
const lastUtil = pts[pts.length - 1].providers[name].utilization_7d;
const delta = lastUtil - firstUtil;
if (Math.abs(delta) < 0.001) continue;
const estUsd = delta * weeklyUsd;
totalEstUsd += Math.max(0, estUsd);
const deltaStr = `${(delta * 100).toFixed(1)}%`;
const usdStr = estUsd >= 0 ? `~$${estUsd.toFixed(2)} est.` : `~-$${Math.abs(estUsd).toFixed(2)} (decreased)`;
lines.push(` ${name.padEnd(16)} 7d: ${pct(firstUtil)}${pct(lastUtil)} (${deltaStr.padStart(5)}) ${usdStr}`);
}
if (totalEstUsd === 0 && window.length < 2) {
lines.push(' (no utilization data in this time window)');
}
// xAI: exact spend via management API
if (XAI_MANAGEMENT_KEY && XAI_TEAM_ID) {
try {
const usageRows = await fetchXaiUsage({
managementKey: XAI_MANAGEMENT_KEY,
teamId: XAI_TEAM_ID,
startDate: new Date(startIso),
endDate: new Date(endIso),
});
const byKey = aggregateByKey(usageRows);
for (const [keyName, { total }] of byKey) {
totalXaiUsd += total;
lines.push(` ${keyName.padEnd(16)} $${total.toFixed(2)} exact (management API)`);
}
if (byKey.size === 0) {
lines.push(' xAI $0.00 exact (management API)');
}
} catch (err) {
lines.push(` xAI (error: ${err.message})`);
}
}
lines.push(' ' + '─'.repeat(51));
const totalStr = XAI_MANAGEMENT_KEY
? `~$${totalEstUsd.toFixed(2)} (Anthropic) + $${totalXaiUsd.toFixed(2)} (xAI) = ~$${(totalEstUsd + totalXaiUsd).toFixed(2)}`
: `~$${totalEstUsd.toFixed(2)} estimated`;
lines.push(` Total estimated: ${totalStr}`);
return lines.join('\n');
}
// ── Main ──────────────────────────────────────────────────────────────────────
const args = process.argv.slice(2);
const showBurnRate = args.includes('--burn-rate');
const showWeekly = args.includes('--weekly');
const showStagger = args.includes('--stagger');
const showRotation = args.includes('--rotation');
const showXai = args.includes('--xai');
const isJson = args.includes('--json');
const isBudgetJson = args.includes('--budget-json');
const isPrune = args.includes('--prune');
const isDryRun = args.includes('--dry-run');
const providerIdx = args.indexOf('--provider');
const providerFilter = providerIdx !== -1 ? args[providerIdx + 1] : null;
const missionIdx = args.indexOf('--mission');
const missionRef = missionIdx !== -1 ? args[missionIdx + 1] : null;
const missionWindowIdx = args.indexOf('--mission-window');
const missionWindowStart = missionWindowIdx !== -1 ? args[missionWindowIdx + 1] : null;
const missionWindowEnd = missionWindowIdx !== -1 ? args[missionWindowIdx + 2] : null;
const showAll = !showBurnRate && !showWeekly && !showStagger && !showRotation && !showXai && !isPrune
&& !isBudgetJson && !missionRef && !missionWindowStart;
if (isPrune) {
pruneLogs(isDryRun);
process.exit(0);
}
const entries = loadLogs(providerFilter);
// Early exit for mission-window (no xAI IIFE needed if pure time-window with no key)
if (missionWindowStart && !missionRef) {
if (!missionWindowEnd) {
console.error('Usage: node analyze.js --mission-window <iso-start> <iso-end>');
process.exit(1);
}
}
// ── xAI billing helpers ────────────────────────────────────────────────────
const XAI_MANAGEMENT_KEY = process.env.XAI_MANAGEMENT_KEY || null;
const XAI_TEAM_ID = process.env.XAI_TEAM_ID || null;
const config = loadConfig();
async function getXaiSection() {
if (!XAI_MANAGEMENT_KEY || !XAI_TEAM_ID) return null;
try {
const now = new Date();
// Current billing period: 1st of this month through today
const startDate = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
const endDate = now;
const startDateStr = startDate.toISOString().slice(0, 10);
const endDateStr = endDate.toISOString().slice(0, 10);
const [usageRows, preview] = await Promise.all([
fetchXaiUsage({ managementKey: XAI_MANAGEMENT_KEY, teamId: XAI_TEAM_ID, startDate, endDate }),
fetchXaiInvoicePreview({ managementKey: XAI_MANAGEMENT_KEY, teamId: XAI_TEAM_ID }),
]);
return renderXaiSection({ usageRows, preview, startDateStr, endDateStr });
} catch (err) {
return `xAI Spend\n (error: ${err.message})`;
}
}
// All remaining paths may need xAI data (async), wrap in IIFE
(async () => {
if (isBudgetJson) {
const result = await buildBudgetJson(entries, config);
console.log(JSON.stringify(result, null, 2));
process.exit(0);
}
if (missionWindowStart && missionWindowEnd && !missionRef) {
const label = null;
const out = await computeMissionCost(missionWindowStart, missionWindowEnd, label, config);
console.log(out);
process.exit(0);
}
if (missionRef) {
try {
const window = await resolveMissionWindow(missionRef);
const label = `${window.title} (${missionRef})`;
let startIso = window.start;
let endIso = window.end;
// --start / --end overrides
const startOverrideIdx = args.indexOf('--start');
const endOverrideIdx = args.indexOf('--end');
if (startOverrideIdx !== -1) startIso = args[startOverrideIdx + 1];
if (endOverrideIdx !== -1) endIso = args[endOverrideIdx + 1];
const out = await computeMissionCost(startIso, endIso, label, config);
console.log(out);
} catch (err) {
console.error(`Error: ${err.message}`);
process.exit(1);
}
process.exit(0);
}
if (isJson) {
const burnRates = {};
for (const name of TEAMS) {
const br = computeBurnRate(entries, name);
if (br) burnRates[name] = br;
}
console.log(JSON.stringify({
timestamp: new Date().toISOString(),
burn_rates: burnRates,
weekly: reconstructWeekly(entries),
stagger: cycleStagger(entries),
rotation: rotationRank(entries),
underspend_alerts: underspendAlerts(entries),
}, null, 2));
process.exit(0);
}
if (showAll) {
const xaiSection = await getXaiSection();
console.log(generateFullReport(entries, xaiSection));
process.exit(0);
}
if (showXai) {
const xaiSection = await getXaiSection();
if (xaiSection) {
console.log(xaiSection);
} else {
console.log('xAI Spend');
console.log(' (XAI_MANAGEMENT_KEY or XAI_TEAM_ID not set — source ~/.secrets/keys.env)');
}
process.exit(0);
}
// Section-specific output
const width = 56;
if (showBurnRate) {
console.log('Burn Rate\n' + '─'.repeat(width));
const latestTeams = latestPerProvider(entries, 'teams-direct');
for (const name of TEAMS) {
const rec = latestTeams[name];
if (!rec) continue;
const br = computeBurnRate(entries, name);
if (br && br.data_points >= 2) {
const exhStr = br.projected_exhaustion_hours != null
? `exhausts ~${Math.round(br.projected_exhaustion_hours)}h`
: 'stable/declining';
console.log(` ${name.padEnd(16)} ${(br.rate_per_hour * 100).toFixed(1)}%/hr | ${exhStr} | ${br.data_points} pts`);
} else {
console.log(` ${name.padEnd(16)} insufficient data`);
}
}
}
if (showWeekly) {
console.log('Weekly Reconstruction\n' + '─'.repeat(width));
const weekly = reconstructWeekly(entries);
for (const [week, w] of Object.entries(weekly).sort()) {
console.log(` ${week}`);
for (const [name, s] of Object.entries(w.providers)) {
console.log(` ${name.padEnd(14)} peak 7d: ${pct(s.peak_util_7d)} | avg: ${pct(s.avg_util_7d)} | ${s.samples} samples`);
}
}
}
if (showStagger) {
console.log('Reset Schedule (next 48h)\n' + '─'.repeat(width));
for (const { provider, resets_in_seconds_from_now, resets_at_iso } of cycleStagger(entries)) {
const timeStr = resets_at_iso.slice(11, 16) + ' UTC';
console.log(` ${provider.padEnd(16)} ~${formatDuration(resets_in_seconds_from_now)} (${timeStr})`);
}
}
if (showRotation) {
console.log('Rotation Recommendation\n' + '─'.repeat(width));
rotationRank(entries).forEach(({ provider, reason, severity }, i) => {
const icon = severity === 'ok' ? '✓' : severity === 'critical' ? '✗' : '~';
console.log(` ${i + 1}. ${provider.padEnd(16)} ${icon} ${reason}`);
});
}
})();