Skill: echoudhry/clawculator (version 1.2.1)

--- name: clawculator description: Analyze OpenClaw costs and detect billing issues. Source code is fully bundled — nothing is fetched at runtime. Requires only the node binary. homepage: https://github.com/echoudhry/clawculator user-invocable: true metadata: {"openclaw":{"emoji":"🦞","requires":{"bins":["node"]}}} --- ## clawculator Cost forensics for OpenClaw. Finds billing issues in your config, sessions, and workspace. Pure deterministic logic — no AI, no network calls, no external dependencies. **Source code is fully bundled in this skill folder.** Nothing is fetched at runtime. You can audit every file before running. **Files this skill reads:** - `~/.openclaw/openclaw.json` — your OpenClaw config - `~/.openclaw/agents/main/sessions/sessions.json` — session token usage - `~/clawd/` — workspace root file count only (no file contents read) - `/tmp/openclaw` — log directory (read only, if present) **Files this skill may write (only when `--md` is used):** - `./clawculator-report.md` — markdown report - Custom path via `--out=PATH` **No network requests are made. No shell commands are spawned.** **Session keys are truncated in all output** (first 8 chars + ellipsis) to avoid exposing sensitive identifiers. --- **Usage** When the user types `clawculator`, `check my costs`, `analyze spend`, or `cost report`, run: ```bash node {baseDir}/run.js --md ``` Return the full markdown report to the user inline. When the user types `snapshot`, `share my grade`, `cost grade`, `clawculator snapshot`, or `what's my grade`, run: ```bash node {baseDir}/run.js --snapshot ``` Return the terminal card output to the user. This shows their grade (A+ to D), cost range, setup complexity, and findings — designed to be screenshot and shared. **Flags** - `--snapshot` — shareable grade card (terminal output, screenshot-ready) - `--md` — write markdown report and print to stdout - `--json` — machine-readable JSON to stdout - `--out=PATH` — custom output path for `--md` - `node {baseDir}/run.js --help` — full usage **What it catches** - 💓 Heartbeat running on paid model instead of Ollama - 🔧 Skill polling loops on paid model - 📱 WhatsApp groups auto-joined on primary model - 🪝 Hooks (boot-md, command-logger, session-memory) on Sonnet - 💬 Orphaned sessions still holding tokens - 🤖 maxConcurrent too high — burst cost multiplier - 📁 Workspace root bloat inflating context - ⚙️ Primary model cost awareness All findings include exact fix commands.

path: README.md
content: <p align="center">
  <img src="logo.png" width="200" />
</p>

# Clawculator

> **Your friendly penny pincher.**

AI cost forensics for OpenClaw and multi-model setups. One command. Full analysis. 100% offline. Zero AI. Pure deterministic logic.

[![npm version](https://badge.fury.io/js/clawculator.svg)](https://badge.fury.io/js/clawculator)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Live Demo](https://img.shields.io/badge/demo-live-38bdf8)](https://echoudhry.github.io/clawculator)

---

## The problem

You set up OpenClaw. It runs great. Then your API bill arrives and you have no idea why it's $150. Was it the heartbeat? A skill running a polling loop? WhatsApp groups processing every message on Sonnet? Orphaned sessions? Hooks on the wrong model?

It could be any of these. Clawculator finds all of them — with zero AI, zero guesswork, and zero data leaving your machine.

---

## [▶ Live Demo](https://echoudhry.github.io/clawculator)

See it run against a real config — findings, fix commands, cost exposure, session breakdown.

---

## One command

```bash
npx clawculator
```

No install. No account. No config. Auto-detects your OpenClaw setup. Full deterministic report in seconds.

---

## 🔒 100% offline. Zero AI.

Clawculator uses **pure switch/case deterministic logic** — no LLM, no Ollama, no model of any kind. Every finding and recommendation is hardcoded. Results are 100% reproducible and non-negotiable.

Your `openclaw.json`, session logs, and API keys never leave your machine. There is no server. Disconnect your internet and run it — it works.

---

## What it finds

| Source | What it catches | Severity |
|--------|----------------|----------|
| 💓 Heartbeat | Running on paid model instead of Ollama | 🔴 Critical |
| 💓 Heartbeat | target not set to "none" (v2026.2.24+) | 🟠 High |
| 🔧 Skills | Polling/cron loops on paid model | 🔴 Critical |
| 📱 WhatsApp | Groups auto-joined on primary model | 🔴 Critical |
| 🪝 Hooks | boot-md, command-logger, session-memory on Sonnet | 🟠 High |
| 💬 Sessions | Orphaned sessions still holding tokens | 🟠 High |
| 🤖 Subagents | maxConcurrent too high — burst multiplier | 🟠 High |
| 📁 Workspace | Too many root .md files inflating context | 🟡 Medium |
| 🧠 Memory | memoryFlush on primary model | 🟡 Medium |
| ⚙️ Primary model | Cost awareness of chosen model tier | ℹ️ Info |

---

## Usage

```bash
npx clawculator                          # Terminal analysis (default)
npx clawculator --md                     # Markdown report (readable by your AI agent)
npx clawculator --report                 # Visual HTML dashboard
npx clawculator --json                   # JSON for piping into other tools
npx clawculator --md --out=~/cost.md     # Custom output path
npx clawculator --config=/path/to/openclaw.json
npx clawculator --help
```

---

## Output formats

**Terminal** — color-coded findings by severity with cost estimates and exact fix commands. Session table includes token count, total cost, $/day burn rate, and last active time (relative + absolute).

**Markdown (`--md`)** — structured report your OpenClaw agent can read directly. Drop it in your workspace and ask your agent "what's my cost status?" Session table columns: Session · Model · Tokens · Total Cost · $/day · Last Active.

**HTML (`--report`)** — visual dashboard with full session breakdown table including age and daily burn rate, cost exposure banner, opens in browser locally. Available via `npx clawculator` only — not supported in the OpenClaw skill (agents don't need to open browsers).

**JSON (`--json`)** — machine-readable, pipeable:
```bash
npx clawculator --json | jq '.summary'
npx clawculator --json > cost-report.json
```

> Session keys are truncated in all output formats (first 8 chars) to avoid exposing sensitive identifiers. Hover over the Last Active column in HTML for the exact timestamp.

---

## Use as an OpenClaw skill (ClawHub)

Install clawculator as a skill so you can type `clawculator` in your OpenClaw webchat and get a full cost report inline.

**Install via ClawHub:**
```bash
npm i -g clawhub
clawhub install clawculator
```

Then start a new OpenClaw session and type:
```
clawculator
```

Your agent runs the analysis and returns the full markdown report directly in chat.

**Or install manually** into your workspace:
```bash
mkdir -p ~/clawd/skills/clawculator

BASE=https://raw.githubusercontent.com/echoudhry/clawculator/main/skills/clawculator
curl -o ~/clawd/skills/clawculator/SKILL.md      $BASE/SKILL.md
curl -o ~/clawd/skills/clawculator/run.js         $BASE/run.js
curl -o ~/clawd/skills/clawculator/analyzer.js    $BASE/analyzer.js
curl -o ~/clawd/skills/clawculator/reporter.js    $BASE/reporter.js
curl -o ~/clawd/skills/clawculator/mdReport.js    $BASE/mdReport.js
curl -o ~/clawd/skills/clawculator/htmlReport.js  $BASE/htmlReport.js
```

Start a new session to pick it up.

---

## Why deterministic?

Every recommendation is a hardcoded switch/case — not generated by an AI. This means:

- Results are identical every time for the same input
- No hallucinations, no surprises
- Works completely offline with no model dependency
- Fast — analysis runs in under a second

---

## Built by

[Ed Choudhry](https://github.com/echoudhry) — after personally losing hundreds of dollars to silent API cost bleed. Every cost source in this tool was discovered the hard way.

If this saved you money, star the repo and share it in the OpenClaw Discord.

---

## License

MIT — free forever, open source, no telemetry, no accounts.


path: analyzer.js
content: 'use strict';

const fs   = require('fs');
const path = require('path');
const os   = require('os');

// ── Model pricing (per million tokens, input/output) ─────────────
// Last updated: 2026-02-28 — Update when Anthropic/OpenAI/Google change pricing
const PRICING_UPDATED = '2026-02-28';
const PRICING_STALE_DAYS = 60;

const MODEL_PRICING = {
  'claude-opus-4-6':              { input: 5.00,  output: 25.00, label: 'Claude Opus 4.6' },
  'claude-opus-4-5':              { input: 5.00,  output: 25.00, label: 'Claude Opus 4.5' },
  'claude-sonnet-4-5-20250929':   { input: 3.00,  output: 15.00, label: 'Claude Sonnet 4.5' },
  'claude-sonnet-4-6':            { input: 3.00,  output: 15.00, label: 'Claude Sonnet 4.6' },
  'claude-haiku-4-5-20251001':    { input: 0.80,  output: 4.00,  label: 'Claude Haiku 4.5' },
  'claude-haiku-4-5':             { input: 0.80,  output: 4.00,  label: 'Claude Haiku 4.5' },
  'claude-3-5-sonnet-20241022':   { input: 3.00,  output: 15.00, label: 'Claude 3.5 Sonnet' },
  'claude-3-5-haiku-20241022':    { input: 0.80,  output: 4.00,  label: 'Claude 3.5 Haiku' },
  'claude-3-opus-20240229':       { input: 15.00, output: 75.00, label: 'Claude 3 Opus' },
  'gpt-4o':                       { input: 2.50,  output: 10.00, label: 'GPT-4o' },
  'gpt-4o-mini':                  { input: 0.15,  output: 0.60,  label: 'GPT-4o Mini' },
  'gpt-4-turbo':                  { input: 10.00, output: 30.00, label: 'GPT-4 Turbo' },
  'gpt-5.2':                      { input: 10.00, output: 40.00, label: 'GPT-5.2' },
  'gpt-5-mini':                   { input: 0.40,  output: 1.60,  label: 'GPT-5 Mini' },
  'gpt-5.3-codex':                { input: 0,     output: 0,     label: 'Codex (subscription)', subscription: true },
  'gemini-1.5-pro':               { input: 1.25,  output: 5.00,  label: 'Gemini 1.5 Pro' },
  'gemini-1.5-flash':             { input: 0.075, output: 0.30,  label: 'Gemini 1.5 Flash' },
  'gemini-2.0-flash':             { input: 0.10,  output: 0.40,  label: 'Gemini 2.0 Flash' },
  'gemini-2.5-flash':             { input: 0.15,  output: 0.60,  label: 'Gemini 2.5 Flash' },
  'gemini-2.5-pro':               { input: 1.25,  output: 10.00, label: 'Gemini 2.5 Pro' },
};

// OpenRouter adds ~8-10% markup on top of provider pricing
const OPENROUTER_MARKUP = 0.10;

// ── Deterministic fix recommendations ────────────────────────────
const FIXES = {
  HEARTBEAT_PAID_MODEL: {
    fix: 'Set heartbeat.model to a free local Ollama model',
    command: 'ollama pull qwen2.5:0.5b  →  set heartbeat.model = "ollama/qwen2.5:0.5b"',
  },
  HEARTBEAT_TARGET: {
    fix: 'Set heartbeat.target to "none" (required since v2026.2.24)',
    command: 'openclaw config set agents.defaults.heartbeat.target none',
  },
  WHATSAPP_GROUPS_OPEN: {
    fix: 'Set whatsapp.groups to {} to block all groups from auto-processing',
    command: 'openclaw config set channels.whatsapp.groups \'{}\'',
  },
  WHATSAPP_GROUPS_ACTIVE: {
    fix: 'Review each active WhatsApp group — every message costs tokens on your primary model',
    command: 'Remove group IDs from channels.whatsapp.groups to stop processing them',
  },
  TELEGRAM_OPEN: {
    fix: 'Telegram dmPolicy is "open" — anyone can find and message your bot, running up your bill',
    command: 'openclaw config set channels.telegram.dmPolicy allowlist',
  },
  DISCORD_OPEN: {
    fix: 'Discord DM policy is "open" — restrict to allowlist to prevent unknown users billing you',
    command: 'openclaw config set channels.discord.dm.policy allowlist',
  },
  SIGNAL_OPEN: {
    fix: 'Signal has no allowlist — anyone who has your number can message your agent',
    command: 'Add allowFrom list to channels.signal config',
  },
  HOOK_PAID_MODEL: (name) => ({
    fix: `Switch hook "${name}" to Haiku — 80% cheaper than Sonnet for simple tasks`,
    command: `openclaw config set hooks.${name}.model claude-haiku-4-5-20251001`,
  }),
  SKILL_POLLING: (name) => ({
    fix: `Skill "${name}" is polling on a paid model — switch to Ollama or increase interval`,
    command: `Set skills.${name}.model = "ollama/qwen2.5:0.5b" in openclaw.json`,
  }),
  CRON_PAID_MODEL: (name, interval) => ({
    fix: `Cron job "${name}" running every ${interval} on a paid model`,
    command: `Add model: "ollama/qwen2.5:0.5b" to cron job "${name}" config, or increase interval`,
  }),
  MAX_CONCURRENT: {
    fix: 'Reduce maxConcurrent to 2 — high concurrency multiplies cost spikes',
    command: 'openclaw config set agents.defaults.subagents.maxConcurrent 2',
  },
  ORPHANED_SESSIONS: {
    fix: 'Delete sessions.json to clear orphaned sessions — they auto-rebuild on next use',
    command: 'rm ~/.openclaw/agents/main/sessions/sessions.json',
  },
  LARGE_SESSIONS: {
    fix: 'Reduce root-level .md files in your workspace to shrink session context size',
    command: 'Move inactive files to ~/clawd/archive/ and ~/clawd/projects/',
  },
  WORKSPACE_BLOAT: {
    fix: 'Move inactive files to /archive/ and /projects/ subdirectories',
    command: 'mkdir -p ~/clawd/archive ~/clawd/projects  # then move unused .md files',
  },
  MEMORY_FLUSH_PAID: {
    fix: 'memoryFlush inherits your primary model — consider routing compaction to Haiku',
    command: 'Check openclaw.json for memoryFlush.model support in your version',
  },
  CONTEXT_PRUNING_MISSING: {
    fix: 'Add contextPruning to prevent unbounded session growth — each message re-sends full history',
    command: 'openclaw config set agents.defaults.session.contextPruning.mode sliding',
  },
  FALLBACK_EXPENSIVE: (model, position) => ({
    fix: `Fallback position ${position} is an expensive model (${model}) — silent cost escalation on rate limits`,
    command: `Replace ${model} in fallbacks array with a cheaper model like claude-haiku-4-5-20251001`,
  }),
  OPENROUTER_MARKUP: {
    fix: 'OpenRouter adds ~10% markup over direct provider pricing — use direct API keys where possible',
    command: 'Replace openrouter/* model refs with direct anthropic/* or openai/* equivalents',
  },
  IMAGE_DIMENSION: {
    fix: 'Lower imageMaxDimensionPx to reduce vision token costs — default 1200px is expensive',
    command: 'openclaw config set agents.defaults.imageMaxDimensionPx 800',
  },
  PRICING_STALE: {
    fix: 'Update clawculator to get the latest model pricing data',
    command: 'npm update -g clawculator',
  },
  MULTI_AGENT_PAID: (agentId) => ({
    fix: `Agent "${agentId}" has its own expensive model config — each agent bills independently`,
    command: `Review agents.list[${agentId}].model config and apply same cost rules as primary agent`,
  }),
};

// ── Helpers ───────────────────────────────────────────────────────
function readJSON(filePath) {
  try {
    if (!fs.existsSync(filePath)) return null;
    return JSON.parse(fs.readFileSync(filePath, 'utf8'));
  } catch { return null; }
}

function resolveModel(modelStr) {
  if (!modelStr) return null;
  // Strip provider prefix for lookup (anthropic/claude-sonnet → claude-sonnet)
  const stripped = modelStr.toLowerCase().replace(/^(anthropic|openai|google|openrouter\/[^/]+)\//, '');
  if (MODEL_PRICING[stripped]) return stripped;
  for (const key of Object.keys(MODEL_PRICING)) {
    if (stripped.includes(key) || key.includes(stripped)) return key;
  }
  return isLocalModel(modelStr) ? 'ollama' : null;
}

function isLocalModel(modelStr) {
  if (!modelStr) return false;
  const lower = modelStr.toLowerCase();
  return lower.startsWith('ollama/') || lower === 'ollama' ||
    ['qwen', 'llama', 'mistral', 'phi', 'gemma', 'deepseek', 'kimi'].some(m => lower.includes(m));
}

function isHaikuTier(modelStr) {
  if (!modelStr) return false;
  const lower = modelStr.toLowerCase();
  return lower.includes('haiku');
}

function isAcceptableForHooks(modelStr) {
  return isLocalModel(modelStr) || isHaikuTier(modelStr);
}

function isOpenRouter(modelStr) {
  return modelStr?.toLowerCase().startsWith('openrouter/');
}

function costPerCall(modelKey, inputTok = 1000, outputTok = 500) {
  if (!modelKey || modelKey === 'ollama') return 0;
  const p = MODEL_PRICING[modelKey];
  if (!p || p.subscription) return 0;
  return (inputTok / 1e6) * p.input + (outputTok / 1e6) * p.output;
}

function modelTier(modelKey) {
  if (!modelKey || modelKey === 'ollama') return 'free';
  const p = MODEL_PRICING[modelKey];
  if (!p) return 'unknown';
  if (p.input >= 5)  return 'expensive';
  if (p.input >= 1)  return 'medium';
  if (p.input >= 0.5) return 'cheap';
  return 'very-cheap';
}

// ── Rule: Analyze a single agent config block ─────────────────────
function analyzeAgentBlock(agentCfg, primaryModel, agentId = 'main') {
  const findings = [];
  const prefix = agentId === 'main' ? '' : `[Agent: ${agentId}] `;

  // ── Heartbeat ──────────────────────────────────────────────────
  const hb = agentCfg.heartbeat;
  if (hb) {
    const hbModel    = hb.model || primaryModel;
    const hbKey      = resolveModel(hbModel);
    const hbInterval = typeof hb.interval === 'number' ? hb.interval :
                       typeof hb.every === 'string' ? parseInterval(hb.every) : 60;
    const hbPerDay   = Math.floor(86400 / Math.max(hbInterval, 1));

    if (!isLocalModel(hbModel)) {
      const daily   = costPerCall(hbKey, 500, 100) * hbPerDay;
      const monthly = daily * 30;
      findings.push({
        severity: 'critical', source: 'heartbeat',
        message: `${prefix}Heartbeat running on paid model: ${hbModel || 'primary'}`,
        detail: `${hbPerDay} pings/day · $${daily.toFixed(4)}/day`,
        monthlyCost: monthly,
        ...FIXES.HEARTBEAT_PAID_MODEL,
      });
    } else {
      findings.push({
        severity: 'info', source: 'heartbeat',
        message: `${prefix}Heartbeat using local model (${hbModel}) ✓`,
        detail: `${hbPerDay} pings/day · $0.00 cost`, monthlyCost: 0,
      });
    }

    if (hb.target && hb.target !== 'none') {
      findings.push({
        severity: 'high', source: 'heartbeat',
        message: `${prefix}Heartbeat target is "${hb.target}" — must be "none" (v2026.2.24+)`,
        detail: 'Non-"none" targets silently trigger paid model sessions',
        ...FIXES.HEARTBEAT_TARGET,
      });
    }
  }

  // ── Fallback chain ─────────────────────────────────────────────
  const fallbacks = agentCfg.model?.fallbacks || agentCfg.models?.fallbacks || [];
  if (fallbacks.length > 0) {
    fallbacks.forEach((fb, i) => {
      const fbKey  = resolveModel(fb);
      const tier   = modelTier(fbKey);
      const isOR   = isOpenRouter(fb);
      if (tier === 'expensive' || tier === 'medium') {
        const monthly = costPerCall(fbKey, 2000, 500) * 50 * 30;
        findings.push({
          severity: tier === 'expensive' ? 'high' : 'medium',
          source: 'fallbacks',
          message: `${prefix}Fallback #${i + 1} is expensive (${fb}) — silent cost spike on rate limits`,
          detail: `If primary hits rate limit, falls back to ${fb} at ~$${monthly.toFixed(2)}/month equivalent`,
          monthlyCost: 0, // only triggered on rate limit, not always-on
          ...FIXES.FALLBACK_EXPENSIVE(fb, i + 1),
        });
      }
      if (isOR) {
        findings.push({
          severity: 'medium', source: 'fallbacks',
          message: `${prefix}Fallback uses OpenRouter (${fb}) — adds ~10% markup over direct pricing`,
          detail: 'OpenRouter routes through their infrastructure and charges a markup',
          ...FIXES.OPENROUTER_MARKUP,
        });
      }
    });
  }

  // ── contextPruning ─────────────────────────────────────────────
  const pruning = agentCfg.session?.contextPruning || agentCfg.contextPruning;
  if (!pruning || pruning.mode === 'none') {
    findings.push({
      severity: 'medium', source: 'context',
      message: `${prefix}contextPruning is not set — sessions grow unbounded`,
      detail: 'Every message re-sends full conversation history as input tokens. A 30-message chat can cost 5-10x a fresh session.',
      ...FIXES.CONTEXT_PRUNING_MISSING,
    });
  } else {
    findings.push({
      severity: 'info', source: 'context',
      message: `${prefix}contextPruning mode: "${pruning.mode}" ✓`, monthlyCost: 0,
    });
  }

  // ── Image dimension ────────────────────────────────────────────
  const imgDim = agentCfg.imageMaxDimensionPx ?? agentCfg.media?.imageMaxDimensionPx;
  if (imgDim === undefined || imgDim === null || imgDim > 800) {
    const dimVal = imgDim ?? 1200;
    findings.push({
      severity: 'medium', source: 'vision',
      message: `${prefix}imageMaxDimensionPx is ${dimVal} — high-res vision tokens are expensive`,
      detail: 'Each screenshot at 1200px can cost 1000-3000 extra tokens. Reduce to 800px or lower.',
      ...FIXES.IMAGE_DIMENSION,
    });
  }

  return findings;
}

// ── Parse human interval strings like "30m", "2h", "1d" ──────────
function parseInterval(str) {
  if (!str) return 60;
  const match = str.match(/^(\d+)(s|m|h|d)$/);
  if (!match) return 60;
  const val = parseInt(match[1]);
  return { s: val, m: val * 60, h: val * 3600, d: val * 86400 }[match[2]] || 60;
}

// ── Config analysis ───────────────────────────────────────────────
function analyzeConfig(configPath) {
  const findings = [];
  const config = readJSON(configPath);

  if (!config) {
    return {
      exists: false,
      findings: [{ severity: 'info', source: 'config', message: `openclaw.json not found at ${configPath}`, detail: 'Skipping config analysis — run from a machine with OpenClaw installed' }],
      config: null, primaryModel: null,
    };
  }

  const agentDefaults = config.agents?.defaults || config.agent || {};
  const primaryModel  = agentDefaults.model?.primary || agentDefaults.model || config.model || null;
  const primaryKey    = resolveModel(primaryModel);

  // ── Primary model OpenRouter check ────────────────────────────
  if (primaryModel && isOpenRouter(primaryModel)) {
    findings.push({
      severity: 'medium', source: 'primary_model',
      message: `Primary model is routed via OpenRouter (${primaryModel})`,
      detail: 'OpenRouter adds ~10% markup. Direct provider access is cheaper.',
      ...FIXES.OPENROUTER_MARKUP,
    });
  }

  // ── Analyze main agent block ───────────────────────────────────
  findings.push(...analyzeAgentBlock(agentDefaults, primaryModel, 'main'));

  // ── Multi-agent scanning ───────────────────────────────────────
  const agentList = config.agents?.list || [];
  for (const agent of agentList) {
    if (!agent.id || agent.id === 'main') continue;
    const agentModel = agent.model?.primary || agent.model || primaryModel;
    const agentKey   = resolveModel(agentModel);

    // Flag expensive per-agent model overrides
    if (agentModel && !isLocalModel(agentModel) && agentKey) {
      const tier = modelTier(agentKey);
      if (tier === 'expensive' || tier === 'medium') {
        const monthly = costPerCall(agentKey, 2000, 500) * 30 * 30;
        findings.push({
          severity: tier === 'expensive' ? 'high' : 'medium',
          source: 'multi_agent',
          message: `Agent "${agent.id}" using expensive model: ${agentModel}`,
          detail: `Each agent has its own sessions, heartbeat, and hooks — all bill independently`,
          monthlyCost: monthly,
          ...FIXES.MULTI_AGENT_PAID(agent.id),
        });
      }
    }

    // Run full rule engine on each agent's own config
    const agentBlock = { ...agentDefaults, ...agent };
    const agentFindings = analyzeAgentBlock(agentBlock, agentModel, agent.id)
      .filter(f => f.severity !== 'info'); // only surface issues for secondary agents
    findings.push(...agentFindings);
  }

  // ── Hooks ──────────────────────────────────────────────────────
  const hooks = config.hooks?.internal?.entries || config.hooks || {};
  const hookNames = Object.keys(hooks).filter(k => k !== 'enabled' && k !== 'token' && k !== 'path');
  let hookIssues = 0;
  let haikuHooks = 0;

  for (const name of hookNames) {
    const hook = typeof hooks[name] === 'object' ? hooks[name] : {};
    if (hook.enabled === false) continue;
    const hookModel = hook.model || primaryModel;
    if (isLocalModel(hookModel)) {
      // local = free, no finding needed
    } else if (isHaikuTier(hookModel) && resolveModel(hookModel)) {
      const monthly = costPerCall(resolveModel(hookModel), 1000, 200) * 50 * 30;
      haikuHooks++;
      findings.push({
        severity: 'low', source: 'hooks',
        message: `Hook "${name}" on Haiku — minimal cost, good choice`,
        detail: `~50 fires/day estimated · $${monthly.toFixed(2)}/month`,
        monthlyCost: monthly,
      });
    } else if (resolveModel(hookModel)) {
      const monthly = costPerCall(resolveModel(hookModel), 1000, 200) * 50 * 30;
      hookIssues++;
      findings.push({
        severity: 'high', source: 'hooks',
        message: `Hook "${name}" running on ${hookModel} — switch to Haiku or local`,
        detail: `~50 fires/day estimated · $${monthly.toFixed(2)}/month`,
        monthlyCost: monthly,
        ...FIXES.HOOK_PAID_MODEL(name),
      });
    }
  }
  if (hookNames.length > 0 && hookIssues === 0 && haikuHooks === 0) {
    findings.push({ severity: 'info', source: 'hooks', message: `All ${hookNames.length} hooks on local models ✓`, monthlyCost: 0 });
  }

  // ── WhatsApp ───────────────────────────────────────────────────
  const wa     = config.channels?.whatsapp || {};
  const groups = wa.groups;
  if (wa.enabled !== false) {
    if (groups === undefined || groups === null) {
      findings.push({ severity: 'critical', source: 'whatsapp', message: 'WhatsApp groups policy unset — ALL groups auto-joined on primary model', detail: 'Every message from every group you\'re in hits your primary model', ...FIXES.WHATSAPP_GROUPS_OPEN });
    } else if (typeof groups === 'object' && Object.keys(groups).length > 0) {
      findings.push({ severity: 'high', source: 'whatsapp', message: `${Object.keys(groups).length} WhatsApp group(s) actively processing on primary model`, detail: `Group IDs: ${Object.keys(groups).join(', ')}`, ...FIXES.WHATSAPP_GROUPS_ACTIVE });
    } else {
      findings.push({ severity: 'info', source: 'whatsapp', message: 'WhatsApp groups blocked ✓', monthlyCost: 0 });
    }
  }

  // ── Telegram ───────────────────────────────────────────────────
  const tg = config.channels?.telegram || {};
  if (tg.enabled !== false && tg.botToken) {
    const policy = tg.dmPolicy || tg.dm?.policy || 'pairing';
    if (policy === 'open') {
      findings.push({ severity: 'critical', source: 'telegram', message: 'Telegram dmPolicy is "open" — anyone can message your bot and bill you', detail: 'Any Telegram user who finds your bot can trigger paid API calls', ...FIXES.TELEGRAM_OPEN });
    } else {
      findings.push({ severity: 'info', source: 'telegram', message: `Telegram dmPolicy: "${policy}" ✓`, monthlyCost: 0 });
    }
  }

  // ── Discord ────────────────────────────────────────────────────
  const dc = config.channels?.discord || {};
  if (dc.enabled !== false && dc.token) {
    const dcPolicy = dc.dm?.policy || dc.dmPolicy || 'pairing';
    if (dcPolicy === 'open') {
      findings.push({ severity: 'high', source: 'discord', message: 'Discord DM policy is "open" — unknown users can trigger paid API calls', detail: 'Anyone in your server can DM your bot', ...FIXES.DISCORD_OPEN });
    } else {
      findings.push({ severity: 'info', source: 'discord', message: `Discord DM policy: "${dcPolicy}" ✓`, monthlyCost: 0 });
    }
  }

  // ── Signal ─────────────────────────────────────────────────────
  const sig = config.channels?.signal || {};
  if (sig.enabled !== false && sig.phoneNumber) {
    if (!sig.allowFrom || sig.allowFrom.length === 0) {
      findings.push({ severity: 'high', source: 'signal', message: 'Signal has no allowFrom list — anyone with your number can message your agent', detail: 'No sender restriction on Signal channel', ...FIXES.SIGNAL_OPEN });
    }
  }

  // ── Cron jobs ──────────────────────────────────────────────────
  const cronJobs = config.cron?.jobs || config.cron || {};
  if (typeof cronJobs === 'object' && !Array.isArray(cronJobs)) {
    for (const [name, job] of Object.entries(cronJobs)) {
      if (typeof job !== 'object' || job.enabled === false) continue;
      const jobModel   = job.model || primaryModel;
      const jobKey     = resolveModel(jobModel);
      const interval   = job.every ? parseInterval(job.every) : (job.interval || 3600);
      const perDay     = Math.floor(86400 / Math.max(interval, 1));

      if (!isLocalModel(jobModel) && jobKey) {
        const monthly = costPerCall(jobKey, 2000, 500) * perDay * 30;
        findings.push({
          severity: perDay > 24 ? 'critical' : 'high',
          source: 'cron',
          message: `Cron job "${name}" runs ${perDay}x/day on paid model: ${jobModel || 'primary'}`,
          detail: `Interval: ${job.every || interval + 's'} · $${monthly.toFixed(2)}/month estimated`,
          monthlyCost: monthly,
          ...FIXES.CRON_PAID_MODEL(name, job.every || `${interval}s`),
        });
      }
    }
  }

  // ── Skills polling ─────────────────────────────────────────────
  const skills = config.skills?.entries || config.skills || {};
  for (const [name, skill] of Object.entries(skills)) {
    if (typeof skill !== 'object' || skill.enabled === false) continue;
    if (skill.interval || skill.cron || skill.poll) {
      const interval   = typeof skill.interval === 'number' ? skill.interval : 60;
      const perDay     = Math.floor(86400 / interval);
      const skillModel = skill.model || primaryModel;
      const skillKey   = resolveModel(skillModel);
      if (!isLocalModel(skillModel) && skillKey) {
        const monthly = costPerCall(skillKey, 2000, 500) * perDay * 30;
        findings.push({
          severity: 'critical', source: 'skills',
          message: `Skill "${name}" has polling loop on paid model: ${skillModel || 'primary'}`,
          detail: `~${perDay} calls/day · $${monthly.toFixed(2)}/month estimated`,
          monthlyCost: monthly,
          ...FIXES.SKILL_POLLING(name),
        });
      }
    }
  }

  // ── Subagents concurrency ──────────────────────────────────────
  const maxC = agentDefaults.subagents?.maxConcurrent ?? config.subagents?.maxConcurrent ?? null;
  if (maxC !== null && maxC > 2) {
    findings.push({ severity: 'high', source: 'subagents', message: `maxConcurrent = ${maxC} — ${maxC}x cost multiplier during bursts`, detail: `${maxC} paid model calls can fire simultaneously`, ...FIXES.MAX_CONCURRENT });
  } else if (maxC !== null) {
    findings.push({ severity: 'info', source: 'subagents', message: `maxConcurrent = ${maxC} ✓`, monthlyCost: 0 });
  }

  // ── memoryFlush ────────────────────────────────────────────────
  const mfModel = config.memory?.flushModel || config.memoryFlush?.model || primaryModel;
  if (mfModel && !isLocalModel(mfModel)) {
    findings.push({ severity: 'medium', source: 'memory', message: `memoryFlush using paid model: ${mfModel}`, detail: 'Runs on every session compaction — cost scales with context size', ...FIXES.MEMORY_FLUSH_PAID });
  }

  // ── Primary model awareness ────────────────────────────────────
  if (primaryKey && MODEL_PRICING[primaryKey] && !MODEL_PRICING[primaryKey].subscription) {
    const p = MODEL_PRICING[primaryKey];
    const monthly = costPerCall(primaryKey, 2000, 500) * 50 * 30;
    findings.push({
      severity: p.input >= 5 ? 'high' : p.input >= 1 ? 'medium' : 'info',
      source: 'primary_model',
      message: `Primary model: ${p.label} · $${p.input}/$${p.output} per MTok`,
      detail: `Baseline at 50 queries/day: ~$${monthly.toFixed(2)}/month`,
      monthlyCost: monthly,
    });
  }

  return { exists: true, findings, config, primaryModel };
}

// ── Session analysis ──────────────────────────────────────────────

/**
 * Parse a .jsonl session transcript file and sum up real usage/cost data.
 * Version-aware: handles multiple OpenClaw schema formats:
 *   - v2026.2.x+: entry.message.usage (standard)
 *   - v2026.1.x:  entry.usage (legacy)
 *   - Anthropic raw: usage.cache_creation_input_tokens / usage.cache_read_input_tokens
 * Returns { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs }
 */
function parseTranscript(jsonlPath) {
  try {
    const content = fs.readFileSync(jsonlPath, 'utf8').trim();
    if (!content) return null;

    let input = 0, output = 0, cacheRead = 0, cacheWrite = 0, totalTokens = 0, totalCost = 0;
    let messageCount = 0, model = null, firstTs = null, lastTs = null;
    let schemaDetected = null; // track which schema we're seeing

    for (const line of content.split('\n')) {
      if (!line.trim()) continue;
      let entry;
      try { entry = JSON.parse(line); } catch { continue; }

      // Track timestamps from all message types
      const ts = entry.timestamp || entry.message?.timestamp;
      if (ts) {
        const t = typeof ts === 'number' ? ts : new Date(ts).getTime();
        if (!firstTs || t < firstTs) firstTs = t;
        if (!lastTs  || t > lastTs)  lastTs = t;
      }

      // Only assistant messages with usage blocks have cost data
      // Some schemas use entry.type === 'message', others use entry.role === 'assistant'
      if (entry.type !== 'message' && entry.role !== 'assistant') continue;

      // Usage can be in multiple locations depending on OpenClaw version
      const u = entry.usage || entry.message?.usage || entry.response?.usage;
      if (!u) continue;

      if (!schemaDetected) {
        schemaDetected = entry.usage ? 'legacy' : entry.message?.usage ? 'standard' : 'response';
      }

      messageCount++;

      // Model can be at multiple locations
      const entryModel = entry.model || entry.message?.model || entry.response?.model;
      if (entryModel && !model) model = entryModel;

      // Token fields: handle both camelCase (OpenClaw) and snake_case (raw Anthropic API)
      input       += u.input       || u.input_tokens               || 0;
      output      += u.output      || u.output_tokens              || 0;
      cacheRead   += u.cacheRead   || u.cache_read_input_tokens    || 0;
      cacheWrite  += u.cacheWrite  || u.cache_creation_input_tokens || 0;
      totalTokens += u.totalTokens || ((u.input || u.input_tokens || 0) + (u.output || u.output_tokens || 0)) || 0;

      // Prefer API-reported cost (already accounts for cache pricing)
      if (u.cost) {
        if (typeof u.cost === 'object' && u.cost.total != null) {
          totalCost += u.cost.total;
        } else if (typeof u.cost === 'number') {
          totalCost += u.cost;
        }
      }
    }

    if (messageCount === 0) return null;

    return { input, output, cacheRead, cacheWrite, totalTokens, totalCost, messageCount, model, firstTs, lastTs, schemaDetected };
  } catch {
    return null;
  }
}

/**
 * Discover all agent session directories (not just main).
 */
function discoverAgentDirs() {
  const openclawHome = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
  const agentsDir = path.join(openclawHome, 'agents');
  const dirs = [];
  try {
    for (const agent of fs.readdirSync(agentsDir)) {
      const sessionsDir = path.join(agentsDir, agent, 'sessions');
      if (fs.existsSync(sessionsDir) && fs.statSync(sessionsDir).isDirectory()) {
        dirs.push({ agent, sessionsDir });
      }
    }
  } catch { /* agents dir doesn't exist */ }
  return dirs;
}

/**
 * Discover web-chat session transcripts.
 */
function discoverWebChatSessions() {
  const openclawHome = process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
  const webChatDir = path.join(openclawHome, 'web-chat');
  const sessions = [];
  try {
    for (const file of fs.readdirSync(webChatDir)) {
      if (file.endsWith('.jsonl')) {
        sessions.push(path.join(webChatDir, file));
      }
    }
  } catch { /* web-chat dir doesn't exist */ }
  return sessions;
}

function analyzeSessions(sessionsPath) {
  const findings = [];
  const sessions = readJSON(sessionsPath);
  const sessionsDir = sessionsPath ? path.dirname(sessionsPath) : null;

  if (!sessions) return { exists: false, findings: [], sessions: [], totalInputTokens: 0, totalOutputTokens: 0, totalCost: 0, totalCacheRead: 0, totalCacheWrite: 0, totalRealCost: 0, sessionCount: 0 };

  let totalIn = 0, totalOut = 0, totalCost = 0;
  let totalCacheRead = 0, totalCacheWrite = 0, totalRealCost = 0;
  const breakdown = [], orphaned = [], large = [];
  let transcriptHits = 0, transcriptMisses = 0;

  // Track which sessionIds we've already parsed to avoid double-counting
  // (multiple session keys can share the same sessionId / .jsonl file)
  const parsedTranscripts = new Map(); // sessionId -> transcript result
  const countedSessionIds = new Set();

  for (const key of Object.keys(sessions)) {
    const s        = sessions[key];
    const model    = s.model || s.primaryModel || null;
    const modelKey = resolveModel(model);
    const inTok    = s.inputTokens  || s.tokensIn  || s.tokens?.input  || 0;
    const outTok   = s.outputTokens || s.tokensOut || s.tokens?.output || 0;
    const estimatedCost = costPerCall(modelKey, inTok, outTok);
    const updatedAt = s.updatedAt || s.lastActive || null;

    // Look up transcript by sessionId (not by key name)
    let transcript = null;
    const sessionId = s.sessionId;
    if (sessionsDir && sessionId) {
      if (parsedTranscripts.has(sessionId)) {
        transcript = parsedTranscripts.get(sessionId);
      } else {
        const jsonlPath = path.join(sessionsDir, `${sessionId}.jsonl`);
        transcript = parseTranscript(jsonlPath);
        parsedTranscripts.set(sessionId, transcript);
      }
    }

    // Deduplicate: if multiple keys share a sessionId, only count transcript cost once
    const isSharedSession = sessionId && countedSessionIds.has(sessionId);
    if (sessionId) countedSessionIds.add(sessionId);

    // Use transcript data when available, fall back to sessions.json estimates
    let realIn, realOut, realCacheRead, realCacheWrite, realCost, realModel, realTotalTokens;
    if (transcript && !isSharedSession) {
      transcriptHits++;
      realIn         = transcript.input;
      realOut        = transcript.output;
      realCacheRead  = transcript.cacheRead;
      realCacheWrite = transcript.cacheWrite;
      realCost       = transcript.totalCost;
      realModel      = transcript.model || model;
      realTotalTokens = transcript.totalTokens;
    } else if (transcript && isSharedSession) {
      // Shared sessionId — transcript already counted, show it but zero out cost
      transcriptHits++;
      realIn         = 0;
      realOut        = 0;
      realCacheRead  = 0;
      realCacheWrite = 0;
      realCost       = 0;
      realModel      = transcript.model || model;
      realTotalTokens = 0;
    } else {
      transcriptMisses++;
      realIn         = inTok;
      realOut        = outTok;
      realCacheRead  = 0;
      realCacheWrite = 0;
      realCost       = estimatedCost;
      realModel      = model;
      realTotalTokens = inTok + outTok;
    }

    totalIn         += realIn;
    totalOut        += realOut;
    totalCacheRead  += realCacheRead;
    totalCacheWrite += realCacheWrite;
    totalCost       += estimatedCost;
    totalRealCost   += realCost;

    const allTokens = realTotalTokens || (realIn + realOut + realCacheRead + realCacheWrite);

    const isOrphaned = key.includes('cron') || key.includes('deleted') ||
      (updatedAt && Date.now() - new Date(updatedAt).getTime() > 48 * 3600 * 1000 && !key.includes('main'));

    if (isOrphaned) orphaned.push({ key, model: realModel, tokens: allTokens, cost: realCost });
    if (allTokens > 50000) large.push({ key, model: realModel, tokens: allTokens });

    const ageMs    = updatedAt ? Date.now() - new Date(updatedAt).getTime() : null;
    const ageDays  = ageMs ? ageMs / (1000 * 3600 * 24) : null;
    const dailyCost = (ageDays && ageDays > 1.0 && realCost > 0) ? realCost / ageDays : null;

    const realModelKey = resolveModel(realModel);
    breakdown.push({
      key, sessionId: sessionId || null, model: realModel,
      modelLabel: realModelKey ? (MODEL_PRICING[realModelKey]?.label || realModelKey) : (modelKey ? (MODEL_PRICING[modelKey]?.label || modelKey) : 'unknown'),
      inputTokens: realIn, outputTokens: realOut,
      cacheRead: realCacheRead, cacheWrite: realCacheWrite,
      cost: realCost, estimatedCost,
      hasTranscript: !!transcript,
      isSharedSession,
      messageCount: transcript?.messageCount || 0,
      updatedAt, ageMs, dailyCost, isOrphaned,
    });
  }

  // Scan for untracked .jsonl files (sessions not in sessions.json)
  const trackedIds = new Set(Object.values(sessions).map(s => s.sessionId).filter(Boolean));
  let untrackedCount = 0, untrackedCost = 0, untrackedTokens = 0;
  let untrackedRecentCount = 0, untrackedRecentCost = 0;
  const NOW = Date.now();
  const TODAY_START = new Date(); TODAY_START.setHours(0, 0, 0, 0);
  const todayMs = TODAY_START.getTime();

  if (sessionsDir) {
    try {
      for (const file of fs.readdirSync(sessionsDir)) {
        if (!file.endsWith('.jsonl')) continue;
        const fileId = file.replace('.jsonl', '');
        if (trackedIds.has(fileId)) continue; // already counted
        const filePath = path.join(sessionsDir, file);
        const transcript = parseTranscript(filePath);
        if (transcript && transcript.messageCount > 0) {
          untrackedCount++;
          untrackedCost += transcript.totalCost;
          untrackedTokens += transcript.totalTokens;
          totalRealCost += transcript.totalCost;
          totalCacheRead += transcript.cacheRead;
          totalCacheWrite += transcript.cacheWrite;

          // Check if this file was modified today (recent vs historical)
          let fileMtime = null;
          try { fileMtime = fs.statSync(filePath).mtimeMs; } catch {}
          const isRecent = fileMtime && (NOW - fileMtime < 48 * 3600 * 1000);
          const isToday = fileMtime && fileMtime >= todayMs;
          if (isRecent) { untrackedRecentCount++; untrackedRecentCost += transcript.totalCost; }

          breakdown.push({
            key: `untracked:${fileId.slice(0, 8)}`, sessionId: fileId, model: transcript.model,
            modelLabel: transcript.model ? (MODEL_PRICING[resolveModel(transcript.model)]?.label || transcript.model) : 'unknown',
            inputTokens: transcript.input, outputTokens: transcript.output,
            cacheRead: transcript.cacheRead, cacheWrite: transcript.cacheWrite,
            cost: transcript.totalCost, estimatedCost: 0,
            hasTranscript: true, isSharedSession: false,
            messageCount: transcript.messageCount,
            updatedAt: transcript.lastTs ? new Date(transcript.lastTs).toISOString() : null,
            ageMs: transcript.lastTs ? NOW - transcript.lastTs : null,
            dailyCost: null, isOrphaned: false, isUntracked: true,
            isRecent, isToday,
          });
        }
      }
    } catch { /* can't read dir */ }
  }

  if (untrackedCount > 0) {
    const detail = [`${untrackedTokens.toLocaleString()} tokens · $${untrackedCost.toFixed(4)} total API costs`];
    if (untrackedRecentCount > 0) detail.push(`${untrackedRecentCount} active in last 48h ($${untrackedRecentCost.toFixed(4)})`);
    detail.push('These are sessions not listed in sessions.json — old/deleted or spawned by subprocesses');
    findings.push({
      severity: untrackedRecentCost > 1 ? 'high' : (untrackedCost > 1 ? 'medium' : 'info'),
      source: 'sessions',
      message: `${untrackedCount} untracked session(s) found (${untrackedRecentCount} recent)`,
      detail: detail.join('\n'),
    });
  }

  // Findings
  if (orphaned.length > 0) findings.push({ severity: 'high', source: 'sessions', message: `${orphaned.length} orphaned session(s) — still holding tokens on paid models`, detail: orphaned.map(s => `${s.key}: ${s.tokens.toLocaleString()} tokens ($${s.cost.toFixed(4)})`).join('\n  '), ...FIXES.ORPHANED_SESSIONS });
  if (large.length > 0) findings.push({ severity: 'medium', source: 'sessions', message: `${large.length} session(s) with >50k tokens per conversation`, detail: large.map(s => `${s.key}: ${s.tokens.toLocaleString()} tokens`).join('\n  '), ...FIXES.LARGE_SESSIONS });
  if (Object.keys(sessions).length > 0 && !orphaned.length && !large.length) findings.push({ severity: 'info', source: 'sessions', message: `${Object.keys(sessions).length} session(s) healthy ✓`, detail: `Total tokens: ${(totalIn + totalOut).toLocaleString()}` });

  // Cache cost finding
  if (totalCacheWrite > 0 || totalCacheRead > 0) {
    const cacheDetail = [];
    if (totalCacheRead > 0)  cacheDetail.push(`Cache reads: ${totalCacheRead.toLocaleString()} tokens`);
    if (totalCacheWrite > 0) cacheDetail.push(`Cache writes: ${totalCacheWrite.toLocaleString()} tokens`);
    const cacheCostPortion = totalRealCost - totalCost;
    if (cacheCostPortion > 0.01) {
      findings.push({
        severity: cacheCostPortion > 1 ? 'high' : 'medium',
        source: 'sessions',
        message: `Prompt caching added $${cacheCostPortion.toFixed(4)} beyond base token costs`,
        detail: cacheDetail.join('\n  ') + `\n  Cache writes are 3.75x input cost · Cache reads are 0.1x input cost`,
        monthlyCost: 0, // already counted in session costs, not a recurring config bleed
      });
    } else {
      findings.push({ severity: 'info', source: 'sessions', message: `Prompt caching active — ${(totalCacheRead + totalCacheWrite).toLocaleString()} cache tokens tracked`, detail: cacheDetail.join(' · ') });
    }
  }

  // Transcript coverage finding
  if (transcriptHits > 0 || transcriptMisses > 0) {
    const total = transcriptHits + transcriptMisses;
    if (transcriptMisses > 0 && transcriptHits > 0) {
      findings.push({ severity: 'info', source: 'sessions', message: `Transcript data: ${transcriptHits}/${total} sessions have .jsonl transcripts (${transcriptMisses} estimated)`, detail: `Sessions with transcripts show real API-reported costs including cache tokens` });
    }
  }

  // Cost discrepancy finding
  if (totalRealCost > 0 && totalCost > 0) {
    const ratio = totalRealCost / totalCost;
    if (ratio > 2) {
      findings.push({
        severity: 'high',
        source: 'sessions',
        message: `Real cost $${totalRealCost.toFixed(4)} is ${ratio.toFixed(1)}x higher than sessions.json estimate ($${totalCost.toFixed(4)})`,
        detail: `sessions.json only tracks input/output tokens — cache tokens, which are the bulk of real spending, are only in .jsonl transcripts`,
      });
    }
  }

  // Compute today's cost across all sessions (tracked + untracked)
  let todayCost = 0;
  for (const s of breakdown) {
    if (s.isToday) { todayCost += s.cost; continue; }
    // For tracked sessions, check if updatedAt is today
    if (!s.isUntracked && s.updatedAt) {
      const upd = new Date(s.updatedAt).getTime();
      if (upd >= todayMs) todayCost += s.cost;
    }
  }

  return {
    exists: true, findings, sessions: breakdown,
    totalInputTokens: totalIn, totalOutputTokens: totalOut,
    totalCost, totalCacheRead, totalCacheWrite, totalRealCost,
    todayCost,
    sessionCount: Object.keys(sessions).length,
  };
}

// ── Workspace analysis ────────────────────────────────────────────
function analyzeWorkspace() {
  const findings = [];
  const workspaceDir = path.join(os.homedir(), 'clawd');
  if (!fs.existsSync(workspaceDir)) return { exists: false, findings: [] };

  try {
    const rootFiles = fs.readdirSync(workspaceDir).filter(f => f.endsWith('.md') || f.endsWith('.txt'));
    const count = rootFiles.length;
    const estimatedTokens = count * 500;
    if (count > 20) {
      findings.push({ severity: 'medium', source: 'workspace', message: `${count} files at workspace root — all loaded into every session context`, detail: `~${estimatedTokens.toLocaleString()} tokens/session from workspace files`, ...FIXES.WORKSPACE_BLOAT });
    } else {
      findings.push({ severity: 'info', source: 'workspace', message: `${count} files at workspace root — lean ✓`, detail: `~${estimatedTokens.toLocaleString()} tokens estimated` });
    }
  } catch { /* not readable */ }

  return { exists: true, findings };
}

// ── Main ──────────────────────────────────────────────────────────
async function runAnalysis({ configPath, sessionsPath, logsDir }) {
  const configResult    = analyzeConfig(configPath);
  const sessionResult   = analyzeSessions(sessionsPath);
  const workspaceResult = analyzeWorkspace();

  // Scan additional agent folders beyond main
  const agentDirs = discoverAgentDirs();
  const additionalAgentSessions = [];
  for (const { agent, sessionsDir } of agentDirs) {
    const sjPath = path.join(sessionsDir, 'sessions.json');
    // Skip the primary sessions path (already analyzed above)
    if (sjPath === sessionsPath) continue;
    if (fs.existsSync(sjPath)) {
      const extra = analyzeSessions(sjPath);
      if (extra.exists) {
        additionalAgentSessions.push({ agent, ...extra });
      }
    }
  }

  // Scan web-chat sessions
  const webChatPaths = discoverWebChatSessions();
  const webChatSessions = [];
  for (const wcp of webChatPaths) {
    const transcript = parseTranscript(wcp);
    if (transcript) {
      const sessionId = path.basename(wcp, '.jsonl');
      webChatSessions.push({ key: `web-chat/${sessionId}`, ...transcript });
    }
  }

  // Merge all findings
  const allFindings = [
    ...configResult.findings,
    ...sessionResult.findings,
    ...workspaceResult.findings,
  ];

  // Add additional agent findings
  for (const extra of additionalAgentSessions) {
    if (extra.findings.length > 0) {
      allFindings.push({ severity: 'info', source: 'sessions', message: `Agent "${extra.agent}": ${extra.sessionCount} session(s) found`, detail: `Tokens: ${(extra.totalInputTokens + extra.totalOutputTokens).toLocaleString()} · Cost: $${extra.totalRealCost.toFixed(4)}` });
    }
    // Merge their sessions into the main breakdown
    sessionResult.sessions.push(...(extra.sessions || []));
    sessionResult.totalRealCost += extra.totalRealCost || 0;
    sessionResult.totalCacheRead += extra.totalCacheRead || 0;
    sessionResult.totalCacheWrite += extra.totalCacheWrite || 0;
  }

  // Add web-chat finding if any
  if (webChatSessions.length > 0) {
    const wcTotal = webChatSessions.reduce((sum, s) => sum + s.totalCost, 0);
    const wcTokens = webChatSessions.reduce((sum, s) => sum + s.totalTokens, 0);
    allFindings.push({ severity: 'info', source: 'sessions', message: `${webChatSessions.length} web-chat session(s) found`, detail: `Tokens: ${wcTokens.toLocaleString()} · Cost: $${wcTotal.toFixed(4)}` });
  }

  const estimatedMonthlyBleed = allFindings
    .filter(f => f.monthlyCost && f.severity !== 'info')
    .reduce((sum, f) => sum + f.monthlyCost, 0);

  const realCost = sessionResult.totalRealCost || 0;

  // Check pricing staleness — only affects cost estimates for config findings
  // (actual transcript costs use API-reported cost.total, not the pricing table)
  const pricingAge = Math.floor((Date.now() - new Date(PRICING_UPDATED).getTime()) / 86400000);
  if (pricingAge > PRICING_STALE_DAYS) {
    allFindings.push({
      severity: 'low',
      source: 'pricing',
      title: `Model pricing table is ${pricingAge} days old`,
      detail: `Pricing was last updated ${PRICING_UPDATED}. Actual costs from transcripts are unaffected (they use API-reported totals). Config-based cost estimates (heartbeat bleed, cron projections) may be slightly off. Update: npm update -g clawculator`,
      ...FIXES.PRICING_STALE,
    });
  }

  return {
    scannedAt:    new Date().toISOString(),
    configPath,
    sessionsPath,
    primaryModel: configResult.primaryModel,
    findings:     allFindings,
    summary: {
      critical:              allFindings.filter(f => f.severity === 'critical').length,
      high:                  allFindings.filter(f => f.severity === 'high').length,
      medium:                allFindings.filter(f => f.severity === 'medium').length,
      low:                   allFindings.filter(f => f.severity === 'low').length,
      info:                  allFindings.filter(f => f.severity === 'info').length,
      estimatedMonthlyBleed,
      sessionsAnalyzed:      sessionResult.sessionCount,
      totalTokensFound:      (sessionResult.totalInputTokens || 0) + (sessionResult.totalOutputTokens || 0),
      totalCacheRead:        sessionResult.totalCacheRead || 0,
      totalCacheWrite:       sessionResult.totalCacheWrite || 0,
      totalRealCost:         realCost,
      totalEstimatedCost:    sessionResult.totalCost || 0,
      todayCost:             sessionResult.todayCost || 0,
    },
    sessions: sessionResult.sessions || [],
    webChatSessions,
    config:   configResult.config,
  };
}

module.exports = { runAnalysis, MODEL_PRICING, resolveModel, costPerCall, parseTranscript, discoverAgentDirs, discoverWebChatSessions };


path: htmlReport.js
content: 'use strict';

const fs = require('fs');
const path = require('path');
const os = require('os');

function severityColor(severity) {
  return { critical: '#ef4444', high: '#f97316', medium: '#eab308', low: '#06b6d4', info: '#22c55e' }[severity] || '#6b7280';
}

function severityBg(severity) {
  return { critical: '#fef2f2', high: '#fff7ed', medium: '#fefce8', low: '#ecfeff', info: '#f0fdf4' }[severity] || '#f9fafb';
}

function severityIcon(severity) {
  return { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵', info: '✅' }[severity] || '⚪';
}

function relativeAge(ageMs) {
  if (!ageMs) return 'unknown';
  const s = Math.floor(ageMs / 1000);
  if (s < 60)        return `${s}s ago`;
  const m = Math.floor(s / 60);
  if (m < 60)        return `${m}m ago`;
  const h = Math.floor(m / 60);
  if (h < 24)        return `${h}h ago`;
  const d = Math.floor(h / 24);
  if (d < 30)        return `${d}d ago`;
  return `${Math.floor(d / 30)}mo ago`;
}

const SOURCE_LABELS = {
  heartbeat: '💓 Heartbeat', hooks: '🪝 Hooks', whatsapp: '📱 WhatsApp',
  subagents: '🤖 Subagents', skills: '🔧 Skills', memory: '🧠 Memory',
  primary_model: '⚙️ Primary Model', sessions: '💬 Sessions',
  workspace: '📁 Workspace', config: '📄 Config',
};

async function generateHTMLReport(analysis, outPath) {
  const { summary, findings, sessions } = analysis;
  const bleed = summary.estimatedMonthlyBleed;

  // Burn summary calculation — only tracked sessions (not historical untracked)
  const trackedSessions = (sessions || []).filter(s => !s.isUntracked && s.dailyCost);
  const totalDaily = trackedSessions.reduce((sum, s) => sum + (s.dailyCost || 0), 0);
  const totalMonthly = totalDaily * 30;
  const burnSummary = totalDaily > 0 ? `
    <div class="section">
      <div class="section-title">Session Burn Rate</div>
      <div style="display:flex; gap:32px; flex-wrap:wrap;">
        <div>
          <div style="font-size:24px; font-weight:800; color:#f59e0b;">$${totalDaily.toFixed(4)}/day</div>
          <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Combined daily burn rate across ${trackedSessions.length} active session${trackedSessions.length !== 1 ? 's' : ''}</div>
        </div>
        <div>
          <div style="font-size:24px; font-weight:800; color:#f97316;">$${totalMonthly.toFixed(2)}/month</div>
          <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Projected monthly from active sessions</div>
        </div>
      </div>
    </div>` : '';

  const findingCards = findings.map(f => `
    <div class="finding" style="border-left: 4px solid ${severityColor(f.severity)}; background: ${severityBg(f.severity)}; padding: 16px; margin-bottom: 12px; border-radius: 0 8px 8px 0;">
      <div style="display:flex; align-items:center; gap:8px; margin-bottom:6px;">
        <span style="font-size:18px">${severityIcon(f.severity)}</span>
        <strong style="color:${severityColor(f.severity)}">${f.severity.toUpperCase()}</strong>
        <span style="color:#6b7280; font-size:14px">${SOURCE_LABELS[f.source] || f.source}</span>
        ${f.monthlyCost ? `<span style="margin-left:auto; color:${severityColor(f.severity)}; font-weight:bold">$${f.monthlyCost.toFixed(2)}/mo</span>` : ''}
      </div>
      <div style="font-weight:600; color:#111; margin-bottom:4px">${f.message}</div>
      ${f.detail ? `<div style="color:#555; font-size:14px; margin-bottom:6px; white-space:pre-line">${f.detail}</div>` : ''}
      ${f.fix ? `<div style="color:#16a34a; font-size:14px; margin-top:8px;">→ ${f.fix}</div>` : ''}
      ${f.command ? `<div style="background:#0f172a; color:#38bdf8; font-family:monospace; font-size:13px; padding:10px 14px; border-radius:6px; margin-top:6px; white-space:pre-wrap; border:1px solid #1e293b;"><span style="color:#6b7280; user-select:none;">$ </span>${f.command}</div>` : ''}
    </div>
  `).join('');

  const makeSessionRow = (s) => {
      const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '…' : s.key;
      const flag = s.isOrphaned ? ' ⚠️' : '';
      const txBadge = s.hasTranscript ? '<span style="color:#22c55e; font-size:10px; margin-left:4px" title="Cost from .jsonl transcript (API-reported)">●</span>' : '<span style="color:#6b7280; font-size:10px; margin-left:4px" title="Estimated cost (no transcript found)">○</span>';
      const age = s.ageMs ? relativeAge(s.ageMs) : 'unknown';
      const absDate = s.updatedAt ? new Date(s.updatedAt).toLocaleString() : '';
      const daily = s.dailyCost ? `$${s.dailyCost.toFixed(4)}/day` : '—';
      const cacheTitle = (s.cacheRead || s.cacheWrite) ? `Cache R: ${(s.cacheRead||0).toLocaleString()} · W: ${(s.cacheWrite||0).toLocaleString()}` : '';
      const rowBg = s.isOrphaned ? 'background:#fff7ed' : (s.isRecent ? 'background:#fffbeb' : '');
      return `
      <tr style="${rowBg}">
        <td style="padding:8px 12px; font-family:monospace; font-size:13px">${keyDisplay}${flag}${txBadge}</td>
        <td style="padding:8px 12px">${s.modelLabel || s.model}</td>
        <td style="padding:8px 12px; text-align:right" title="${cacheTitle}">${(s.inputTokens + s.outputTokens).toLocaleString()}${(s.cacheRead || s.cacheWrite) ? `<span style="color:#6b7280; font-size:11px;"> +${((s.cacheRead||0)+(s.cacheWrite||0)).toLocaleString()} cache</span>` : ''}</td>
        <td style="padding:8px 12px; text-align:right; color:${s.cost > 0.01 ? '#ef4444' : '#22c55e'}">$${s.cost.toFixed(4)}</td>
        <td style="padding:8px 12px; text-align:right; color:#f59e0b">${daily}</td>
        <td style="padding:8px 12px; color:#6b7280; font-size:13px" title="${absDate}">${age}</td>
      </tr>`;
  };

  const activeSessions = (sessions || []).filter(s => !s.isUntracked);
  const untrackedSessions = (sessions || []).filter(s => s.isUntracked);

  const activeRows = activeSessions
    .sort((a, b) => b.cost - a.cost)
    .slice(0, 20)
    .map(makeSessionRow).join('');

  const untrackedRows = untrackedSessions
    .sort((a, b) => b.cost - a.cost)
    .slice(0, 10)
    .map(makeSessionRow).join('');

  const untrackedTotal = untrackedSessions.reduce((sum, s) => sum + s.cost, 0);
  const untrackedHidden = Math.max(0, untrackedSessions.length - 10);

  const html = `<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Clawculator Report — ${new Date(analysis.scannedAt).toLocaleString()}</title>
  <style>
    * { box-sizing: border-box; margin: 0; padding: 0; }
    body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0f172a; color: #e2e8f0; min-height: 100vh; }
    .header { background: linear-gradient(135deg, #0f172a 0%, #1e3a5f 100%); padding: 48px 32px; text-align: center; border-bottom: 1px solid #1e40af; }
    .logo { font-size: 42px; font-weight: 900; letter-spacing: -2px; background: linear-gradient(90deg, #38bdf8, #818cf8); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
    .tagline { color: #94a3b8; margin-top: 8px; font-size: 16px; }
    .container { max-width: 1000px; margin: 0 auto; padding: 32px 24px; }
    .cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin-bottom: 32px; }
    .card { background: #1e293b; border-radius: 12px; padding: 20px; border: 1px solid #334155; }
    .card-value { font-size: 32px; font-weight: 800; }
    .card-label { font-size: 13px; color: #94a3b8; margin-top: 4px; }
    .section { background: #1e293b; border-radius: 12px; padding: 24px; margin-bottom: 24px; border: 1px solid #334155; }
    .section-title { font-size: 18px; font-weight: 700; margin-bottom: 16px; color: #f1f5f9; }
    table { width: 100%; border-collapse: collapse; }
    th { background: #0f172a; padding: 10px 12px; text-align: left; font-size: 13px; color: #94a3b8; }
    tr:nth-child(even) { background: #0f172a33; }
    .footer { text-align: center; color: #475569; font-size: 13px; padding: 32px; }
    .bleed { background: linear-gradient(135deg, #7f1d1d, #991b1b); border-radius: 12px; padding: 20px 24px; margin-bottom: 24px; border: 1px solid #ef4444; }
    .bleed-amount { font-size: 36px; font-weight: 900; color: #fca5a5; }
  </style>
</head>
<body>
  <div class="header">
    <div class="logo">CLAWCULATOR</div>
    <div class="tagline">Your friendly penny pincher. · 100% offline · Zero AI · Pure deterministic logic</div>
    <div style="color:#64748b; font-size:13px; margin-top:12px">Report generated: ${new Date(analysis.scannedAt).toLocaleString()}</div>
  </div>

  <div class="container">
    ${bleed > 0 ? `
    <div class="bleed">
      <div style="color:#fca5a5; font-size:14px; font-weight:600; margin-bottom:4px">⚠️ ESTIMATED MONTHLY COST EXPOSURE</div>
      <div class="bleed-amount">$${bleed.toFixed(2)}/month</div>
      <div style="color:#fca5a5; font-size:14px; margin-top:4px">Based on current config — fix the critical issues below to stop the bleed</div>
    </div>` : `
    <div style="background:#14532d; border-radius:12px; padding:20px 24px; margin-bottom:24px; border:1px solid #22c55e">
      <div style="color:#86efac; font-size:18px; font-weight:700">✅ No significant cost bleed detected</div>
    </div>`}

    ${summary.totalRealCost > 0 ? `
    <div class="section" style="border:1px solid #f59e0b;">
      <div class="section-title" style="color:#f59e0b;">💰 Actual API Spend (from transcripts)</div>
      <div style="display:flex; gap:32px; flex-wrap:wrap; align-items:center;">
        ${summary.todayCost > 0 ? `
        <div>
          <div style="font-size:36px; font-weight:900; color:#ef4444;">$${summary.todayCost.toFixed(2)}</div>
          <div style="font-size:13px; color:#94a3b8; margin-top:4px;">Today's spend</div>
        </div>` : ''}
        <div>
          <div style="font-size:36px; font-weight:900; color:#fbbf24;">$${summary.totalRealCost.toFixed(2)}</div>
          <div style="font-size:13px; color:#94a3b8; margin-top:4px;">All-time total (API-reported)</div>
        </div>
        ${summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1 ? `
        <div style="background:#451a03; padding:10px 16px; border-radius:8px; border:1px solid #92400e;">
          <div style="font-size:14px; color:#fbbf24; font-weight:600;">${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x higher than sessions.json estimate</div>
          <div style="font-size:12px; color:#d97706; margin-top:2px;">sessions.json: $${summary.totalEstimatedCost.toFixed(4)} — missing cache token costs</div>
        </div>` : ''}
      </div>
      ${summary.totalCacheRead > 0 || summary.totalCacheWrite > 0 ? `
      <div style="margin-top:16px; display:flex; gap:24px; flex-wrap:wrap;">
        ${summary.totalCacheWrite > 0 ? `<div style="color:#f97316; font-size:14px;">📝 Cache writes: <strong>${summary.totalCacheWrite.toLocaleString()}</strong> tokens</div>` : ''}
        ${summary.totalCacheRead > 0 ? `<div style="color:#22c55e; font-size:14px;">📖 Cache reads: <strong>${summary.totalCacheRead.toLocaleString()}</strong> tokens</div>` : ''}
      </div>` : ''}
    </div>` : ''}

    <div class="cards">
      <div class="card">
        <div class="card-value" style="color:#ef4444">${summary.critical}</div>
        <div class="card-label">🔴 Critical</div>
      </div>
      <div class="card">
        <div class="card-value" style="color:#f97316">${summary.high}</div>
        <div class="card-label">🟠 High</div>
      </div>
      <div class="card">
        <div class="card-value" style="color:#eab308">${summary.medium}</div>
        <div class="card-label">🟡 Medium</div>
      </div>
      <div class="card">
        <div class="card-value" style="color:#06b6d4">${summary.low || 0}</div>
        <div class="card-label">🔵 Low</div>
      </div>
      <div class="card">
        <div class="card-value" style="color:#22c55e">${summary.info}</div>
        <div class="card-label">✅ OK</div>
      </div>
      <div class="card">
        <div class="card-value" style="color:#38bdf8">${summary.sessionsAnalyzed}</div>
        <div class="card-label">Sessions Analyzed</div>
      </div>
      <div class="card">
        <div class="card-value" style="color:#818cf8">${(summary.totalTokensFound || 0).toLocaleString()}</div>
        <div class="card-label">Total Tokens Found</div>
      </div>
    </div>

    <div class="section">
      <div class="section-title">Findings</div>
      <div style="color:#0f172a">
        ${findingCards}
      </div>
    </div>

    ${activeRows ? `
    <div class="section">
      <div class="section-title">Active Sessions</div>
      <div style="overflow-x:auto">
        <table>
          <thead><tr><th>Session</th><th>Model</th><th style="text-align:right">Tokens</th><th style="text-align:right">Total Cost</th><th style="text-align:right">$/day</th><th>Last Active</th></tr></thead>
          <tbody>${activeRows}</tbody>
        </table>
      </div>
      <div style="margin-top:8px; font-size:12px; color:#64748b;">● = cost from transcript · ○ = estimated · ⚠️ = orphaned</div>
    </div>` : ''}

    ${untrackedRows ? `
    <div class="section" style="border:1px solid #475569;">
      <div class="section-title" style="color:#94a3b8;">📦 Historical Sessions — $${untrackedTotal.toFixed(2)} total</div>
      <div style="font-size:13px; color:#64748b; margin-bottom:12px;">Old/deleted sessions still on disk. Top 10 by cost.</div>
      <div style="overflow-x:auto">
        <table>
          <thead><tr><th>Session</th><th>Model</th><th style="text-align:right">Tokens</th><th style="text-align:right">Total Cost</th><th style="text-align:right">$/day</th><th>Last Active</th></tr></thead>
          <tbody>${untrackedRows}</tbody>
        </table>
      </div>
      ${untrackedHidden > 0 ? `<div style="margin-top:8px; font-size:12px; color:#64748b;">+ ${untrackedHidden} more not shown</div>` : ''}
    </div>` : ''}

    ${burnSummary}

    <div class="section">
      <div class="section-title">Summary</div>
      <div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:16px;">
        <div>
          <div style="font-size:13px; color:#94a3b8;">Severity Breakdown</div>
          <div style="margin-top:4px;">🔴 ${summary.critical} critical · 🟠 ${summary.high} high · 🟡 ${summary.medium} medium · 🔵 ${summary.low||0} low · ✅ ${summary.info} ok</div>
        </div>
        <div>
          <div style="font-size:13px; color:#94a3b8;">Sessions Analyzed</div>
          <div style="margin-top:4px; font-size:18px; font-weight:700;">${summary.sessionsAnalyzed}</div>
        </div>
        <div>
          <div style="font-size:13px; color:#94a3b8;">Total Tokens</div>
          <div style="margin-top:4px; font-size:18px; font-weight:700;">${(summary.totalTokensFound||0).toLocaleString()}</div>
        </div>
        ${summary.todayCost > 0 ? `<div>
          <div style="font-size:13px; color:#94a3b8;">Today's Spend</div>
          <div style="margin-top:4px; font-size:18px; font-weight:700; color:#ef4444;">$${summary.todayCost.toFixed(2)}</div>
        </div>` : ''}
        <div>
          <div style="font-size:13px; color:#94a3b8;">All-Time Spend</div>
          <div style="margin-top:4px; font-size:18px; font-weight:700; color:#fbbf24;">$${summary.totalRealCost.toFixed(2)}</div>
        </div>
        ${summary.totalCacheRead > 0 ? `<div>
          <div style="font-size:13px; color:#94a3b8;">Cache Tokens</div>
          <div style="margin-top:4px; font-size:14px;">${(summary.totalCacheRead||0).toLocaleString()} read<br/>${(summary.totalCacheWrite||0).toLocaleString()} write</div>
        </div>` : ''}
        ${summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1 ? `<div>
          <div style="font-size:13px; color:#94a3b8;">sessions.json Gap</div>
          <div style="margin-top:4px; font-size:14px; color:#f59e0b;">Estimate: $${summary.totalEstimatedCost.toFixed(4)}<br/>${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x under-reported</div>
        </div>` : ''}
        ${bleed > 0 ? `<div>
          <div style="font-size:13px; color:#94a3b8;">Monthly Bleed</div>
          <div style="margin-top:4px; font-size:18px; font-weight:700; color:#ef4444;">$${bleed.toFixed(2)}/mo</div>
        </div>` : ''}
      </div>
    </div>

    ${(() => {
      const wins = findings.filter(f => f.fix && f.severity !== 'info');
      if (!wins.length) return '';
      return `
    <div class="section" style="border:1px solid #22c55e;">
      <div class="section-title" style="color:#22c55e;">⚡ Quick Wins</div>
      ${wins.slice(0, 5).map((f, i) => `
        <div style="margin-bottom:16px;">
          <div style="color:#22c55e; font-weight:600; margin-bottom:4px;">${i+1}. ${f.fix}</div>
          ${f.command ? `<div style="background:#0f172a; color:#38bdf8; font-family:monospace; font-size:13px; padding:10px 14px; border-radius:6px; white-space:pre-wrap; border:1px solid #1e293b;"><span style="color:#6b7280; user-select:none;">$ </span>${f.command}</div>` : ''}
        </div>
      `).join('')}
    </div>`;
    })()}

  </div>
  <div class="footer">
    Clawculator · github.com/echoudhry/clawculator · Your friendly penny pincher.
  </div>
</body>
</html>`;

  if (!outPath) {
    outPath = path.join(process.cwd(), `clawculator-report-${Date.now()}.html`);
  }
  fs.writeFileSync(outPath, html, 'utf8');
  return outPath;
}

module.exports = { generateHTMLReport };


path: liveDashboard.js
content: 'use strict';

const fs = require('fs');
const path = require('path');
const os = require('os');
const { resolveModel, MODEL_PRICING } = require('./analyzer');

// ── ANSI codes ───────────────────────────────────────────
const R   = '\x1b[0m';
const B   = '\x1b[1m';
const D   = '\x1b[90m';
const RED = '\x1b[31m';
const GRN = '\x1b[32m';
const YEL = '\x1b[33m';
const CYN = '\x1b[36m';
const WHT = '\x1b[37m';
const BG_DARK = '\x1b[48;5;233m';
const CLEAR = '\x1b[2J\x1b[H';
const HIDE_CURSOR = '\x1b[?25l';
const SHOW_CURSOR = '\x1b[?25h';

// ── Helpers ──────────────────────────────────────────────
function modelLabel(modelStr) {
  const key = resolveModel(modelStr);
  return key ? (MODEL_PRICING[key]?.label || key) : (modelStr || 'unknown');
}

function fmtCost(n) {
  if (n >= 1)    return `$${n.toFixed(2)}`;
  if (n >= 0.01) return `$${n.toFixed(4)}`;
  return `$${n.toFixed(6)}`;
}

function fmtTokens(n) {
  if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
  if (n >= 1_000)     return `${(n / 1_000).toFixed(1)}K`;
  return String(n);
}

function timestamp() {
  return new Date().toLocaleTimeString();
}

function relTime(ms) {
  const s = Math.floor(ms / 1000);
  if (s < 60)  return `${s}s ago`;
  if (s < 3600) return `${Math.floor(s / 60)}m ago`;
  return `${Math.floor(s / 3600)}h ago`;
}

// ── Parse a single .jsonl line for usage ─────────────────
function parseUsageLine(line) {
  try {
    const entry = JSON.parse(line);
    if (entry.type !== 'message') return null;
    const u = entry.usage || entry.message?.usage;
    if (!u) return null;
    const model = entry.model || entry.message?.model;
    const ts = entry.timestamp || entry.message?.timestamp;
    const cost = u.cost
      ? (typeof u.cost === 'object' ? u.cost.total || 0 : u.cost)
      : 0;
    return {
      model,
      input: u.input || 0,
      output: u.output || 0,
      cacheRead: u.cacheRead || 0,
      cacheWrite: u.cacheWrite || 0,
      totalTokens: u.totalTokens || 0,
      cost,
      timestamp: ts ? new Date(ts).getTime() : Date.now(),
    };
  } catch {
    return null;
  }
}

// ── Live Dashboard ───────────────────────────────────────
function startLiveDashboard(opts = {}) {
  const openclawHome = opts.openclawHome || process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
  const refreshMs = opts.refreshMs || 2000;

  // State
  const sessions = new Map(); // sessionId -> { name, model, cost, tokens, messages, lastSeen, cacheRead, cacheWrite }
  const feed = [];           // last N events for the activity feed
  const MAX_FEED = 12;
  let todayCost = 0;
  let todayMessages = 0;
  let todayTokens = 0;
  let todayCacheRead = 0;
  let todayCacheWrite = 0;
  let peakCostPerMsg = 0;
  let startTime = Date.now();
  let lastEventTime = null;

  // File watchers & byte offsets
  const watchers = new Map();  // filePath -> fs.FSWatcher
  const offsets = new Map();   // filePath -> byte offset (for tailing)
  const fileToSession = new Map(); // filePath -> { id, name }

  // ── Discover sessions.json for friendly names ──────────
  function getSessionNames() {
    const names = new Map(); // sessionId -> friendly key name
    const agentsDir = path.join(openclawHome, 'agents');
    try {
      for (const agent of fs.readdirSync(agentsDir)) {
        const sjPath = path.join(agentsDir, agent, 'sessions', 'sessions.json');
        if (!fs.existsSync(sjPath)) continue;
        const sj = JSON.parse(fs.readFileSync(sjPath, 'utf8'));
        for (const [key, val] of Object.entries(sj)) {
          if (val.sessionId) {
            // Use shortest meaningful name
            const short = key
              .replace('agent:main:', '')
              .replace(/:[a-f0-9-]{36}/g, '')
              .replace(/:run$/, '');
            if (!names.has(val.sessionId) || short.length < names.get(val.sessionId).length) {
              names.set(val.sessionId, short);
            }
          }
        }
      }
    } catch { /* ok */ }
    return names;
  }

  // ── Discover & watch .jsonl files ──────────────────────
  function discoverFiles() {
    const sessionNames = getSessionNames();
    const agentsDir = path.join(openclawHome, 'agents');
    try {
      for (const agent of fs.readdirSync(agentsDir)) {
        const sessDir = path.join(agentsDir, agent, 'sessions');
        if (!fs.existsSync(sessDir)) continue;
        for (const file of fs.readdirSync(sessDir)) {
          if (!file.endsWith('.jsonl')) continue;
          const filePath = path.join(sessDir, file);
          if (watchers.has(filePath)) continue; // already watching

          const sessionId = file.replace('.jsonl', '');
          const friendlyName = sessionNames.get(sessionId) || sessionId.slice(0, 8);
          fileToSession.set(filePath, { id: sessionId, name: friendlyName });

          // Initial parse — only count today's messages
          initialParse(filePath, sessionId, friendlyName);

          // Watch for changes
          try {
            const watcher = fs.watch(filePath, () => {
              tailFile(filePath);
            });
            watchers.set(filePath, watcher);
          } catch { /* can't watch */ }
        }
      }
    } catch { /* agents dir missing */ }
  }

  // ── Initial parse: read existing today's data ──────────
  function initialParse(filePath, sessionId, friendlyName) {
    try {
      const content = fs.readFileSync(filePath, 'utf8');
      const stat = fs.statSync(filePath);
      offsets.set(filePath, stat.size); // start tailing from end

      const todayStart = new Date();
      todayStart.setHours(0, 0, 0, 0);
      const todayMs = todayStart.getTime();

      let sessionCost = 0, sessionTokens = 0, sessionMessages = 0;
      let sessionCacheRead = 0, sessionCacheWrite = 0;
      let sessionModel = null, sessionLastSeen = null;

      for (const line of content.split('\n')) {
        if (!line.trim()) continue;
        const usage = parseUsageLine(line);
        if (!usage) continue;

        if (!sessionModel) sessionModel = usage.model;
        sessionLastSeen = usage.timestamp;

        // Only count today for the live totals
        if (usage.timestamp >= todayMs) {
          sessionCost += usage.cost;
          sessionTokens += usage.totalTokens;
          sessionMessages++;
          sessionCacheRead += usage.cacheRead;
          sessionCacheWrite += usage.cacheWrite;
          todayCost += usage.cost;
          todayMessages++;
          todayTokens += usage.totalTokens;
          todayCacheRead += usage.cacheRead;
          todayCacheWrite += usage.cacheWrite;
          if (usage.cost > peakCostPerMsg) peakCostPerMsg = usage.cost;
        }
      }

      if (sessionMessages > 0 || sessionModel) {
        sessions.set(sessionId, {
          name: friendlyName,
          model: sessionModel,
          cost: sessionCost,
          tokens: sessionTokens,
          messages: sessionMessages,
          lastSeen: sessionLastSeen,
          cacheRead: sessionCacheRead,
          cacheWrite: sessionCacheWrite,
        });
      }
    } catch { /* can't read */ }
  }

  // ── Tail new data from file ────────────────────────────
  function tailFile(filePath) {
    try {
      const stat = fs.statSync(filePath);
      const prevOffset = offsets.get(filePath) || 0;
      if (stat.size <= prevOffset) return;

      const fd = fs.openSync(filePath, 'r');
      const buf = Buffer.alloc(stat.size - prevOffset);
      fs.readSync(fd, buf, 0, buf.length, prevOffset);
      fs.closeSync(fd);
      offsets.set(filePath, stat.size);

      const newContent = buf.toString('utf8');
      const session = fileToSession.get(filePath);
      if (!session) return;

      for (const line of newContent.split('\n')) {
        if (!line.trim()) continue;
        const usage = parseUsageLine(line);
        if (!usage) continue;

        // Update session totals
        const existing = sessions.get(session.id) || {
          name: session.name, model: null, cost: 0, tokens: 0,
          messages: 0, lastSeen: null, cacheRead: 0, cacheWrite: 0,
        };
        existing.model = usage.model || existing.model;
        existing.cost += usage.cost;
        existing.tokens += usage.totalTokens;
        existing.messages++;
        existing.lastSeen = usage.timestamp;
        existing.cacheRead += usage.cacheRead;
        existing.cacheWrite += usage.cacheWrite;
        sessions.set(session.id, existing);

        // Update today totals
        todayCost += usage.cost;
        todayMessages++;
        todayTokens += usage.totalTokens;
        todayCacheRead += usage.cacheRead;
        todayCacheWrite += usage.cacheWrite;
        if (usage.cost > peakCostPerMsg) peakCostPerMsg = usage.cost;
        lastEventTime = Date.now();

        // Add to activity feed
        feed.unshift({
          time: new Date(usage.timestamp).toLocaleTimeString(),
          session: session.name,
          model: modelLabel(usage.model),
          cost: usage.cost,
          tokens: usage.totalTokens,
          cacheWrite: usage.cacheWrite,
        });
        if (feed.length > MAX_FEED) feed.length = MAX_FEED;
      }

      render();
    } catch { /* file read error */ }
  }

  // ── Render the dashboard ───────────────────────────────
  function render() {
    const cols = process.stdout.columns || 80;
    const rows = process.stdout.rows || 24;
    const line = D + '─'.repeat(Math.min(cols - 2, 80)) + R;
    const uptime = Math.floor((Date.now() - startTime) / 1000);
    const uptimeStr = uptime < 60 ? `${uptime}s` : `${Math.floor(uptime / 60)}m ${uptime % 60}s`;

    let out = CLEAR;

    // Header
    out += `${CYN}${B}  CLAWCULATOR LIVE${R}  ${D}·${R} ${D}watching ${watchers.size} transcript${watchers.size !== 1 ? 's' : ''}${R}  ${D}·${R} ${D}uptime ${uptimeStr}${R}  ${D}·${R} ${D}${timestamp()}${R}\n`;
    out += `${line}\n`;

    // Big numbers row
    const costColor = todayCost > 10 ? RED : todayCost > 1 ? YEL : GRN;
    out += `\n`;
    out += `  ${D}TODAY'S SPEND${R}      ${D}MESSAGES${R}        ${D}AVG $/MSG${R}       ${D}PEAK $/MSG${R}\n`;
    out += `  ${B}${costColor}${fmtCost(todayCost)}${R}`;
    out += `${' '.repeat(Math.max(1, 18 - fmtCost(todayCost).length))}`;
    out += `${B}${WHT}${todayMessages}${R}`;
    out += `${' '.repeat(Math.max(1, 16 - String(todayMessages).length))}`;
    const avgCost = todayMessages > 0 ? todayCost / todayMessages : 0;
    out += `${B}${WHT}${fmtCost(avgCost)}${R}`;
    out += `${' '.repeat(Math.max(1, 16 - fmtCost(avgCost).length))}`;
    out += `${B}${RED}${fmtCost(peakCostPerMsg)}${R}\n`;
    out += `\n`;

    // Token breakdown bar
    const totalTok = todayTokens + todayCacheRead + todayCacheWrite;
    if (totalTok > 0) {
      out += `  ${D}TOKENS${R}  ${WHT}${fmtTokens(todayTokens)} i/o${R}  ${D}·${R}  ${GRN}${fmtTokens(todayCacheRead)} cache read${R}  ${D}·${R}  ${YEL}${fmtTokens(todayCacheWrite)} cache write${R}\n`;
      out += `\n`;
    }

    out += `${line}\n`;

    // Active sessions
    out += `  ${CYN}${B}ACTIVE SESSIONS${R}\n`;
    const sortedSessions = [...sessions.entries()]
      .filter(([, s]) => s.messages > 0)
      .sort((a, b) => b[1].cost - a[1].cost);

    if (sortedSessions.length === 0) {
      out += `  ${D}No API calls yet today. Waiting...${R}\n`;
    } else {
      out += `  ${D}${'Name'.padEnd(20)} ${'Model'.padEnd(22)} ${'Msgs'.padEnd(6)} ${'Cost'.padEnd(12)} Last Active${R}\n`;
      for (const [, s] of sortedSessions.slice(0, 8)) {
        const name = s.name.length > 18 ? s.name.slice(0, 16) + '…' : s.name;
        const model = modelLabel(s.model);
        const modelDisp = model.length > 20 ? model.slice(0, 18) + '…' : model;
        const age = s.lastSeen ? relTime(Date.now() - s.lastSeen) : '—';
        const costStr = fmtCost(s.cost);
        const costClr = s.cost > 5 ? RED : s.cost > 0.5 ? YEL : GRN;
        out += `  ${WHT}${name.padEnd(20)}${R} ${D}${modelDisp.padEnd(22)}${R} ${WHT}${String(s.messages).padEnd(6)}${R} ${costClr}${costStr.padEnd(12)}${R} ${D}${age}${R}\n`;
      }
    }
    out += `\n${line}\n`;

    // Live activity feed
    out += `  ${CYN}${B}LIVE FEED${R}${lastEventTime ? `  ${D}last event: ${relTime(Date.now() - lastEventTime)}${R}` : ''}\n`;
    if (feed.length === 0) {
      out += `  ${D}Waiting for API calls...${R}\n`;
    } else {
      for (const ev of feed.slice(0, 8)) {
        const costClr = ev.cost > 0.5 ? RED : ev.cost > 0.05 ? YEL : GRN;
        const cacheTag = ev.cacheWrite > 10000 ? ` ${D}(${fmtTokens(ev.cacheWrite)} cache write)${R}` : '';
        out += `  ${D}${ev.time}${R} ${WHT}${ev.session.padEnd(14)}${R} ${D}${ev.model.slice(0, 18).padEnd(18)}${R} ${costClr}${fmtCost(ev.cost).padEnd(10)}${R} ${D}${fmtTokens(ev.tokens)} tok${R}${cacheTag}\n`;
      }
    }
    out += `\n${line}\n`;

    // Footer
    out += `  ${D}Press ${WHT}q${D} to quit · ${WHT}r${D} to refresh · Ctrl+C to exit${R}\n`;

    process.stdout.write(out);
  }

  // ── Keyboard input ─────────────────────────────────────
  if (process.stdin.isTTY) {
    process.stdin.setRawMode(true);
    process.stdin.resume();
    process.stdin.setEncoding('utf8');
    process.stdin.on('data', (key) => {
      if (key === 'q' || key === '\u0003') { // q or Ctrl+C
        cleanup();
        process.exit(0);
      }
      if (key === 'r') {
        // Force re-discover and re-render
        discoverFiles();
        render();
      }
    });
  }

  // ── Cleanup ────────────────────────────────────────────
  function cleanup() {
    process.stdout.write(SHOW_CURSOR);
    for (const [, watcher] of watchers) {
      try { watcher.close(); } catch {}
    }
    watchers.clear();
    console.log(`\n${CYN}Clawculator Live${R} stopped. Today's total: ${B}${fmtCost(todayCost)}${R} across ${todayMessages} messages.\n`);
  }

  process.on('SIGINT', () => { cleanup(); process.exit(0); });
  process.on('SIGTERM', () => { cleanup(); process.exit(0); });

  // ── Start ──────────────────────────────────────────────
  process.stdout.write(HIDE_CURSOR);
  discoverFiles();
  render();

  // Periodic refresh + file discovery (catch new sessions)
  const refreshInterval = setInterval(() => {
    render();
  }, refreshMs);

  // Re-discover new files every 30s
  const discoverInterval = setInterval(() => {
    discoverFiles();
  }, 30000);

  return { cleanup, render };
}

module.exports = { startLiveDashboard };


path: mdReport.js
content: 'use strict';

const SEVERITY_ICON = { critical: '🔴', high: '🟠', medium: '🟡', low: '🔵', info: '✅' };
const SOURCE_LABELS = {
  heartbeat: '💓 Heartbeat', hooks: '🪝 Hooks', whatsapp: '📱 WhatsApp',
  subagents: '🤖 Subagents', skills: '🔧 Skills', memory: '🧠 Memory',
  primary_model: '⚙️ Primary Model', sessions: '💬 Sessions',
  workspace: '📁 Workspace', config: '📄 Config',
};

function relativeAge(ageMs) {
  if (!ageMs) return 'unknown';
  const s = Math.floor(ageMs / 1000);
  if (s < 60)          return `${s}s ago`;
  const m = Math.floor(s / 60);
  if (m < 60)          return `${m}m ago`;
  const h = Math.floor(m / 60);
  if (h < 24)          return `${h}h ago`;
  const d = Math.floor(h / 24);
  if (d < 30)          return `${d}d ago`;
  const mo = Math.floor(d / 30);
  return `${mo}mo ago`;
}

function absoluteDate(updatedAt) {
  if (!updatedAt) return '';
  return new Date(updatedAt).toLocaleString();
}

function generateMarkdownReport(analysis) {
  const { summary, findings, sessions, primaryModel, scannedAt } = analysis;
  const bleed = summary.estimatedMonthlyBleed;
  const lines = [];

  lines.push('# Clawculator Report');
  lines.push('> Your friendly penny pincher. · 100% offline · Zero AI · Pure deterministic logic');
  lines.push('');
  lines.push(`**Scanned:** ${new Date(scannedAt).toLocaleString()}`);
  if (primaryModel) lines.push(`**Primary model:** ${primaryModel}`);
  lines.push('');

  // Cost alert
  if (bleed > 0) {
    lines.push(`## ⚠️ Estimated Monthly Cost Exposure: $${bleed.toFixed(2)}/month`);
    lines.push('');
  } else {
    lines.push('## ✅ No significant cost bleed detected');
    lines.push('');
  }

  // Summary table
  lines.push('## Summary');
  lines.push('');
  lines.push('| Metric | Value |');
  lines.push('|--------|-------|');
  lines.push(`| 🔴 Critical | ${summary.critical} |`);
  lines.push(`| 🟠 High | ${summary.high} |`);
  lines.push(`| 🟡 Medium | ${summary.medium} |`);
  lines.push(`| 🔵 Low | ${summary.low || 0} |`);
  lines.push(`| ✅ OK | ${summary.info} |`);
  lines.push(`| Sessions analyzed | ${summary.sessionsAnalyzed} |`);
  lines.push(`| Total tokens found | ${(summary.totalTokensFound || 0).toLocaleString()} |`);

  // Session cost summary
  if (sessions?.length > 0) {
    const totalCost = sessions.reduce((sum, s) => sum + s.cost, 0);
    const dailyRates = sessions.filter(s => s.dailyCost).map(s => s.dailyCost);
    const totalDailyRate = dailyRates.reduce((sum, r) => sum + r, 0);
    if (totalCost > 0)      lines.push(`| Total session cost (lifetime) | $${totalCost.toFixed(4)} |`);
    if (totalDailyRate > 0) lines.push(`| Avg daily burn rate | $${totalDailyRate.toFixed(4)}/day (~$${(totalDailyRate * 30).toFixed(2)}/month) |`);
  }
  if (summary.totalRealCost > 0) {
    lines.push(`| 💰 **Actual API spend** | **$${summary.totalRealCost.toFixed(4)}** |`);
    if (summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1) {
      lines.push(`| sessions.json estimate | $${summary.totalEstimatedCost.toFixed(4)} (${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x gap) |`);
    }
  }
  if (summary.totalCacheWrite > 0) lines.push(`| Cache writes | ${summary.totalCacheWrite.toLocaleString()} tokens |`);
  if (summary.totalCacheRead > 0)  lines.push(`| Cache reads | ${summary.totalCacheRead.toLocaleString()} tokens |`);
  lines.push('');

  // Findings by severity
  lines.push('## Findings');
  lines.push('');

  for (const severity of ['critical', 'high', 'medium', 'low', 'info']) {
    const group = findings.filter(f => f.severity === severity);
    if (!group.length) continue;

    lines.push(`### ${SEVERITY_ICON[severity]} ${severity.toUpperCase()} (${group.length})`);
    lines.push('');

    for (const f of group) {
      lines.push(`#### ${SOURCE_LABELS[f.source] || f.source}`);
      lines.push('');
      lines.push(`**${f.message}**`);
      lines.push('');
      if (f.detail)          lines.push(`${f.detail}`);
      if (f.monthlyCost > 0) lines.push(`**Monthly cost:** $${f.monthlyCost.toFixed(2)}/month`);
      if (f.fix) {
        lines.push('');
        lines.push(`**Fix:** ${f.fix}`);
      }
      if (f.command) {
        lines.push('');
        lines.push('```bash');
        lines.push(f.command);
        lines.push('```');
      }
      lines.push('');
    }
  }

  // Session breakdown
  if (sessions?.length > 0) {
    lines.push('## Session Breakdown');
    lines.push('');
    lines.push('| Session | Model | Tokens | Total Cost | $/day | Last Active |');
    lines.push('|---------|-------|--------|-----------|-------|-------------|');

    [...sessions]
      .sort((a, b) => (b.inputTokens + b.outputTokens) - (a.inputTokens + a.outputTokens))
      .slice(0, 20)
      .forEach(s => {
        const tok        = (s.inputTokens + s.outputTokens).toLocaleString();
        const flag       = s.isOrphaned ? ' ⚠️' : '';
        const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '…' : s.key;
        const age        = s.ageMs ? `${relativeAge(s.ageMs)} (${absoluteDate(s.updatedAt)})` : 'unknown';
        const daily      = s.dailyCost ? `$${s.dailyCost.toFixed(4)}` : '—';
        lines.push(`| \`${keyDisplay}${flag}\` | ${s.modelLabel || s.model} | ${tok} | $${s.cost.toFixed(6)} | ${daily} | ${age} |`);
      });

    lines.push('');
    lines.push('> ⚠️ = orphaned session · $/day = total cost ÷ session age');
    lines.push('');
  }

  // Quick wins
  const wins = findings.filter(f => f.fix && f.severity !== 'info');
  if (wins.length > 0) {
    lines.push('## Quick Wins');
    lines.push('');
    wins.slice(0, 5).forEach((f, i) => {
      lines.push(`${i + 1}. **${f.fix}**`);
      if (f.command) lines.push(`   \`${f.command}\``);
    });
    lines.push('');
  }

  lines.push('---');
  lines.push('*Generated by [Clawculator](https://github.com/echoudhry/clawculator) · Your friendly penny pincher.*');

  return lines.join('\n');
}

module.exports = { generateMarkdownReport };


path: reporter.js
content: 'use strict';

const SEVERITY_CONFIG = {
  critical: { color: '\x1b[31m', icon: '🔴', label: 'CRITICAL' },
  high:     { color: '\x1b[33m', icon: '🟠', label: 'HIGH' },
  medium:   { color: '\x1b[33m', icon: '🟡', label: 'MEDIUM' },
  low:      { color: '\x1b[36m', icon: '🔵', label: 'LOW' },
  info:     { color: '\x1b[32m', icon: '✅', label: 'OK' },
};

const SOURCE_LABELS = {
  heartbeat:     '💓 Heartbeat',
  hooks:         '🪝 Hooks',
  whatsapp:      '📱 WhatsApp',
  subagents:     '🤖 Subagents',
  skills:        '🔧 Skills',
  memory:        '🧠 Memory',
  primary_model: '⚙️  Primary Model',
  sessions:      '💬 Sessions',
  workspace:     '📁 Workspace',
  context:       '📏 Context Pruning',
  vision:        '🖼️  Vision Tokens',
  fallbacks:     '🔀 Model Fallbacks',
  multi_agent:   '👥 Multi-Agent',
  cron:          '⏰ Cron Jobs',
  telegram:      '✈️  Telegram',
  discord:       '💬 Discord',
  signal:        '📡 Signal',
  config:        '📄 Config',
};

function formatCost(cost) {
  if (!cost) return '';
  if (cost === 0) return '\x1b[32m$0.00/mo\x1b[0m';
  if (cost < 1)  return `\x1b[33m$${cost.toFixed(4)}/mo\x1b[0m`;
  return `\x1b[31m$${cost.toFixed(2)}/mo\x1b[0m`;
}

function relativeAge(ageMs) {
  if (!ageMs) return 'unknown';
  const s = Math.floor(ageMs / 1000);
  if (s < 60)        return `${s}s ago`;
  const m = Math.floor(s / 60);
  if (m < 60)        return `${m}m ago`;
  const h = Math.floor(m / 60);
  if (h < 24)        return `${h}h ago`;
  const d = Math.floor(h / 24);
  if (d < 30)        return `${d}d ago`;
  return `${Math.floor(d / 30)}mo ago`;
}

function generateTerminalReport(analysis) {
  const { summary, findings, sessions } = analysis;
  const R = '\x1b[0m';
  const B = '\x1b[1m';
  const D = '\x1b[90m';
  const C = '\x1b[36m';
  const RED = '\x1b[31m';
  const GRN = '\x1b[32m';

  console.log(`${C}━━━ Scan Complete ━━━${R}`);
  console.log(`${D}${new Date(analysis.scannedAt).toLocaleString()}${R}`);
  if (analysis.primaryModel) console.log(`${D}Primary model: ${analysis.primaryModel}${R}`);
  console.log();

  const bleed = summary.estimatedMonthlyBleed;
  if (bleed > 0) {
    console.log(`${B}${RED}⚠️  Estimated monthly cost exposure: $${bleed.toFixed(2)}/month${R}\n`);
  }

  for (const severity of ['critical', 'high', 'medium', 'low', 'info']) {
    const group = findings.filter(f => f.severity === severity);
    if (!group.length) continue;

    const cfg = SEVERITY_CONFIG[severity];
    console.log(`${cfg.color}${B}${cfg.icon} ${cfg.label} (${group.length})${R}`);
    console.log(`${C}${'─'.repeat(60)}${R}`);

    for (const f of group) {
      console.log(`  ${B}${SOURCE_LABELS[f.source] || f.source}${R}`);
      console.log(`  ${f.message}`);
      if (f.detail)          console.log(`  ${D}${f.detail}${R}`);
      if (f.monthlyCost > 0) console.log(`  ${D}Cost: ${R}${formatCost(f.monthlyCost)}`);
      if (f.fix)             console.log(`  ${GRN}→ ${f.fix}${R}`);
      if (f.command)         console.log(`  ${D}  ${f.command}${R}`);
      console.log();
    }
  }

  // Session breakdown — split active from historical
  const activeSess = (sessions || []).filter(s => !s.isUntracked);
  const untrackedSess = (sessions || []).filter(s => s.isUntracked);

  if (activeSess.length > 0) {
    console.log(`${C}━━━ Active Sessions ━━━${R}\n`);
    const sorted = [...activeSess].sort((a, b) => b.cost - a.cost).slice(0, 8);
    console.log(`  ${D}${'Session'.padEnd(16)} ${'Model'.padEnd(20)} ${'Tokens'.padEnd(10)} ${'Total Cost'.padEnd(12)} ${'$/day'.padEnd(10)} Last Active${R}`);
    console.log(`  ${D}${'\u2500'.repeat(95)}${R}`);
    for (const s of sorted) {
      const tok        = (s.inputTokens + s.outputTokens).toLocaleString();
      const flag       = s.isOrphaned ? ' \u26a0\ufe0f' : '';
      const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '\u2026' : s.key;
      const age        = s.ageMs ? `${relativeAge(s.ageMs)} (${new Date(s.updatedAt).toLocaleDateString()})` : 'unknown';
      const daily      = s.dailyCost ? `$${s.dailyCost.toFixed(4)}` : '\u2014';
      console.log(`  ${(keyDisplay + flag).padEnd(16)} ${(s.modelLabel || s.model || 'unknown').slice(0, 20).padEnd(20)} ${tok.padEnd(10)} $${s.cost.toFixed(4).padEnd(11)} ${daily.padEnd(10)} ${D}${age}${R}`);
    }

    // Daily burn rate — tracked sessions only
    const totalDailyRate = activeSess.filter(s => s.dailyCost).reduce((sum, s) => sum + s.dailyCost, 0);
    if (totalDailyRate > 0) {
      console.log();
      console.log(`  ${D}Combined burn rate: ${R}${RED}$${totalDailyRate.toFixed(4)}/day${R}${D} \u00b7 ~$${(totalDailyRate * 30).toFixed(2)}/month${R}`);
    }
    console.log();
  }

  if (untrackedSess.length > 0) {
    const untrackedTotal = untrackedSess.reduce((sum, s) => sum + s.cost, 0);
    console.log(`${C}━━━ Historical Sessions (${untrackedSess.length} untracked) \u2014 $${untrackedTotal.toFixed(2)} total ━━━${R}\n`);
    const sorted = [...untrackedSess].sort((a, b) => b.cost - a.cost).slice(0, 5);
    console.log(`  ${D}${'Session'.padEnd(16)} ${'Model'.padEnd(20)} ${'Tokens'.padEnd(10)} ${'Total Cost'.padEnd(12)} Last Active${R}`);
    console.log(`  ${D}${'\u2500'.repeat(80)}${R}`);
    for (const s of sorted) {
      const tok        = (s.inputTokens + s.outputTokens).toLocaleString();
      const keyDisplay = s.key.length > 12 ? s.key.slice(0, 8) + '\u2026' : s.key;
      const age        = s.ageMs ? `${relativeAge(s.ageMs)} (${new Date(s.updatedAt).toLocaleDateString()})` : 'unknown';
      console.log(`  ${keyDisplay.padEnd(16)} ${(s.modelLabel || s.model || 'unknown').slice(0, 20).padEnd(20)} ${tok.padEnd(10)} $${s.cost.toFixed(4).padEnd(11)} ${D}${age}${R}`);
    }
    const hidden = Math.max(0, untrackedSess.length - 5);
    if (hidden > 0) console.log(`  ${D}+ ${hidden} more not shown${R}`);
    console.log();
  }


  // Summary
  console.log(`${C}━━━ Summary ━━━${R}`);
  console.log(`  🔴 ${RED}${summary.critical}${R} critical  🟠 ${summary.high} high  🟡 ${summary.medium} medium  🔵 ${summary.low||0} low  ✅ ${summary.info} ok`);
  console.log(`  Sessions analyzed: ${summary.sessionsAnalyzed} · Tokens found: ${(summary.totalTokensFound||0).toLocaleString()}`);
  if (summary.totalCacheRead > 0 || summary.totalCacheWrite > 0) {
    console.log(`  ${D}Cache tokens: ${(summary.totalCacheRead||0).toLocaleString()} read · ${(summary.totalCacheWrite||0).toLocaleString()} write${R}`);
  }
  if (summary.totalRealCost > 0) {
    if (summary.todayCost > 0) {
      console.log(`  ${B}Today's spend: ${RED}$${summary.todayCost.toFixed(2)}${R}${B} · All-time: ${RED}$${summary.totalRealCost.toFixed(2)}${R}${B} (from .jsonl transcripts)${R}`);
    } else {
      console.log(`  ${B}Actual API spend: ${RED}$${summary.totalRealCost.toFixed(2)}${R}${B} (from .jsonl transcripts)${R}`);
    }
    if (summary.totalEstimatedCost > 0 && summary.totalRealCost > summary.totalEstimatedCost * 1.1) {
      console.log(`  ${D}sessions.json estimate: $${summary.totalEstimatedCost.toFixed(4)} — ${(summary.totalRealCost / summary.totalEstimatedCost).toFixed(1)}x gap (cache tokens)${R}`);
    }
  }
  if (bleed > 0) {
    console.log(`  ${RED}${B}Estimated monthly bleed: $${bleed.toFixed(2)}/month${R}`);
  } else {
    console.log(`  ${GRN}No significant cost bleed detected ✓${R}`);
  }
  console.log();

  // Quick wins
  const wins = findings.filter(f => f.fix && f.severity !== 'info');
  if (wins.length > 0) {
    console.log(`${C}━━━ Quick Wins ━━━${R}`);
    wins.slice(0, 5).forEach((f, i) => {
      console.log(`  ${i + 1}. ${GRN}${f.fix}${R}`);
      if (f.command) console.log(`     ${D}${f.command}${R}`);
    });
    console.log();
  }
}

module.exports = { generateTerminalReport };

// Source label additions for new sources


path: run.js
content: #!/usr/bin/env node
'use strict';

const { runAnalysis } = require('./analyzer');
const { generateTerminalReport } = require('./reporter');
const { generateMarkdownReport } = require('./mdReport');
const path = require('path');
const os = require('os');
const fs = require('fs');

const args = process.argv.slice(2);
const flags = {
  json:     args.includes('--json'),
  md:       args.includes('--md'),
  snapshot: args.includes('--snapshot'),
  help:     args.includes('--help') || args.includes('-h'),
  config:   args.find(a => a.startsWith('--config='))?.split('=')[1],
  out:      args.find(a => a.startsWith('--out='))?.split('=')[1],
};

const BANNER = `
\x1b[36m
   ██████╗██╗      █████╗ ██╗    ██╗ ██████╗ █████╗ ██╗      ██████╗
  ██╔════╝██║     ██╔══██╗██║    ██║██╔════╝██╔══██╗██║     ██╔════╝
  ██║     ██║     ███████║██║ █╗ ██║██║     ███████║██║     ██║     
  ██║     ██║     ██╔══██║██║███╗██║██║     ██╔══██║██║     ██║     
  ╚██████╗███████╗██║  ██║╚███╔███╔╝╚██████╗██║  ██║███████╗╚██████╗
   ╚═════╝╚══════╝╚═╝  ╚═╝ ╚══╝╚══╝  ╚═════╝╚═╝  ╚═╝╚══════╝ ╚═════╝
\x1b[0m
  \x1b[33mYour friendly penny pincher.\x1b[0m
  \x1b[90m100% offline · Zero AI · Pure deterministic logic · Your data never leaves your machine\x1b[0m
`;

const HELP = `
Usage: node run.js [options]

Options:
  (no flags)        Full terminal analysis
  --md              Save markdown report to ./clawculator-report.md
  --json            Output raw JSON to stdout
  --out=PATH        Custom output path for --md
  --config=PATH     Path to openclaw.json (auto-detected by default)
  --help, -h        Show this help

Files read (read-only):
  ~/.openclaw/openclaw.json
  ~/.openclaw/agents/main/sessions/sessions.json
  ~/clawd/ (file count only, no contents)
  /tmp/openclaw (if present)

Files written (only when --md is used):
  ./clawculator-report.md (or --out path)

No network requests are made. No data leaves your machine.
Session keys are truncated in all output to protect sensitive identifiers.
`;

async function main() {
  if (flags.help) {
    console.log(BANNER);
    console.log(HELP);
    process.exit(0);
  }

  console.log(BANNER);
  console.log('\x1b[90mScanning your setup...\x1b[0m\n');

  const configPath   = flags.config || path.join(os.homedir(), '.openclaw', 'openclaw.json');
  const sessionsPath = path.join(os.homedir(), '.openclaw', 'agents', 'main', 'sessions', 'sessions.json');
  const logsDir      = '/tmp/openclaw';

  let analysis;
  try {
    analysis = await runAnalysis({ configPath, sessionsPath, logsDir });
  } catch (err) {
    console.error('\x1b[31mError:\x1b[0m', err.message);
    process.exit(1);
  }

  if (flags.json) {
    console.log(JSON.stringify(analysis, null, 2));
    process.exit(0);
  }

  if (flags.snapshot) {
    const { generateSnapshotCard } = require('./snapshotCard');
    const outDir = flags.out || process.cwd();
    generateSnapshotCard(analysis, outDir);
    process.exit(0);
  }

  if (flags.md) {
    const outPath = flags.out || path.join(process.cwd(), 'clawculator-report.md');
    fs.writeFileSync(outPath, generateMarkdownReport(analysis), 'utf8');
    console.log(`\x1b[32m✓ Markdown report saved:\x1b[0m ${outPath}`);
    generateTerminalReport(analysis);
    process.exit(0);
  }

  generateTerminalReport(analysis);
  console.log('\x1b[90m────────────────────────────────────────────────────\x1b[0m');
  console.log('\x1b[36mClawculator\x1b[0m · github.com/echoudhry/clawculator · Your friendly penny pincher.');
  console.log('\x1b[90mTip: --md saves a report your AI agent can read directly\x1b[0m');
  console.log('\x1b[90m────────────────────────────────────────────────────\x1b[0m\n');
}

main().catch(err => {
  console.error('\x1b[31mFatal:\x1b[0m', err.message);
  process.exit(1);
});


path: snapshotCard.js
content: 'use strict';

const fs = require('fs');
const path = require('path');

/**
 * Generate a shareable cost snapshot card.
 * Square format — works on Twitter, Instagram, TikTok, Discord.
 * Shows grade, cost range, setup complexity — no exact dollars or session names.
 */
function generateSnapshotCard(analysis, outputDir) {
  const s = analysis.summary;

  // ── Compute grade ──────────────────────────────────────
  const criticals = s.critical || 0;
  const highs = s.high || 0;
  const mediums = s.medium || 0;
  const totalFindings = criticals + highs + mediums;

  // ── Filter findings by type ────────────────────────────
  // For the grade, only count CONFIG findings (things a new user would inherit)
  // Exclude: historical session costs, untracked sessions, cache totals, cost gaps
  // These are noise for "what does it cost to run this setup?"
  const configFindings = (analysis.findings || []).filter(f => {
    const t = (f.title || '').toLowerCase();
    const src = (f.source || '').toLowerCase();
    // Skip historical/session findings
    if (t.includes('orphaned session')) return false;
    if (t.includes('untracked session')) return false;
    if (t.includes('prompt caching added')) return false;
    if (t.includes('real cost') && t.includes('higher than')) return false;
    if (t.includes('>50k tokens')) return false;
    if (t.includes('pricing table')) return false;
    // Keep config findings (heartbeat, whatsapp, hooks, crons, models, vision, memory)
    return true;
  });

  const cfgCriticals = configFindings.filter(f => f.severity === 'critical').length;
  const cfgHighs = configFindings.filter(f => f.severity === 'high').length;
  const cfgMediums = configFindings.filter(f => f.severity === 'medium').length;

  // ── Cost range (bucketed, not exact) ───────────────────
  const todayCost = s.todayCost || 0;
  let costRange, costEmoji;
  if (todayCost === 0)       { costRange = 'Free tier';     costEmoji = '🆓'; }
  else if (todayCost < 0.50) { costRange = 'Under $0.50/day'; costEmoji = '💲'; }
  else if (todayCost < 1)    { costRange = 'Under $1/day';  costEmoji = '💲'; }
  else if (todayCost < 5)    { costRange = '$1–5/day';      costEmoji = '💵'; }
  else if (todayCost < 10)   { costRange = '$5–10/day';     costEmoji = '💰'; }
  else if (todayCost < 25)   { costRange = '$10–25/day';    costEmoji = '💰'; }
  else if (todayCost < 50)   { costRange = '$25–50/day';    costEmoji = '🔥'; }
  else                       { costRange = '$50+/day';      costEmoji = '🚨'; }

  // ── Compute grade ──────────────────────────────────────
  // Grade = "how well is this SETUP configured for cost efficiency?"
  // Only counts config findings, not historical session data
  let grade, gradeColor, gradeGlow, gradeEmoji;

  let score = (cfgCriticals * 5) + (cfgHighs * 2) + (cfgMediums * 0.5);

  // Cost bonus: low daily spend is the whole point
  if (todayCost < 1)       score -= 4;
  else if (todayCost < 5)  score -= 3;
  else if (todayCost < 10) score -= 1;
  else if (todayCost > 50) score += 3;

  // No criticals bonus
  if (cfgCriticals === 0) score -= 2;

  if (score < 0) score = 0;

  if (score === 0) {
    grade = 'A+'; gradeColor = '#22c55e'; gradeGlow = 'rgba(34,197,94,0.3)'; gradeEmoji = '🏆';
  } else if (score <= 2) {
    grade = 'A'; gradeColor = '#22c55e'; gradeGlow = 'rgba(34,197,94,0.2)'; gradeEmoji = '✅';
  } else if (score <= 5) {
    grade = 'B+'; gradeColor = '#84cc16'; gradeGlow = 'rgba(132,204,22,0.2)'; gradeEmoji = '👍';
  } else if (score <= 8) {
    grade = 'B'; gradeColor = '#f59e0b'; gradeGlow = 'rgba(245,158,11,0.2)'; gradeEmoji = '👍';
  } else if (score <= 12) {
    grade = 'C'; gradeColor = '#f97316'; gradeGlow = 'rgba(249,115,22,0.2)'; gradeEmoji = '⚠️';
  } else {
    grade = 'D'; gradeColor = '#ef4444'; gradeGlow = 'rgba(239,68,68,0.2)'; gradeEmoji = '🔥';
  }

  // ── Setup complexity from config ───────────────────────
  const config = analysis.config || {};

  const channels = [];
  if (config.channels?.whatsapp) channels.push('WhatsApp');
  if (config.channels?.telegram) channels.push('Telegram');
  if (config.channels?.discord)  channels.push('Discord');
  if (config.channels?.signal)   channels.push('Signal');
  if (config.channels?.slack)    channels.push('Slack');
  if (config.webchat || config.channels?.webchat) channels.push('Webchat');

  const skillCount = config.skills ? Object.keys(config.skills).length : 0;
  const cronCount = config.cron ? Object.keys(config.cron).length : 0;
  const hookCount = config.hooks ? Object.keys(config.hooks).length : 0;
  const agentCount = config.agents?.list ? Object.keys(config.agents.list).length : 1;
  const sessionCount = s.sessionsAnalyzed || 0;

  const modelSet = new Set();
  for (const sess of (analysis.sessions || [])) {
    if (sess.model || sess.modelLabel) modelSet.add(sess.modelLabel || sess.model);
  }
  const modelCount = modelSet.size || 1;

  const totalTokens = s.totalTokensFound || 1;
  const totalWithCache = totalTokens + (s.totalCacheRead || 0) + (s.totalCacheWrite || 0);
  const cacheEfficiency = Math.min(99, Math.round((s.totalCacheRead || 0) / totalWithCache * 100));

  // ── Build stat pills ──────────────────────────────────
  const pills = [];
  if (channels.length > 0)  pills.push({ icon: '📱', label: `${channels.length} channel${channels.length>1?'s':''}` });
  if (skillCount > 0)       pills.push({ icon: '🔧', label: `${skillCount} skill${skillCount>1?'s':''}` });
  if (cronCount > 0)        pills.push({ icon: '⏰', label: `${cronCount} cron${cronCount>1?'s':''}` });
  if (hookCount > 0)        pills.push({ icon: '🪝', label: `${hookCount} hook${hookCount>1?'s':''}` });
  if (agentCount > 1)       pills.push({ icon: '🤖', label: `${agentCount} agents` });
  if (sessionCount > 0)     pills.push({ icon: '💬', label: `${sessionCount} sessions` });
  pills.push({ icon: '🧠', label: `${modelCount} model${modelCount>1?'s':''}` });
  if (cacheEfficiency > 0)  pills.push({ icon: '⚡', label: `${cacheEfficiency}% cache` });

  // ── Findings summary (config only) ─────────────────────
  const findingSummary = [];
  if (cfgCriticals > 0) findingSummary.push(`🔴 ${cfgCriticals} critical`);
  if (cfgHighs > 0)     findingSummary.push(`🟠 ${cfgHighs} high`);
  if (cfgMediums > 0)   findingSummary.push(`🟡 ${cfgMediums} medium`);

  const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clawculator Snapshot</title>
<style>
  @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700;800&family=Outfit:wght@400;500;600;700;800;900&display=swap');

  * { box-sizing: border-box; margin: 0; padding: 0; }

  html, body {
    min-height: 100vh;
    font-family: 'Outfit', sans-serif;
    background: #020408;
    color: #e2e8f0;
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .card {
    width: 480px; max-width: 96vw;
    background: #05080f;
    border-radius: 24px;
    border: 1px solid #1a2744;
    padding: 32px 28px 24px;
    display: flex; flex-direction: column;
    align-items: center;
    position: relative;
    overflow: hidden;
    box-shadow: 0 0 80px rgba(34,211,238,0.04), 0 20px 60px rgba(0,0,0,0.5);
  }

  /* Grid bg */
  .card::before {
    content: '';
    position: absolute; top: 0; left: 0; right: 0; bottom: 0;
    background:
      linear-gradient(rgba(34,211,238,0.02) 1px, transparent 1px),
      linear-gradient(90deg, rgba(34,211,238,0.02) 1px, transparent 1px);
    background-size: 32px 32px;
    pointer-events: none;
  }

  .card > * { position: relative; z-index: 1; }

  /* Header */
  .header {
    display: flex; align-items: center; gap: 10px;
    margin-bottom: 20px;
  }
  .logo-claw { font-size: 28px; }
  .logo-text {
    font-family: 'JetBrains Mono', monospace;
    font-weight: 800; font-size: 18px; letter-spacing: -0.5px;
    background: linear-gradient(135deg, #22d3ee, #818cf8);
    -webkit-background-clip: text; -webkit-text-fill-color: transparent;
  }

  /* Grade */
  .grade-container {
    display: flex; flex-direction: column; align-items: center;
    margin-bottom: 16px;
  }
  .grade-ring {
    width: 120px; height: 120px;
    border-radius: 50%;
    border: 3px solid ${gradeColor}44;
    display: flex; align-items: center; justify-content: center;
    box-shadow: 0 0 40px ${gradeGlow}, inset 0 0 20px ${gradeGlow};
    position: relative;
  }
  .grade-ring::before {
    content: '';
    position: absolute; inset: -2px;
    border-radius: 50%;
    border: 2px solid ${gradeColor};
    opacity: 0.5;
  }
  .grade-letter {
    font-family: 'JetBrains Mono', monospace;
    font-weight: 900; font-size: 52px;
    color: ${gradeColor};
    text-shadow: 0 0 20px ${gradeGlow};
    line-height: 1;
  }
  .grade-label {
    font-size: 11px; font-weight: 600; color: #64748b;
    text-transform: uppercase; letter-spacing: 1.5px;
    margin-top: 8px;
  }

  /* Cost */
  .cost-range {
    font-family: 'JetBrains Mono', monospace;
    font-weight: 700; font-size: 22px;
    color: #e2e8f0;
    margin-bottom: 14px;
    text-align: center;
  }

  /* Divider */
  .divider {
    width: 60px; height: 1px;
    background: linear-gradient(90deg, transparent, #1e3a5f, transparent);
    margin-bottom: 14px;
  }

  /* Pills */
  .pills {
    display: flex; flex-wrap: wrap; justify-content: center;
    gap: 6px;
    margin-bottom: 14px;
    max-width: 400px;
  }
  .pill {
    display: flex; align-items: center; gap: 4px;
    background: #0b1120;
    border: 1px solid #1a2744;
    border-radius: 14px;
    padding: 5px 12px;
    font-size: 12px; font-weight: 500;
    color: #94a3b8;
  }

  /* Findings */
  .findings {
    text-align: center;
    margin-bottom: 14px;
  }
  .findings-badges {
    display: flex; justify-content: center; gap: 8px;
    flex-wrap: wrap;
  }
  .findings-badge {
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px; font-weight: 600;
    padding: 4px 10px; border-radius: 6px;
  }
  .findings-clean {
    font-size: 14px; color: #22c55e; font-weight: 600;
  }

  /* CTA */
  .cta {
    margin-top: auto;
    text-align: center;
    width: 100%;
  }
  .cta-divider {
    width: 100%; height: 1px;
    background: linear-gradient(90deg, transparent, #1a2744, transparent);
    margin-bottom: 14px;
  }
  .cta-prompt {
    font-size: 13px; color: #64748b;
    margin-bottom: 8px;
  }
  .cta-command {
    font-family: 'JetBrains Mono', monospace;
    font-size: 14px; font-weight: 700;
    color: #22d3ee;
    background: #0b1120;
    border: 1px solid #1a2744;
    border-radius: 8px;
    padding: 10px 18px;
    display: inline-block;
    margin-bottom: 6px;
  }
  .cta-sub {
    font-size: 10px; color: #334155;
  }
</style>
</head>
<body>
<div class="card">

  <div class="header">
    <div class="logo-claw">🦞</div>
    <div class="logo-text">CLAWCULATOR</div>
  </div>

  <div class="grade-container">
    <div class="grade-ring">
      <div class="grade-letter">${grade}</div>
    </div>
    <div class="grade-label">${gradeEmoji} cost health</div>
  </div>

  <div class="cost-range">${costEmoji} ${costRange}</div>

  <div class="divider"></div>

  <div class="pills">
    ${pills.map(p => `<div class="pill"><span>${p.icon}</span>${p.label}</div>`).join('\n    ')}
  </div>

  <div class="findings">
    ${findingSummary.length > 0 ?
      `<div class="findings-badges">${findingSummary.map(f => {
        const c = f.startsWith('🔴') ? '#ef4444' : f.startsWith('🟠') ? '#f97316' : '#eab308';
        return `<div class="findings-badge" style="background:${c}18;color:${c}">${f}</div>`;
      }).join('')}</div>` :
      `<div class="findings-clean">✅ No issues found</div>`
    }
  </div>

  <div class="cta">
    <div class="cta-divider"></div>
    <div class="cta-prompt">Get your OpenClaw cost grade</div>
    <div class="cta-command">npx clawculator --snapshot</div>
    <div class="cta-sub">100% offline · your data never leaves your machine</div>
  </div>

</div>
</body>
</html>`;

  const htmlPath = path.join(outputDir, 'clawculator-snapshot.html');
  fs.writeFileSync(htmlPath, html, 'utf8');

  // ── Terminal card (screenshot-ready) ───────────────────
  const C = '\x1b[36m';   // cyan
  const G = gradeColor === '#22c55e' ? '\x1b[32m' : gradeColor === '#f59e0b' ? '\x1b[33m' : gradeColor === '#f97316' ? '\x1b[33m' : '\x1b[31m';
  const D = '\x1b[90m';   // dim
  const B = '\x1b[1m';    // bold
  const R = '\x1b[0m';    // reset
  const W = '\x1b[37m';   // white

  const pillStr = pills.map(p => `${p.icon} ${p.label}`).join('  ');
  const findStr = findingSummary.length > 0
    ? findingSummary.join('  ')
    : '✅ No issues found';

  const lines = [
    ``,
    `${D}  ╔══════════════════════════════════════════════╗${R}`,
    `${D}  ║${R}                                              ${D}║${R}`,
    `${D}  ║${R}       ${C}🦞  C L A W C U L A T O R${R}            ${D}║${R}`,
    `${D}  ║${R}                                              ${D}║${R}`,
    `${D}  ║${R}              ${G}${B}┌─────────┐${R}                  ${D}║${R}`,
    `${D}  ║${R}              ${G}${B}│  ${grade.padStart(3)}    │${R}                  ${D}║${R}`,
    `${D}  ║${R}              ${G}${B}└─────────┘${R}                  ${D}║${R}`,
    `${D}  ║${R}           ${D}cost health grade${R}                ${D}║${R}`,
    `${D}  ║${R}                                              ${D}║${R}`,
    `${D}  ║${R}        ${W}${B}${costEmoji} ${costRange}${R}`,
    `${D}  ║${R}                                              ${D}║${R}`,
    `${D}  ║${R}  ${D}──────────────────────────────────────────${R}  ${D}║${R}`,
    `${D}  ║${R}                                              ${D}║${R}`,
    `${D}  ║${R}  ${pillStr}`,
    `${D}  ║${R}                                              ${D}║${R}`,
    `${D}  ║${R}  ${findStr}`,
    `${D}  ║${R}                                              ${D}║${R}`,
    `${D}  ║${R}  ${D}──────────────────────────────────────────${R}  ${D}║${R}`,
    `${D}  ║${R}                                              ${D}║${R}`,
    `${D}  ║${R}     ${D}Get your OpenClaw cost grade${R}            ${D}║${R}`,
    `${D}  ║${R}     ${C}${B}npx clawculator --snapshot${R}             ${D}║${R}`,
    `${D}  ║${R}                                              ${D}║${R}`,
    `${D}  ╚══════════════════════════════════════════════╝${R}`,
    ``,
  ];

  console.log(lines.join('\n'));

  // Also mention the HTML file
  console.log(`  ${D}HTML card saved: ${htmlPath}${R}`);
  console.log(`  ${D}Screenshot this terminal or open the HTML — both work!${R}\n`);

  return { htmlPath, grade, costRange };
}

module.exports = { generateSnapshotCard };


path: webDashboard.js
content: 'use strict';

const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { resolveModel, MODEL_PRICING, parseTranscript } = require('./analyzer');

// ── Helpers ──────────────────────────────────────────────
function modelLabel(m) {
  const k = resolveModel(m);
  return k ? (MODEL_PRICING[k]?.label || k) : (m || 'unknown');
}

// ── SQLite Setup ─────────────────────────────────────────
function initDB(dbPath) {
  let Database;
  try {
    Database = require('better-sqlite3');
  } catch {
    console.error('\n\x1b[31m  ✗ better-sqlite3 is required for --web\x1b[0m\n');
    console.error('  Install it with:\n');
    console.error('    \x1b[36mnpm install -g better-sqlite3\x1b[0m');
    console.error('    \x1b[90m# or, if installed locally:\x1b[0m');
    console.error('    \x1b[36mcd $(npm root -g)/clawculator && npm install better-sqlite3\x1b[0m\n');
    console.error('  \x1b[90mThis is a native module that compiles on install.\x1b[0m');
    console.error('  \x1b[90mRequires: Node.js 18+, Python 3, and a C++ compiler (Xcode CLI tools on macOS).\x1b[0m\n');
    process.exit(1);
  }

  const db = new Database(dbPath);
  db.pragma('journal_mode = WAL');

  db.exec(`
    CREATE TABLE IF NOT EXISTS daily_snapshots (
      date TEXT PRIMARY KEY,
      total_cost REAL DEFAULT 0,
      total_messages INTEGER DEFAULT 0,
      total_tokens INTEGER DEFAULT 0,
      cache_read INTEGER DEFAULT 0,
      cache_write INTEGER DEFAULT 0,
      model_breakdown TEXT DEFAULT '{}',
      session_breakdown TEXT DEFAULT '{}',
      updated_at TEXT
    );

    CREATE TABLE IF NOT EXISTS events (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      timestamp TEXT,
      session_name TEXT,
      session_id TEXT,
      model TEXT,
      cost REAL,
      input_tokens INTEGER,
      output_tokens INTEGER,
      cache_read INTEGER,
      cache_write INTEGER,
      total_tokens INTEGER
    );

    CREATE TABLE IF NOT EXISTS session_daily (
      date TEXT,
      session_id TEXT,
      session_name TEXT,
      model TEXT,
      cost REAL DEFAULT 0,
      messages INTEGER DEFAULT 0,
      tokens INTEGER DEFAULT 0,
      cache_read INTEGER DEFAULT 0,
      cache_write INTEGER DEFAULT 0,
      PRIMARY KEY (date, session_id)
    );

    CREATE INDEX IF NOT EXISTS idx_events_ts ON events(timestamp);
    CREATE INDEX IF NOT EXISTS idx_events_session ON events(session_id);
  `);

  // Auto-prune events older than 14 days
  db.exec(`DELETE FROM events WHERE timestamp < datetime('now', '-14 days')`);

  return db;
}

// ── Parse single JSONL line ──────────────────────────────
function parseUsageLine(line) {
  try {
    const entry = JSON.parse(line);
    if (entry.type !== 'message') return null;
    const u = entry.usage || entry.message?.usage;
    if (!u) return null;
    const model = entry.model || entry.message?.model;
    const ts = entry.timestamp || entry.message?.timestamp;
    const cost = u.cost ? (typeof u.cost === 'object' ? u.cost.total || 0 : u.cost) : 0;
    return {
      model, cost,
      input: u.input || 0, output: u.output || 0,
      cacheRead: u.cacheRead || 0, cacheWrite: u.cacheWrite || 0,
      totalTokens: u.totalTokens || 0,
      timestamp: ts || new Date().toISOString(),
    };
  } catch { return null; }
}

// ── Web Dashboard Server ─────────────────────────────────
function startWebDashboard(opts = {}) {
  const openclawHome = opts.openclawHome || process.env.OPENCLAW_HOME || path.join(os.homedir(), '.openclaw');
  const port = opts.port || 3457;
  const dbPath = path.join(openclawHome, 'clawculator.db');

  const db = initDB(dbPath);

  // Prepared statements
  const upsertDaily = db.prepare(`
    INSERT INTO daily_snapshots (date, total_cost, total_messages, total_tokens, cache_read, cache_write, model_breakdown, session_breakdown, updated_at)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT(date) DO UPDATE SET
      total_cost=excluded.total_cost, total_messages=excluded.total_messages,
      total_tokens=excluded.total_tokens, cache_read=excluded.cache_read,
      cache_write=excluded.cache_write, model_breakdown=excluded.model_breakdown,
      session_breakdown=excluded.session_breakdown, updated_at=excluded.updated_at
  `);

  const insertEvent = db.prepare(`
    INSERT INTO events (timestamp, session_name, session_id, model, cost, input_tokens, output_tokens, cache_read, cache_write, total_tokens)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  `);

  const upsertSessionDaily = db.prepare(`
    INSERT INTO session_daily (date, session_id, session_name, model, cost, messages, tokens, cache_read, cache_write)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT(date, session_id) DO UPDATE SET
      cost=cost+excluded.cost, messages=messages+excluded.messages,
      tokens=tokens+excluded.tokens, cache_read=cache_read+excluded.cache_read,
      cache_write=cache_write+excluded.cache_write,
      model=excluded.model, session_name=excluded.session_name
  `);

  // ── State ──────────────────────────────────────────────
  const sseClients = new Set();
  const watchers = new Map();
  const offsets = new Map();
  const fileToSession = new Map();

  // Today's live state
  let today = { cost: 0, messages: 0, tokens: 0, cacheRead: 0, cacheWrite: 0, models: {}, sessions: {} };
  let peakCostPerMsg = 0;
  let recentEvents = [];
  const MAX_RECENT = 50;

  function todayStr() { return new Date().toISOString().slice(0, 10); }

  // ── Session name resolution ────────────────────────────
  function getSessionNames() {
    const names = new Map();
    const agentsDir = path.join(openclawHome, 'agents');
    try {
      for (const agent of fs.readdirSync(agentsDir)) {
        const sjPath = path.join(agentsDir, agent, 'sessions', 'sessions.json');
        if (!fs.existsSync(sjPath)) continue;
        const sj = JSON.parse(fs.readFileSync(sjPath, 'utf8'));
        for (const [key, val] of Object.entries(sj)) {
          if (val.sessionId) {
            const short = key.replace('agent:main:', '').replace(/:[a-f0-9-]{36}/g, '').replace(/:run$/, '');
            if (!names.has(val.sessionId) || short.length < names.get(val.sessionId).length) {
              names.set(val.sessionId, short);
            }
          }
        }
      }
    } catch {}
    return names;
  }

  // ── File watching ──────────────────────────────────────
  function discoverFiles() {
    const sessionNames = getSessionNames();
    const agentsDir = path.join(openclawHome, 'agents');
    try {
      for (const agent of fs.readdirSync(agentsDir)) {
        const sessDir = path.join(agentsDir, agent, 'sessions');
        if (!fs.existsSync(sessDir)) continue;
        for (const file of fs.readdirSync(sessDir)) {
          if (!file.endsWith('.jsonl')) continue;
          const filePath = path.join(sessDir, file);
          if (watchers.has(filePath)) continue;

          const sessionId = file.replace('.jsonl', '');
          const friendlyName = sessionNames.get(sessionId) || sessionId.slice(0, 8);
          fileToSession.set(filePath, { id: sessionId, name: friendlyName });

          initialParse(filePath, sessionId, friendlyName);

          try {
            const watcher = fs.watch(filePath, () => tailFile(filePath));
            watchers.set(filePath, watcher);
          } catch {}
        }
      }
    } catch {}
  }

  function initialParse(filePath, sessionId, friendlyName) {
    try {
      const content = fs.readFileSync(filePath, 'utf8');
      const stat = fs.statSync(filePath);
      offsets.set(filePath, stat.size);

      const todayStart = new Date(); todayStart.setHours(0, 0, 0, 0);
      const todayMs = todayStart.getTime();

      for (const line of content.split('\n')) {
        if (!line.trim()) continue;
        const usage = parseUsageLine(line);
        if (!usage) continue;
        const ts = new Date(usage.timestamp).getTime();
        if (ts >= todayMs) {
          recordEvent(usage, sessionId, friendlyName, false); // don't broadcast on init
        }
      }
    } catch {}
  }

  function tailFile(filePath) {
    try {
      const stat = fs.statSync(filePath);
      const prev = offsets.get(filePath) || 0;
      if (stat.size <= prev) return;

      const fd = fs.openSync(filePath, 'r');
      const buf = Buffer.alloc(stat.size - prev);
      fs.readSync(fd, buf, 0, buf.length, prev);
      fs.closeSync(fd);
      offsets.set(filePath, stat.size);

      const session = fileToSession.get(filePath);
      if (!session) return;

      for (const line of buf.toString('utf8').split('\n')) {
        if (!line.trim()) continue;
        const usage = parseUsageLine(line);
        if (!usage) continue;
        recordEvent(usage, session.id, session.name, true);
      }
    } catch {}
  }

  // ── Record & broadcast ─────────────────────────────────
  function recordEvent(usage, sessionId, sessionName, broadcast) {
    const ml = modelLabel(usage.model);

    // Update live state
    today.cost += usage.cost;
    today.messages++;
    today.tokens += usage.totalTokens;
    today.cacheRead += usage.cacheRead;
    today.cacheWrite += usage.cacheWrite;
    today.models[ml] = (today.models[ml] || 0) + usage.cost;

    if (!today.sessions[sessionId]) {
      today.sessions[sessionId] = { name: sessionName, model: ml, cost: 0, messages: 0, tokens: 0, lastSeen: null };
    }
    const sess = today.sessions[sessionId];
    sess.cost += usage.cost;
    sess.messages++;
    sess.tokens += usage.totalTokens;
    sess.model = ml;
    sess.lastSeen = usage.timestamp;

    if (usage.cost > peakCostPerMsg) peakCostPerMsg = usage.cost;

    const event = {
      time: usage.timestamp,
      session: sessionName,
      sessionId,
      model: ml,
      cost: usage.cost,
      tokens: usage.totalTokens,
      cacheWrite: usage.cacheWrite,
      cacheRead: usage.cacheRead,
    };
    recentEvents.unshift(event);
    if (recentEvents.length > MAX_RECENT) recentEvents.length = MAX_RECENT;

    // Persist to SQLite
    try {
      insertEvent.run(usage.timestamp, sessionName, sessionId, usage.model, usage.cost,
        usage.input, usage.output, usage.cacheRead, usage.cacheWrite, usage.totalTokens);

      const d = todayStr();
      upsertSessionDaily.run(d, sessionId, sessionName, usage.model, usage.cost, 1,
        usage.totalTokens, usage.cacheRead, usage.cacheWrite);

      upsertDaily.run(d, today.cost, today.messages, today.tokens, today.cacheRead,
        today.cacheWrite, JSON.stringify(today.models), JSON.stringify(
          Object.fromEntries(Object.entries(today.sessions).map(([k, v]) => [k, { name: v.name, cost: v.cost, messages: v.messages }]))
        ), new Date().toISOString());
    } catch {}

    // Broadcast to SSE clients
    if (broadcast) {
      const payload = JSON.stringify({ type: 'event', event, today: getTodaySummary() });
      for (const res of sseClients) {
        try { res.write(`data: ${payload}\n\n`); } catch { sseClients.delete(res); }
      }
    }
  }

  function getTodaySummary() {
    return {
      cost: today.cost,
      messages: today.messages,
      tokens: today.tokens,
      cacheRead: today.cacheRead,
      cacheWrite: today.cacheWrite,
      models: today.models,
      sessions: Object.entries(today.sessions).map(([id, s]) => ({
        id, name: s.name, model: s.model, cost: s.cost, messages: s.messages, tokens: s.tokens, lastSeen: s.lastSeen,
      })).sort((a, b) => b.cost - a.cost),
      peakCostPerMsg,
      avgCostPerMsg: today.messages > 0 ? today.cost / today.messages : 0,
    };
  }

  // ── API endpoints ──────────────────────────────────────
  function getHistory(days = 30) {
    try {
      return db.prepare(`SELECT * FROM daily_snapshots ORDER BY date DESC LIMIT ?`).all(days);
    } catch { return []; }
  }

  function getSessionHistory(days = 7) {
    try {
      return db.prepare(`SELECT * FROM session_daily WHERE date >= date('now', ?) ORDER BY date, cost DESC`).all(`-${days} days`);
    } catch { return []; }
  }

  function getHourlyToday() {
    try {
      return db.prepare(`
        SELECT strftime('%H', timestamp) as hour, SUM(cost) as cost, COUNT(*) as messages, SUM(total_tokens) as tokens
        FROM events WHERE date(timestamp) = date('now') GROUP BY hour ORDER BY hour
      `).all();
    } catch { return []; }
  }

  function getTopMessages(limit = 10) {
    try {
      return db.prepare(`
        SELECT timestamp, session_name, model, cost, total_tokens, cache_write
        FROM events WHERE date(timestamp) = date('now') ORDER BY cost DESC LIMIT ?
      `).all(limit);
    } catch { return []; }
  }

  // ── HTTP Server ────────────────────────────────────────
  const server = http.createServer((req, res) => {
    const url = new URL(req.url, `http://localhost:${port}`);

    // SSE endpoint
    if (url.pathname === '/api/stream') {
      res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive',
        'Access-Control-Allow-Origin': '*',
      });
      res.write(`data: ${JSON.stringify({ type: 'init', today: getTodaySummary(), recent: recentEvents.slice(0, 20) })}\n\n`);
      sseClients.add(res);
      req.on('close', () => sseClients.delete(res));
      return;
    }

    // JSON API endpoints
    if (url.pathname === '/api/today') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(getTodaySummary()));
      return;
    }

    if (url.pathname === '/api/history') {
      const days = parseInt(url.searchParams.get('days') || '30');
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(getHistory(days)));
      return;
    }

    if (url.pathname === '/api/hourly') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(getHourlyToday()));
      return;
    }

    if (url.pathname === '/api/top-messages') {
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(getTopMessages()));
      return;
    }

    if (url.pathname === '/api/sessions') {
      const days = parseInt(url.searchParams.get('days') || '7');
      res.writeHead(200, { 'Content-Type': 'application/json' });
      res.end(JSON.stringify(getSessionHistory(days)));
      return;
    }

    // Serve dashboard HTML
    if (url.pathname === '/' || url.pathname === '/index.html') {
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end(getDashboardHTML());
      return;
    }

    res.writeHead(404);
    res.end('Not found');
  });

  // ── Start ──────────────────────────────────────────────
  discoverFiles();

  // Re-discover new files every 30s
  setInterval(discoverFiles, 30000);

  // Periodic snapshot save every 60s
  setInterval(() => {
    try {
      const d = todayStr();
      upsertDaily.run(d, today.cost, today.messages, today.tokens, today.cacheRead,
        today.cacheWrite, JSON.stringify(today.models), JSON.stringify(
          Object.fromEntries(Object.entries(today.sessions).map(([k, v]) => [k, { name: v.name, cost: v.cost, messages: v.messages }]))
        ), new Date().toISOString());
    } catch {}
  }, 60000);

  server.listen(port, '127.0.0.1', () => {
    console.log(`\n\x1b[36m  CLAWCULATOR WEB DASHBOARD\x1b[0m`);
    console.log(`\x1b[90m  ─────────────────────────────────\x1b[0m`);
    console.log(`  🦞 Dashboard:  \x1b[1m\x1b[36mhttp://localhost:${port}\x1b[0m`);
    console.log(`  📊 Watching:   ${watchers.size} transcript(s)`);
    console.log(`  💾 Database:   ${dbPath}`);
    console.log(`  📅 Today:      $${today.cost.toFixed(2)} across ${today.messages} messages`);
    console.log(`\x1b[90m  ─────────────────────────────────\x1b[0m`);
    console.log(`\x1b[90m  💡 Pin this tab for always-on cost monitoring\x1b[0m`);
    console.log(`\x1b[90m  Press Ctrl+C to stop\x1b[0m\n`);

    // Auto-open browser
    const { exec } = require('child_process');
    exec(`open "http://localhost:${port}" 2>/dev/null || xdg-open "http://localhost:${port}" 2>/dev/null`);
  });

  // Cleanup
  process.on('SIGINT', () => {
    console.log(`\n\x1b[36mClawculator Web\x1b[0m stopped. Today: $${today.cost.toFixed(2)} across ${today.messages} messages.\n`);
    for (const [, w] of watchers) { try { w.close(); } catch {} }
    try { db.close(); } catch {}
    process.exit(0);
  });

  return { server, db };
}

// ── Dashboard HTML ───────────────────────────────────────
function getDashboardHTML() {
  return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Clawculator — Live Cost Dashboard</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🦞</text></svg>">
<style>
  @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;600;700;800&family=Outfit:wght@300;400;500;600;700;800;900&display=swap');

  :root {
    --bg-deep: #05080f;
    --bg-card: #0b1120;
    --bg-card-hover: #0f1729;
    --border: #1a2744;
    --border-glow: #1e3a5f;
    --cyan: #22d3ee;
    --cyan-dim: #0891b2;
    --amber: #f59e0b;
    --amber-dim: #92400e;
    --red: #ef4444;
    --red-dim: #991b1b;
    --green: #22c55e;
    --green-dim: #166534;
    --purple: #a78bfa;
    --text: #e2e8f0;
    --text-dim: #64748b;
    --text-muted: #334155;
    --glass: rgba(11, 17, 32, 0.8);
  }

  * { box-sizing: border-box; margin: 0; padding: 0; }

  body {
    font-family: 'Outfit', sans-serif;
    background: var(--bg-deep);
    color: var(--text);
    min-height: 100vh;
    overflow-x: hidden;
  }

  /* Animated background grid */
  body::before {
    content: '';
    position: fixed; top: 0; left: 0; right: 0; bottom: 0;
    background:
      linear-gradient(rgba(34,211,238,0.03) 1px, transparent 1px),
      linear-gradient(90deg, rgba(34,211,238,0.03) 1px, transparent 1px);
    background-size: 60px 60px;
    z-index: 0;
    animation: gridScroll 20s linear infinite;
  }
  @keyframes gridScroll { to { background-position: 60px 60px; } }

  /* Glow orb in background */
  body::after {
    content: '';
    position: fixed; top: -200px; right: -200px;
    width: 600px; height: 600px;
    background: radial-gradient(circle, rgba(34,211,238,0.08) 0%, transparent 70%);
    z-index: 0;
    animation: orbFloat 15s ease-in-out infinite alternate;
  }
  @keyframes orbFloat {
    0% { transform: translate(0, 0); }
    100% { transform: translate(-100px, 100px); }
  }

  .app { position: relative; z-index: 1; max-width: 1400px; margin: 0 auto; padding: 24px; }

  /* Header */
  .header {
    display: flex; align-items: center; justify-content: space-between;
    padding: 20px 0; margin-bottom: 24px;
    border-bottom: 1px solid var(--border);
  }
  .logo-area { display: flex; align-items: center; gap: 16px; }
  .logo-claw {
    font-size: 48px;
    animation: chomp 0.6s steps(2) infinite;
    filter: drop-shadow(0 0 12px rgba(34,211,238,0.3));
  }
  @keyframes chomp {
    0%, 100% { transform: scaleX(1); }
    50% { transform: scaleX(0.9); }
  }
  .logo-text {
    font-family: 'JetBrains Mono', monospace;
    font-weight: 800; font-size: 28px; letter-spacing: -1px;
    background: linear-gradient(135deg, var(--cyan), #818cf8);
    -webkit-background-clip: text; -webkit-text-fill-color: transparent;
  }
  .logo-sub { font-size: 13px; color: var(--text-dim); margin-top: 2px; }
  .header-right { text-align: right; }
  .live-dot {
    display: inline-block; width: 8px; height: 8px; border-radius: 50%;
    background: var(--green); margin-right: 6px;
    animation: pulse 2s ease-in-out infinite;
  }
  @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.3; } }
  .header-time { font-family: 'JetBrains Mono', monospace; font-size: 13px; color: var(--text-dim); }

  /* Pac-Claw Chase */
  .pacclaw-track {
    position: absolute; top: 14px; left: 140px; right: 200px; height: 50px;
    overflow: hidden; pointer-events: none;
  }
  .pacclaw-claw {
    position: absolute; left: -40px; top: 8px; font-size: 32px; z-index: 2;
    animation: clawRun 12s linear infinite;
    filter: drop-shadow(0 0 8px rgba(34,211,238,0.4));
  }
  .pacclaw-claw .claw-top {
    display: block; transform-origin: bottom center;
    animation: clawChomp 0.3s steps(2) infinite;
    line-height: 0.5;
  }
  .pacclaw-claw .claw-bot {
    display: block; transform-origin: top center;
    animation: clawChompBot 0.3s steps(2) infinite;
    line-height: 0.5;
  }
  @keyframes clawChomp {
    0%, 100% { transform: rotate(0deg); }
    50% { transform: rotate(-15deg); }
  }
  @keyframes clawChompBot {
    0%, 100% { transform: rotate(0deg); }
    50% { transform: rotate(15deg); }
  }
  @keyframes clawRun {
    0% { left: -40px; }
    100% { left: calc(100% + 40px); }
  }
  .pacclaw-penny {
    position: absolute; top: 12px; font-size: 22px; z-index: 1;
    transition: opacity 0.15s, transform 0.15s;
  }
  .pacclaw-penny.eaten {
    opacity: 0 !important;
    transform: scale(0.3) !important;
  }
  /* Speed up claw on cost spike */
  .pacclaw-track.turbo .pacclaw-claw {
    animation-duration: 4s;
    filter: drop-shadow(0 0 16px rgba(239,68,68,0.6));
  }

  /* Cards */
  .card {
    background: var(--bg-card);
    border: 1px solid var(--border);
    border-radius: 16px;
    padding: 24px;
    transition: all 0.3s ease;
    position: relative; overflow: hidden;
  }
  .card::before {
    content: ''; position: absolute; top: 0; left: 0; right: 0; height: 1px;
    background: linear-gradient(90deg, transparent, var(--cyan), transparent);
    opacity: 0; transition: opacity 0.3s;
  }
  .card:hover::before { opacity: 1; }
  .card:hover { border-color: var(--border-glow); }
  .card-label { font-size: 12px; font-weight: 600; color: var(--text-dim); text-transform: uppercase; letter-spacing: 1.5px; margin-bottom: 8px; }
  .card-value {
    font-family: 'JetBrains Mono', monospace;
    font-weight: 800; font-size: 36px; line-height: 1;
    transition: all 0.4s ease;
  }
  .card-sub { font-size: 13px; color: var(--text-dim); margin-top: 8px; }

  /* Big numbers grid */
  .stats-grid {
    display: grid;
    grid-template-columns: 2fr 1fr 1fr 1fr;
    gap: 16px; margin-bottom: 24px;
  }
  .stat-primary .card-value { font-size: 52px; }

  /* Charts area */
  .charts-grid {
    display: grid;
    grid-template-columns: 2fr 1fr;
    gap: 16px; margin-bottom: 24px;
  }

  /* Session table */
  .sessions-table { width: 100%; border-collapse: collapse; }
  .sessions-table th {
    font-size: 11px; font-weight: 600; color: var(--text-dim);
    text-transform: uppercase; letter-spacing: 1px;
    padding: 12px 16px; text-align: left;
    border-bottom: 1px solid var(--border);
  }
  .sessions-table td {
    padding: 12px 16px; font-size: 14px;
    border-bottom: 1px solid var(--text-muted);
    font-family: 'JetBrains Mono', monospace;
  }
  .sessions-table tr:hover { background: var(--bg-card-hover); }

  /* Live feed */
  .feed-item {
    display: grid;
    grid-template-columns: 80px 120px 1fr 80px 80px;
    gap: 8px; padding: 8px 12px;
    font-family: 'JetBrains Mono', monospace;
    font-size: 12px; border-bottom: 1px solid var(--text-muted);
    animation: feedSlide 0.3s ease-out;
  }
  @keyframes feedSlide { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }

  /* Donut chart */
  .donut-container { display: flex; align-items: center; gap: 24px; }
  .donut-svg { width: 160px; height: 160px; }
  .donut-legend { flex: 1; }
  .legend-item { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; font-size: 13px; }
  .legend-dot { width: 10px; height: 10px; border-radius: 50%; }

  /* Bar chart */
  .bar-chart { display: flex; align-items: flex-end; gap: 4px; height: 200px; padding-top: 20px; }
  .bar-group { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; }
  .bar {
    width: 100%; border-radius: 4px 4px 0 0;
    background: linear-gradient(180deg, var(--cyan), var(--cyan-dim));
    transition: height 0.6s ease;
    min-height: 2px; position: relative;
  }
  .bar:hover { filter: brightness(1.3); }
  .bar-label { font-size: 10px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; }
  .bar-value {
    font-size: 10px; color: var(--cyan); font-family: 'JetBrains Mono', monospace;
    position: absolute; top: -16px; left: 50%; transform: translateX(-50%); white-space: nowrap;
  }

  /* Heat map */
  .heatmap { display: grid; grid-template-columns: repeat(7, 1fr); gap: 3px; }
  .heat-cell {
    aspect-ratio: 1; border-radius: 3px;
    border: 1px solid var(--text-muted);
    transition: all 0.2s; cursor: pointer;
    position: relative;
  }
  .heat-cell:hover { border-color: var(--cyan); transform: scale(1.2); z-index: 2; }
  .heat-cell[title]:hover::after {
    content: attr(title); position: absolute; bottom: 110%; left: 50%; transform: translateX(-50%);
    background: var(--bg-card); color: var(--text); padding: 4px 8px; border-radius: 4px;
    font-size: 11px; white-space: nowrap; border: 1px solid var(--border);
    font-family: 'JetBrains Mono', monospace; z-index: 10;
  }

  /* Meter / gauge */
  .gauge-container { display: flex; flex-direction: column; align-items: center; }
  .gauge-svg { width: 200px; height: 120px; }
  .gauge-label { font-size: 13px; color: var(--text-dim); margin-top: 8px; }

  /* Velocity indicator */
  .velocity { display: flex; align-items: center; gap: 6px; font-size: 13px; }
  .velocity-arrow { font-size: 16px; }
  .velocity-up { color: var(--red); }
  .velocity-down { color: var(--green); }
  .velocity-flat { color: var(--text-dim); }

  /* Leaderboard */
  .leaderboard-item {
    display: flex; align-items: center; gap: 12px;
    padding: 10px 0; border-bottom: 1px solid var(--text-muted);
  }
  .leaderboard-rank {
    width: 28px; height: 28px; border-radius: 50%;
    display: flex; align-items: center; justify-content: center;
    font-weight: 800; font-size: 13px;
    font-family: 'JetBrains Mono', monospace;
  }
  .rank-1 { background: linear-gradient(135deg, #fbbf24, #f59e0b); color: #000; }
  .rank-2 { background: linear-gradient(135deg, #94a3b8, #64748b); color: #000; }
  .rank-3 { background: linear-gradient(135deg, #c2855a, #a0714a); color: #000; }
  .rank-n { background: var(--text-muted); color: var(--text-dim); }

  /* Responsive */
  @media (max-width: 900px) {
    .stats-grid { grid-template-columns: 1fr 1fr; }
    .charts-grid { grid-template-columns: 1fr; }
    .stat-primary .card-value { font-size: 36px; }
  }

  /* Counter animation */
  .counter-animate {
    display: inline-block;
    transition: transform 0.2s ease;
  }
  .counter-animate.bump { transform: scale(1.1); }

  /* Sections */
  .section-title {
    font-size: 14px; font-weight: 700; color: var(--text-dim);
    text-transform: uppercase; letter-spacing: 2px;
    margin-bottom: 16px;
    display: flex; align-items: center; gap: 8px;
  }
  .two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 24px; }

  /* No data state */
  .no-data { text-align: center; padding: 40px; color: var(--text-dim); font-size: 14px; }
</style>
</head>
<body>
<div class="app">

  <!-- Header -->
  <div class="header">
    <div class="logo-area">
      <div class="logo-claw">🦞</div>
      <div>
        <div class="logo-text">CLAWCULATOR</div>
        <div class="logo-sub">Your friendly penny pincher</div>
      </div>
    </div>
    <div class="pacclaw-track" id="pacclawTrack">
      <div class="pacclaw-claw" id="pacclawClaw"><span class="claw-top">🦞</span></div>
    </div>
    <div class="header-right">
      <div><span class="live-dot"></span><span style="color:var(--green); font-weight:600; font-size:13px;">LIVE</span></div>
      <div class="header-time" id="clock"></div>
    </div>
  </div>

  <!-- Big Numbers -->
  <div class="stats-grid">
    <div class="card stat-primary">
      <div class="card-label">Today's Spend</div>
      <div class="card-value counter-animate" id="todayCost" style="color:var(--amber)">$0.00</div>
      <div class="card-sub" id="todayVsYesterday"></div>
    </div>
    <div class="card">
      <div class="card-label">Messages</div>
      <div class="card-value counter-animate" id="todayMessages" style="color:var(--text)">0</div>
      <div class="card-sub" id="avgCost"></div>
    </div>
    <div class="card">
      <div class="card-label">Peak $/msg</div>
      <div class="card-value counter-animate" id="peakCost" style="color:var(--red)">$0.00</div>
      <div class="card-sub">Most expensive single call</div>
    </div>
    <div class="card">
      <div class="card-label">Burn Rate</div>
      <div class="card-value counter-animate" id="burnRate" style="color:var(--purple)">—</div>
      <div class="card-sub" id="projectedMonth"></div>
    </div>
  </div>

  <!-- Charts Row -->
  <div class="charts-grid">
    <div class="card">
      <div class="section-title">📊 Hourly Cost Today</div>
      <div class="bar-chart" id="hourlyChart"></div>
    </div>
    <div class="card">
      <div class="section-title">🧠 Model Mix</div>
      <div class="donut-container" id="modelDonut">
        <div class="no-data">Waiting for data...</div>
      </div>
    </div>
  </div>

  <!-- Sessions + Feed -->
  <div class="two-col">
    <div class="card">
      <div class="section-title">⚡ Active Sessions</div>
      <table class="sessions-table">
        <thead><tr><th>Session</th><th>Model</th><th>Msgs</th><th>Cost</th></tr></thead>
        <tbody id="sessionsBody"></tbody>
      </table>
    </div>
    <div class="card">
      <div class="section-title">🏆 Costliest Calls Today</div>
      <div id="leaderboard"></div>
    </div>
  </div>

  <!-- History + Heat Map -->
  <div class="two-col">
    <div class="card">
      <div class="section-title">📅 Daily History</div>
      <div class="bar-chart" id="historyChart" style="height:160px"></div>
    </div>
    <div class="card">
      <div class="section-title">🔥 Spend Heat Map (30 days)</div>
      <div class="heatmap" id="heatmap"></div>
    </div>
  </div>

  <!-- Live Feed -->
  <div class="card" style="margin-bottom:24px">
    <div class="section-title">🌊 Live Feed</div>
    <div id="liveFeed"></div>
  </div>

</div>

<script>
// ── State ─────────────────────────────────────────────
let state = { cost: 0, messages: 0, tokens: 0, cacheRead: 0, cacheWrite: 0, models: {}, sessions: [], peakCostPerMsg: 0, avgCostPerMsg: 0 };
let recentEvents = [];
let historyData = [];

// ── Helpers ───────────────────────────────────────────
function fmtCost(n) {
  if (n >= 1) return '$' + n.toFixed(2);
  if (n >= 0.01) return '$' + n.toFixed(4);
  return '$' + n.toFixed(6);
}
function fmtTokens(n) {
  if (n >= 1e6) return (n/1e6).toFixed(1) + 'M';
  if (n >= 1e3) return (n/1e3).toFixed(1) + 'K';
  return String(n);
}

// ── Pac-Claw Chase Engine ─────────────────────────────
const pacTrack = document.getElementById('pacclawTrack');
const pacClaw = document.getElementById('pacclawClaw');
let pacPennies = [];
let pacTurboTimeout = null;

function spawnPacPennies() {
  // Clear old pennies
  pacPennies.forEach(p => p.el.remove());
  pacPennies = [];
  const trackWidth = pacTrack.offsetWidth || 800;
  const count = 8 + Math.floor(Math.random() * 5);
  for (let i = 0; i < count; i++) {
    const el = document.createElement('span');
    el.className = 'pacclaw-penny';
    el.textContent = '🪙';
    const x = 40 + (i / count) * (trackWidth - 80);
    el.style.left = x + 'px';
    el.style.opacity = '0.6';
    pacTrack.appendChild(el);
    pacPennies.push({ el, x, eaten: false });
  }
}
spawnPacPennies();

// Check claw position and eat pennies
function pacClawLoop() {
  if (!pacClaw) return;
  const clawRect = pacClaw.getBoundingClientRect();
  const trackRect = pacTrack.getBoundingClientRect();
  const clawX = clawRect.left - trackRect.left + clawRect.width / 2;

  for (const penny of pacPennies) {
    if (!penny.eaten && Math.abs(penny.x - clawX) < 22) {
      penny.eaten = true;
      penny.el.classList.add('eaten');
    }
  }

  // Respawn when claw completes a lap (all eaten or claw past right edge)
  const allEaten = pacPennies.every(p => p.eaten);
  if (allEaten || clawX > (pacTrack.offsetWidth || 800) + 30) {
    setTimeout(spawnPacPennies, 500);
  }

  requestAnimationFrame(pacClawLoop);
}
requestAnimationFrame(pacClawLoop);

// Turbo mode on cost events
function triggerPacTurbo() {
  pacTrack.classList.add('turbo');
  // Spawn extra pennies during turbo
  const trackWidth = pacTrack.offsetWidth || 800;
  for (let i = 0; i < 3; i++) {
    const el = document.createElement('span');
    el.className = 'pacclaw-penny';
    el.textContent = '🪙';
    const x = Math.random() * trackWidth;
    el.style.left = x + 'px';
    el.style.opacity = '0.8';
    pacTrack.appendChild(el);
    pacPennies.push({ el, x, eaten: false });
  }
  clearTimeout(pacTurboTimeout);
  pacTurboTimeout = setTimeout(() => pacTrack.classList.remove('turbo'), 5000);
}

// ── Clock ─────────────────────────────────────────────
setInterval(() => {
  document.getElementById('clock').textContent = new Date().toLocaleTimeString();
}, 1000);
document.getElementById('clock').textContent = new Date().toLocaleTimeString();

// ── Render Functions ──────────────────────────────────
function updateBigNumbers() {
  const costEl = document.getElementById('todayCost');
  costEl.textContent = fmtCost(state.cost);
  costEl.style.color = state.cost > 10 ? 'var(--red)' : state.cost > 1 ? 'var(--amber)' : 'var(--green)';
  bump(costEl);

  const msgEl = document.getElementById('todayMessages');
  msgEl.textContent = state.messages;
  bump(msgEl);

  const peakEl = document.getElementById('peakCost');
  peakEl.textContent = fmtCost(state.peakCostPerMsg);
  bump(peakEl);

  document.getElementById('avgCost').textContent = 'Avg: ' + fmtCost(state.avgCostPerMsg) + '/msg';

  // Burn rate
  const now = new Date();
  const hoursElapsed = now.getHours() + now.getMinutes() / 60;
  if (hoursElapsed > 0.5 && state.cost > 0) {
    const hourlyRate = state.cost / hoursElapsed;
    const dailyProjection = hourlyRate * 24;
    document.getElementById('burnRate').textContent = fmtCost(hourlyRate) + '/hr';
    document.getElementById('projectedMonth').textContent = '~' + fmtCost(dailyProjection * 30) + '/month projected';
  }
}

function bump(el) {
  el.classList.remove('bump');
  void el.offsetWidth;
  el.classList.add('bump');
  setTimeout(() => el.classList.remove('bump'), 300);
}

function renderSessions() {
  const tbody = document.getElementById('sessionsBody');
  if (!state.sessions.length) {
    tbody.innerHTML = '<tr><td colspan="4" style="color:var(--text-dim);text-align:center;padding:20px;">Waiting for API calls...</td></tr>';
    return;
  }
  tbody.innerHTML = state.sessions.slice(0, 8).map(s => {
    const costColor = s.cost > 5 ? 'var(--red)' : s.cost > 0.5 ? 'var(--amber)' : 'var(--green)';
    return '<tr>' +
      '<td style="color:var(--cyan)">' + (s.name.length > 18 ? s.name.slice(0,16)+'…' : s.name) + '</td>' +
      '<td style="color:var(--text-dim)">' + (s.model || '—') + '</td>' +
      '<td>' + s.messages + '</td>' +
      '<td style="color:' + costColor + '">' + fmtCost(s.cost) + '</td>' +
    '</tr>';
  }).join('');
}

function renderModelDonut() {
  const container = document.getElementById('modelDonut');
  const models = Object.entries(state.models).sort((a,b) => b[1] - a[1]);
  if (!models.length) { container.innerHTML = '<div class="no-data">Waiting for data...</div>'; return; }

  const total = models.reduce((s, [,v]) => s + v, 0);
  const colors = ['#22d3ee', '#a78bfa', '#f59e0b', '#ef4444', '#22c55e', '#f97316'];
  let cumAngle = 0;
  const paths = models.map(([name, cost], i) => {
    const pct = cost / total;
    const startAngle = cumAngle;
    cumAngle += pct * 360;
    const endAngle = cumAngle;
    const r = 60, cx = 80, cy = 80;
    const x1 = cx + r * Math.cos((startAngle - 90) * Math.PI / 180);
    const y1 = cy + r * Math.sin((startAngle - 90) * Math.PI / 180);
    const x2 = cx + r * Math.cos((endAngle - 90) * Math.PI / 180);
    const y2 = cy + r * Math.sin((endAngle - 90) * Math.PI / 180);
    const large = pct > 0.5 ? 1 : 0;
    const ri = 35;
    const x3 = cx + ri * Math.cos((endAngle - 90) * Math.PI / 180);
    const y3 = cy + ri * Math.sin((endAngle - 90) * Math.PI / 180);
    const x4 = cx + ri * Math.cos((startAngle - 90) * Math.PI / 180);
    const y4 = cy + ri * Math.sin((startAngle - 90) * Math.PI / 180);
    return { path: 'M'+x1+','+y1+' A'+r+','+r+' 0 '+large+' 1 '+x2+','+y2+' L'+x3+','+y3+' A'+ri+','+ri+' 0 '+large+' 0 '+x4+','+y4+' Z', color: colors[i % colors.length], name, cost, pct };
  });

  container.innerHTML =
    '<svg class="donut-svg" viewBox="0 0 160 160">' +
    paths.map(p => '<path d="'+p.path+'" fill="'+p.color+'" opacity="0.85"><title>'+p.name+': '+fmtCost(p.cost)+' ('+(p.pct*100).toFixed(1)+'%)</title></path>').join('') +
    '<text x="80" y="78" text-anchor="middle" fill="white" font-family="JetBrains Mono" font-weight="800" font-size="16">'+fmtCost(total)+'</text>' +
    '<text x="80" y="94" text-anchor="middle" fill="#64748b" font-family="JetBrains Mono" font-size="10">total</text>' +
    '</svg>' +
    '<div class="donut-legend">' +
    paths.map(p => '<div class="legend-item"><div class="legend-dot" style="background:'+p.color+'"></div><span style="color:var(--text-dim)">'+p.name+'</span><span style="margin-left:auto;font-family:JetBrains Mono;font-size:12px;color:'+p.color+'">'+fmtCost(p.cost)+'</span></div>').join('') +
    '</div>';
}

function renderHourlyChart() {
  fetch('/api/hourly').then(r => r.json()).then(data => {
    const chart = document.getElementById('hourlyChart');
    if (!data.length) { chart.innerHTML = '<div class="no-data">No hourly data yet</div>'; return; }
    const max = Math.max(...data.map(d => d.cost), 0.01);
    const hours = Array.from({length: 24}, (_, i) => String(i).padStart(2, '0'));
    const dataMap = Object.fromEntries(data.map(d => [d.hour, d]));

    chart.innerHTML = hours.map(h => {
      const d = dataMap[h];
      const cost = d ? d.cost : 0;
      const height = Math.max(2, (cost / max) * 180);
      const now = new Date().getHours();
      const isCurrent = parseInt(h) === now;
      const color = isCurrent ? 'var(--amber)' : 'var(--cyan)';
      return '<div class="bar-group">' +
        '<div class="bar" style="height:'+height+'px;background:linear-gradient(180deg,'+color+','+color+'44)">' +
        (cost > 0 ? '<div class="bar-value">'+fmtCost(cost)+'</div>' : '') +
        '</div>' +
        '<div class="bar-label"'+(isCurrent?' style="color:var(--amber);font-weight:700"':'')+'>'+h+'</div>' +
      '</div>';
    }).join('');
  }).catch(() => {});
}

function renderHistory() {
  fetch('/api/history?days=14').then(r => r.json()).then(data => {
    historyData = data;
    const chart = document.getElementById('historyChart');
    if (!data.length) { chart.innerHTML = '<div class="no-data">No history yet — data builds over time</div>'; return; }
    const sorted = [...data].sort((a,b) => a.date.localeCompare(b.date));
    const max = Math.max(...sorted.map(d => d.total_cost), 0.01);

    chart.innerHTML = sorted.map(d => {
      const height = Math.max(2, (d.total_cost / max) * 140);
      const isToday = d.date === new Date().toISOString().slice(0,10);
      const color = isToday ? 'var(--amber)' : 'var(--cyan)';
      const label = d.date.slice(5);
      return '<div class="bar-group">' +
        '<div class="bar" style="height:'+height+'px;background:linear-gradient(180deg,'+color+','+color+'44)" title="'+d.date+': '+fmtCost(d.total_cost)+' ('+d.total_messages+' msgs)">' +
        '<div class="bar-value">'+fmtCost(d.total_cost)+'</div>' +
        '</div>' +
        '<div class="bar-label"'+(isToday?' style="color:var(--amber);font-weight:700"':'')+'>'+label+'</div>' +
      '</div>';
    }).join('');

    // Yesterday comparison
    if (sorted.length >= 2) {
      const todayData = sorted[sorted.length - 1];
      const yesterData = sorted[sorted.length - 2];
      if (todayData && yesterData && yesterData.total_cost > 0) {
        const diff = todayData.total_cost - yesterData.total_cost;
        const pct = (diff / yesterData.total_cost * 100);
        const el = document.getElementById('todayVsYesterday');
        if (diff > 0) {
          el.innerHTML = '<span style="color:var(--red)">▲ +'+fmtCost(diff)+' vs yesterday (+'+pct.toFixed(0)+'%)</span>';
        } else {
          el.innerHTML = '<span style="color:var(--green)">▼ '+fmtCost(diff)+' vs yesterday ('+pct.toFixed(0)+'%)</span>';
        }
      }
    }
  }).catch(() => {});
}

function renderHeatmap() {
  fetch('/api/history?days=35').then(r => r.json()).then(data => {
    const heatmap = document.getElementById('heatmap');
    const costMap = Object.fromEntries(data.map(d => [d.date, d.total_cost]));
    const maxCost = Math.max(...data.map(d => d.total_cost), 1);

    let cells = '';
    for (let i = 34; i >= 0; i--) {
      const d = new Date(); d.setDate(d.getDate() - i);
      const ds = d.toISOString().slice(0, 10);
      const cost = costMap[ds] || 0;
      const intensity = cost > 0 ? Math.max(0.15, cost / maxCost) : 0;
      const color = cost === 0 ? 'var(--text-muted)' :
        intensity > 0.7 ? 'rgba(239,68,68,' + intensity + ')' :
        intensity > 0.3 ? 'rgba(245,158,11,' + intensity + ')' :
        'rgba(34,211,238,' + intensity + ')';
      cells += '<div class="heat-cell" style="background:' + color + '" title="' + ds + ': ' + fmtCost(cost) + '"></div>';
    }
    heatmap.innerHTML = cells;
  }).catch(() => {});
}

function renderLeaderboard() {
  fetch('/api/top-messages').then(r => r.json()).then(data => {
    const el = document.getElementById('leaderboard');
    if (!data.length) { el.innerHTML = '<div class="no-data">No calls yet today</div>'; return; }
    el.innerHTML = data.slice(0, 5).map((msg, i) => {
      const rankClass = i === 0 ? 'rank-1' : i === 1 ? 'rank-2' : i === 2 ? 'rank-3' : 'rank-n';
      const time = new Date(msg.timestamp).toLocaleTimeString();
      return '<div class="leaderboard-item">' +
        '<div class="leaderboard-rank ' + rankClass + '">' + (i+1) + '</div>' +
        '<div style="flex:1"><div style="font-weight:600;font-size:14px;color:var(--amber)">'+fmtCost(msg.cost)+'</div>' +
        '<div style="font-size:11px;color:var(--text-dim)">' + msg.session_name + ' · ' + (msg.model||'').split('/').pop() + ' · ' + time + '</div></div>' +
        '<div style="font-size:11px;color:var(--text-dim);text-align:right">' + fmtTokens(msg.total_tokens) + ' tok' +
        (msg.cache_write > 10000 ? '<br/>' + fmtTokens(msg.cache_write) + ' cache' : '') + '</div>' +
      '</div>';
    }).join('');
  }).catch(() => {});
}

function renderFeed() {
  const el = document.getElementById('liveFeed');
  if (!recentEvents.length) { el.innerHTML = '<div class="no-data">Waiting for API calls...</div>'; return; }
  el.innerHTML = recentEvents.slice(0, 12).map(ev => {
    const time = new Date(ev.time).toLocaleTimeString();
    const costColor = ev.cost > 0.5 ? 'var(--red)' : ev.cost > 0.05 ? 'var(--amber)' : 'var(--green)';
    return '<div class="feed-item">' +
      '<span style="color:var(--text-dim)">' + time + '</span>' +
      '<span style="color:var(--cyan)">' + ev.session + '</span>' +
      '<span style="color:var(--text-dim)">' + (ev.model||'').slice(0,20) + '</span>' +
      '<span style="color:' + costColor + ';text-align:right">' + fmtCost(ev.cost) + '</span>' +
      '<span style="color:var(--text-dim);text-align:right">' + fmtTokens(ev.tokens) + '</span>' +
    '</div>';
  }).join('');
}

// ── SSE Connection ────────────────────────────────────
function connect() {
  const es = new EventSource('/api/stream');

  es.onmessage = (e) => {
    const data = JSON.parse(e.data);

    if (data.type === 'init') {
      state = data.today;
      recentEvents = data.recent || [];
      renderAll();
      return;
    }

    if (data.type === 'event') {
      state = data.today;
      recentEvents.unshift(data.event);
      if (recentEvents.length > 50) recentEvents.length = 50;

      updateBigNumbers();
      renderSessions();
      renderModelDonut();
      renderFeed();
      triggerPacTurbo(); // 🦞 CHOMP CHOMP
    }
  };

  es.onerror = () => {
    es.close();
    setTimeout(connect, 3000);
  };
}

function renderAll() {
  updateBigNumbers();
  renderSessions();
  renderModelDonut();
  renderHourlyChart();
  renderHistory();
  renderHeatmap();
  renderLeaderboard();
  renderFeed();
}

// ── Init ──────────────────────────────────────────────
connect();

// Refresh charts every 30s
setInterval(() => {
  renderHourlyChart();
  renderHistory();
  renderHeatmap();
  renderLeaderboard();
}, 30000);
</script>
</body>
</html>`;
}

module.exports = { startWebDashboard };


Security scan verdicts — ClawScan: suspicious | static analysis: suspicious | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)