diff --git a/analyze.js b/analyze.js index 1eb5f99..6ca92a7 100644 --- a/analyze.js +++ b/analyze.js @@ -9,6 +9,8 @@ * - 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 @@ -20,6 +22,9 @@ * 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 # mission cost (e.g. bookmarko#1 or commons#13) + * node analyze.js --mission-window # mission cost, manual time range */ import { @@ -27,7 +32,30 @@ import { } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; -import { fetchXaiUsage, fetchXaiInvoicePreview, renderXaiSection } from './providers/xai-billing.js'; +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']; @@ -463,6 +491,227 @@ function generateFullReport(entries, xaiSection = null) { 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); @@ -472,11 +721,18 @@ 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 showAll = !showBurnRate && !showWeekly && !showStagger && !showRotation && !showXai && !isPrune; +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); @@ -485,10 +741,19 @@ if (isPrune) { 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 '); + 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; @@ -512,6 +777,39 @@ async function getXaiSection() { // 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) {