Skill: vintlin/usage-visualizer (version 1.1.3)

--- name: usage-visualizer description: Advanced usage statistics and high-fidelity visual reporting for OpenClaw. 100% local processing. Audit-verified privacy (No credentials stored). metadata: openclaw: emoji: "📊" os: - darwin - linux requires: bins: - python3 - chromium env: - OPENCLAW_WORKSPACE install: - id: pip-deps kind: exec command: "pip3 install -r requirements.txt" label: "Install Python dependencies" --- # Usage Visualizer **Usage Visualizer** is a high-fidelity analytics engine for OpenClaw that transforms raw session logs into professional, actionable visual reports. ## 🚀 Quick Start ```bash # Generate today's visual report python3 scripts/run_usage_report.py --mode image --period today ``` ## 📈 Usage Guide ### Visual Reports The visualizer syncs logs first, then generates the report image. - `python3 scripts/run_usage_report.py --mode image --period today` - `python3 scripts/run_usage_report.py --mode image --period week --json` ### Text Summaries - `python3 scripts/run_usage_report.py --mode text --period today --json` ## 🛡 Delivery Protocol (MANDATORY FOR AGENTS) 1. **Image Delivery**: Extract `image_path` from JSON and send as attachment using `message` tool's `filePath`. **NEVER** send the local path as a string. 2. **Verification**: Verify the file exists and is a valid PNG before sending. 3. **No Network**: This skill is 100% local. It has zero network dependencies. ## 📄 License MIT

path: CHANGELOG.md
content: # Changelog

All notable changes to this project will be documented in this file.

## [1.0.1] - 2026-02-17
### Fixed
- Removed redundant generated images from `assets/` directory.
- Cleaned up project root to ensure only essential files are packaged.

## [1.0.0] - 2026-02-17
### Added
- Professional horizontal PPT-style reporting.
- 30-day SVG trend lines and model efficiency metrics.
- Anthropic prompt caching (read/write) savings tracking.
- Multi-dimensional charts for token breakdown and provider distribution.
- SQLite persistence layer for fast querying.
- Automatic session log detection for OpenClaw/Clawdbot.

### Changed
- **Major Rename**: Project changed from `llm-cost-monitor` to `usage-visualizer` to better reflect focus on usage analytics and high-fidelity visualization.
- Refactored project structure: moved assets to `/assets`, cleaned up redundant scripts.
- Updated documentation and skill metadata for improved discovery.


path: README.md
content: # Usage Visualizer 📊

**Usage Visualizer** is a high-fidelity analytics engine for OpenClaw that transforms raw session logs into professional, actionable visual reports.

## 🛡 Security & Privacy First

- **100% Local**: All processing happens on your machine.
- **No External Calls**: This skill does NOT use webhooks or external APIs. It has zero network dependencies.
- **Privacy**: Only extracts token counts and model names. No conversation content is transmitted.
- **Native Delivery**: Reports are delivered via OpenClaw's internal message system.

## ✨ Key Features

- 📊 **High-Res Visual Reporting** - Generates horizontal PPT-style cards with 30-day SVG trend lines.
- ⚡ **Token-First Analytics** - Deep dive into input/output tokens and cache performance.
- 🔄 **Zero-Config Sync** - Auto-detects OpenClaw session logs from your workspace.
- 🎨 **Beautiful Console Output** - Clean, emoji-rich text summaries.

## 🚀 Quick Start

```bash
# Install dependencies
pip install -r requirements.txt

# Generate your first visual report (Today)
python3 scripts/run_usage_report.py --mode image --period today
```

## 📈 Usage Guide

### Visual Reports
```bash
# Today image report
python3 scripts/run_usage_report.py --mode image --period today

# Weekly image report
python3 scripts/run_usage_report.py --mode image --period week
```

### Text Summaries
```bash
# Current day summary
python3 scripts/run_usage_report.py --mode text --period today
```

## 🛠 Project Structure

- `scripts/fetch_usage.py`: Log parser and SQLite sync engine.
- `scripts/report.py`: Text/JSON reporting.
- `scripts/generate_report_image.py`: PNG renderer (headless browser).
- `scripts/run_usage_report.py`: One-step runner for Agents.

## 📝 Prerequisites

- **Python 3.8+**
- **Chromium/Chrome**: Required for image rendering.
- **jq**: Required for shell integrations.

## 📄 License
MIT


path: _meta.json
content: ownerId: kn7brrz9rnywpzp52pka3bcjy980magk
slug: usage-visualizer
version: 1.1.3
publishedAt: 1771308392915

path: package.json
content: {
  "name": "usage-visualizer",
  "version": "1.1.3",
  "description": "Advanced usage statistics and high-fidelity visual reporting for OpenClaw. 100% local processing.",
  "main": "scripts/run_usage_report.py",
  "scripts": {
    "fetch": "python3 scripts/fetch_usage.py",
    "report": "python3 scripts/report.py",
    "report:image": "python3 scripts/run_usage_report.py --mode image --period today",
    "report:text": "python3 scripts/run_usage_report.py --mode text --period today"
  },
  "keywords": [
    "openclaw",
    "usage",
    "analytics",
    "visualizer",
    "local-first"
  ],
  "author": "VintLin",
  "license": "MIT",
  "homepage": "https://github.com/VintLin/llm-cost-monitor",
  "repository": {
    "type": "git",
    "url": "https://github.com/VintLin/llm-cost-monitor"
  },
  "openclaw": {
    "emoji": "📊",
    "os": [
      "darwin",
      "linux"
    ],
    "requires": {
      "bins": [
        "python3",
        "chromium"
      ],
      "env": [
        "OPENCLAW_WORKSPACE"
      ]
    },
    "install": [
      {
        "id": "pip-deps",
        "kind": "exec",
        "command": "pip3 install -r requirements.txt",
        "label": "Install Python dependencies"
      }
    ]
  }
}


path: requirements.txt
content: pyyaml>=6.0
html2image>=2.0.0
Pillow>=10.0.0


path: scripts/calc_cost.py
content: """
Model pricing data for LLM cost calculation
Based on LiteLLM's model_prices_and_context_window.json
Updated: 2026-02-16
"""
import json
import os
from typing import Dict, Optional, Tuple, Set

# Track models without pricing
UNKNOWN_MODELS: Set[str] = set()


def get_unknown_models() -> Set[str]:
    """Get set of models without pricing"""
    return UNKNOWN_MODELS.copy()


def clear_unknown_models():
    """Clear unknown models tracking"""
    UNKNOWN_MODELS.clear()

# Model pricing (per 1M tokens)
# Format: "model_name": {"input": price, "output": price, "cache_read": price, "cache_creation": price}
MODEL_PRICING = {
    # OpenAI Models
    "gpt-5": {"input": 1.25, "output": 10.0, "cache_read": 0, "cache_creation": 0},
    "gpt-5-mini": {"input": 0.25, "output": 1.0, "cache_read": 0, "cache_creation": 0},
    "gpt-4.1": {"input": 2.0, "output": 8.0, "cache_read": 0, "cache_creation": 0},
    "gpt-4.1-mini": {"input": 0.15, "output": 0.6, "cache_read": 0, "cache_creation": 0},
    "gpt-4.1-nano": {"input": 0.1, "output": 0.4, "cache_read": 0, "cache_creation": 0},
    "gpt-4o": {"input": 2.5, "output": 10.0, "cache_read": 0, "cache_creation": 0},
    "gpt-4o-mini": {"input": 0.15, "output": 0.6, "cache_read": 0, "cache_creation": 0},
    "gpt-4o-audio-preview": {"input": 2.5, "output": 10.0, "cache_read": 0, "cache_creation": 0},
    "gpt-4o-realtime-preview": {"input": 2.5, "output": 10.0, "cache_read": 0, "cache_creation": 0},
    "o1": {"input": 15.0, "output": 60.0, "cache_read": 0, "cache_creation": 0},
    "o1-mini": {"input": 1.1, "output": 4.4, "cache_read": 0, "cache_creation": 0},
    "o1-pro": {"input": 15.0, "output": 60.0, "cache_read": 0, "cache_creation": 0},
    "gpt-4-turbo": {"input": 10.0, "output": 30.0, "cache_read": 0, "cache_creation": 0},
    "gpt-4-turbo-2024-04-09": {"input": 10.0, "output": 30.0, "cache_read": 0, "cache_creation": 0},
    "gpt-4": {"input": 30.0, "output": 60.0, "cache_read": 0, "cache_creation": 0},
    "gpt-4-32k": {"input": 60.0, "output": 120.0, "cache_read": 0, "cache_creation": 0},
    "gpt-3.5-turbo": {"input": 0.5, "output": 1.5, "cache_read": 0, "cache_creation": 0},
    "gpt-3.5-turbo-0125": {"input": 0.5, "output": 1.5, "cache_read": 0, "cache_creation": 0},
    "gpt-3.5-turbo-1106": {"input": 1.0, "output": 2.0, "cache_read": 0, "cache_creation": 0},

    # Anthropic Models
    "claude-opus-4.1": {"input": 15.0, "output": 75.0, "cache_read": 1.5, "cache_creation": 18.75},
    "claude-opus-4": {"input": 15.0, "output": 75.0, "cache_read": 1.5, "cache_creation": 18.75},
    "claude-opus-3-5": {"input": 15.0, "output": 75.0, "cache_read": 1.5, "cache_creation": 18.75},
    "claude-sonnet-4.5": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_creation": 3.75},
    "claude-sonnet-4": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_creation": 3.75},
    "claude-sonnet-3.5": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_creation": 3.75},
    "claude-3.5-sonnet-20241022": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_creation": 3.75},
    "claude-3.5-sonnet-20240620": {"input": 3.0, "output": 15.0, "cache_read": 0.3, "cache_creation": 3.75},
    "claude-3.5-haiku": {"input": 0.25, "output": 1.25, "cache_read": 0.03, "cache_creation": 0.3},
    "claude-3.5-haiku-20240620": {"input": 0.25, "output": 1.25, "cache_read": 0.03, "cache_creation": 0.3},
    "claude-3-opus": {"input": 15.0, "output": 75.0, "cache_read": 0, "cache_creation": 0},
    "claude-3-sonnet": {"input": 3.0, "output": 15.0, "cache_read": 0, "cache_creation": 0},
    "claude-3-haiku": {"input": 0.25, "output": 1.25, "cache_read": 0, "cache_creation": 0},

    # MiniMax Models
    "minimax": {"input": 0.0, "output": 0.0, "cache_read": 0, "cache_creation": 0},
    "minimax-m2.5": {"input": 0.0, "output": 0.0, "cache_read": 0, "cache_creation": 0},
    "MiniMax-M2.5": {"input": 0.0, "output": 0.0, "cache_read": 0, "cache_creation": 0},

    # Gemini Models
    "gemini-2.0-flash-exp": {"input": 0.0, "output": 0.0, "cache_read": 0, "cache_creation": 0},
    "gemini-2.0-flash-thinking-exp": {"input": 0.0, "output": 0.0, "cache_read": 0, "cache_creation": 0},
    "gemini-3-flash": {"input": 0.075, "output": 0.3, "cache_read": 0, "cache_creation": 0},
    "gemini-1.5-pro": {"input": 1.25, "output": 5.0, "cache_read": 0, "cache_creation": 0},
    "gemini-1.5-flash": {"input": 0.075, "output": 0.3, "cache_read": 0, "cache_creation": 0},
    "gemini-1.5-flash-8b": {"input": 0.0375, "output": 0.15, "cache_read": 0, "cache_creation": 0},
    "gemini-1.0-pro": {"input": 0.5, "output": 1.5, "cache_read": 0, "cache_creation": 0},
    "gemini-1.0-pro-vision": {"input": 0.25, "output": 0.5, "cache_read": 0, "cache_creation": 0},
    "gemini-ultra": {"input": 2.0, "output": 6.0, "cache_read": 0, "cache_creation": 0},
}

# Model aliases (map common names to pricing keys)
MODEL_ALIASES = {
    # OpenAI
    "gpt-4o-2024-05-13": "gpt-4o",
    "gpt-4o-mini-2024-07-18": "gpt-4o-mini",
    "chatgpt-4o-latest": "gpt-4o",
    "chatgpt-4o-mini-latest": "gpt-4o-mini",
    "gpt-4-turbo-preview": "gpt-4-turbo",
    "gpt-4-0125-preview": "gpt-4-turbo",
    "gpt-4-1106-preview": "gpt-4-turbo",
    "o1-2024-12-17": "o1",
    "o1-mini-2024-09-16": "o1-mini",

    # Anthropic
    "claude-opus-4-5-20250501": "claude-opus-4.1",
    "claude-sonnet-4-5-20250501": "claude-sonnet-4.5",
    "claude-sonnet-3.5-20240620": "claude-sonnet-3.5",
    "claude-3-5-sonnet-20240620": "claude-sonnet-3.5",
    "claude-3-5-haiku-20240620": "claude-3.5-haiku",
    "claude-3-opus-20240229": "claude-3-opus",
    "claude-3-sonnet-20240229": "claude-3-sonnet",
    "claude-3-haiku-20240307": "claude-3-haiku",

    # Gemini
    "gemini-1.5-pro-001": "gemini-1.5-pro",
    "gemini-1.5-flash-001": "gemini-1.5-flash",
    "gemini-1.5-flash-8b-exp-0924": "gemini-1.5-flash-8b",
    "gemini-1.5-flash-exp-0820": "gemini-1.5-flash",
    "gemini-1.0-pro-001": "gemini-1.0-pro",
    "gemini-1.0-pro-vision-001": "gemini-1.0-pro-vision",
}


def get_pricing(model: str) -> Optional[Dict]:
    """Get pricing for a model"""
    # Check aliases first
    pricing_key = MODEL_ALIASES.get(model, model)

    # Try exact match
    if pricing_key in MODEL_PRICING:
        return MODEL_PRICING[pricing_key]

    # Try partial match (e.g., gpt-4o-mini matches gpt-4o-mini)
    for key in MODEL_PRICING:
        if key in model or model in key:
            return MODEL_PRICING[key]

    return None


def calculate_cost(
    model: str,
    input_tokens: int = 0,
    output_tokens: int = 0,
    cache_read_tokens: int = 0,
    cache_creation_tokens: int = 0
) -> Tuple[float, float]:
    """
    Calculate cost for a request

    Args:
        model: Model name
        input_tokens: Number of input tokens
        output_tokens: Number of output tokens
        cache_read_tokens: Number of cache read tokens (Anthropic)
        cache_creation_tokens: Number of cache creation tokens (Anthropic)

    Returns:
        (Cost, Savings) in USD
    """
    pricing = get_pricing(model)

    if pricing is None:
        # No pricing available - track and return 0
        UNKNOWN_MODELS.add(model)
        return 0.0, 0.0

    # Base cost calculation
    input_price = pricing.get("input", 0) / 1_000_000
    output_price = pricing.get("output", 0) / 1_000_000
    cache_read_price = pricing.get("cache_read", 0) / 1_000_000
    cache_creation_price = pricing.get("cache_creation", 0) / 1_000_000

    # Calculate costs
    # For regular input tokens (not from cache)
    billable_input = input_tokens - cache_read_tokens
    if billable_input < 0:
        billable_input = 0

    input_cost = billable_input * input_price

    # Cache read tokens (90% discount for Anthropic)
    cache_read_cost = cache_read_tokens * cache_read_price

    # Cache creation tokens
    cache_creation_cost = cache_creation_tokens * cache_creation_price

    # Output tokens
    output_cost = output_tokens * output_price

    total_cost = input_cost + cache_read_cost + cache_creation_cost + output_cost

    # Calculate Savings (What it would have cost without cache)
    # Savings = (cache_read_tokens * input_price) - cache_read_cost
    savings = cache_read_tokens * (input_price - cache_read_price)
    if savings < 0: savings = 0

    return float(round(total_cost, 6)), float(round(savings, 6))


def load_pricing_from_file(filepath: str) -> Dict:
    """Load pricing from a JSON file"""
    if os.path.exists(filepath):
        with open(filepath, 'r') as f:
            return json.load(f)
    return {}


def save_pricing_to_file(pricing: Dict, filepath: str):
    """Save pricing to a JSON file"""
    with open(filepath, 'w') as f:
        json.dump(pricing, f, indent=2)


if __name__ == "__main__":
    # Test
    test_cases = [
        ("gpt-4o", 1000, 500, 0, 0),
        ("claude-sonnet-4.5", 1000, 500, 2000, 500),
        ("gemini-1.5-pro", 5000, 1000, 0, 0),
    ]

    print("Cost Calculation Test:")
    print("-" * 50)
    for model, inp, out, cache_r, cache_c in test_cases:
        cost, savings = calculate_cost(model, inp, out, cache_r, cache_c)
        print(f"{model}: {inp} in, {out} out, {cache_r} cache_r, {cache_c} cache_c = ${cost:.4f} (Saved: ${savings:.4f})")


path: scripts/fetch_usage.py
content: #!/usr/bin/env python3
"""
Fetch usage data from OpenClaw session logs. 100% Local.
"""
import argparse
import json
import os
import sys
import glob
from datetime import datetime, timedelta
from typing import Dict, List

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from store import UsageStore
from calc_cost import calculate_cost


# Default OpenClaw session paths
DEFAULT_OPENCLAW_PATHS = [
    os.path.expanduser("~/.openclaw/agents/*/sessions/*.jsonl"),
    os.path.expanduser("~/.clawdbot/agents/*/sessions/*.jsonl"),
]


def find_session_files(patterns: List[str] = None) -> List[str]:
    """Find all OpenClaw session JSONL files"""
    if patterns is None:
        patterns = DEFAULT_OPENCLAW_PATHS

    files = []
    for pattern in patterns:
        files.extend(glob.glob(pattern))

    return sorted(set(files))


def parse_openclaw_session(file_path: str, date: str = None) -> List[Dict]:
    """Parse OpenClaw session file and extract usage data"""
    usage_records = []

    try:
        with open(file_path, 'r') as f:
            for line in f:
                if not line.strip():
                    continue

                try:
                    data = json.loads(line)
                except json.JSONDecodeError:
                    continue

                # Extract timestamp to filter by date
                timestamp = data.get("timestamp") or data.get("created_at")
                record_date = datetime.now().strftime("%Y-%m-%d")
                if timestamp:
                    try:
                        # Handle various timestamp formats
                        if isinstance(timestamp, (int, float)):
                            record_date = datetime.fromtimestamp(timestamp / 1000).strftime("%Y-%m-%d")
                        else:
                            record_date = datetime.fromisoformat(timestamp.replace("Z", "+00:00")).strftime("%Y-%m-%d")

                        if date and record_date != date:
                            continue
                    except (ValueError, OSError):
                        pass

                # Extract usage data from message
                message = data.get("message", {})

                # Try different paths for usage
                usage = (
                    message.get("usage") or
                    data.get("usage") or
                    data.get("metrics", {}).get("usage")
                )

                if not usage:
                    continue

                # Extract model info
                model = (
                    message.get("model") or
                    data.get("model") or
                    data.get("model_alias") or
                    "unknown"
                )

                # Determine provider from model
                model_lower = model.lower()
                if "claude" in model_lower or "anthropic" in model_lower:
                    provider = "anthropic"
                elif "gpt" in model_lower or "openai" in model_lower:
                    provider = "openai"
                elif "gemini" in model_lower:
                    provider = "gemini"
                else:
                    provider = "unknown"

                # Extract token counts
                input_tokens = usage.get("inputTokens", 0) or usage.get("input_tokens", 0) or 0
                output_tokens = usage.get("outputTokens", 0) or usage.get("output_tokens", 0) or 0
                total_tokens = usage.get("totalTokens", 0) or usage.get("total_tokens", 0) or 0

                # Handle combined total if separate not available
                if not input_tokens and not output_tokens and total_tokens:
                    input_tokens = total_tokens // 2
                    output_tokens = total_tokens - input_tokens

                # Cache tokens (Anthropic)
                cache_read_tokens = usage.get("cacheReadTokens", 0) or usage.get("cache_read_tokens", 0) or 0
                cache_creation_tokens = usage.get("cacheCreationTokens", 0) or usage.get("cache_creation_tokens", 0) or 0

                # Cost - prefer real cost if available
                cost = None
                if isinstance(usage.get("cost"), dict):
                    cost = usage.get("cost", {}).get("total")
                elif "cost" in usage:
                    cost = usage.get("cost")
                elif "totalCost" in usage:
                    cost = usage.get("totalCost")
                
                # If no real cost, calculate it
                if cost is None:
                    cost_val, savings = calculate_cost(model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens)
                else:
                    cost_val = cost
                    _, savings = calculate_cost(model, input_tokens, output_tokens, cache_read_tokens, cache_creation_tokens)

                if input_tokens or output_tokens or cost_val:
                    usage_records.append({
                        "date": record_date,
                        "provider": provider,
                        "model": model,
                        "input_tokens": input_tokens,
                        "output_tokens": output_tokens,
                        "cache_read_tokens": cache_read_tokens,
                        "cache_creation_tokens": cache_creation_tokens,
                        "cost": cost_val,
                        "savings": savings
                    })

    except Exception as e:
        pass

    return usage_records


def fetch_openclow_usage(date: str = None, storage_path: str = "~/.llm-cost-monitor", force_full: bool = False) -> int:
    """Fetch usage from OpenClaw sessions"""
    store = UsageStore(storage_path)

    if date and not force_full:
        store.clear_records(date=date, source="session")
    elif force_full:
        store.clear_records(source="session")

    session_files = find_session_files()
    if not session_files:
        return 0

    total_records = 0
    for file_path in session_files:
        records = parse_openclaw_session(file_path, date)
        for record in records:
            # Use the agent directory name as the unique source identifier
            source_id = os.path.basename(os.path.dirname(os.path.dirname(file_path)))
            app = "openclaw" if ".openclaw" in file_path else "clawdbot"

            store.add_usage(
                date=record["date"],
                provider=record["provider"],
                source_id=source_id,
                model=record["model"],
                app=app,
                source="session",
                input_tokens=record["input_tokens"],
                output_tokens=record["output_tokens"],
                cache_read_tokens=record.get("cache_read_tokens", 0),
                cache_creation_tokens=record.get("cache_creation_tokens", 0),
                cost=record["cost"],
                savings=record.get("savings", 0)
            )
            total_records += 1

    return total_records


def main():
    parser = argparse.ArgumentParser(description="Fetch LLM usage data")
    parser.add_argument("--date", type=str, help="Specific date (YYYY-MM-DD)")
    parser.add_argument("--today", action="store_true", help="Fetch today's usage")
    parser.add_argument("--yesterday", action="store_true", help="Fetch yesterday's usage")
    parser.add_argument("--last-days", type=int, help="Fetch last N days")
    parser.add_argument("--full", action="store_true", help="Full scan")

    args = parser.parse_args()

    dates = []
    today = datetime.now()

    if args.today:
        dates.append(today.strftime("%Y-%m-%d"))
    elif args.yesterday:
        dates.append((today - timedelta(days=1)).strftime("%Y-%m-%d"))
    elif args.last_days:
        for i in range(args.last_days):
            dates.append((today - timedelta(days=i+1)).strftime("%Y-%m-%d"))
    elif args.date:
        dates.append(args.date)
    else:
        dates.append(today.strftime("%Y-%m-%d"))

    # Workspace handling
    storage_path = os.environ.get("OPENCLAW_WORKSPACE", "~/.llm-cost-monitor")

    if args.full:
        fetch_openclow_usage(date=None, storage_path=storage_path, force_full=True)
    else:
        for date in dates:
            fetch_openclow_usage(date, storage_path)


if __name__ == "__main__":
    main()


path: scripts/generate_report_image.py
content: #!/usr/bin/env python3
"""
Generate LLM cost report image with smart cropping.
Usage: python3 generate_report_image.py [--today|--yesterday|--period week|month]
"""
import argparse
import os
import sys
from pathlib import Path

# Add scripts dir to path
SCRIPT_DIR = Path(__file__).parent
sys.path.insert(0, str(SCRIPT_DIR))

from html2image import Html2Image
from PIL import Image
from fetch_usage import UsageStore
from datetime import datetime, timedelta


def generate_image(start_date: str = None, end_date: str = None, output_path: str = None):
    """Generate cost report image with smart cropping."""
    
    # Default: today
    if not start_date:
        today = datetime.now()
        start_date = today.replace(day=1).strftime("%Y-%m-%d")
        end_date = today.strftime("%Y-%m-%d")
    
    # Use script directory as working dir
    script_dir = Path(__file__).parent
    os.chdir(script_dir)
    
    # Determine trusted output directory
    # 1. User provided output_path
    # 2. OPENCLAW_WORKSPACE environment variable
    # 3. Project root (parent of script_dir)
    workspace = os.environ.get("OPENCLAW_WORKSPACE")
    project_root = script_dir.parent
    
    if output_path:
        final_output_path = Path(output_path)
    elif workspace:
        final_output_path = Path(workspace) / "llm-cost-report.png"
    else:
        final_output_path = project_root / "llm-cost-report.png"
    
    # Temporary files for processing
    # Use the same directory as final output for intermediate files to stay in "trusted" zone
    output_dir = final_output_path.parent
    html_path = output_dir / "llm-cost-report.html"
    raw_png_path = output_dir / "llm-cost-report-raw.png"
    
    print(f"Generating report for {start_date} ~ {end_date}")
    
    # Fetch data
    store = UsageStore()
    data = store.get_daily_summary(start_date, end_date)
    
    if not data:
        print("No data found for the specified period")
        return None
    
    # Import here to generate HTML
    from html_report import generate_html_report
    html = generate_html_report(store, start_date, end_date)
    
    # Save HTML
    with open(html_path, "w", encoding="utf-8") as f:
        f.write(html)
    print(f"HTML saved to {html_path}")
    
    # Generate image with html2image
    hti = Html2Image()
    hti.output_path = str(output_dir)
    # High-resolution PPT viewport (1440p style ratio, increased height for safety)
    hti.size = (1200, 1000) 
    hti.screenshot(url=str(html_path), save_as=raw_png_path.name)
    
    # Verify raw image was created
    if not raw_png_path.exists() or raw_png_path.stat().st_size < 100:
        print(f"Error: Failed to generate raw image at {raw_png_path}")
        return None

    # Smart crop: detect content bounds
    try:
        img = Image.open(raw_png_path)
        w, h = img.size
        data = img.load()
        bg = data[0, 0]  # Background color from top-left
        
        # Find content bounds
        # Use a slightly lower threshold to catch subtle text/borders at the bottom
        threshold = 5
        top, bottom, left, right = h, 0, w, 0
        for y in range(h):
            for x in range(w):
                p = data[x, y]
                if abs(p[0]-bg[0]) > threshold or abs(p[1]-bg[1]) > threshold or abs(p[2]-bg[2]) > threshold:
                    top, bottom = min(top, y), max(bottom, y)
                    left, right = min(left, x), max(right, x)
        
        # Crop with generous padding to ensure rounded corners and shadows are visible
        padding_x = 40
        padding_y = 50
        left = max(0, left - padding_x)
        top = max(0, top - padding_y)
        right = min(w, right + padding_x)
        bottom = min(h, bottom + padding_y)
        
        cropped = img.crop((left, top, right, bottom))
        
        # Save to final path
        cropped.save(final_output_path)
    except Exception as e:
        print(f"Error during image processing: {e}")
        return None
    finally:
        # Cleanup temp files
        if html_path.exists(): html_path.unlink()
        if raw_png_path.exists(): raw_png_path.unlink()
    
    # Final validation
    if not final_output_path.exists() or final_output_path.stat().st_size < 100:
        print(f"Error: Final image missing or invalid at {final_output_path}")
        return None

    final_w = right - left
    final_h = bottom - top
    print(f"Generated: {final_w}x{final_h} -> {final_output_path}")
    
    return str(final_output_path)


def main():
    parser = argparse.ArgumentParser(description="Generate LLM cost report image")
    parser.add_argument("--today", action="store_true", help="Today's report")
    parser.add_argument("--yesterday", action="store_true", help="Yesterday's report")
    parser.add_argument("--period", choices=["week", "month"], help="Period: week or month")
    parser.add_argument("--start", help="Start date (YYYY-MM-DD)")
    parser.add_argument("--end", help="End date (YYYY-MM-DD)")
    parser.add_argument("--output", "-o", help="Output path")
    
    args = parser.parse_args()
    
    today = datetime.now()
    
    if args.today:
        start = end = today.strftime("%Y-%m-%d")
    elif args.yesterday:
        yesterday = today - timedelta(days=1)
        start = end = yesterday.strftime("%Y-%m-%d")
    elif args.period == "week":
        start = (today - timedelta(days=7)).strftime("%Y-%m-%d")
        end = today.strftime("%Y-%m-%d")
    elif args.period == "month":
        start = today.replace(day=1).strftime("%Y-%m-%d")
        end = today.strftime("%Y-%m-%d")
    elif args.start and args.end:
        start, end = args.start, args.end
    else:
        # Default: this month
        start = today.replace(day=1).strftime("%Y-%m-%d")
        end = today.strftime("%Y-%m-%d")
    
    # Change to script directory
    os.chdir(Path(__file__).parent)
    
    res = generate_image(start, end, args.output)
    if not res:
        sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/html_report.py
content: #!/usr/bin/env python3
"""
Generate visually appealing HTML reports for LLM cost monitoring - Vertical Stacked PPT Style V5 (Fixed Logic)
"""
import argparse
import os
import sys
from datetime import datetime, timedelta
from pathlib import Path

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from store import UsageStore


def generate_html_report(
    store: UsageStore,
    start_date: str = None,
    end_date: str = None,
    title: str = "AI 消耗"
) -> str:
    """Generate a horizontal PPT-style HTML report with improved Top-N logic and detailed labels"""
    
    today_dt = datetime.now()
    if not start_date:
        # Default start to the beginning of the week or month
        start_date = today_dt.replace(day=1).strftime("%Y-%m-%d")
    if not end_date:
        end_date = today_dt.strftime("%Y-%m-%d")
    
    # Trend ranges (Fixed 30 days)
    last_30_dates = [(today_dt - timedelta(days=i)).strftime("%Y-%m-%d") for i in range(29, -1, -1)]
    trend_start_date = last_30_dates[0]
    
    # Comparison range (Previous period of same length)
    requested_start = datetime.strptime(start_date, "%Y-%m-%d")
    requested_end = datetime.strptime(end_date, "%Y-%m-%d")
    period_days = (requested_end - requested_start).days + 1
    prev_start_date = (requested_start - timedelta(days=period_days)).strftime("%Y-%m-%d")
    prev_end_date = (requested_start - timedelta(days=1)).strftime("%Y-%m-%d")

    # Fetch data
    all_data = store.get_usage(min(trend_start_date, prev_start_date), end_date)
    
    daily_total = {}
    daily_models = {}
    period_model_tokens = {}
    period_model_cost = {}
    total_savings = 0
    prev_period_cost = 0
    trend_model_tokens = {} # For selecting Top N models over 30 days

    for record in all_data:
        date = record['date']
        model = record['model']
        
        # Daily aggregations for trends
        if date not in daily_total: daily_total[date] = {'cost': 0, 'tokens': 0}
        daily_total[date]['cost'] += record['cost']
        daily_total[date]['tokens'] += record['input_tokens'] + record['output_tokens']
        
        if date not in daily_models: daily_models[date] = {}
        if model not in daily_models[date]: daily_models[date][model] = {'cost': 0, 'tokens': 0}
        daily_models[date][model]['cost'] += record['cost']
        daily_models[date][model]['tokens'] += record['input_tokens'] + record['output_tokens']
        
        # Requested period stats
        if start_date <= date <= end_date:
            period_model_tokens[model] = period_model_tokens.get(model, 0) + record['input_tokens'] + record['output_tokens']
            period_model_cost[model] = period_model_cost.get(model, 0) + record['cost']
            total_savings += record.get('savings', 0)
            
        # 30-day Trend period stats (for picking Top models to show as lines)
        if trend_start_date <= date <= end_date:
            trend_model_tokens[model] = trend_model_tokens.get(model, 0) + record['input_tokens'] + record['output_tokens']
            
        # Previous period stats
        if prev_start_date <= date <= prev_end_date:
            prev_period_cost += record['cost']

    total_cost = sum(period_model_cost.values())
    total_tokens = sum(period_model_tokens.values())
    days_count = len(set(r['date'] for r in all_data if start_date <= r['date'] <= end_date)) or 1
    
    # Increase to Top 5 for Trend lines
    top_trend_models = sorted(trend_model_tokens.keys(), key=lambda x: -trend_model_tokens[x])[:5]
    colors = ["#10b981", "#3b82f6", "#f59e0b", "#a855f7", "#ec4899", "#06b6d4"]

    def fmt_tokens(t):
        if t >= 1000000: return f"{t/1000000:.1f}M"
        if t >= 1000: return f"{t/1000:.1f}K"
        return str(int(t))
    def fmt_cost(c): return f"${c:.2f}"

    def get_trend_svg(dates, key, width=580, height=140):
        max_val = max([daily_total.get(d, {}).get(key, 0) for d in dates] or [1]) or 1
        svg = f'<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" style="overflow:visible;display:block">'
        svg += f'<defs><linearGradient id="g-{key}" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" style="stop-color:{colors[0]};stop-opacity:0.2"/><stop offset="100%" style="stop-color:{colors[0]};stop-opacity:0"/></linearGradient></defs>'
        pts = [f"{(i/(len(dates)-1))*width:.1f},{height-(daily_total.get(d,{}).get(key,0)/max_val*height):.1f}" for i, d in enumerate(dates)]
        path = "L".join(pts)
        svg += f'<path d="M{path} L{width},{height} L0,{height} Z" fill="url(#g-{key})" /><path d="M{path}" fill="none" stroke="{colors[0]}" stroke-width="3" />'
        for idx, m in enumerate(top_trend_models):
            m_pts = [f"{(i/(len(dates)-1))*width:.1f},{height-(daily_models.get(d,{}).get(m,{}).get(key,0)/max_val*height):.1f}" for i, d in enumerate(dates)]
            svg += f'<path d="M{"L".join(m_pts)}" fill="none" stroke="{colors[(idx+1)%len(colors)]}" stroke-width="1.2" stroke-dasharray="3,2" opacity="0.7" />'
        return svg + '</svg>'

    last_7_dates = [(today_dt - timedelta(days=i)).strftime("%Y-%m-%d") for i in range(6, -1, -1)]
    avg_unit_cost = (total_cost / total_tokens * 1000000) if total_tokens > 0 else 0
    daily_avg_cost = (total_cost / days_count) if days_count > 0 else 0
    
    growth_html = ""
    if prev_period_cost > 0:
        growth_pct_val = ((total_cost - prev_period_cost) / prev_period_cost * 100)
        # Use high-contrast colors for visibility on gradient background
        # White text on translucent dark background for maximum readability
        color = "#ff4d4f" if growth_pct_val > 0 else "#b7eb8f"
        symbol = "↑" if growth_pct_val > 0 else "↓"
        growth_html = f'<span style="background:rgba(0,0,0,0.25);color:{color};padding:2px 10px;border-radius:100px;font-size:14px;margin-left:12px;font-weight:700;display:inline-flex;align-items:center">{symbol}{abs(growth_pct_val):.1f}%</span>'

    def clean_m(m):
        for k in ['MiniMax', 'Claude', 'Gemini', 'GPT']:
            if k.lower() in m.lower(): return k
        return (m[:10]+'..') if len(m)>10 else m

    html = f"""<!DOCTYPE html>
<html>
<head><meta charset="UTF-8"><title>{title}</title></head>
<body style="margin:0;padding:0;font-family:-apple-system,sans-serif;background:#0d0d0d;color:#fff">
    <div style="width:1100px;margin:0 auto;padding:40px;display:flex;gap:24px;align-items:stretch">
        
        <!-- Left Column -->
        <div style="width:380px;display:flex;flex-direction:column;gap:20px">
            <div style="background:linear-gradient(135deg,#059669,#10b981);border-radius:24px;padding:32px;box-shadow:0 10px 15px rgba(0,0,0,0.3)">
                <div style="font-size:16px;opacity:0.9;margin-bottom:8px">Usage Summary</div>
                <div style="font-size:56px;font-weight:800;line-height:1">{fmt_tokens(total_tokens)} <span style="font-size:20px;opacity:0.8;font-weight:600">Tokens</span></div>
                <div style="display:flex;align-items:baseline;margin-top:12px">
                    <span style="font-size:28px;font-weight:700">{fmt_cost(total_cost)}</span>{growth_html}
                </div>
                {f'<div style="margin-top:16px;font-size:14px;font-weight:600;color:#f0fdf4;display:flex;align-items:center"><span style="background:rgba(255,255,255,0.2);width:18px;height:18px;border-radius:50%;display:inline-flex;align-items:center;justify-content:center;margin-right:8px;font-size:10px">⚡</span>Saved {fmt_cost(total_savings)}</div>' if total_savings > 0 else ""}
            </div>

            <div style="background:#1a1a1a;border-radius:24px;padding:28px;border:1px solid #2a2a2a;flex-grow:1;display:flex;flex-direction:column">
                <div style="font-size:16px;font-weight:700;margin-bottom:20px;color:#10b981">Model Efficiency (Period)</div>
                <div style="flex-grow:1">
                """
    
    # Efficiency list: models used in CURRENT PERIOD
    sorted_period_models = sorted(period_model_tokens.keys(), key=lambda x: -period_model_tokens[x])
    for m in sorted_period_models[:6]:
        tokens = period_model_tokens[m]
        cost = period_model_cost[m]
        pct = (tokens / total_tokens * 100) if total_tokens > 0 else 0
        u_cost = (cost / tokens * 1000000) if tokens > 0 else 0
        html += f"""<div style="margin-bottom:24px">
                <div style="display:flex;justify-content:space-between;margin-bottom:8px;font-size:13px"><span style="color:#10b981;font-weight:600">{m[:18]}</span><span>{fmt_tokens(tokens)}</span></div>
                <div style="height:6px;background:#2a2a2a;border-radius:3px;overflow:hidden;margin-bottom:6px"><div style="height:100%;width:{pct:.1f}%;background:#10b981"></div></div>
                <div style="font-size:10px;color:#6b7280;display:flex;justify-content:space-between"><span>Unit Cost: ${u_cost:.2f}/M</span><span>Cost: {fmt_cost(cost)}</span></div></div>"""
    
    html += f"""
                </div>
            </div>

            <div style="background:#1a1a1a;border-radius:24px;padding:28px;border:1px solid #2a2a2a;display:flex;flex-direction:column;gap:16px">
                <div style="display:flex;justify-content:space-between;align-items:center"><span style="font-size:12px;color:#6b7280">Unit Cost</span><span style="font-size:20px;font-weight:700;color:#10b981">${avg_unit_cost:.2f}/M</span></div>
                <div style="height:1px;background:#2a2a2a"></div>
                <div style="display:flex;justify-content:space-between;align-items:center"><span style="font-size:12px;color:#6b7280">Daily Avg</span><span style="font-size:20px;font-weight:700;color:#10b981">{fmt_cost(daily_avg_cost)}</span></div>
            </div>
        </div>

        <!-- Right Column -->
        <div style="flex:1;display:flex;flex-direction:column;gap:24px">
            <div style="background:#1a1a1a;border-radius:24px;padding:32px;border:1px solid #2a2a2a">
                <div style="font-size:18px;font-weight:700;margin-bottom:24px;color:#10b981">Last 7 Days Activity</div>
                <div style="display:flex;gap:12px;align-items:flex-end;height:140px;padding-top:20px">
                """
    
    max_day_tok = max([daily_total.get(d,{}).get('tokens',0) for d in last_7_dates] or [1]) or 1
    for d in last_7_dates:
        tokens = daily_total.get(d,{}).get('tokens',0)
        h_pct = (tokens / max_day_tok * 100)
        label = datetime.strptime(d,"%Y-%m-%d").strftime("%m/%d")
        html += f"""<div style="flex:1;display:flex;flex-direction:column;align-items:center;gap:10px">
                    <div style="font-size:10px;color:#10b981;font-weight:600">{fmt_tokens(tokens) if tokens>0 else ''}</div>
                    <div style="width:100%;background:#2a2a2a;border-radius:8px;height:120px;position:relative;overflow:hidden">
                    <div style="position:absolute;bottom:0;width:100%;height:{h_pct:.1f}%;background:linear-gradient(0deg,#059669,#10b981);border-radius:4px"></div></div>
                    <span style="font-size:11px;color:#6b7280">{label}</span></div>"""
                    
    html += f"""
                </div>
            </div>

            <div style="background:#1a1a1a;border-radius:24px;padding:32px;border:1px solid #2a2a2a;flex-grow:1;display:flex;flex-direction:column">
                <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:24px">
                    <div style="font-size:18px;font-weight:700;color:#10b981">Trends (30D)</div>
                    <div style="display:flex;flex-wrap:wrap;justify-content:flex-end;gap:8px;font-size:10px;max-width:350px">
                        <div style="display:flex;align-items:center;gap:4px"><span style="width:10px;height:2px;background:{colors[0]}"></span>Total</div>
                        {" ".join([f'<div style="display:flex;align-items:center;gap:4px"><span style="width:10px;height:0;border-top:1.5px dashed {colors[i+1]}"></span>{clean_m(m)}</div>' for i, m in enumerate(top_trend_models)])}
                    </div>
                </div>
                <div style="margin-bottom:40px;flex-grow:1">
                    <div style="display:flex;justify-content:space-between;font-size:11px;color:#6b7280;margin-bottom:12px"><span>Token Trends</span><span>Peak: {fmt_tokens(max([daily_total.get(d,{}).get('tokens',0) for d in last_30_dates] or [0]))}</span></div>
                    {get_trend_svg(last_30_dates, 'tokens', width=620, height=140)}
                </div>
                <div style="flex-grow:1">
                    <div style="display:flex;justify-content:space-between;font-size:11px;color:#6b7280;margin-bottom:12px"><span>Cost Trends (USD)</span><span>Peak: {fmt_cost(max([daily_total.get(d,{}).get('cost',0) for d in last_30_dates] or [0]))}</span></div>
                    {get_trend_svg(last_30_dates, 'cost', width=620, height=140)}
                </div>
            </div>
        </div>
    </div>
</body>
</html>
"""
    return html


def main():
    parser = argparse.ArgumentParser(description="Generate HTML report")
    parser.add_argument("--start", type=str, help="Start date (YYYY-MM-DD)")
    parser.add_argument("--end", type=str, help="End date (YYYY-MM-DD)")
    parser.add_argument("--output", type=str, default="/tmp/llm-cost-report.html", help="Output HTML file")
    parser.add_argument("--title", type=str, default="AI Usage", help="Report title")
    args = parser.parse_args()
    store = UsageStore()
    html = generate_html_report(store, args.start, args.end, args.title)
    with open(args.output, 'w', encoding='utf-8') as f:
        f.write(html)
    print(f"HTML report saved to: {args.output}")


if __name__ == "__main__":
    main()


path: scripts/report.py
content: #!/usr/bin/env python3
"""
Generate usage and cost reports - text/JSON format with cache discount info
"""
import argparse
import json
import os
import sys
import yaml
from datetime import datetime, timedelta
from typing import Dict

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))

from store import UsageStore
from calc_cost import get_pricing


def get_date_range(period: str) -> tuple:
    """Get start and end dates for a period"""
    today = datetime.now()
    today_str = today.strftime("%Y-%m-%d")
    
    if period == "today":
        return today_str, today_str
    elif period == "yesterday":
        yesterday = today - timedelta(days=1)
        return yesterday.strftime("%Y-%m-%d"), yesterday.strftime("%Y-%m-%d")
    elif period == "week":
        week_ago = today - timedelta(days=7)
        return week_ago.strftime("%Y-%m-%d"), today_str
    elif period == "month":
        month_ago = today - timedelta(days=30)
        return month_ago.strftime("%Y-%m-%d"), today_str
    else:
        return today_str, today_str


def fmt_cost(cost: float) -> str:
    if cost >= 1:
        return f"${cost:.2f}"
    return f"${cost:.4f}"


def fmt_tokens(t: int) -> str:
    if t >= 1_000_000:
        return f"{t/1_000_000:.1f}M"
    elif t >= 1_000:
        return f"{t/1_000:.1f}K"
    return str(t)


def calc_cache_savings(tokens_summary: dict) -> dict:
    """Calculate cache savings - how much saved with 90% discount on cache reads"""
    cache_read = tokens_summary.get("cache_read_tokens", 0)
    cache_write = tokens_summary.get("cache_creation_tokens", 0)
    
    if cache_read == 0 and cache_write == 0:
        return {"read_savings": 0, "write_cost": 0, "total_savings": 0}
    
    # Estimate: without cache, would pay full input price
    avg_input_price = 3.0 / 1_000_000  # Rough average $3/1M
    read_savings = cache_read * avg_input_price * 0.9
    write_cost = cache_write * avg_input_price * 1.25
    
    return {
        "read_savings": read_savings,
        "write_cost": write_cost,
        "total_savings": read_savings - write_cost
    }


def print_report(period: str, storage_path: str = "~/.llm-cost-monitor"):
    """Print usage report in text format"""
    store = UsageStore(storage_path)
    start_date, end_date = get_date_range(period)
    
    # Get data
    total_cost = store.get_total_cost(start_date, end_date)
    by_provider = store.get_cost_by_provider(start_date, end_date)
    by_model = store.get_cost_by_model(start_date, end_date)
    tokens_summary = store.get_tokens_summary(start_date, end_date)
    
    # Cache savings
    cache_savings = calc_cache_savings(tokens_summary)
    
    date_label = {"today": "Today", "yesterday": "Yesterday", "week": "This Week", "month": "This Month"}.get(period, period)
    
    print(f"\n📊 LLM Cost Report - {date_label}")
    print("=" * 50)
    print(f"Period: {start_date} to {end_date}")
    print(f"\nTotal Cost: {fmt_cost(total_cost)}")
    print(f"Total Tokens: {fmt_tokens(tokens_summary['total_tokens'])}")
    
    # Token breakdown
    print(f"\n📈 Token Breakdown:")
    print(f"   Input:  {fmt_tokens(tokens_summary['input_tokens'])}")
    print(f"   Output: {fmt_tokens(tokens_summary['output_tokens'])}")
    
    if tokens_summary.get("cache_read_tokens", 0) > 0 or tokens_summary.get("cache_creation_tokens", 0) > 0:
        print(f"   Cache R: {fmt_tokens(tokens_summary.get('cache_read_tokens', 0))}")
        print(f"   Cache W: {fmt_tokens(tokens_summary.get('cache_creation_tokens', 0))}")
        print(f"   💡 Cache Savings: {fmt_cost(cache_savings['total_savings'])}")
    
    # By provider
    if by_provider:
        print(f"\n🏢 By Provider:")
        for provider, cost in sorted(by_provider.items(), key=lambda x: x[1], reverse=True):
            pct = (cost / total_cost * 100) if total_cost > 0 else 0
            print(f"  • {provider}: {fmt_cost(cost)} ({pct:.0f}%)")
    
    # By model
    if by_model:
        print(f"\n🤖 By Model (Top 10):")
        for model, cost in sorted(by_model.items(), key=lambda x: x[1], reverse=True)[:10]:
            pct = (cost / total_cost * 100) if total_cost > 0 else 0
            print(f"  • {model}: {fmt_cost(cost)} ({pct:.0f}%)")
    
    # Unknown models
    unknown = [m for m in by_model.keys() if get_pricing(m) is None]
    if unknown:
        print(f"\n⚠️ Note: Showing real cost from sessions (no local pricing): {', '.join(unknown)}")
    
    print()


def print_json(period: str, storage_path: str = "~/.llm-cost-monitor"):
    """Print report as JSON"""
    store = UsageStore(storage_path)
    start_date, end_date = get_date_range(period)
    
    total_cost = store.get_total_cost(start_date, end_date)
    by_provider = store.get_cost_by_provider(start_date, end_date)
    by_model = store.get_cost_by_model(start_date, end_date)
    tokens_summary = store.get_tokens_summary(start_date, end_date)
    cache_savings = calc_cache_savings(tokens_summary)
    
    output = {
        "status": "success",
        "period": period,
        "start_date": start_date,
        "end_date": end_date,
        "total_cost": round(total_cost, 6),
        "tokens": {
            "input": tokens_summary.get("input_tokens", 0),
            "output": tokens_summary.get("output_tokens", 0),
            "cache_read": tokens_summary.get("cache_read_tokens", 0),
            "cache_write": tokens_summary.get("cache_creation_tokens", 0),
            "total": tokens_summary.get("total_tokens", 0)
        },
        "cache_savings": {
            "read_savings": round(cache_savings["read_savings"], 4),
            "write_cost": round(cache_savings["write_cost"], 4),
            "total_savings": round(cache_savings["total_savings"], 4)
        },
        "by_provider": {k: round(v, 4) for k, v in by_provider.items()},
        "by_model": {k: round(v, 4) for k, v in by_model.items()}
    }
    
    print(json.dumps(output, indent=2, ensure_ascii=False))


def main():
    parser = argparse.ArgumentParser(description="Generate LLM cost reports")
    parser.add_argument("--period", type=str, choices=["today", "yesterday", "week", "month"],
                       default="today", help="Report period")
    parser.add_argument("--json", action="store_true", help="Output as JSON")
    parser.add_argument("--text", action="store_true", help="Output as text (default)")
    
    args = parser.parse_args()
    
    # Workspace handling
    workspace = os.environ.get("OPENCLAW_WORKSPACE", "~/.llm-cost-monitor")
    
    if args.json:
        print_json(args.period, workspace)
    else:
        print_report(args.period, workspace)


if __name__ == "__main__":
    main()


path: scripts/run_usage_report.py
content: #!/usr/bin/env python3
"""
One-step usage reporting runner.
Always syncs latest usage data first (unless --no-sync), then generates text or image report.
"""

import argparse
import os
import json
import subprocess
import sys
from pathlib import Path

BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SCRIPTS_DIR = os.path.join(BASE_DIR, "scripts")


def run(cmd):
    result = subprocess.run(cmd, cwd=BASE_DIR, text=True, capture_output=True)
    if result.returncode != 0:
        return False, result.stdout.strip(), result.stderr.strip()
    return True, result.stdout.strip(), result.stderr.strip()


def main():
    parser = argparse.ArgumentParser(description="Sync usage data, then generate usage report")
    parser.add_argument("--mode", choices=["image", "text"], default="image", help="Report output mode")
    parser.add_argument("--period", choices=["today", "yesterday", "week", "month"], default="today")
    parser.add_argument("--no-sync", action="store_true", help="Skip fetch/sync step")
    parser.add_argument("--json", action="store_true", help="Output result as JSON")
    args = parser.parse_args()

    py = sys.executable
    results = {"steps": []}

    # Step 1: Sync
    if not args.no_sync:
        success, out, err = run([py, os.path.join(SCRIPTS_DIR, "fetch_usage.py")])
        results["steps"].append({"name": "sync", "success": success, "output": out, "error": err})
        if not success and not args.json:
            print(f"Sync failed: {err}")
            sys.exit(1)

    # Step 2: Generate Report
    if args.mode == "text":
        success, out, err = run([py, os.path.join(SCRIPTS_DIR, "report.py"), "--period", args.period, "--text"])
        results["steps"].append({"name": "text_report", "success": success, "output": out, "error": err})
        
        if args.json:
            results["status"] = "success" if success else "error"
            results["text_summary"] = out
            print(json.dumps(results, indent=2, ensure_ascii=False))
        else:
            if out: print(out)
            if err: print(err, file=sys.stderr)
            if not success: sys.exit(1)
        return

    # Image mode
    img_cmd = [py, os.path.join(SCRIPTS_DIR, "generate_report_image.py")]
    if args.period == "today":
        img_cmd.append("--today")
    elif args.period == "yesterday":
        img_cmd.append("--yesterday")
    else:
        img_cmd.extend(["--period", args.period])

    success, out, err = run(img_cmd)
    results["steps"].append({"name": "image_report", "success": success, "output": out, "error": err})

    # Try to find image path from output
    image_path = None
    if success and "->" in out:
        image_path = out.split("->")[-1].strip()

    if args.json:
        results["status"] = "success" if success else "error"
        results["image_path"] = image_path
        results["output"] = out
        print(json.dumps(results, indent=2, ensure_ascii=False))
    else:
        if out: print(out)
        if err: print(err, file=sys.stderr)
        if not success: sys.exit(1)


if __name__ == "__main__":
    main()


path: scripts/store.py
content: """
SQLite storage for LLM Cost Monitor
"""
import json
import os
import sqlite3
import hashlib
from datetime import datetime
from pathlib import Path
from typing import Dict, List, Optional


class UsageStore:
    def __init__(self, storage_path: str = "~/.llm-cost-monitor"):
        self.storage_path = os.path.expanduser(storage_path)
        os.makedirs(self.storage_path, exist_ok=True)
        self.db_path = os.path.join(self.storage_path, "usage.db")
        self._init_db()

    def _init_db(self):
        """Initialize database schema and handle migrations"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()

        # Check if we need to migrate api_key_hash to source_id_hash
        cursor.execute("PRAGMA table_info(usage_records)")
        columns = [row[1] for row in cursor.fetchall()]
        
        if 'api_key_hash' in columns and 'source_id_hash' not in columns:
            print("Migrating database: api_key_hash -> source_id_hash")
            cursor.execute("ALTER TABLE usage_records RENAME COLUMN api_key_hash TO source_id_hash")

        # Usage records table (Safe create if not exists)
        cursor.execute("""
            CREATE TABLE IF NOT EXISTS usage_records (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                date TEXT NOT NULL,
                provider TEXT NOT NULL,
                source_id_hash TEXT NOT NULL,
                model TEXT NOT NULL,
                app TEXT DEFAULT 'openclaw',
                source TEXT DEFAULT 'session',
                input_tokens INTEGER DEFAULT 0,
                output_tokens INTEGER DEFAULT 0,
                cache_read_tokens INTEGER DEFAULT 0,
                cache_creation_tokens INTEGER DEFAULT 0,
                cost REAL DEFAULT 0,
                savings REAL DEFAULT 0,
                created_at TEXT DEFAULT CURRENT_TIMESTAMP,
                UNIQUE(date, provider, source_id_hash, model, app, source)
            )
        """)

        # Create index for faster queries
        cursor.execute("""
            CREATE INDEX IF NOT EXISTS idx_usage_date_provider
            ON usage_records(date, provider)
        """)

        conn.commit()
        conn.close()

    def add_usage(
        self,
        date: str,
        provider: str,
        source_id: str,
        model: str,
        app: str = "openclaw",
        source: str = "session",
        input_tokens: int = 0,
        output_tokens: int = 0,
        cache_read_tokens: int = 0,
        cache_creation_tokens: int = 0,
        cost: float = 0.0,
        savings: float = 0.0,
        incremental: bool = True
    ):
        """
        Add or update usage record
        source_id is typically a local session identifier (e.g., directory name), NOT a credential.
        """
        id_hash = self._hash_source_id(source_id)

        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()

        if incremental:
            cursor.execute("""
                INSERT INTO usage_records
                (date, provider, source_id_hash, model, app, source, input_tokens, output_tokens,
                 cache_read_tokens, cache_creation_tokens, cost, savings)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(date, provider, source_id_hash, model, app, source)
                DO UPDATE SET
                    input_tokens = usage_records.input_tokens + excluded.input_tokens,
                    output_tokens = usage_records.output_tokens + excluded.output_tokens,
                    cache_read_tokens = usage_records.cache_read_tokens + excluded.cache_read_tokens,
                    cache_creation_tokens = usage_records.cache_creation_tokens + excluded.cache_creation_tokens,
                    cost = usage_records.cost + excluded.cost,
                    savings = usage_records.savings + excluded.savings
            """, (date, provider, id_hash, model, app, source, input_tokens, output_tokens,
                  cache_read_tokens, cache_creation_tokens, cost, savings))
        else:
            cursor.execute("""
                INSERT INTO usage_records
                (date, provider, source_id_hash, model, app, source, input_tokens, output_tokens,
                 cache_read_tokens, cache_creation_tokens, cost, savings)
                VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
                ON CONFLICT(date, provider, source_id_hash, model, app, source)
                DO UPDATE SET
                    input_tokens = excluded.input_tokens,
                    output_tokens = excluded.output_tokens,
                    cache_read_tokens = excluded.cache_read_tokens,
                    cache_creation_tokens = excluded.cache_creation_tokens,
                    cost = excluded.cost,
                    savings = excluded.savings
            """, (date, provider, id_hash, model, app, source, input_tokens, output_tokens,
                  cache_read_tokens, cache_creation_tokens, cost, savings))

        conn.commit()
        conn.close()

    def clear_records(self, date: Optional[str] = None, source: Optional[str] = None):
        """Clear records for a specific date or source"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()
        
        if date and source:
            cursor.execute("DELETE FROM usage_records WHERE date = ? AND source = ?", (date, source))
        elif date:
            cursor.execute("DELETE FROM usage_records WHERE date = ?", (date,))
        elif source:
            cursor.execute("DELETE FROM usage_records WHERE source = ?", (source,))
        else:
            cursor.execute("DELETE FROM usage_records")
            
        conn.commit()
        conn.close()

    def get_usage(
        self,
        start_date: str,
        end_date: str,
        provider: Optional[str] = None,
        app: Optional[str] = None,
        source: Optional[str] = None
    ) -> List[Dict]:
        """Get usage records for date range"""
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()

        query = """
            SELECT date, provider, model, app, source,
                   SUM(input_tokens) as input_tokens,
                   SUM(output_tokens) as output_tokens,
                   SUM(cache_read_tokens) as cache_read_tokens,
                   SUM(cache_creation_tokens) as cache_creation_tokens,
                   SUM(cost) as cost
            FROM usage_records
            WHERE date >= ? AND date <= ?
        """
        params = [start_date, end_date]

        if provider:
            query += " AND provider = ?"
            params.append(provider)
        if app:
            query += " AND app = ?"
            params.append(app)
        if source:
            query += " AND source = ?"
            params.append(source)

        query += " GROUP BY date, provider, model, app, source ORDER BY date DESC"

        cursor.execute(query, params)
        rows = cursor.fetchall()
        conn.close()

        return [dict(row) for row in rows]

    def get_total_cost(
        self,
        start_date: str,
        end_date: str,
        provider: Optional[str] = None
    ) -> float:
        """Get total cost for date range"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()

        query = "SELECT SUM(cost) FROM usage_records WHERE date >= ? AND date <= ?"
        params = [start_date, end_date]

        if provider:
            query += " AND provider = ?"
            params.append(provider)

        cursor.execute(query, params)
        result = cursor.fetchone()[0]
        conn.close()

        return result or 0.0

    def get_cost_by_model(
        self,
        start_date: str,
        end_date: str,
        provider: Optional[str] = None
    ) -> Dict[str, float]:
        """Get cost breakdown by model"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()

        query = """
            SELECT model, SUM(cost) as cost
            FROM usage_records
            WHERE date >= ? AND date <= ?
        """
        params = [start_date, end_date]

        if provider:
            query += " AND provider = ?"
            params.append(provider)

        query += " GROUP BY model ORDER BY cost DESC"

        cursor.execute(query, params)
        rows = cursor.fetchall()
        conn.close()

        return {row[0]: row[1] for row in rows}

    def get_cost_by_provider(
        self,
        start_date: str,
        end_date: str
    ) -> Dict[str, float]:
        """Get cost breakdown by provider"""
        conn = sqlite3.connect(self.db_path)
        cursor = conn.cursor()

        query = """
            SELECT provider, SUM(cost) as cost
            FROM usage_records
            WHERE date >= ? AND date <= ?
            GROUP BY provider
        """

        cursor.execute(query, [start_date, end_date])
        rows = cursor.fetchall()
        conn.close()

        return {row[0]: row[1] for row in rows}

    def get_tokens_summary(
        self,
        start_date: str,
        end_date: str,
        provider: Optional[str] = None
    ) -> Dict:
        """Get detailed token summary including cache tokens"""
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()

        query = """
            SELECT 
                SUM(input_tokens) as total_input,
                SUM(output_tokens) as total_output,
                SUM(cache_read_tokens) as total_cache_read,
                SUM(cache_creation_tokens) as total_cache_write,
                SUM(input_tokens + output_tokens) as total_tokens,
                SUM(cost) as total_cost
            FROM usage_records
            WHERE date >= ? AND date <= ?
        """
        params = [start_date, end_date]

        if provider:
            query += " AND provider = ?"
            params.append(provider)

        cursor.execute(query, params)
        row = cursor.fetchone()
        conn.close()

        if row:
            return {
                "input_tokens": row["total_input"] or 0,
                "output_tokens": row["total_output"] or 0,
                "cache_read_tokens": row["total_cache_read"] or 0,
                "cache_creation_tokens": row["total_cache_write"] or 0,
                "total_tokens": row["total_tokens"] or 0,
                "total_cost": row["total_cost"] or 0
            }
        return {
            "input_tokens": 0, "output_tokens": 0, "cache_read_tokens": 0,
            "cache_creation_tokens": 0, "total_tokens": 0, "total_cost": 0
        }

    def get_daily_summary(
        self,
        start_date: str,
        end_date: str,
        provider: Optional[str] = None
    ) -> List[Dict]:
        """Get daily summary grouped by date"""
        conn = sqlite3.connect(self.db_path)
        conn.row_factory = sqlite3.Row
        cursor = conn.cursor()

        query = """
            SELECT date,
                   SUM(input_tokens) as input_tokens,
                   SUM(output_tokens) as output_tokens,
                   SUM(cache_read_tokens) as cache_read_tokens,
                   SUM(cache_creation_tokens) as cache_creation_tokens,
                   SUM(cost) as cost
            FROM usage_records
            WHERE date >= ? AND date <= ?
        """
        params = [start_date, end_date]

        if provider:
            query += " AND provider = ?"
            params.append(provider)

        query += " GROUP BY date ORDER BY date DESC"

        cursor.execute(query, params)
        rows = cursor.fetchall()
        conn.close()

        return [dict(row) for row in rows]

    @staticmethod
    def _hash_source_id(source_id: str) -> str:
        """
        Hashes the local source identifier (e.g., session directory name) 
        using SHA-256 to ensure data uniqueness and idempotent syncing.
        This is NOT a credential or API key.
        """
        return hashlib.sha256(source_id.encode()).hexdigest()[:16]


def get_store() -> UsageStore:
    """Get default usage store instance"""
    return UsageStore()


path: scripts/text_summary.py
content: #!/usr/bin/env python3
"""
Generate raw data/text for LLM cost monitoring
"""
import os
import sys
from datetime import datetime, timedelta

# Find store.py
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from store import UsageStore

def fmt_cost(c):
    return f"${c:.2f}"

def fmt_tokens(t):
    if t >= 1000000:
        return f"{t/1000000:.1f}M"
    elif t >= 1000:
        return f"{t/1000:.1f}K"
    return str(t)

def main():
    store = UsageStore()
    today = datetime.now()
    start_date = today.replace(day=1).strftime("%Y-%m-%d")
    end_date = today.strftime("%Y-%m-%d")
    
    daily_data = store.get_usage(start_date, end_date)
    total_cost = store.get_total_cost(start_date, end_date)
    by_model = store.get_cost_by_model(start_date, end_date)
    
    # Total tokens
    total_tokens = sum(r['input_tokens'] + r['output_tokens'] for r in daily_data)
    
    # By model tokens
    by_model_tokens = {}
    for r in daily_data:
        m = r['model']
        if m not in by_model_tokens: by_model_tokens[m] = 0
        by_model_tokens[m] += r['input_tokens'] + r['output_tokens']

    print(f"📊 本月用量报告 ({start_date} ~ {end_date})")
    print(f"总消耗: {fmt_cost(total_cost)} (约 {fmt_tokens(total_tokens)} tokens)")
    print("-" * 20)
    print("模型分布 (Top 5):")
    
    sorted_models = sorted(by_model.keys(), key=lambda x: -by_model[x])[:5]
    for model in sorted_models:
        cost = by_model[model]
        tokens = by_model_tokens.get(model, 0)
        print(f"- {model[:15]}: {fmt_cost(cost)} ({fmt_tokens(tokens)})")
        
if __name__ == "__main__":
    main()


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