Phase 2: analysis layer (analyze.js), cache guard, log hygiene

- 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 <hannibal@trentuna.com>
This commit is contained in:
Hannibal Smith 2026-04-05 04:49:05 +00:00
parent 1b4e299461
commit 34898b1196
Signed by: hannibal
GPG key ID: 6EB37F7E6190AF1C
6 changed files with 745 additions and 2 deletions

View file

@ -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;
}