Initial commit
This commit is contained in:
610
skills/guardian/scripts/monitor_session.py
Normal file
610
skills/guardian/scripts/monitor_session.py
Normal file
@@ -0,0 +1,610 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user