Files
gh-overlord-z-claudeshack-g…/skills/guardian/scripts/monitor_session.py
2025-11-30 08:46:58 +08:00

611 lines
20 KiB
Python

#!/usr/bin/env python3
"""
Guardian Session Monitor
Tracks session health metrics and detects when intervention would be helpful.
This script monitors in the background and triggers Guardian when thresholds are crossed.
Key Principle: MINIMAL STORAGE - only tracks metrics, NOT full conversation content.
Usage:
# Track a code write event
python monitor_session.py --event code-written --file auth.py --lines 60
# Track an error
python monitor_session.py --event error --message "TypeError: cannot unpack non-iterable"
# Track a user correction
python monitor_session.py --event correction --message "that's wrong, use bcrypt instead"
# Check session health
python monitor_session.py --check-health
# Reset session metrics
python monitor_session.py --reset
Environment Variables:
GUARDIAN_CONFIG_PATH: Path to Guardian config file [default: .guardian/config.json]
"""
import os
import sys
import json
import argparse
from datetime import datetime, timedelta
from pathlib import Path
from typing import Dict, List, Optional, Any
from collections import defaultdict
def parse_timestamp_with_tz(ts_str: str, reference_time: datetime) -> Optional[datetime]:
"""Parse ISO timestamp and make it comparable with reference_time.
Args:
ts_str: ISO format timestamp string
reference_time: Reference datetime to match timezone with
Returns:
Parsed datetime that's comparable with reference_time, or None if parsing fails
"""
try:
ts = datetime.fromisoformat(ts_str)
# If timestamp has timezone but reference doesn't, make timestamp naive
if ts.tzinfo and not reference_time.tzinfo:
ts = ts.replace(tzinfo=None)
# If reference has timezone but timestamp doesn't, make timestamp aware
elif not ts.tzinfo and reference_time.tzinfo:
ts = ts.replace(tzinfo=reference_time.tzinfo)
return ts
except (ValueError, TypeError):
return None
def find_guardian_root() -> Optional[Path]:
"""Find the .guardian directory."""
current = Path.cwd()
while current != current.parent:
guardian_path = current / '.guardian'
if guardian_path.exists():
return guardian_path
current = current.parent
return None
def init_guardian_if_needed() -> Path:
"""Initialize .guardian directory if it doesn't exist."""
guardian_path = Path.cwd() / '.guardian'
if not guardian_path.exists():
guardian_path.mkdir(parents=True, exist_ok=True)
# Create default config
default_config = {
"enabled": True,
"sensitivity": {
"lines_threshold": 50,
"error_repeat_threshold": 3,
"file_churn_threshold": 5,
"correction_threshold": 3,
"context_warning_percent": 0.7
},
"trigger_phrases": {
"review_needed": ["can you review", "does this look right"],
"struggling": ["still not working", "same error"],
"complexity": ["this is complex", "not sure how to"]
},
"auto_review": {
"enabled": True,
"always_review": ["auth", "security", "crypto", "payment"],
"never_review": ["test", "mock", "fixture"]
},
"learning": {
"acceptance_rate_target": 0.7,
"adjustment_speed": 0.1,
"memory_window_days": 30
},
"model": "haiku"
}
config_path = guardian_path / 'config.json'
with open(config_path, 'w', encoding='utf-8') as f:
json.dump(default_config, f, indent=2)
# Create session state file
state_path = guardian_path / 'session_state.json'
with open(state_path, 'w', encoding='utf-8') as f:
json.dump({
"session_start": datetime.now().isoformat(),
"metrics": {},
"history": []
}, f, indent=2)
return guardian_path
def load_config(guardian_path: Path) -> Dict[str, Any]:
"""Load Guardian configuration."""
config_path = guardian_path / 'config.json'
if not config_path.exists():
# Return default config
return {
"enabled": True,
"sensitivity": {
"lines_threshold": 50,
"error_repeat_threshold": 3,
"file_churn_threshold": 5,
"correction_threshold": 3,
"context_warning_percent": 0.7
}
}
try:
with open(config_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, FileNotFoundError, OSError, IOError):
return {"enabled": False}
def load_session_state(guardian_path: Path) -> Dict[str, Any]:
"""Load current session state."""
state_path = guardian_path / 'session_state.json'
if not state_path.exists():
return {
"session_start": datetime.now().isoformat(),
"metrics": {},
"history": []
}
try:
with open(state_path, 'r', encoding='utf-8') as f:
return json.load(f)
except (json.JSONDecodeError, FileNotFoundError, OSError, IOError):
return {
"session_start": datetime.now().isoformat(),
"metrics": {},
"history": []
}
def save_session_state(guardian_path: Path, state: Dict[str, Any]) -> None:
"""Save session state."""
state_path = guardian_path / 'session_state.json'
try:
with open(state_path, 'w', encoding='utf-8') as f:
json.dump(state, f, indent=2)
except (OSError, IOError) as e:
print(f"Warning: Failed to save session state: {e}", file=sys.stderr)
def track_code_written(state: Dict[str, Any], file_path: str, lines: int) -> None:
"""Track code writing event."""
MAX_EVENTS = 100 # Limit to prevent unbounded memory growth
if 'code_written' not in state['metrics']:
state['metrics']['code_written'] = {}
if file_path not in state['metrics']['code_written']:
state['metrics']['code_written'][file_path] = {
'total_lines': 0,
'events': []
}
state['metrics']['code_written'][file_path]['total_lines'] += lines
state['metrics']['code_written'][file_path]['events'].append({
'timestamp': datetime.now().isoformat(),
'lines': lines
})
# Keep only recent events
if len(state['metrics']['code_written'][file_path]['events']) > MAX_EVENTS:
state['metrics']['code_written'][file_path]['events'] = \
state['metrics']['code_written'][file_path]['events'][-MAX_EVENTS:]
def track_error(state: Dict[str, Any], error_message: str) -> None:
"""Track error occurrence."""
MAX_OCCURRENCES = 50 # Limit to prevent unbounded memory growth
if 'errors' not in state['metrics']:
state['metrics']['errors'] = {}
# Normalize error message (first line only)
error_key = error_message.split('\n')[0].strip()[:200]
if error_key not in state['metrics']['errors']:
state['metrics']['errors'][error_key] = {
'count': 0,
'first_seen': datetime.now().isoformat(),
'last_seen': None,
'occurrences': []
}
state['metrics']['errors'][error_key]['count'] += 1
state['metrics']['errors'][error_key]['last_seen'] = datetime.now().isoformat()
state['metrics']['errors'][error_key]['occurrences'].append({
'timestamp': datetime.now().isoformat()
})
# Keep only recent occurrences
if len(state['metrics']['errors'][error_key]['occurrences']) > MAX_OCCURRENCES:
state['metrics']['errors'][error_key]['occurrences'] = \
state['metrics']['errors'][error_key]['occurrences'][-MAX_OCCURRENCES:]
def track_file_edit(state: Dict[str, Any], file_path: str) -> None:
"""Track file edit event."""
MAX_TIMESTAMPS = 100 # Limit to prevent unbounded memory growth
if 'file_edits' not in state['metrics']:
state['metrics']['file_edits'] = {}
if file_path not in state['metrics']['file_edits']:
state['metrics']['file_edits'][file_path] = {
'count': 0,
'timestamps': []
}
state['metrics']['file_edits'][file_path]['count'] += 1
state['metrics']['file_edits'][file_path]['timestamps'].append(
datetime.now().isoformat()
)
# Keep only recent timestamps
if len(state['metrics']['file_edits'][file_path]['timestamps']) > MAX_TIMESTAMPS:
state['metrics']['file_edits'][file_path]['timestamps'] = \
state['metrics']['file_edits'][file_path]['timestamps'][-MAX_TIMESTAMPS:]
def track_correction(state: Dict[str, Any], message: str) -> None:
"""Track user correction event."""
MAX_CORRECTIONS = 100 # Limit to prevent unbounded memory growth
if 'corrections' not in state['metrics']:
state['metrics']['corrections'] = []
state['metrics']['corrections'].append({
'timestamp': datetime.now().isoformat(),
'message': message[:500] # Truncate to avoid storing too much
})
# Keep only recent corrections
if len(state['metrics']['corrections']) > MAX_CORRECTIONS:
state['metrics']['corrections'] = state['metrics']['corrections'][-MAX_CORRECTIONS:]
def check_code_volume_threshold(state: Dict[str, Any], config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Check if code volume threshold is crossed."""
threshold = config.get('sensitivity', {}).get('lines_threshold', 50)
code_written = state['metrics'].get('code_written', {})
for file_path, data in code_written.items():
if data['total_lines'] >= threshold:
# Check if this file is in auto_review categories
auto_review = config.get('auto_review', {})
always_review = auto_review.get('always_review', [])
never_review = auto_review.get('never_review', [])
# Check never_review first
if any(keyword in file_path.lower() for keyword in never_review):
continue
# Check always_review or threshold
should_review = any(keyword in file_path.lower() for keyword in always_review)
if should_review or data['total_lines'] >= threshold:
return {
'trigger': 'code_volume',
'file': file_path,
'lines': data['total_lines'],
'threshold': threshold,
'priority': 'high' if should_review else 'medium'
}
return None
def check_repeated_errors(state: Dict[str, Any], config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Check if same error is repeated."""
threshold = config.get('sensitivity', {}).get('error_repeat_threshold', 3)
errors = state['metrics'].get('errors', {})
for error_key, data in errors.items():
if data['count'] >= threshold:
return {
'trigger': 'repeated_errors',
'error': error_key,
'count': data['count'],
'threshold': threshold,
'priority': 'critical'
}
return None
def check_file_churn(state: Dict[str, Any], config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Check if a file is being edited too frequently."""
threshold = config.get('sensitivity', {}).get('file_churn_threshold', 5)
time_window_minutes = 10
file_edits = state['metrics'].get('file_edits', {})
for file_path, data in file_edits.items():
timestamps = data['timestamps']
# Count edits in last 10 minutes
cutoff = datetime.now() - timedelta(minutes=time_window_minutes)
recent_edits = []
for ts_str in timestamps:
ts = parse_timestamp_with_tz(ts_str, cutoff)
if ts and ts >= cutoff:
recent_edits.append(ts_str)
if len(recent_edits) >= threshold:
return {
'trigger': 'file_churn',
'file': file_path,
'edits': len(recent_edits),
'time_window_minutes': time_window_minutes,
'threshold': threshold,
'priority': 'high'
}
return None
def check_repeated_corrections(state: Dict[str, Any], config: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Check if user is making repeated corrections."""
threshold = config.get('sensitivity', {}).get('correction_threshold', 3)
time_window_minutes = 30
corrections = state['metrics'].get('corrections', [])
# Count corrections in last 30 minutes
cutoff = datetime.now() - timedelta(minutes=time_window_minutes)
recent_corrections = []
for correction in corrections:
try:
ts = parse_timestamp_with_tz(correction['timestamp'], cutoff)
if ts and ts >= cutoff:
recent_corrections.append(correction)
except KeyError:
continue
if len(recent_corrections) >= threshold:
return {
'trigger': 'repeated_corrections',
'count': len(recent_corrections),
'time_window_minutes': time_window_minutes,
'threshold': threshold,
'priority': 'critical'
}
return None
def calculate_session_health(state: Dict[str, Any], config: Dict[str, Any]) -> Dict[str, Any]:
"""Calculate overall session health score."""
health_score = 100
recommendations = []
# Check error rate
errors = state['metrics'].get('errors', {})
total_errors = sum(data['count'] for data in errors.values())
try:
session_start = datetime.fromisoformat(state['session_start'])
now = datetime.now(session_start.tzinfo) if session_start.tzinfo else datetime.now()
session_duration_minutes = max(1, (now - session_start).total_seconds() / 60)
error_rate = total_errors / session_duration_minutes
except (ValueError, TypeError):
error_rate = 0
session_duration_minutes = 0
if error_rate > 0.5: # More than 1 error per 2 minutes
health_score -= 20
recommendations.append("High error rate - consider taking a break or reassessing approach")
# Check correction rate
corrections = state['metrics'].get('corrections', [])
correction_rate = len(corrections) / max(1, session_duration_minutes)
if correction_rate > 0.1: # More than 1 correction per 10 minutes
health_score -= 15
recommendations.append("Frequent corrections - session may be going off track")
# Check file churn
file_edits = state['metrics'].get('file_edits', {})
for file_path, data in file_edits.items():
if data['count'] > 5:
health_score -= 10
recommendations.append(f"High churn on {file_path} - consider taking a break from this file")
break
# Check repeated errors
for error_key, data in errors.items():
if data['count'] >= 3:
health_score -= 20
recommendations.append(f"Repeated error: {error_key[:50]}... - approach may be fundamentally wrong")
break
return {
'score': max(0, health_score),
'session_duration_minutes': int(session_duration_minutes),
'total_errors': total_errors,
'total_corrections': len(corrections),
'recommendations': recommendations
}
def check_triggers(state: Dict[str, Any], config: Dict[str, Any]) -> List[Dict[str, Any]]:
"""Check all triggers and return those that fired."""
triggered = []
# Check each trigger
trigger = check_code_volume_threshold(state, config)
if trigger:
triggered.append(trigger)
trigger = check_repeated_errors(state, config)
if trigger:
triggered.append(trigger)
trigger = check_file_churn(state, config)
if trigger:
triggered.append(trigger)
trigger = check_repeated_corrections(state, config)
if trigger:
triggered.append(trigger)
return triggered
def main():
parser = argparse.ArgumentParser(
description='Guardian session health monitor',
formatter_class=argparse.RawDescriptionHelpFormatter
)
parser.add_argument(
'--event',
choices=['code-written', 'error', 'file-edit', 'correction'],
help='Type of event to track'
)
parser.add_argument(
'--file',
help='File path (for code-written and file-edit events)'
)
parser.add_argument(
'--lines',
type=int,
help='Number of lines written (for code-written events)'
)
parser.add_argument(
'--message',
help='Error or correction message'
)
parser.add_argument(
'--check-health',
action='store_true',
help='Check current session health'
)
parser.add_argument(
'--check-triggers',
action='store_true',
help='Check if any triggers have fired'
)
parser.add_argument(
'--reset',
action='store_true',
help='Reset session metrics'
)
parser.add_argument(
'--init',
action='store_true',
help='Initialize Guardian for this project'
)
args = parser.parse_args()
# Initialize Guardian if requested
if args.init:
guardian_path = init_guardian_if_needed()
print(f"Guardian initialized at {guardian_path}")
sys.exit(0)
# Find or create Guardian directory
guardian_path = find_guardian_root()
if not guardian_path:
guardian_path = init_guardian_if_needed()
# Load config and state
config = load_config(guardian_path)
if not config.get('enabled', False):
print("Guardian is disabled in config", file=sys.stderr)
sys.exit(0)
state = load_session_state(guardian_path)
# Handle reset
if args.reset:
state = {
"session_start": datetime.now().isoformat(),
"metrics": {},
"history": []
}
save_session_state(guardian_path, state)
print("Session metrics reset")
sys.exit(0)
# Handle health check
if args.check_health:
health = calculate_session_health(state, config)
print(json.dumps(health, indent=2))
sys.exit(0)
# Handle trigger check
if args.check_triggers:
triggers = check_triggers(state, config)
print(json.dumps(triggers, indent=2))
sys.exit(0)
# Handle event tracking
if args.event:
if args.event == 'code-written':
if not args.file or args.lines is None:
print("Error: --file and --lines required for code-written event", file=sys.stderr)
sys.exit(1)
track_code_written(state, args.file, args.lines)
elif args.event == 'error':
if not args.message:
print("Error: --message required for error event", file=sys.stderr)
sys.exit(1)
track_error(state, args.message)
elif args.event == 'file-edit':
if not args.file:
print("Error: --file required for file-edit event", file=sys.stderr)
sys.exit(1)
track_file_edit(state, args.file)
elif args.event == 'correction':
if not args.message:
print("Error: --message required for correction event", file=sys.stderr)
sys.exit(1)
track_correction(state, args.message)
# Save updated state
save_session_state(guardian_path, state)
# Check if any triggers fired
triggers = check_triggers(state, config)
if triggers:
print(json.dumps({'event_recorded': True, 'triggers': triggers}, indent=2))
else:
print(json.dumps({'event_recorded': True}, indent=2))
else:
parser.print_help()
sys.exit(1)
if __name__ == '__main__':
main()