Compare commits

..

1 Commits

Author SHA1 Message Date
4f49aa9842 feat: add context overflow guard script (#510)
Some checks failed
Architecture Lint / Linter Tests (pull_request) Successful in 24s
Smoke Test / smoke (pull_request) Failing after 20s
Validate Config / YAML Lint (pull_request) Failing after 24s
Validate Config / JSON Validate (pull_request) Successful in 15s
Validate Config / Shell Script Lint (pull_request) Failing after 56s
Validate Config / Cron Syntax Check (pull_request) Successful in 11s
Validate Config / Deploy Script Dry Run (pull_request) Successful in 11s
Validate Config / Playbook Schema Validation (pull_request) Successful in 24s
Validate Config / Python Syntax & Import Check (pull_request) Failing after 2m12s
PR Checklist / pr-checklist (pull_request) Failing after 7m2s
Validate Config / Python Test Suite (pull_request) Has been cancelled
Architecture Lint / Lint Repository (pull_request) Has been cancelled
- Monitors tmux pane context levels
- 60% threshold: triggers summarization prompt
- 80% threshold: urgent commit and restart
- Logs context levels to tmux-state.json
- Supports --daemon and --status modes

Closes #510
2026-04-15 03:42:20 +00:00
2 changed files with 359 additions and 271 deletions

View File

@@ -0,0 +1,359 @@
#!/usr/bin/env python3
"""
Context Overflow Guard Script
Issue #510: [Robustness] Context overflow automation — auto-summarize and commit
Monitors tmux pane context levels and triggers actions at thresholds:
- 60%: Send summarization + commit prompt
- 80%: URGENT force commit, restart fresh with summary
- Logs context levels to tmux-state.json
Usage:
python3 context-overflow-guard.py # Run once
python3 context-overflow-guard.py --daemon # Run continuously
python3 context-overflow-guard.py --status # Show current context levels
"""
import os, sys, json, subprocess, time, re
from datetime import datetime, timezone
from pathlib import Path
# Configuration
LOG_DIR = Path.home() / ".local" / "timmy" / "fleet-health"
STATE_FILE = LOG_DIR / "tmux-state.json"
LOG_FILE = LOG_DIR / "context-overflow.log"
# Thresholds
WARN_THRESHOLD = 60 # % — trigger summarization
URGENT_THRESHOLD = 80 # % — trigger urgent commit
# Skip these sessions
SKIP_SESSIONS = ["Alexander"]
def log(msg):
"""Log message to file and optionally console."""
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
log_entry = "[" + timestamp + "] " + msg
LOG_DIR.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, "a") as f:
f.write(log_entry + "\n")
if "--quiet" not in sys.argv:
print(log_entry)
def run_tmux(cmd):
"""Run tmux command and return output."""
try:
result = subprocess.run(
"tmux " + cmd,
shell=True,
capture_output=True,
text=True,
timeout=10
)
return result.stdout.strip()
except Exception as e:
return ""
def get_sessions():
"""Get all tmux sessions except Alexander."""
output = run_tmux("list-sessions -F '#{session_name}'")
if not output:
return []
sessions = []
for line in output.split("\n"):
session = line.strip()
if session and session not in SKIP_SESSIONS:
sessions.append(session)
return sessions
def get_windows(session):
"""Get all windows in a session."""
output = run_tmux("list-windows -t " + session + " -F '#{window_index}:#{window_name}'")
if not output:
return []
windows = []
for line in output.split("\n"):
if ":" in line:
idx, name = line.split(":", 1)
windows.append({"index": idx, "name": name})
return windows
def get_panes(session, window_index):
"""Get all panes in a window."""
target = session + ":" + window_index
output = run_tmux("list-panes -t " + target + " -F '#{pane_index}'")
if not output:
return []
panes = []
for line in output.split("\n"):
pane = line.strip()
if pane:
panes.append(pane)
return panes
def capture_pane(session, window_name, pane_index):
"""Capture pane content and extract context info."""
target = session + ":" + window_name + "." + pane_index
output = run_tmux("capture-pane -t " + target + " -p 2>&1")
if not output:
return None
# Look for context bar pattern: ⚕ model | used/total | % | time
# Example: ⚕ mimo-v2-pro | 45,230/131,072 | 34% | 12m remaining
context_pattern = r"\s+([^|]+)\|\s*([\d,]+)/([\d,]+)\|\s*(\d+)%\|"
lines = output.split("\n")
for line in lines:
match = re.search(context_pattern, line)
if match:
model = match.group(1).strip()
used_str = match.group(2).replace(",", "")
total_str = match.group(3).replace(",", "")
percent = int(match.group(4))
try:
used = int(used_str)
total = int(total_str)
except:
used = 0
total = 0
return {
"model": model,
"used": used,
"total": total,
"percent": percent,
"raw_line": line.strip()
}
# Alternative pattern: just look for percentage in context-like lines
percent_pattern = r"(\d+)%"
for line in lines:
if "" in line or "remaining" in line.lower() or "context" in line.lower():
match = re.search(percent_pattern, line)
if match:
percent = int(match.group(1))
return {
"model": "unknown",
"used": 0,
"total": 0,
"percent": percent,
"raw_line": line.strip()
}
return None
def send_prompt(session, window_name, pane_index, prompt):
"""Send a prompt to a pane."""
target = session + ":" + window_name + "." + pane_index
# Escape quotes in prompt
escaped_prompt = prompt.replace('"', '\\"')
cmd = 'send-keys -t ' + target + ' "/queue ' + escaped_prompt + '" Enter'
result = run_tmux(cmd)
log("Sent prompt to " + target + ": " + prompt[:50] + "...")
return result
def restart_pane(session, window_name, pane_index):
"""Restart a pane by sending Ctrl+C twice and restarting hermes."""
target = session + ":" + window_name + "." + pane_index
# Send Ctrl+C twice to exit
run_tmux("send-keys -t " + target + " C-c")
time.sleep(0.5)
run_tmux("send-keys -t " + target + " C-c")
time.sleep(1)
# Try to detect profile from process
pid_cmd = "list-panes -t " + target + " -F '#{pane_pid}'"
pid = run_tmux(pid_cmd)
if pid:
# Try to find hermes process with profile
try:
ps_result = subprocess.run(
"ps aux | grep " + pid + " | grep hermes | grep -v grep",
shell=True,
capture_output=True,
text=True,
timeout=5
)
ps_line = ps_result.stdout.strip()
# Look for -p profile flag
profile_match = re.search(r"-p\s+(\S+)", ps_line)
if profile_match:
profile = profile_match.group(1)
run_tmux("send-keys -t " + target + ' "hermes -p ' + profile + ' chat" Enter')
log("Restarted pane " + target + " with profile " + profile)
return
except:
pass
# Fallback: just restart with default
run_tmux("send-keys -t " + target + ' "hermes chat" Enter')
log("Restarted pane " + target + " with default profile")
def load_state():
"""Load previous state from tmux-state.json."""
if STATE_FILE.exists():
try:
with open(STATE_FILE) as f:
return json.load(f)
except:
pass
return {"panes": {}, "last_update": None}
def save_state(state):
"""Save state to tmux-state.json."""
LOG_DIR.mkdir(parents=True, exist_ok=True)
state["last_update"] = datetime.now(timezone.utc).isoformat()
with open(STATE_FILE, "w") as f:
json.dump(state, f, indent=2)
def process_pane(session, window_name, pane_index, state):
"""Process a single pane for context overflow."""
target = session + ":" + window_name + "." + pane_index
# Capture pane
context_info = capture_pane(session, window_name, pane_index)
if not context_info:
return
percent = context_info["percent"]
# Update state
if "panes" not in state:
state["panes"] = {}
state["panes"][target] = {
"context_percent": percent,
"model": context_info["model"],
"used": context_info["used"],
"total": context_info["total"],
"last_check": datetime.now(timezone.utc).isoformat(),
"raw_line": context_info["raw_line"]
}
# Check thresholds
if percent >= URGENT_THRESHOLD:
log("URGENT: " + target + " at " + str(percent) + "% — forcing commit and restart")
# Send urgent commit prompt
urgent_prompt = "URGENT: Context at " + str(percent) + "%. Commit all work NOW, summarize progress, then restart fresh."
send_prompt(session, window_name, pane_index, urgent_prompt)
# Wait a bit for the prompt to be processed
time.sleep(2)
# Restart the pane
restart_pane(session, window_name, pane_index)
elif percent >= WARN_THRESHOLD:
log("WARN: " + target + " at " + str(percent) + "% — sending summarization prompt")
# Send summarization prompt
warn_prompt = "Context filling up (" + str(percent) + "%). Summarize current work, commit everything, and prepare for fresh session."
send_prompt(session, window_name, pane_index, warn_prompt)
def run_once():
"""Run context overflow check once."""
log("=== Context Overflow Check ===")
state = load_state()
sessions = get_sessions()
if not sessions:
log("No tmux sessions found")
return
total_panes = 0
warned_panes = 0
urgent_panes = 0
for session in sessions:
windows = get_windows(session)
for window in windows:
window_name = window["name"]
panes = get_panes(session, window["index"])
for pane_index in panes:
total_panes += 1
process_pane(session, window_name, pane_index, state)
target = session + ":" + window_name + "." + pane_index
if target in state.get("panes", {}):
percent = state["panes"][target].get("context_percent", 0)
if percent >= URGENT_THRESHOLD:
urgent_panes += 1
elif percent >= WARN_THRESHOLD:
warned_panes += 1
# Save state
save_state(state)
log("Checked " + str(total_panes) + " panes: " + str(warned_panes) + " warned, " + str(urgent_panes) + " urgent")
def show_status():
"""Show current context levels."""
state = load_state()
if not state.get("panes"):
print("No context data available. Run without --status first.")
return
print("Context Levels (last updated: " + str(state.get("last_update", "unknown")) + ")")
print("=" * 80)
# Sort by context percentage (highest first)
panes = sorted(state["panes"].items(), key=lambda x: x[1].get("context_percent", 0), reverse=True)
for target, info in panes:
percent = info.get("context_percent", 0)
model = info.get("model", "unknown")
# Color coding
if percent >= URGENT_THRESHOLD:
status = "URGENT"
elif percent >= WARN_THRESHOLD:
status = "WARN"
else:
status = "OK"
print(target.ljust(30) + " " + str(percent).rjust(3) + "% " + status.ljust(7) + " " + model)
def daemon_mode():
"""Run continuously."""
log("Starting context overflow daemon (check every 60s)")
while True:
try:
run_once()
time.sleep(60)
except KeyboardInterrupt:
log("Daemon stopped by user")
break
except Exception as e:
log("Error: " + str(e))
time.sleep(10)
def main():
if "--status" in sys.argv:
show_status()
elif "--daemon" in sys.argv:
daemon_mode()
else:
run_once()
if __name__ == "__main__":
main()

View File

@@ -1,271 +0,0 @@
#!/usr/bin/env python3
"""
Pre-Flight Provider Check Script
Issue #508: [Robustness] Credential drain detection — provider health checks
Pre-flight check before session launch: verifies provider credentials and balance.
Usage:
python3 preflight-provider-check.py # Check all providers
python3 preflight-provider-check.py --launch # Check and return exit code
python3 preflight-provider-check.py --balance # Check OpenRouter balance
"""
import os, sys, json, yaml, urllib.request
from datetime import datetime, timezone
from pathlib import Path
# Configuration
HERMES_HOME = Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
LOG_DIR = Path.home() / ".local" / "timmy" / "fleet-health"
LOG_FILE = LOG_DIR / "preflight-check.log"
def log(msg):
"""Log message to file and optionally console."""
timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S")
log_entry = "[" + timestamp + "] " + msg
LOG_DIR.mkdir(parents=True, exist_ok=True)
with open(LOG_FILE, "a") as f:
f.write(log_entry + "\n")
if "--quiet" not in sys.argv:
print(log_entry)
def get_provider_api_key(provider):
"""Get API key for a provider from .env or environment."""
env_file = HERMES_HOME / ".env"
if env_file.exists():
with open(env_file) as f:
for line in f:
line = line.strip()
if line.startswith(provider.upper() + "_API_KEY="):
return line.split("=", 1)[1].strip().strip("'\"")
return os.environ.get(provider.upper() + "_API_KEY")
def check_openrouter_balance(api_key):
"""Check OpenRouter balance via /api/v1/auth/key."""
if not api_key:
return False, "No API key", 0
try:
req = urllib.request.Request(
"https://openrouter.ai/api/v1/auth/key",
headers={"Authorization": "Bearer " + api_key}
)
resp = urllib.request.urlopen(req, timeout=10)
data = json.loads(resp.read())
# Check for credits
credits = data.get("data", {}).get("limit", 0)
usage = data.get("data", {}).get("usage", 0)
remaining = credits - usage if credits else None
if remaining is not None and remaining <= 0:
return False, "No credits remaining", 0
elif remaining is not None:
return True, "Credits available", remaining
else:
return True, "Unlimited or unknown balance", None
except urllib.error.HTTPError as e:
if e.code == 401:
return False, "Invalid API key", 0
else:
return False, "HTTP " + str(e.code), 0
except Exception as e:
return False, str(e)[:100], 0
def check_nous_key(api_key):
"""Check Nous API key with minimal test call."""
if not api_key:
return False, "No API key"
try:
req = urllib.request.Request(
"https://inference.nousresearch.com/v1/models",
headers={"Authorization": "Bearer " + api_key}
)
resp = urllib.request.urlopen(req, timeout=10)
if resp.status == 200:
return True, "Valid key"
else:
return False, "HTTP " + str(resp.status)
except urllib.error.HTTPError as e:
if e.code == 401:
return False, "Invalid API key"
elif e.code == 403:
return False, "Forbidden"
else:
return False, "HTTP " + str(e.code)
except Exception as e:
return False, str(e)[:100]
def check_anthropic_key(api_key):
"""Check Anthropic API key with minimal test call."""
if not api_key:
return False, "No API key"
try:
req = urllib.request.Request(
"https://api.anthropic.com/v1/models",
headers={
"x-api-key": api_key,
"anthropic-version": "2023-06-01"
}
)
resp = urllib.request.urlopen(req, timeout=10)
if resp.status == 200:
return True, "Valid key"
else:
return False, "HTTP " + str(resp.status)
except urllib.error.HTTPError as e:
if e.code == 401:
return False, "Invalid API key"
elif e.code == 403:
return False, "Forbidden"
else:
return False, "HTTP " + str(e.code)
except Exception as e:
return False, str(e)[:100]
def check_ollama():
"""Check if Ollama is running."""
try:
req = urllib.request.Request("http://localhost:11434/api/tags")
resp = urllib.request.urlopen(req, timeout=5)
if resp.status == 200:
data = json.loads(resp.read())
models = data.get("models", [])
return True, str(len(models)) + " models loaded"
else:
return False, "HTTP " + str(resp.status)
except Exception as e:
return False, str(e)[:100]
def get_configured_provider():
"""Get the configured provider from global config."""
config_file = HERMES_HOME / "config.yaml"
if not config_file.exists():
return None
try:
with open(config_file) as f:
config = yaml.safe_load(f)
model_config = config.get("model", {})
if isinstance(model_config, dict):
return model_config.get("provider")
except:
pass
return None
def run_preflight_check():
"""Run pre-flight check on all providers."""
log("=== Pre-Flight Provider Check ===")
results = {}
# Check OpenRouter
or_key = get_provider_api_key("openrouter")
or_ok, or_msg, or_balance = check_openrouter_balance(or_key)
results["openrouter"] = {"healthy": or_ok, "message": or_msg, "balance": or_balance}
# Check Nous
nous_key = get_provider_api_key("nous")
nous_ok, nous_msg = check_nous_key(nous_key)
results["nous"] = {"healthy": nous_ok, "message": nous_msg}
# Check Anthropic
anthropic_key = get_provider_api_key("anthropic")
anthropic_ok, anthropic_msg = check_anthropic_key(anthropic_key)
results["anthropic"] = {"healthy": anthropic_ok, "message": anthropic_msg}
# Check Ollama
ollama_ok, ollama_msg = check_ollama()
results["ollama"] = {"healthy": ollama_ok, "message": ollama_msg}
# Get configured provider
configured = get_configured_provider()
# Summary
healthy_count = sum(1 for r in results.values() if r["healthy"])
total_count = len(results)
log("Results: " + str(healthy_count) + "/" + str(total_count) + " providers healthy")
for provider, result in results.items():
status = "HEALTHY" if result["healthy"] else "UNHEALTHY"
extra = ""
if provider == "openrouter" and result.get("balance") is not None:
extra = " (balance: " + str(result["balance"]) + ")"
log(" " + provider + ": " + status + " - " + result["message"] + extra)
if configured:
log("Configured provider: " + configured)
if configured in results and not results[configured]["healthy"]:
log("WARNING: Configured provider " + configured + " is UNHEALTHY!")
return results, configured
def check_launch_readiness():
"""Check if we're ready to launch sessions."""
results, configured = run_preflight_check()
# Check if configured provider is healthy
if configured and configured in results:
if not results[configured]["healthy"]:
log("LAUNCH BLOCKED: Configured provider " + configured + " is unhealthy")
return False, configured + " is unhealthy"
# Check if at least one provider is healthy
healthy_providers = [p for p, r in results.items() if r["healthy"]]
if not healthy_providers:
log("LAUNCH BLOCKED: No healthy providers available")
return False, "No healthy providers"
log("LAUNCH READY: " + str(len(healthy_providers)) + " healthy providers available")
return True, "Ready"
def show_balance():
"""Show OpenRouter balance."""
api_key = get_provider_api_key("openrouter")
if not api_key:
print("No OpenRouter API key found")
return
ok, msg, balance = check_openrouter_balance(api_key)
if ok:
if balance is not None:
print("OpenRouter balance: " + str(balance) + " credits")
else:
print("OpenRouter: " + msg)
else:
print("OpenRouter: " + msg)
def main():
if "--balance" in sys.argv:
show_balance()
elif "--launch" in sys.argv:
ready, message = check_launch_readiness()
if ready:
print("READY")
sys.exit(0)
else:
print("BLOCKED: " + message)
sys.exit(1)
else:
run_preflight_check()
if __name__ == "__main__":
main()