Skill: 99rebels/context-window-tracker (version 1.1.4)

--- name: context-window-tracker description: > Track and report OpenClaw context window usage with a detailed breakdown of what's consuming tokens. Use when: user asks about context usage, token usage, "how much context am I using", "how full is my context window", "tokens remaining", "am I close to the limit", thinking/reasoning token costs, what's eating context (session setup vs conversation vs overhead), or how many turns are left. NOT for: estimating tokens for arbitrary text, managing context (compact/prune), or cross-session cost aggregation. homepage: https://github.com/99rebels/context-window-tracker --- # Context Window Tracker Shows how much context window is left, without opening the terminal. ## When to Use - "Check my context" - "How much context am I using?" - "How full is my context window?" - "Tokens remaining" - "Am I close to the limit?" - Any question about context usage ## Two Modes ### Compact (default) One line, glanceable. Good for quick checks. ```bash python3 scripts/context_report.py ``` ### Detailed Full breakdown with per-file system prompt, conversation split, trends, and thinking status. Use when the user asks for specifics. ```bash python3 scripts/context_report.py --detailed ``` Both modes auto-detect the most recently updated session. Options: ``` --session <key> Target a specific session --agent <name> Target a specific agent (default: main) --detailed Full breakdown instead of compact one-liner ``` ## Output Format ### Compact ``` 🟢 [███░░░░░░░░░░░░░░░░░] 29.8K / 202.8K tokens (15% used) | ~736 turns left | Cache: 99% ``` ### Detailed ``` 🟢 [███░░░░░░░░░░░░░░░░░] Context Usage: 29.8K / 202.8K (15%) ──────────────────── **Token Breakdown** System Prompt: ~10.2K tokens (5%) AGENTS.md: ~2.0K SOUL.md: ~416 TOOLS.md: ~717 MEMORY.md: ~2.3K 📦 Framework overhead: ~5.3K (tool schemas, skill list, runtime) Conversation: ~19.6K tokens (10%) 📊 Total Used: 29.8K (15%) Remaining: 173.0K (85%) ──────────────────── **Trends** Avg tokens per turn: ~1.2K tokens ⏳ Estimated turns remaining: ~144 ──────────────────── **Session Stats** 📥 Total input: 25K | 📤 Total output: 1.8K | Cache hit rate: 99% Thinking: active (3/12 responses) ──────────────────── ``` The bar uses `█` (filled) and `░` (empty) across 20 segments (each = 5%). The indicator shifts: 🟢 under 60%, 🟡 60-80%, 🔴 over 80%. ## Guidance When the user asks about context usage, you may optionally include a brief note about remaining capacity based on the script output and the current conversation. Only do this at 75%+ usage. Skip for fresh sessions. Rules: - One line max. Reference the actual task, not generic categories. - Don't prescribe actions, describe what fits. - Never suggest deleting workspace files or changing system config. ## What's Exact vs Estimated ``` ✅ Exact (from provider): • Total tokens used (from transcript) • Context window limit (from session store) • Cache hit rate ⚠ Estimated: • Per-file system prompt breakdown (chars ÷ 4) • Turns remaining (extrapolated from recent growth rate) • Thinking token count (bundled by provider, not separately reported) ``` ## Notes - Script reads the transcript (`.jsonl`) as source of truth. The session store can lag behind by thousands of tokens. - If the context window limit is unknown, the script shows tokens used without a percentage. - See [references/data-sources.md](references/data-sources.md) for file paths - See [references/thinking-tokens.md](references/thinking-tokens.md) for how reasoning tokens affect counts

path: references/data-sources.md
content: # Data Sources

Where the script reads from and what each source provides.

## Transcript (source of truth)

```
~/.openclaw/agents/<agent>/sessions/<sessionId>.jsonl
```

Each line is JSON. The script reads `type: "message"` entries with `role: "assistant"` and extracts `message.usage`.

Usage fields (already normalized by OpenClaw from any provider):
- `input` — non-cached input tokens
- `output` — generated tokens (includes thinking/reasoning tokens for most providers)
- `cacheRead` — tokens served from cache
- `cacheWrite` — new tokens written to cache
- `totalTokens` — the authoritative total (always accurate regardless of provider)
- `cost` — per-response cost breakdown

**Prefer transcript over session store** — the store can lag behind by several thousand tokens.

## Session Store

```
~/.openclaw/agents/<agent>/sessions/sessions.json
```

Provides data the transcript doesn't have:
- `contextTokens` — the context window limit for this model
- `systemPromptReport` — per-file character breakdown of the system prompt
- `inputTokens`, `outputTokens` — cumulative totals (may lag behind transcript)
- `sessionFile` — path to the transcript file

### System Prompt Report Structure

```json
{
  "systemPrompt": {
    "chars": 45511,
    "projectContextChars": 23013,
    "nonProjectContextChars": 22498
  },
  "injectedWorkspaceFiles": [
    {"name": "AGENTS.md", "rawChars": 7809, "injectedChars": 7809, "truncated": false}
  ]
}
```

- `projectContextChars` — workspace files (AGENTS.md, SOUL.md, MEMORY.md, etc.)
- `nonProjectContextChars` — framework overhead (tool schemas, skill list, runtime config)

## How OpenClaw Normalizes Provider Responses

```
Provider raw response → OpenClaw normalized format
────────────────────────────────────────────────────
prompt_tokens          → input (minus cached_tokens)
completion_tokens      → output (plus reasoning_tokens if present)
prompt_tokens_details  → cacheRead
.completion_tokens_details
  .reasoning_tokens    → added to output
```

`totalTokens = input + output + cacheRead` is always the real total regardless of provider.


path: references/thinking-tokens.md
content: # Thinking / Reasoning Tokens

How each provider handles extended thinking and how it affects token counts.

Thinking tokens always cost money and always count against the context window. But providers report them differently:

```
Anthropic   → Bundled into input_tokens (same billing rate)
OpenAI      → Separate field: completion_tokens_details.reasoning_tokens
z.ai        → Varies by model, may bundle into input or output
```

OpenClaw's `totalTokens` always includes thinking tokens — it's the authoritative count for "how full is my window."

## Detection

The script can't detect thinking tokens from usage fields alone (they're bundled for most providers). It notes the provider's behavior in the report output:

```
Thinking: bundled into input (Anthropic)
Thinking: separate reasoning_tokens field (OpenAI)
Thinking: varies by model (z.ai)
```

For definitive detection, scan the transcript JSONL for content blocks with `"type": "thinking"`.


path: scripts/context_report.py
content: #!/usr/bin/env python3
"""Context Window Reporter for OpenClaw.

Lightweight context usage check. Shows tokens used, percentage remaining,
estimated turns left, and cache hit rate.

Usage:
    python3 context_report.py [--session <session_key>] [--agent <agent_name>]

If no session/agent specified, auto-detects the most recently updated session.
"""

import argparse
import json
import sys
from pathlib import Path

OPENCLAW_DIR = Path.home() / ".openclaw"
AGENTS_DIR = OPENCLAW_DIR / "agents"


def find_current_session(agent: str | None = None) -> tuple[str, dict, str]:
    """Find the most recently updated session.

    Returns (session_key, session_data, agent_name).
    """
    if agent is None:
        agent = "main"

    store_path = AGENTS_DIR / agent / "sessions" / "sessions.json"
    if not store_path.exists():
        for d in AGENTS_DIR.iterdir():
            if d.is_dir():
                p = d / "sessions" / "sessions.json"
                if p.exists():
                    agent = d.name
                    store_path = p
                    break

    if not store_path.exists():
        print("ERROR: No sessions.json found", file=sys.stderr)
        sys.exit(1)

    with open(store_path) as f:
        sessions = json.load(f)

    if not sessions:
        print("ERROR: No sessions found", file=sys.stderr)
        sys.exit(1)

    best_key = max(sessions, key=lambda k: sessions[k].get("updatedAt", ""))
    return best_key, sessions[best_key], agent


def read_usage_entries(transcript_path: str) -> list[dict]:
    """Read all assistant usage entries from the transcript."""
    entries = []
    if not Path(transcript_path).exists():
        return entries

    with open(transcript_path) as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                entry = json.loads(line)
            except json.JSONDecodeError:
                continue
            if entry.get("type") != "message":
                continue
            msg = entry.get("message", {})
            if msg.get("role") != "assistant":
                continue
            usage = msg.get("usage", {})
            if usage and usage.get("totalTokens"):
                entries.append(usage)

    return entries


def fmt(n: int) -> str:
    """Format number with K suffix."""
    if n >= 1000:
        return f"{n / 1000:.1f}K"
    return str(n)


def health_indicator(pct_used: float) -> str:
    """Return a colour indicator based on context usage percentage."""
    if pct_used >= 80:
        return "🔴"
    if pct_used >= 60:
        return "🟡"
    return "🟢"


def make_bar(pct: float, width: int = 20) -> str:
    """Create a unicode progress bar. pct = 0..100 (percentage used)."""
    filled = round(width * pct / 100)
    empty = width - filled
    return f"[{'█' * filled}{'░' * empty}]"


def pct_str(n: int, total: int) -> str:
    """Format as percentage string."""
    return f"{n / total * 100:.0f}%" if total > 0 else "N/A"


def build_compact_report(session: dict) -> str:
    """Build the compact one-line report."""
    transcript_path = session.get("sessionFile", "")
    context_window = session.get("contextTokens") or 0
    usage_entries = read_usage_entries(transcript_path)

    if not usage_entries:
        return "No usage data found in transcript."

    latest = usage_entries[-1]
    current_total = latest.get("totalTokens", 0)

    if context_window <= 0:
        return f"{fmt(current_total)} tokens used (context limit unknown)"

    pct_used = current_total / context_window * 100
    remaining = context_window - current_total
    bar = make_bar(pct_used)
    indicator = health_indicator(pct_used)

    parts = [f"{indicator} {bar} {fmt(current_total)} / {fmt(context_window)} tokens ({pct_str(current_total, context_window)} used)"]

    # Turns estimate
    if len(usage_entries) >= 2:
        window = min(10, len(usage_entries))
        recent = usage_entries[-window:]
        growths = []
        for i in range(1, len(recent)):
            growth = recent[i]["totalTokens"] - recent[i - 1]["totalTokens"]
            if growth > 0:
                growths.append(growth)
        if growths and remaining > 0:
            avg_g = sum(growths) / len(growths)
            turns = int(remaining / avg_g)
            parts.append(f"~{turns} turns left")

    # Cache hit rate (from latest response — most accurate for real-time)
    cache_read = latest.get("cacheRead", 0)
    cache_total = latest.get("totalTokens", 0)
    if cache_total > 0 and cache_read > 0:
        hit_rate = cache_read / cache_total * 100
        parts.append(f"Cache: {hit_rate:.0f}%")

    return " | ".join(parts)


SPACER = "─" * 20  # visible divider for Slack compatibility


def build_detailed_report(session: dict) -> str:
    """Build the detailed multi-line report."""
    transcript_path = session.get("sessionFile", "")
    context_window = session.get("contextTokens") or 0
    usage_entries = read_usage_entries(transcript_path)

    if not usage_entries:
        return "No usage data found in transcript."

    latest = usage_entries[-1]
    current_total = latest.get("totalTokens", 0)
    input_tokens = session.get("inputTokens", 0)
    output_tokens = session.get("outputTokens", 0)

    lines = []

    if context_window > 0:
        pct_used = current_total / context_window * 100
        pct_remaining = 100 - pct_used
        remaining = context_window - current_total
        indicator = health_indicator(pct_used)
        bar = make_bar(pct_used)

        lines.append(f"{indicator} {bar} Context Usage: {fmt(current_total)} / {fmt(context_window)} ({pct_str(current_total, context_window)})")
    else:
        lines.append(f"📊 Context Usage: {fmt(current_total)} (context limit unknown)")
        context_window = 0
        remaining = 0

    # System prompt breakdown
    spr = session.get("systemPromptReport", {})
    sys_chars = spr.get("systemPrompt", {}).get("chars", 0)
    project_chars = spr.get("systemPrompt", {}).get("projectContextChars", 0)
    non_project_chars = spr.get("systemPrompt", {}).get("nonProjectContextChars", 0)
    injected_files = spr.get("injectedWorkspaceFiles", [])

    # Use first response input as system prompt token estimate
    sys_prompt_tokens = usage_entries[0].get("input", 0) if usage_entries else sys_chars // 4
    conversation_tokens = max(0, current_total - sys_prompt_tokens)
    framework_tokens = non_project_chars // 4

    if context_window > 0:
        lines.append("")
        lines.append(SPACER)
        lines.append("**Token Breakdown**")
        lines.append(f"  System Prompt: ~{fmt(sys_prompt_tokens)} tokens ({pct_str(sys_prompt_tokens, context_window)})")
        for f in injected_files:
            f_tokens = f["injectedChars"] // 4
            trunc = " [TRUNCATED]" if f.get("truncated") else ""
            lines.append(f"    {f['name']}: ~{fmt(f_tokens)} tokens{trunc}")
        lines.append(f"  📦 Framework overhead: ~{fmt(framework_tokens)} (tool schemas, skill list, runtime)")
        lines.append(f"• Conversation: ~{fmt(conversation_tokens)} tokens ({pct_str(conversation_tokens, context_window)})")
        lines.append(f"• 📊 Total Used: {fmt(current_total)} ({pct_str(current_total, context_window)})")
        lines.append(f"• Remaining: {fmt(remaining)} ({pct_str(remaining, context_window)})")

    # Trends
    if len(usage_entries) >= 2 and context_window > 0:
        window = min(10, len(usage_entries))
        recent = usage_entries[-window:]
        growths = []
        for i in range(1, len(recent)):
            growth = recent[i]["totalTokens"] - recent[i - 1]["totalTokens"]
            if growth > 0:
                growths.append(growth)
        if growths and remaining > 0:
            avg_g = sum(growths) / len(growths)
            turns = int(remaining / avg_g)
            lines.append(SPACER)
            lines.append("**Trends**")
            lines.append(f"• Avg tokens per turn: ~{fmt(int(avg_g))} tokens")
            lines.append(f"• ⏳ Estimated turns remaining: ~{turns}")

    # Session stats
    # Cache hit rate (from latest response)
    cache_read = latest.get("cacheRead", 0)
    cache_hit_rate = (cache_read / current_total * 100) if current_total > 0 else 0
    thinking_count = _count_thinking(transcript_path)
    total_responses = len(usage_entries)

    lines.append(SPACER)
    lines.append("**Session Stats**")
    lines.append(f"• 📥 Total input: {fmt(input_tokens)} | 📤 Total output: {fmt(output_tokens)} | Cache hit rate: {cache_hit_rate:.0f}%")
    if thinking_count > 0:
        lines.append(f"• Thinking: active ({thinking_count}/{total_responses} responses)")
    else:
        lines.append(f"• Thinking: not active")
    lines.append(SPACER)

    return "\n".join(lines)


def _count_thinking(transcript_path: str) -> int:
    """Count assistant responses that contain thinking blocks."""
    if not Path(transcript_path).exists():
        return 0
    count = 0
    with open(transcript_path) as f:
        for line in f:
            line = line.strip()
            if not line:
                continue
            try:
                entry = json.loads(line)
            except json.JSONDecodeError:
                continue
            if entry.get("type") != "message":
                continue
            msg = entry.get("message", {})
            if msg.get("role") != "assistant":
                continue
            content = msg.get("content", [])
            if any(isinstance(c, dict) and c.get("type") == "thinking" for c in content):
                count += 1
    return count


def build_report(session: dict, detailed: bool = False) -> str:
    """Build the context usage report."""
    if detailed:
        return build_detailed_report(session)
    return build_compact_report(session)


def main():
    parser = argparse.ArgumentParser(description="OpenClaw Context Window Reporter")
    parser.add_argument("--session", "-s", help="Session key")
    parser.add_argument("--agent", "-a", help="Agent name (default: auto-detect)")
    parser.add_argument("--detailed", "-d", action="store_true", help="Full breakdown instead of compact")
    args = parser.parse_args()

    if args.session:
        agent = args.agent or "main"
        store_path = AGENTS_DIR / agent / "sessions" / "sessions.json"
        if not store_path.exists():
            print(f"ERROR: {store_path} not found", file=sys.stderr)
            sys.exit(1)
        with open(store_path) as f:
            sessions = json.load(f)
        if args.session not in sessions:
            print(f"ERROR: Session '{args.session}' not found", file=sys.stderr)
            sys.exit(1)
        session = sessions[args.session]
    else:
        _, session, _ = find_current_session(args.agent)

    print(build_report(session, detailed=args.detailed))


if __name__ == "__main__":
    main()


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