burnmeter 0.3.8__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
burnmeter/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """burnmeter — Local Claude Code token usage meter.
2
+
3
+ Privacy: Reads only ~/.claude/projects/. No network calls except to fetch
4
+ Chart.js from CDN in the browser. The Python server itself is fully offline.
5
+ """
6
+ __version__ = "0.3.8"
burnmeter/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+ import sys
3
+ sys.exit(main())
burnmeter/_proc.py ADDED
@@ -0,0 +1,13 @@
1
+ """Process helper.
2
+
3
+ When Burnmeter runs windowless (system tray / pythonw — no console of its own),
4
+ any console child process it spawns (git, the build worker, taskkill, powershell)
5
+ would otherwise POP its own console window for the instant it runs. Across a
6
+ running session that shows up as cmd windows "flashing" every cache rebuild.
7
+ Passing CREATE_NO_WINDOW suppresses that window. On non-Windows it's 0 (no-op),
8
+ so call sites can pass `creationflags=NO_WINDOW` unconditionally.
9
+ """
10
+ import sys
11
+
12
+ # CREATE_NO_WINDOW (0x08000000) on Windows; 0 elsewhere.
13
+ NO_WINDOW = 0x08000000 if sys.platform.startswith("win") else 0
burnmeter/_worker.py ADDED
@@ -0,0 +1,141 @@
1
+ """Short-lived build worker — bounds the long-running server's RSS.
2
+
3
+ The server spawns this as a subprocess, sends a JSON config on stdin, and reads
4
+ the built report JSON from stdout. The worker loads ~half-a-million records,
5
+ builds the report (transiently spiking to ~800MB), prints the JSON, then EXITS
6
+ — so the OS reclaims that memory immediately. The long-running server process
7
+ only ever holds the small report JSON (~0.5MB), never the raw records.
8
+
9
+ This is a zero-logic-change RAM fix: the exact same load_records / build_report
10
+ run, just in a process that dies after each build. No streaming-aggregate
11
+ rewrite, no risk of subtly wrong numbers.
12
+
13
+ Usage: `python3 -m burnmeter._worker` then write config JSON to stdin:
14
+ {"source":"codex","projects_dir":"...","extra_roots":[...],
15
+ "codex_dir":"...","codex_extra_roots":[...],"plan":null}
16
+ Prints report JSON to stdout. Non-zero exit on error.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import sys
23
+ import threading
24
+ import time
25
+ from pathlib import Path
26
+
27
+
28
+ def _exit_with_parent(poll: float = 2.0) -> None:
29
+ """Exit when the server that spawned this worker dies.
30
+
31
+ A killed or crashed server left its worker running until the build ended:
32
+ 1.2-1.8 GB for minutes, and a restarted server started a second one. The
33
+ server passes its pid; the worker removes its config and report files, which
34
+ no one will read or delete now, and exits. Runs as a daemon thread.
35
+ """
36
+ ppid = int(os.environ.get("BURNMETER_WORKER_PARENT") or os.getppid())
37
+ if sys.platform.startswith("win"):
38
+ import ctypes
39
+ k32 = ctypes.WinDLL("kernel32")
40
+ k32.OpenProcess.restype = ctypes.c_void_p
41
+ k32.OpenProcess.argtypes = [ctypes.c_uint32, ctypes.c_int, ctypes.c_uint32]
42
+ k32.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
43
+ h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE; None: already gone
44
+ if h:
45
+ k32.WaitForSingleObject(h, 0xFFFFFFFF) # INFINITE
46
+ else:
47
+ while os.getppid() == ppid: # re-parented once it dies
48
+ time.sleep(poll)
49
+ for var in ("BURNMETER_WORKER_IN", "BURNMETER_WORKER_OUT", "BURNMETER_WORKER_PROGRESS"):
50
+ try:
51
+ os.unlink(os.environ[var])
52
+ except (KeyError, OSError):
53
+ pass
54
+ os._exit(3)
55
+
56
+
57
+ def _progress_writer(path):
58
+ """A callback that keeps the read's progress in `path` for the server's loading
59
+ screen: the latest counts, merged and written whole (temp file, then rename), so
60
+ the server never reads half a file. None when no path was given."""
61
+ if not path:
62
+ return None
63
+ state = {"phase": "reading", "started_at": time.time()}
64
+
65
+ def write(update: dict) -> None:
66
+ state.update(update)
67
+ tmp = f"{path}.tmp"
68
+ try:
69
+ Path(tmp).write_text(json.dumps(state), encoding="utf-8")
70
+ os.replace(tmp, path)
71
+ except OSError:
72
+ pass
73
+ write({})
74
+ return write
75
+
76
+
77
+ def main() -> int:
78
+ threading.Thread(target=_exit_with_parent, name="parent-watch", daemon=True).start()
79
+ # Config in / report out go through FILES (env vars) when set — a frozen
80
+ # --windowed (no-console) process has no usable stdin/stdout, so writing the
81
+ # report to stdout raised OSError [Errno 22]. Fall back to stdin/stdout for
82
+ # plain `python -m burnmeter._worker` use.
83
+ src = os.environ.get("BURNMETER_WORKER_IN")
84
+ raw = Path(src).read_text(encoding="utf-8") if src else (sys.stdin.read() or "{}")
85
+ try:
86
+ cfg = json.loads(raw or "{}")
87
+ except json.JSONDecodeError as e:
88
+ try:
89
+ sys.stderr.write(f"[worker] bad config: {e}\n")
90
+ except Exception:
91
+ pass
92
+ return 2
93
+
94
+ source = cfg.get("source", "claude")
95
+ plan = cfg.get("plan")
96
+ progress = _progress_writer(os.environ.get("BURNMETER_WORKER_PROGRESS"))
97
+
98
+ from .analytics import build_report
99
+
100
+ if source == "codex":
101
+ from .codex_parser import load_codex_records, CODEX_SESSIONS_DIR
102
+ root = Path(cfg["codex_dir"]) if cfg.get("codex_dir") else CODEX_SESSIONS_DIR
103
+ extra = [Path(p) for p in (cfg.get("codex_extra_roots") or [])]
104
+ records, stats, intents = load_codex_records(
105
+ root, extra_roots=extra, since_days=int(cfg.get("codex_since_days", 90)),
106
+ progress=progress)
107
+ errors = []
108
+ else:
109
+ from datetime import datetime, timedelta, timezone
110
+ from .parser import load_records, CLAUDE_PROJECTS_DIR
111
+ root = Path(cfg["projects_dir"]) if cfg.get("projects_dir") else CLAUDE_PROJECTS_DIR
112
+ extra = [Path(p) for p in (cfg.get("extra_roots") or [])]
113
+ # since_hours: the quick report a fresh install shows while its first full
114
+ # read runs (only the recent logs, no parse cache).
115
+ since = (datetime.now(timezone.utc) - timedelta(hours=float(cfg["since_hours"]))
116
+ if cfg.get("since_hours") else None)
117
+ records, stats, intents, errors = load_records(root, extra_roots=extra, since=since,
118
+ progress=progress)
119
+
120
+ if progress:
121
+ progress({"phase": "building"})
122
+ report = build_report(records, plan=plan, user_intents=intents,
123
+ error_events=errors, source=source)
124
+ report["_meta"] = {**stats, "source": source}
125
+ if source == "codex" and stats.get("rate_limits"):
126
+ report["codex_rate_limits"] = stats["rate_limits"]
127
+
128
+ # Release before serializing (records no longer needed).
129
+ records = None
130
+ out = json.dumps(report, default=str)
131
+ dest = os.environ.get("BURNMETER_WORKER_OUT")
132
+ if dest:
133
+ Path(dest).write_text(out, encoding="utf-8")
134
+ else:
135
+ sys.stdout.write(out)
136
+ sys.stdout.flush()
137
+ return 0
138
+
139
+
140
+ if __name__ == "__main__":
141
+ sys.exit(main())
burnmeter/alerts.py ADDED
@@ -0,0 +1,223 @@
1
+ """Burnmeter — local, privacy-preserving pre-limit alerts.
2
+
3
+ Runs ENTIRELY on the user's machine: the local `burnmeter` process evaluates the
4
+ same thresholds the dashboard shows and pushes a notification to the user's OWN
5
+ webhook / Slack / email when a binding constraint is about to be hit. Nothing is
6
+ ever sent to a Burnmeter-operated server — the local-first promise holds (no
7
+ phone-home). This is what lets an alert reach you when the dashboard tab is
8
+ closed: the local server keeps evaluating in a background thread.
9
+
10
+ Threshold logic is a faithful port of dashboard.js `alertLevelOf`, so the
11
+ terminal/server alerts and the in-browser toast never disagree.
12
+
13
+ Config: ~/.config/burnmeter/alerts.json (chmod 600 — may hold SMTP creds).
14
+ Senders use only the Python standard library (urllib, smtplib).
15
+
16
+ Example alerts.json:
17
+ {
18
+ "enabled": true,
19
+ "sources": ["claude", "codex"],
20
+ "destinations": {
21
+ "webhook_url": "https://example.com/hook",
22
+ "slack_webhook_url": "https://hooks.slack.com/services/...",
23
+ "email": {"host": "smtp.gmail.com", "port": 587, "tls": true,
24
+ "username": "you@gmail.com", "password": "app-password",
25
+ "from": "you@gmail.com", "to": "you@gmail.com"}
26
+ }
27
+ }
28
+ """
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import smtplib
33
+ import ssl
34
+ import urllib.request
35
+ from email.message import EmailMessage
36
+ from pathlib import Path
37
+ from typing import Optional
38
+
39
+ CONFIG_PATH = Path.home() / ".config" / "burnmeter" / "alerts.json"
40
+
41
+ # Thresholds — keep in sync with dashboard.js alertLevelOf.
42
+ RATE_LIMIT_WARN = 85 # % of account rate-limit (Codex)
43
+ RATE_LIMIT_CRIT = 95 # %
44
+ BURN_HEAVY_MULT = 1.6 # Claude: warn when burn-rate >= 'heavy' zone * this
45
+ WEEK_OVER_P95_MULT = 1.2 # budget pace: warn when this week's spend > heaviest week * this
46
+
47
+ LEVEL_NAMES = {0: "ok", 2: "warn", 3: "crit"}
48
+
49
+
50
+ # ---------- config ----------
51
+ def load_config(path: Path = CONFIG_PATH) -> dict:
52
+ try:
53
+ return json.loads(path.read_text(encoding="utf-8"))
54
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
55
+ return {}
56
+
57
+
58
+ def save_config(cfg: dict, path: Path = CONFIG_PATH) -> None:
59
+ path.parent.mkdir(parents=True, exist_ok=True)
60
+ path.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
61
+ try:
62
+ path.chmod(0o600) # may contain an SMTP password
63
+ except OSError:
64
+ pass
65
+
66
+
67
+ def is_enabled(cfg: Optional[dict] = None) -> bool:
68
+ c = cfg if cfg is not None else load_config()
69
+ return bool(c.get("enabled")) and bool(c.get("destinations"))
70
+
71
+
72
+ def _money(n) -> str:
73
+ try:
74
+ return f"${float(n):,.0f}"
75
+ except (TypeError, ValueError):
76
+ return str(n)
77
+
78
+
79
+ # ---------- evaluate (faithful port of dashboard.js alertLevelOf) ----------
80
+ def evaluate(report: dict, source: str) -> tuple[int, str]:
81
+ """Return (level, message). level: 0 safe, 2 warn, 3 critical.
82
+
83
+ - Codex (has account rate-limits): max(primary, secondary) used_percent —
84
+ >=95 critical, >=85 warning.
85
+ - Claude (no account limit): burn-rate vs the 'heavy' reference zone —
86
+ >= heavy * 1.6 warning.
87
+ """
88
+ level, msg = 0, ""
89
+ if source == "codex":
90
+ rls = report.get("codex_rate_limits") or {}
91
+ if rls:
92
+ dev = rls.get("mac") or next(iter(rls.values()), {}) or {}
93
+ primary = (dev.get("primary") or {}).get("used_percent") or 0
94
+ secondary = (dev.get("secondary") or {}).get("used_percent") or 0
95
+ bp = max(primary, secondary)
96
+ if bp >= RATE_LIMIT_CRIT:
97
+ level, msg = 3, f"\U0001f534 Codex limit at {round(bp)}% — throttle is IMMINENT"
98
+ elif bp >= RATE_LIMIT_WARN:
99
+ level, msg = 2, f"\U0001f7e0 Codex limit at {round(bp)}% — approaching the wall"
100
+ else:
101
+ # claude — no account limit, so we warn on excessive burn rate
102
+ fc = report.get("forecast") or {}
103
+ zones = report.get("burn_rate_zones") or {}
104
+ by_hours = fc.get("burn_rates_by_hours") or {}
105
+ rate = by_hours.get("2")
106
+ if rate is None:
107
+ rate = fc.get("burn_rate_per_hour_recent") or 0
108
+ heavy = zones.get("heavy")
109
+ if heavy and rate >= heavy * BURN_HEAVY_MULT:
110
+ level, msg = 2, f"\U0001f7e0 Burn rate is very high: {_money(rate)}/hr"
111
+
112
+ # Budget pace (any source) — the math was ALWAYS computed by analytics (weekly_cap +
113
+ # forecast.month) but never read here; this is the "you're on pace to overspend" alert.
114
+ # Fire only vs the user's OWN history (p95 of their heaviest week) — no invented budgets.
115
+ wc = report.get("weekly_cap") or {}
116
+ week = wc.get("rolling_7d_cost") or 0
117
+ p95 = wc.get("historical_7d_p95") or 0
118
+ if p95 > 0 and week >= p95 * WEEK_OVER_P95_MULT:
119
+ eom = ((report.get("forecast") or {}).get("month") or {}).get("projected_eom")
120
+ pace = (f"\U0001f4c8 Spending pace: {_money(week)} this week — "
121
+ f"{round(week / p95 * 100 - 100)}% above your heaviest week ({_money(p95)})")
122
+ if eom:
123
+ pace += f". On pace for {_money(eom)} this month."
124
+ if 2 > level:
125
+ level, msg = 2, pace
126
+ return level, msg
127
+
128
+
129
+ # ---------- senders (stdlib only) ----------
130
+ def send_webhook(url: str, payload: dict, timeout: int = 10) -> None:
131
+ data = json.dumps(payload).encode("utf-8")
132
+ req = urllib.request.Request(
133
+ url, data=data, method="POST",
134
+ headers={"Content-Type": "application/json", "User-Agent": "burnmeter-alerts"},
135
+ )
136
+ with urllib.request.urlopen(req, timeout=timeout) as r:
137
+ r.read()
138
+
139
+
140
+ def send_slack(url: str, text: str, timeout: int = 10) -> None:
141
+ # Slack Incoming Webhooks accept {"text": ...}.
142
+ send_webhook(url, {"text": text}, timeout=timeout)
143
+
144
+
145
+ def send_email(smtp: dict, subject: str, body: str) -> None:
146
+ msg = EmailMessage()
147
+ msg["Subject"] = subject
148
+ msg["From"] = smtp.get("from") or smtp.get("username") or "burnmeter@localhost"
149
+ msg["To"] = smtp["to"]
150
+ msg.set_content(body)
151
+ host = smtp.get("host", "localhost")
152
+ port = int(smtp.get("port", 587))
153
+ with smtplib.SMTP(host, port, timeout=20) as s:
154
+ if smtp.get("tls", True):
155
+ s.starttls(context=ssl.create_default_context())
156
+ if smtp.get("username"):
157
+ s.login(smtp["username"], smtp.get("password", ""))
158
+ s.send_message(msg)
159
+
160
+
161
+ def dispatch(cfg: dict, level: int, msg: str, source: str) -> list[str]:
162
+ """Send `msg` to every configured destination. Never raises — returns a list
163
+ of "<dest>:ok" / "<dest>:err:<detail>" strings so callers can log results."""
164
+ dests = (cfg or {}).get("destinations") or {}
165
+ results: list[str] = []
166
+ subject = f"Burnmeter {LEVEL_NAMES.get(level, '')} · {source}"
167
+ if dests.get("webhook_url"):
168
+ try:
169
+ send_webhook(dests["webhook_url"], {
170
+ "source": source, "level": level,
171
+ "level_name": LEVEL_NAMES.get(level), "message": msg,
172
+ })
173
+ results.append("webhook:ok")
174
+ except Exception as e:
175
+ results.append(f"webhook:err:{e}")
176
+ if dests.get("slack_webhook_url"):
177
+ try:
178
+ send_slack(dests["slack_webhook_url"], msg)
179
+ results.append("slack:ok")
180
+ except Exception as e:
181
+ results.append(f"slack:err:{e}")
182
+ if dests.get("email"):
183
+ try:
184
+ send_email(dests["email"], subject, msg)
185
+ results.append("email:ok")
186
+ except Exception as e:
187
+ results.append(f"email:err:{e}")
188
+ return results
189
+
190
+
191
+ def check_and_fire(reports: dict, cfg: Optional[dict] = None,
192
+ state: Optional[dict] = None) -> dict:
193
+ """Evaluate each source's report and dispatch on an UPWARD threshold crossing.
194
+
195
+ `reports`: {source: report_dict}. `state`: {source: last_level}, mutated and
196
+ returned. De-dup matches dashboard.js: fire only when level >= 2 AND it is
197
+ higher than the previous level for that source (so a sustained warning emits
198
+ once, and a warn->crit escalation re-fires).
199
+ """
200
+ cfg = cfg if cfg is not None else load_config()
201
+ state = state if state is not None else {}
202
+ if not is_enabled(cfg):
203
+ return state
204
+ sources = cfg.get("sources") or list(reports.keys())
205
+ for source in sources:
206
+ rep = reports.get(source)
207
+ if not rep:
208
+ continue
209
+ level, msg = evaluate(rep, source)
210
+ prev = state.get(source, 0)
211
+ state[source] = level
212
+ if level >= 2 and level > prev:
213
+ dispatch(cfg, level, msg, source)
214
+ return state
215
+
216
+
217
+ def send_test(cfg: Optional[dict] = None) -> list[str]:
218
+ """Send a test alert to every configured destination (ignores enabled/level
219
+ gating) so users can verify their setup."""
220
+ cfg = cfg if cfg is not None else load_config()
221
+ return dispatch(cfg, 2,
222
+ "\U0001f514 Burnmeter test alert — your notification destinations work.",
223
+ "test")