#!/usr/bin/env python3 """ Token Burn (BurnVibe) — Official Local MCP Server & Privacy-Strict CLI Collector Supports: Antigravity IDE/CLI, Claude Code CLI, OpenAI Codex CLI. Zero external dependencies (Python 3.9+ stdlib only). STRICT ZERO-PROJECT PRIVACY POLICY: - Transmits EXCLUSIVELY aggregate daily numeric counters: (date, cli_source, tokens, input_tokens, output_tokens, cached_tokens, dialogs, steps, hours). - NEVER reads, collects, or transmits project names, folder paths, git repos, workspace names, filenames, source code, or conversation/prompt text. Modes: 1. MCP Stdio Server (default): python3 tokenburn_mcp.py 2. One-shot CLI Sync: python3 tokenburn_mcp.py --sync [--key tb_live_...] 3. Local Dry-Run Audit: python3 tokenburn_mcp.py --dry-run 4. Inspect Outgoing JSON Payload: python3 tokenburn_mcp.py --inspect-payload """ from __future__ import annotations import argparse import datetime as dt import glob import json import os import sqlite3 import sys import threading import time import urllib.error import urllib.request from pathlib import Path API_BASE = os.environ.get("TOKENBURN_API_URL", "").strip().rstrip("/") API_ENDPOINTS = ( [API_BASE] if API_BASE else [ "https://burn.vibebase.online/api/burnvibe", "https://a-sage.ru/api/burnvibe", ] ) CONFIG_FILE = Path.home() / ".tokenburn" / "config.json" CLIENT_VERSION = "vibebase-burn-mcp/3.0.0" VBT_MULTIPLIERS = { "antigravity": 1.0, "codex": 1.35, "claude_code": 1.75, } # Strict whitelist of allowed numeric telemetry keys per daily record ALLOWED_RECORD_KEYS = { "date", "cli_source", "tokens", "input_tokens", "output_tokens", "cached_tokens", "dialogs", "steps", "hours", } def load_api_key(explicit_key: str = "") -> str: if explicit_key: return explicit_key.strip() env_k = os.environ.get("TOKENBURN_API_KEY", "") or os.environ.get("TOKENBURN_KEY", "") if env_k.strip(): return env_k.strip() if CONFIG_FILE.exists(): try: cfg = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) return str(cfg.get("api_key", "")).strip() except Exception: pass return "" def save_api_key(key: str) -> None: CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) CONFIG_FILE.write_text( json.dumps({"api_key": key.strip(), "updated_at": dt.datetime.now(dt.timezone.utc).isoformat()}, indent=2), encoding="utf-8", ) def fmt_num(val: float) -> str: val = float(val or 0) if val >= 1_000_000_000: return f"{val / 1_000_000_000:.2f}B" if val >= 1_000_000: return f"{val / 1_000_000:.2f}M" if val >= 1_000: return f"{val / 1_000:.1f}K" return str(int(round(val))) def add_day_stat( bucket: dict, date_str: str, cli: str, tokens: int, in_tok: int, out_tok: int, cache_tok: int, dialogs: int, steps: int, hours: float, ) -> None: if tokens <= 0 or not date_str or len(date_str) != 10: return key = (date_str, cli) cur = bucket.get(key) if not cur: bucket[key] = { "date": date_str, "cli_source": cli, "tokens": int(tokens), "input_tokens": int(in_tok), "output_tokens": int(out_tok), "cached_tokens": int(cache_tok), "dialogs": int(dialogs), "steps": int(steps), "hours": round(float(hours), 2), } else: cur["tokens"] += int(tokens) cur["input_tokens"] += int(in_tok) cur["output_tokens"] += int(out_tok) cur["cached_tokens"] += int(cache_tok) cur["dialogs"] += int(dialogs) cur["steps"] += int(steps) cur["hours"] = round(min(24.0, cur["hours"] + float(hours)), 2) def scan_antigravity(bucket: dict) -> tuple[int, int, float]: """ Scan Antigravity local telemetry (1:1 parity with /stats/ collector): 1. ~/.gemini/antigravity/conversation_summaries.db -> ONLY (step_count, YYYY-MM-DD date). 2. ~/.gemini/antigravity/brain/*/.system_generated/logs/transcript.jsonl -> ONLY token counters & timestamps. STRICT PRIVACY: Never queries or reads title, workspace_uris, project_id, paths, or prompt text. """ total_tok = 0 total_dlg = 0 total_hrs = 0.0 ag_root = Path.home() / ".gemini" / "antigravity" if not ag_root.exists(): return 0, 0, 0.0 db_daily_steps: dict[str, int] = {} db_daily_dialogs: dict[str, int] = {} db_path = ag_root / "conversation_summaries.db" if db_path.exists(): try: conn = sqlite3.connect(f"file:{db_path}?mode=ro", uri=True, timeout=5.0) try: rows = conn.execute( "SELECT step_count, substr(last_modified_time, 1, 10) FROM conversation_summaries WHERE step_count > 0" ).fetchall() for st_cnt, d_str in rows: if d_str and len(d_str) == 10 and d_str[4] == "-": db_daily_steps[d_str] = db_daily_steps.get(d_str, 0) + int(st_cnt or 0) db_daily_dialogs[d_str] = db_daily_dialogs.get(d_str, 0) + 1 finally: conn.close() except Exception: pass tr_daily_tok: dict[str, list[int]] = {} # day -> [tot, in, out, cache, steps] tr_daily_dialogs: dict[str, int] = {} tr_daily_ts: dict[str, list[float]] = {} pattern = str(ag_root / "brain" / "*" / ".system_generated" / "logs" / "transcript.jsonl") for fpath in glob.glob(pattern): try: p = Path(fpath) file_days_seen: set[str] = set() with p.open("r", encoding="utf-8", errors="ignore") as f: for raw in f: if "output_tokens" not in raw and "input_tokens" not in raw: continue try: obj = json.loads(raw) except Exception: continue in_t = int(obj.get("input_tokens") or 0) ca_t = int(obj.get("cache_read_tokens") or 0) ou_t = int(obj.get("output_tokens") or 0) created = str(obj.get("created_at", "")) if (in_t + ca_t + ou_t) > 0 and len(created) >= 10 and created[4] == "-": d_str = created[:10] tot_t = in_t + ca_t + ou_t rec = tr_daily_tok.setdefault(d_str, [0, 0, 0, 0, 0]) rec[0] += tot_t rec[1] += in_t rec[2] += ou_t rec[3] += ca_t rec[4] += 1 file_days_seen.add(d_str) try: ts_v = dt.datetime.fromisoformat(created.replace("Z", "+00:00")).timestamp() tr_daily_ts.setdefault(d_str, []).append(ts_v) except Exception: pass if file_days_seen: first_d = sorted(file_days_seen)[0] tr_daily_dialogs[first_d] = tr_daily_dialogs.get(first_d, 0) + 1 except Exception: continue avg_tokens_per_step = 139_297 all_days = sorted(set(db_daily_steps.keys()) | set(tr_daily_tok.keys())) for d_str in all_days: t_rec = tr_daily_tok.get(d_str) db_st = db_daily_steps.get(d_str, 0) dlg = max(db_daily_dialogs.get(d_str, 0), tr_daily_dialogs.get(d_str, 0)) if t_rec and t_rec[0] > 0: if db_st > t_rec[4] and t_rec[4] > 0: scale = db_st / t_rec[4] tok = int(t_rec[0] * scale) in_tok = int(t_rec[1] * scale) out_tok = int(t_rec[2] * scale) cache_tok = int(t_rec[3] * scale) steps = db_st else: tok, in_tok, out_tok, cache_tok = t_rec[0], t_rec[1], t_rec[2], t_rec[3] steps = max(t_rec[4], db_st) ts_list = tr_daily_ts.get(d_str, []) if len(ts_list) >= 2: hrs = (max(ts_list) - min(ts_list)) / 3600.0 hrs = max(0.5, min(18.0, hrs)) else: hrs = max(0.2, min(16.0, steps * 0.015)) elif db_st > 0: steps = db_st tok = int(steps * avg_tokens_per_step) in_tok = int(tok * 0.22) out_tok = int(tok * 0.04) cache_tok = int(tok * 0.74) hrs = max(0.2, min(16.0, steps * 0.012)) else: continue if dlg <= 0 and tok > 0: dlg = max(1, round(steps / 280.0)) add_day_stat(bucket, d_str, "antigravity", tok, in_tok, out_tok, cache_tok, dlg, steps, round(hrs, 1)) total_tok += tok total_dlg += dlg total_hrs += round(hrs, 1) return total_tok, total_dlg, round(total_hrs, 1) def scan_claude_code(bucket: dict) -> tuple[int, int, float]: """ Scan ~/.claude/projects/**/*.jsonl Privacy: Ignores directory names, cwd, gitBranch, and message text. Extracts ONLY numeric token counters from usage objects and ISO dates. """ total_tok = 0 total_dlg = 0 total_hrs = 0.0 claude_root = Path.home() / ".claude" / "projects" if not claude_root.exists(): return 0, 0, 0.0 for p in claude_root.rglob("*.jsonl"): try: day_tok: dict[str, list[int]] = {} first_day = "" with p.open("r", encoding="utf-8", errors="ignore") as f: for raw in f: if '"usage"' not in raw: continue try: obj = json.loads(raw) except Exception: continue msg = obj.get("message") if isinstance(obj.get("message"), dict) else obj usage = msg.get("usage") if isinstance(msg, dict) else None if not isinstance(usage, dict): continue in_t = int(usage.get("input_tokens", 0) or 0) out_t = int(usage.get("output_tokens", 0) or 0) cc_t = int(usage.get("cache_creation_input_tokens", 0) or 0) cr_t = int(usage.get("cache_read_input_tokens", 0) or 0) turn_sum = in_t + out_t + cc_t + cr_t if turn_sum <= 0: continue ts_str = str(obj.get("timestamp", ""))[:10] if len(ts_str) != 10 or ts_str[4] != "-": ts_str = dt.date.fromtimestamp(p.stat().st_mtime).isoformat() if not first_day: first_day = ts_str cur = day_tok.setdefault(ts_str, [0, 0, 0, 0, 0]) cur[0] += turn_sum cur[1] += in_t + cc_t cur[2] += out_t cur[3] += cr_t cur[4] += 1 if day_tok: total_dlg += 1 primary_day = first_day if first_day in day_tok else sorted(day_tok.keys())[0] for d_str, vals in day_tok.items(): hrs = round(max(0.1, vals[4] * 0.025), 2) dlg_inc = 1 if d_str == primary_day else 0 add_day_stat(bucket, d_str, "claude_code", vals[0], vals[1], vals[2], vals[3], dlg_inc, vals[4], hrs) total_tok += vals[0] total_hrs += hrs except Exception: continue return total_tok, total_dlg, round(total_hrs, 1) def scan_codex(bucket: dict) -> tuple[int, int, float]: """ Scan ~/.codex/sessions/**/*.jsonl and ~/.codex/archived_sessions/**/*.jsonl Privacy: Ignores session_meta, cwd, repo paths, and instructions. Extracts ONLY total_token_usage integers and YYYY-MM-DD date. """ total_tok = 0 total_dlg = 0 total_hrs = 0.0 codex_root = Path.home() / ".codex" if not codex_root.exists(): return 0, 0, 0.0 dirs = [codex_root / "sessions", codex_root / "archived_sessions"] for droot in dirs: if not droot.exists(): continue for p in droot.rglob("*.jsonl"): try: max_total = 0 max_in = 0 max_out = 0 max_cache = 0 turns = 0 session_date = "" m_parts = p.parts for idx in range(len(m_parts) - 2): if len(m_parts[idx]) == 4 and m_parts[idx].isdigit() and len(m_parts[idx + 1]) == 2 and len(m_parts[idx + 2]) == 2: session_date = f"{m_parts[idx]}-{m_parts[idx + 1]}-{m_parts[idx + 2]}" break if not session_date: session_date = dt.date.fromtimestamp(p.stat().st_mtime).isoformat() with p.open("r", encoding="utf-8", errors="ignore") as f: for raw in f: if "total_tokens" not in raw: continue try: obj = json.loads(raw) except Exception: continue ts_s = str(obj.get("timestamp", ""))[:10] if len(ts_s) == 10 and ts_s[4] == "-": session_date = ts_s payload = obj.get("payload") if isinstance(obj.get("payload"), dict) else {} info = payload.get("info") if isinstance(payload.get("info"), dict) else {} tot_u = info.get("total_token_usage") if isinstance(info.get("total_token_usage"), dict) else None if tot_u: tt = int(tot_u.get("total_tokens", 0) or 0) if tt >= max_total: max_total = tt max_in = int(tot_u.get("input_tokens", 0) or 0) max_cache = int(tot_u.get("cached_input_tokens", 0) or 0) max_out = int(tot_u.get("output_tokens", 0) or 0) turns += 1 if max_total > 0: total_dlg += 1 steps = max(1, turns) hrs = round(max(0.1, steps * 0.025), 2) add_day_stat(bucket, session_date, "codex", max_total, max_in, max_out, max_cache, 1, steps, hrs) total_tok += max_total total_hrs += hrs except Exception: continue return total_tok, total_dlg, round(total_hrs, 1) def sanitize_outgoing_days(days: list[dict]) -> list[dict]: """Hard privacy gate: strips any field outside ALLOWED_RECORD_KEYS.""" clean = [] for d in days: clean.append({k: d[k] for k in ALLOWED_RECORD_KEYS if k in d}) return clean def collect_all_local_stats() -> dict: bucket: dict[tuple[str, str], dict] = {} ag_tok, ag_dlg, ag_hrs = scan_antigravity(bucket) cc_tok, cc_dlg, cc_hrs = scan_claude_code(bucket) cx_tok, cx_dlg, cx_hrs = scan_codex(bucket) days = sanitize_outgoing_days(sorted(bucket.values(), key=lambda x: (x["date"], x["cli_source"]))) vbt = int(round(ag_tok * VBT_MULTIPLIERS["antigravity"] + cc_tok * VBT_MULTIPLIERS["claude_code"] + cx_tok * VBT_MULTIPLIERS["codex"])) return { "totals": { "antigravity": ag_tok, "claude_code": cc_tok, "codex": cx_tok, "all": ag_tok + cc_tok + cx_tok, "vbt": vbt, "dialogs": ag_dlg + cc_dlg + cx_dlg, "hours": round(ag_hrs + cc_hrs + cx_hrs, 1), }, "days": days, } def api_post(path: str, payload: dict, api_key: str) -> dict: last_err: Exception | None = None body = json.dumps(payload).encode("utf-8") for base in API_ENDPOINTS: try: req = urllib.request.Request( f"{base}{path}", data=body, headers={ "Content-Type": "application/json", "Authorization": f"Bearer {api_key}", "User-Agent": CLIENT_VERSION, }, method="POST", ) with urllib.request.urlopen(req, timeout=15) as resp: return json.loads(resp.read().decode("utf-8")) except Exception as exc: last_err = exc continue raise RuntimeError(f"All TokenBurn endpoints unreachable: {last_err}") def sync_to_platform(api_key: str) -> dict: scan = collect_all_local_stats() if not api_key: return {"ok": False, "error": "TOKENBURN_API_KEY not set", "scan": scan["totals"]} res = api_post( "/sync", { "mode": "replace_daily", "exact": True, "client_version": CLIENT_VERSION, "days": scan["days"], }, api_key=api_key, ) res["local_scan"] = scan["totals"] return res # ── STDIO MCP SERVER ────────────────────────────────────────────────────────── LOCAL_TOOLS = [ { "name": "sync_local_cli_tokens", "description": "Privacy-strict: scan ONLY numeric counters (tokens, dialogs, hours, steps — zero project names or paths) across Antigravity, Claude Code, and Codex CLI and sync to BurnVibe.", "inputSchema": {"type": "object", "properties": {}}, }, { "name": "burn_get_my_stats", "description": "Get my live Vibe Burn Token (VBT) score, rank, raw tokens, dialogs, hours, and dashboard URL.", "inputSchema": {"type": "object", "properties": {}}, }, { "name": "burn_get_leaderboard", "description": "View the live Top Vibe-Coders ladder sorted by VBT, raw tokens, dialogs, or hours.", "inputSchema": { "type": "object", "properties": { "sort": {"type": "string", "enum": ["vbt", "tokens", "dialogs", "hours"]}, "period": {"type": "string", "enum": ["all", "30d", "7d", "today"]}, "cli": {"type": "string", "enum": ["all", "antigravity", "claude_code", "codex"]}, }, }, }, { "name": "burn_report_tokens", "description": "Report numeric counters only (tokens, dialogs, hours, steps). Never pass project names or paths.", "inputSchema": { "type": "object", "properties": { "tokens": {"type": "integer"}, "cli_source": {"type": "string", "enum": ["antigravity", "claude_code", "codex"]}, "dialogs": {"type": "integer"}, "hours": {"type": "number"}, }, "required": ["tokens", "cli_source"], }, }, ] def run_stdio_mcp(api_key: str) -> None: def bg_autosync() -> None: while True: try: if api_key: sync_to_platform(api_key) except Exception: pass time.sleep(600) t = threading.Thread(target=bg_autosync, daemon=True) t.start() for raw_line in sys.stdin: line = raw_line.strip() if not line: continue try: msg = json.loads(line) except Exception: continue rpc_id = msg.get("id") method = str(msg.get("method", "")) params = msg.get("params") if isinstance(msg.get("params"), dict) else {} if method.startswith("notifications/"): continue if method == "initialize": proto = str(params.get("protocolVersion", "2024-11-05")) resp = { "jsonrpc": "2.0", "id": rpc_id, "result": { "protocolVersion": proto, "capabilities": {"tools": {"listChanged": False}}, "serverInfo": {"name": "burnvibe-local-mcp", "version": "2.0.0"}, }, } elif method == "ping": resp = {"jsonrpc": "2.0", "id": rpc_id, "result": {}} elif method == "tools/list": resp = {"jsonrpc": "2.0", "id": rpc_id, "result": {"tools": LOCAL_TOOLS}} elif method == "tools/call": tname = str(params.get("name", "")) args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {} try: if tname == "sync_local_cli_tokens": r = sync_to_platform(api_key) if not r.get("ok"): txt = f"⚠️ Ошибка синхронизации: {r.get('error')}" else: ls = r.get("local_scan", {}) txt = ( f"⚡ Телеметрия синхронизирована (Zero-Project Privacy Mode)!\n" f"• Игрок: @{r.get('nickname')} (Ранг #{r.get('rank')})\n" f"• Универсальный счёт: {r.get('total_vbt_formatted', fmt_num(ls.get('vbt', 0)))} VBT\n" f"• Сырые токены: {r.get('total_tokens_formatted')} | Диалоги: {ls.get('dialogs', 0)} | Часы: {ls.get('hours', 0)}\n" f"• Дашборд: {r.get('profile_url')}" ) resp = {"jsonrpc": "2.0", "id": rpc_id, "result": {"content": [{"type": "text", "text": txt}]}} else: # Strip any unexpected keys before proxying safe_args = {k: v for k, v in args.items() if k in {"tokens", "cli_source", "dialogs", "hours", "steps", "date", "sort", "period", "cli", "limit"}} remote = api_post( "/mcp", {"jsonrpc": "2.0", "id": rpc_id, "method": "tools/call", "params": {"name": tname, "arguments": safe_args}}, api_key=api_key, ) resp = remote except Exception as e: resp = { "jsonrpc": "2.0", "id": rpc_id, "result": {"isError": True, "content": [{"type": "text", "text": f"Error: {e}"}]}, } else: resp = {"jsonrpc": "2.0", "id": rpc_id, "error": {"code": -32601, "message": f"Unknown method {method}"}} sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n") sys.stdout.flush() def main() -> None: parser = argparse.ArgumentParser(description="Token Burn / BurnVibe Privacy-Strict MCP Collector") parser.add_argument("--key", default="", help="API Key (tb_live_...)") parser.add_argument("--save-key", action="store_true", help="Save API key to ~/.tokenburn/config.json") parser.add_argument("--sync", "--sync-now", dest="sync", action="store_true", help="Scan numeric counters and upload once") parser.add_argument("--dry-run", action="store_true", help="Print local counters summary without uploading") parser.add_argument("--inspect-payload", action="store_true", help="Print exact outgoing JSON payload to verify zero project data") args = parser.parse_args() if args.key and args.save_key: save_api_key(args.key) key = load_api_key(args.key) if args.inspect_payload: res = collect_all_local_stats() sample = {"mode": "replace_daily", "client_version": CLIENT_VERSION, "days_sample": res["days"][-5:], "total_days": len(res["days"])} print(json.dumps(sample, indent=2, ensure_ascii=False)) return if args.dry_run: res = collect_all_local_stats() t = res["totals"] print("⚡ Token Burn · VibeBase Local Audit (Zero-Project Privacy Mode)") print(f" • Antigravity (×1.00) : {fmt_num(t['antigravity'])}") print(f" • Codex CLI (×1.35) : {fmt_num(t['codex'])}") print(f" • Claude Code (×1.75) : {fmt_num(t['claude_code'])}") print(f" • VBT Total : {fmt_num(t['vbt'])} VBT") print(f" • Dialogs / Hours : {t['dialogs']} dialogs · {t['hours']} h") print(" • Project data read : NONE (0 project names, paths, or prompts)") return if args.sync: if not key: print("❌ Укажите ключ: python3 tokenburn_mcp.py --sync --key tb_live_...", file=sys.stderr) sys.exit(1) out = sync_to_platform(key) if out.get("ok"): ls = out.get("local_scan", {}) print(f"✅ BurnVibe синхронизирован! Игрок: @{out['nickname']} · Ранг: #{out['rank']}") print(f" Vibe Burn Token: {out.get('total_vbt_formatted', fmt_num(ls.get('vbt', 0)))} VBT (сырых: {out['total_tokens_formatted']})") print(f" Диалогов: {ls.get('dialogs', 0)} · Часов: {ls.get('hours', 0)} · Проектных данных передано: 0") print(f" Дашборд: {out['profile_url']}") else: print(f"❌ Ошибка: {out}", file=sys.stderr) sys.exit(1) return run_stdio_mcp(key) if __name__ == "__main__": main()