app
Grok Bot
Public Made by Adomby adom
Grok Bridge lets Adom talk to Grok Bot. Adom's AI environment is for electrical engineers: any provider, any desktop power tool, one place.
main
John Lauer
v1.3.13 (insiders): Shared-mailbox protection for the Grok webhook: 6 fires a minute per PC and 3 per thread, a shared cooldown after Grok throttles (Retry-After), an activity log of every call with time, thread and result, grok_activity to read it, and a cached readiness probe.
2d046db
5d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
"""Activity log + webhook governor (1.3.13).
Why: John (2026-10-15) saw Claude Desktop's Grok requests fail with throttling while other AI
threads were hammering the same Grok Bridge webhook, and grok.log could not say who, when or
what came back (no timestamps, no results). Now every verb call is one JSONL line in
~/.adom/grok-bridge-activity.jsonl, and grok_webhook_fire goes through a governor:
- a per-PC budget (default 6 fires a minute) and a per-thread budget (default 3 a minute),
so one noisy thread cannot use up the shared mailbox;
- a cooldown after Grok answers 429 / "rate limit": honour Retry-After (else 60 s, doubling
to 300 s), refuse locally until it passes, so callers do not extend the throttle.
Never logs the webhook key, payload messages or transcript text. Limits are overridable in
~/.adom/grok-bridge-settings.json: {"webhookPerMinute": 6, "webhookPerThreadPerMinute": 3}.
"""
from __future__ import annotations
import collections
import json
import os
import threading
import time
from datetime import datetime
_DIR = os.path.join(os.path.expanduser("~"), ".adom")
LOG_PATH = os.path.join(_DIR, "grok-bridge-activity.jsonl")
COOLDOWN_PATH = os.path.join(_DIR, "grok-webhook-cooldown.json")
SETTINGS_PATH = os.path.join(_DIR, "grok-bridge-settings.json")
MAX_BYTES = 5 * 1024 * 1024
DEFAULT_PER_MINUTE = 6
DEFAULT_PER_THREAD_PER_MINUTE = 3
COOLDOWN_DEFAULT = 60
COOLDOWN_MAX = 300
_lock = threading.Lock()
_fires: collections.deque = collections.deque() # (t, caller) of accepted fires
# Result fields worth keeping (never message text, keys or transcripts).
_KEEP = ("errorCode", "event", "httpStatus", "runUuid", "nonce", "agent", "retryAfterSeconds",
"throttledBy", "warning", "computerState", "launched", "restarted", "mainWindowHidden")
def now_iso() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def _write(entry: dict) -> None:
try:
os.makedirs(_DIR, exist_ok=True)
with _lock:
if os.path.exists(LOG_PATH) and os.path.getsize(LOG_PATH) > MAX_BYTES:
os.replace(LOG_PATH, LOG_PATH + ".1")
with open(LOG_PATH, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
pass # logging must never break a verb
def log_call(verb: str, caller: str, ms: int, result) -> None:
entry = {"ts": now_iso(), "verb": verb, "caller": caller or "anonymous", "ms": ms}
if isinstance(result, dict):
entry["ok"] = result.get("success") is not False
for k in _KEEP:
if result.get(k) is not None:
entry[k] = result.get(k)
_write(entry)
def _settings() -> dict:
try:
with open(SETTINGS_PATH, encoding="utf-8") as f:
d = json.load(f)
return d if isinstance(d, dict) else {}
except Exception:
return {}
def limits() -> tuple[int, int]:
s = _settings()
try:
per_min = max(1, int(s.get("webhookPerMinute") or DEFAULT_PER_MINUTE))
except (TypeError, ValueError):
per_min = DEFAULT_PER_MINUTE
try:
per_thread = max(1, int(s.get("webhookPerThreadPerMinute") or DEFAULT_PER_THREAD_PER_MINUTE))
except (TypeError, ValueError):
per_thread = DEFAULT_PER_THREAD_PER_MINUTE
return per_min, min(per_thread, per_min)
def _read_cooldown() -> dict:
try:
with open(COOLDOWN_PATH, encoding="utf-8") as f:
return json.load(f) or {}
except Exception:
return {}
def cooldown_state() -> dict:
c = _read_cooldown()
left = int(float(c.get("until") or 0) - time.time())
return {"active": left > 0, "secondsLeft": max(0, left), "since": c.get("since"),
"httpStatus": c.get("httpStatus"), "strikes": c.get("strikes", 0)}
def note_throttled(http_status, retry_after) -> int:
"""Grok throttled us: start/extend the shared cooldown. Returns seconds to wait."""
c = _read_cooldown()
strikes = int(c.get("strikes") or 0) + 1 if float(c.get("until") or 0) > time.time() - 600 else 1
try:
wait = int(float(retry_after)) if retry_after not in (None, "") else 0
except (TypeError, ValueError):
wait = 0
if wait <= 0:
wait = min(COOLDOWN_MAX, COOLDOWN_DEFAULT * (2 ** (strikes - 1)))
wait = min(max(wait, 5), COOLDOWN_MAX)
try:
os.makedirs(_DIR, exist_ok=True)
with open(COOLDOWN_PATH, "w", encoding="utf-8") as f:
json.dump({"until": time.time() + wait, "since": now_iso(), "httpStatus": http_status,
"strikes": strikes}, f)
except Exception:
pass
return wait
def note_ok() -> None:
"""A fire went through: clear strikes once any cooldown has passed."""
c = _read_cooldown()
if c and float(c.get("until") or 0) <= time.time():
try:
os.remove(COOLDOWN_PATH)
except Exception:
pass
def admit(caller: str) -> dict | None:
"""None = may fire now (and the fire is counted). Otherwise a refusal dict."""
cd = cooldown_state()
if cd["active"]:
return {"throttledBy": "grok", "retryAfterSeconds": cd["secondsLeft"],
"why": f"Grok throttled this PC's webhook (HTTP {cd['httpStatus']}) at {cd['since']}; "
f"cooling down {cd['secondsLeft']} s more. Not sent, so the throttle is not extended."}
per_min, per_thread = limits()
caller = caller or "anonymous"
now = time.time()
with _lock:
while _fires and now - _fires[0][0] > 60:
_fires.popleft()
mine = [t for t, c in _fires if c == caller]
if len(mine) >= per_thread:
wait = int(60 - (now - mine[0])) + 1
return {"throttledBy": "thread-budget", "retryAfterSeconds": wait,
"why": f"thread {caller!r} already fired {len(mine)} times in the last minute "
f"(limit {per_thread} per thread, so other threads can still reach Grok)."}
if len(_fires) >= per_min:
wait = int(60 - (now - _fires[0][0])) + 1
return {"throttledBy": "pc-budget", "retryAfterSeconds": wait,
"why": f"{len(_fires)} webhook fires from this PC in the last minute (limit {per_min})."}
_fires.append((now, caller))
return None
def budget_state() -> dict:
per_min, per_thread = limits()
now = time.time()
with _lock:
recent = [(t, c) for t, c in _fires if now - t <= 60]
by = collections.Counter(c for _, c in recent)
return {"perMinute": per_min, "perThreadPerMinute": per_thread, "firesLastMinute": len(recent),
"byThreadLastMinute": dict(by), "cooldown": cooldown_state()}
def read_recent(minutes: int = 60, limit: int = 40, verb: str = "", caller: str = "") -> dict:
cutoff = time.time() - minutes * 60
rows = []
for path in (LOG_PATH + ".1", LOG_PATH):
try:
with open(path, encoding="utf-8") as f:
for line in f:
try:
e = json.loads(line)
t = datetime.fromisoformat(e["ts"]).timestamp()
except Exception:
continue
if t < cutoff:
continue
if verb and e.get("verb") != verb:
continue
if caller and e.get("caller") != caller:
continue
rows.append(e)
except FileNotFoundError:
continue
counts = collections.Counter((e.get("caller"), e.get("verb")) for e in rows)
fires = [e for e in rows if e.get("verb") == "grok_webhook_fire"]
return {
"windowMinutes": minutes,
"calls": len(rows),
"byCallerVerb": [{"caller": c, "verb": v, "count": n} for (c, v), n in counts.most_common(30)],
"webhook": {
"fires": len(fires),
"sent": sum(1 for e in fires if e.get("httpStatus") == 200),
"throttledByGrok": sum(1 for e in fires if e.get("httpStatus") == 429
or e.get("throttledBy") == "grok"),
"refusedByBudget": sum(1 for e in fires if e.get("throttledBy") in ("thread-budget", "pc-budget")),
"byCaller": dict(collections.Counter(e.get("caller") for e in fires)),
},
"recent": rows[-limit:],
}