From 34898b119604c59617ae5236ddedbb491239605f Mon Sep 17 00:00:00 2001 From: Hannibal Smith Date: Sun, 5 Apr 2026 04:49:05 +0000 Subject: [PATCH] Phase 2: analysis layer (analyze.js), cache guard, log hygiene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - analyze.js: burn rate, weekly reconstruction, cycle stagger, rotation rank, underspend alerts, log prune with weekly archive - logger.js: getCachedRun(maxAgeMinutes) — skip probing if recent data exists - monitor.js: cache guard at wake — 20-min dedup, zero extra API calls - test.js: fix type assertion for gemini-api/xai-api providers (+5 passing); add 14 new tests for cache guard and analyze.js (162 total, all green) - docs/analyze.md: usage reference Co-authored-by: Hannibal Smith --- analyze.js | 546 ++++++++++++++++++++ docs/analyze.md | 54 ++ docs/missions/token-monitor-phase2-gates.md | 54 ++ logger.js | 30 +- monitor.js | 14 + test.js | 49 +- 6 files changed, 745 insertions(+), 2 deletions(-) create mode 100644 analyze.js create mode 100644 docs/analyze.md create mode 100644 docs/missions/token-monitor-phase2-gates.md diff --git a/analyze.js b/analyze.js new file mode 100644 index 0000000..8b470b2 --- /dev/null +++ b/analyze.js @@ -0,0 +1,546 @@ +#!/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) + * + * 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 + */ + +import { + readdirSync, readFileSync, writeFileSync, mkdirSync, unlinkSync, existsSync, +} from 'fs'; +import { homedir } from 'os'; +import { join } from 'path'; + +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) { + 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.'); + } + + 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 isJson = args.includes('--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 && !isPrune; + +if (isPrune) { + pruneLogs(isDryRun); + process.exit(0); +} + +const entries = loadLogs(providerFilter); + +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) { + console.log(generateFullReport(entries)); + 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}`); + }); +} diff --git a/docs/analyze.md b/docs/analyze.md new file mode 100644 index 0000000..d5c347b --- /dev/null +++ b/docs/analyze.md @@ -0,0 +1,54 @@ +# analyze.js — Token Monitor Analysis CLI + +Reads accumulated JSONL logs from `~/.logs/token-monitor/` and produces burn +rates, weekly stats, reset schedule, and rotation recommendations. + +## Usage + +``` +node analyze.js # full report (default) +node analyze.js --burn-rate # burn rate per account only +node analyze.js --weekly # weekly budget reconstruction +node analyze.js --stagger # reset schedule (next 48h) +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 # prune logs older than 30 days +node analyze.js --prune --dry-run # dry run — show what would be pruned +``` + +## Output sections + +**Burn Rate** — delta analysis of 7d utilization over time, projected +exhaustion at current rate. Requires ≥ 2 data points per provider. + +**Reset Schedule** — providers resetting within the next 48 hours, sorted +ascending by time to reset. + +**Weekly Reconstruction** — peak and average 7d utilization per provider per +ISO week. Shows exhaustion events (status=rejected). + +**Rotation Recommendation** — ranked provider list. Rules in priority order: +1. Invalid key → bottom (unusable) +2. Maxed/rejected → deprioritize; soonest reset wins tiebreaker +3. Dormant → reserve for cycle staggering +4. Active: rank by headroom (1 - utilization_7d) + +**Underspend Alerts** — active accounts with ≥ 40% of 5h window unused and +< 2h until reset. These tokens expire unused — boost them. + +## Log format + +Input: `~/.logs/token-monitor/YYYY-MM-DD.jsonl` — one JSON object per line. + +Pruned archives: `~/.logs/token-monitor/weeks/YYYY-WNN.json` — weekly +aggregates with peak/avg utilization and sample counts. + +## Cadence + +Logs are written by `monitor.js` at each Vigilio wake (max once per 20 +minutes — cache guard prevents double-logging within a session). Expected: +~15–20 data points/day per active provider, ~100–140/week. + +At target cadence, log footprint is < 1MB/month. --prune trims files older +than 30 days after archiving them into weekly summaries. diff --git a/docs/missions/token-monitor-phase2-gates.md b/docs/missions/token-monitor-phase2-gates.md new file mode 100644 index 0000000..acd954a --- /dev/null +++ b/docs/missions/token-monitor-phase2-gates.md @@ -0,0 +1,54 @@ +## Pre-Build Gate + +**Verdict: PASS** + +**Gate assessed:** 2026-04-05 (assessment #7 — replaces #6) +**Mission:** token-monitor-phase2 (trentuna/token-monitor#1) +**Assessor:** Amy Allen + +--- + +### What I checked + +1. **Mission spec (Forgejo #1):** ✅ Complete. Four deliverables with operational outcomes: cache guard, analyze.js with six subcommands, log hygiene with prune, token-status.sh integration. + +2. **Architecture (Hannibal's comment on #1):** ✅ Thorough. Function signatures (`getCachedRun`, `loadLogs`, `computeBurnRate`, `reconstructWeekly`, `cycleStagger`, `underspendAlerts`, `rotationRank`), CLI interface, output formats, internal module contracts all specified. Single new file + minimal mods to existing code. Low blast radius. + +3. **Objective clarity:** ✅ Unambiguous. Each deliverable has specified behavior and output format. + +4. **Success criteria testability:** ✅ Five explicit assertions in Hannibal's architecture — all concrete and automatable: + - `node analyze.js` exits 0 with non-empty output from existing log data + - `node analyze.js --rotation` outputs a ranked list + - Two consecutive `node monitor.js --json` within 20 min: second returns cached data, no new JSONL entry + - `node analyze.js --prune --dry-run` reports files without deleting + - `node test.js` still passes + +5. **Test baseline (prior Concern 1):** ✅ **RESOLVED.** `test.js:102` whitelist now includes `'gemini-api'` and `'xai-api'`. All 146 tests pass (verified just now). The "tests still pass" criterion is now meetable. + +6. **Recon completeness:** ✅ No external unknowns. All data sources are local JSONL files (102 entries across 2 days — sufficient for immediate testing). No Face recon needed. `token-status.sh` already logs by default (no `--no-log` flag present). + +7. **Role assignments:** ✅ Explicit in both the issue and architecture comment. + +8. **Brief quality per mission-standards.md:** + - [x] Objective describes operational outcome (trend-line intelligence, not just "a script") + - [x] Success criteria are testable assertions (5 concrete checks) + - [x] Role assignments name who does what + - [x] No agent-affecting changes requiring self-verification + +### What's clean + +- Spec + architecture together form one of the cleanest briefs I've reviewed +- Function signatures, output formats, internal module contracts all specified +- Low blast radius: one new file (`analyze.js`), two small mods (`logger.js`, `monitor.js`), one external mod (`token-status.sh`) +- No external dependencies or API unknowns +- 102 real log entries in `~/.logs/token-monitor/` for immediate testing +- Stealth constraint clearly specified with sound cache guard design +- Prior blocking concern (test baseline) is now resolved + +### Outstanding items + +None. B.A. is clear to build. + +--- + +*Triple A reporting. Seventh assessment — the test baseline is fixed, all 146 pass, and the spec remains excellent. No concerns remain. Hannibal's architecture is thorough and B.A. has everything he needs. PASS.* diff --git a/logger.js b/logger.js index 7fd8b7c..cb771c0 100644 --- a/logger.js +++ b/logger.js @@ -2,7 +2,7 @@ * logger.js — persistent JSONL log to ~/.logs/token-monitor/YYYY-MM-DD.jsonl */ -import { appendFileSync, mkdirSync } from 'fs'; +import { appendFileSync, mkdirSync, existsSync, readFileSync } from 'fs'; import { homedir } from 'os'; import { join } from 'path'; @@ -17,3 +17,31 @@ export function getLogPath() { const today = new Date().toISOString().slice(0, 10); return join(homedir(), '.logs', 'token-monitor', `${today}.jsonl`); } + +/** + * Returns the last logged run if it was within maxAgeMinutes, otherwise null. + * Skips test/empty entries (entries where providers has no typed providers). + */ +export function getCachedRun(maxAgeMinutes = 20) { + const dir = join(homedir(), '.logs', 'token-monitor'); + const today = new Date().toISOString().slice(0, 10); + const file = join(dir, `${today}.jsonl`); + if (!existsSync(file)) return null; + + const lines = readFileSync(file, 'utf-8').trim().split('\n').filter(Boolean); + + for (let i = lines.length - 1; i >= 0; i--) { + try { + const entry = JSON.parse(lines[i]); + const providers = entry.providers || {}; + // Skip test/empty entries — real entries have at least one provider with a type + const hasRealData = Object.values(providers).some(p => p && p.type); + if (!hasRealData) continue; + + const ageMinutes = (Date.now() - new Date(entry.ts).getTime()) / 60000; + if (ageMinutes <= maxAgeMinutes) return entry; + return null; // last real entry is too old + } catch { continue; } + } + return null; +} diff --git a/monitor.js b/monitor.js index c904d03..b1ca7d7 100644 --- a/monitor.js +++ b/monitor.js @@ -58,6 +58,20 @@ async function probeProvider(p) { } async function main() { + // Cache guard — return last logged run if within 20 minutes (skip on --summary, --no-log, --provider filter) + if (!noLog && !filterProvider && !isSummaryOnly) { + const { getCachedRun } = await import('./logger.js'); + const cached = getCachedRun(20); + if (cached) { + if (isJson) { + console.log(JSON.stringify(cached, null, 2)); + } else { + console.log(generateReport(cached)); + } + return; + } + } + const allProviders = getProviders(); const providerNames = filterProvider ? [filterProvider] diff --git a/test.js b/test.js index 567c28d..8b1a530 100644 --- a/test.js +++ b/test.js @@ -99,7 +99,7 @@ assert('api-ateam in registry', names.includes('api-ateam')); assert('zai NOT in registry (not anthropic-messages)', !names.includes('zai')); for (const [name, p] of Object.entries(providers)) { assert(`${name} has baseUrl`, typeof p.baseUrl === 'string' && p.baseUrl.length > 0); - assert(`${name} has type`, ['teams-direct', 'shelley-proxy', 'api-direct'].includes(p.type)); + assert(`${name} has type`, ['teams-direct', 'shelley-proxy', 'api-direct', 'gemini-api', 'xai-api'].includes(p.type)); } // ── 5. Teams header parser ─────────────────────────────────────────────────── @@ -415,6 +415,53 @@ assert('xai 429: severity = critical', x429.severity === 'critical'); const x401 = parseXaiHeaders(null, 401, 'xai-key'); assert('xai 401: status = invalid_key', x401.status === 'invalid_key'); +// ── 13. Cache guard (getCachedRun) ───────────────────────────────────────── +console.log('\n── 13. Cache guard ─────────────────────────────────────────────'); +const { getCachedRun } = await import('./logger.js'); + +assert('getCachedRun(0) returns null (zero-minute threshold)', getCachedRun(0) === null); + +const cached20 = getCachedRun(20); +assert('getCachedRun(20) returns null or valid object', + cached20 === null || (typeof cached20 === 'object' && typeof cached20.ts === 'string')); + +// ── 14. analyze.js smoke tests ─────────────────────────────────────────────── +console.log('\n── 14. analyze.js ──────────────────────────────────────────────'); +assert('analyze.js exists', existsSync(join(root, 'analyze.js'))); +assert('docs/analyze.md exists', existsSync(join(root, 'docs', 'analyze.md'))); + +const analyzeResult = runSafe('node analyze.js'); +assert('analyze.js exits 0', analyzeResult.code === 0, + `exit code: ${analyzeResult.code}\n${analyzeResult.stderr}`); +assert('analyze.js produces output', analyzeResult.stdout.length > 0); + +const analyzeJson = runSafe('node analyze.js --json'); +assert('analyze.js --json exits 0', analyzeJson.code === 0, + `exit code: ${analyzeJson.code}\n${analyzeJson.stderr}`); +let analyzeData; +try { + analyzeData = JSON.parse(analyzeJson.stdout); + assert('analyze.js --json is valid JSON', true); +} catch (e) { + assert('analyze.js --json is valid JSON', false, e.message); +} +if (analyzeData) { + assert('analyze.js --json has burn_rates', 'burn_rates' in analyzeData); + assert('analyze.js --json has stagger', 'stagger' in analyzeData); + assert('analyze.js --json has rotation', 'rotation' in analyzeData); + assert('analyze.js --json has weekly', 'weekly' in analyzeData); + assert('analyze.js --json stagger is array', Array.isArray(analyzeData.stagger)); + assert('analyze.js --json rotation is array', Array.isArray(analyzeData.rotation)); +} + +const analyzeRotation = runSafe('node analyze.js --rotation'); +assert('analyze.js --rotation exits 0', analyzeRotation.code === 0, + `exit code: ${analyzeRotation.code}\n${analyzeRotation.stderr}`); + +const analyzePruneDry = runSafe('node analyze.js --prune --dry-run'); +assert('analyze.js --prune --dry-run exits 0', analyzePruneDry.code === 0, + `exit code: ${analyzePruneDry.code}\n${analyzePruneDry.stderr}`); + // ── Results ────────────────────────────────────────────────────────────────── console.log('\n' + '═'.repeat(50)); console.log(`Tests: ${passed + failed} | Passed: ${passed} | Failed: ${failed}`);