project-standalo-todo-super/skills/guardrail-orchestrator/scripts/context_compact.py

505 lines
16 KiB
Python

#!/usr/bin/env python3
"""
Context Compaction Manager
Handles pre-compact state saving and post-compact resume injection.
Monitors context usage and triggers appropriate hooks.
Usage:
python3 context_compact.py save [--workflow-dir .workflow/versions/v001]
python3 context_compact.py resume [--workflow-dir .workflow/versions/v001]
python3 context_compact.py status [--workflow-dir .workflow/versions/v001]
"""
import argparse
import json
import os
import subprocess
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
# ============================================================================
# Configuration
# ============================================================================
DEFAULT_WORKFLOW_DIR = ".workflow/versions/v001"
STATE_FILE = "context_state.json"
RESUME_PROMPT_FILE = "resume_prompt.md"
MODIFIED_FILES_FILE = "modified_files.json"
# Context thresholds (percentage)
THRESHOLDS = {
"warning": 0.70,
"save": 0.80,
"compact": 0.90,
"critical": 0.95
}
# ============================================================================
# File Operations
# ============================================================================
def load_json(filepath: str) -> dict:
"""Load JSON file."""
if not os.path.exists(filepath):
return {}
try:
with open(filepath, 'r') as f:
return json.load(f)
except json.JSONDecodeError:
return {}
def save_json(filepath: str, data: dict):
"""Save data to JSON file."""
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, 'w') as f:
json.dump(data, f, indent=2, default=str)
def save_text(filepath: str, content: str):
"""Save text to file."""
os.makedirs(os.path.dirname(filepath), exist_ok=True)
with open(filepath, 'w') as f:
f.write(content)
# ============================================================================
# Git Operations
# ============================================================================
def get_git_status() -> List[Dict[str, str]]:
"""Get list of modified files from git."""
try:
result = subprocess.run(
['git', 'status', '--porcelain'],
capture_output=True, text=True, check=True
)
files = []
for line in result.stdout.strip().split('\n'):
if line:
status = line[:2].strip()
path = line[3:]
action = {
'M': 'modified',
'A': 'added',
'D': 'deleted',
'?': 'untracked',
'R': 'renamed'
}.get(status[0] if status else '?', 'unknown')
files.append({'path': path, 'action': action, 'summary': ''})
return files
except subprocess.CalledProcessError:
return []
def get_recent_commits(count: int = 5) -> List[Dict[str, str]]:
"""Get recent commit messages."""
try:
result = subprocess.run(
['git', 'log', f'-{count}', '--oneline'],
capture_output=True, text=True, check=True
)
commits = []
for line in result.stdout.strip().split('\n'):
if line:
parts = line.split(' ', 1)
commits.append({
'hash': parts[0],
'message': parts[1] if len(parts) > 1 else ''
})
return commits
except subprocess.CalledProcessError:
return []
def create_checkpoint(message: str = "WIP: Pre-compaction checkpoint"):
"""Create a git checkpoint with uncommitted changes."""
try:
# Check if there are changes
result = subprocess.run(
['git', 'status', '--porcelain'],
capture_output=True, text=True
)
if result.stdout.strip():
# Stage all changes
subprocess.run(['git', 'add', '-A'], check=True)
# Commit
subprocess.run(['git', 'commit', '-m', message], check=True)
print(f"Created checkpoint: {message}")
return True
except subprocess.CalledProcessError as e:
print(f"Warning: Could not create checkpoint: {e}")
return False
# ============================================================================
# Workflow State Operations
# ============================================================================
def load_workflow_state(workflow_dir: str) -> dict:
"""Load current workflow state."""
state_path = os.path.join(workflow_dir, 'workflow_state.json')
return load_json(state_path)
def load_active_tasks(workflow_dir: str) -> List[dict]:
"""Load tasks that are in progress."""
tasks_dir = os.path.join(workflow_dir, 'tasks')
active_tasks = []
if os.path.exists(tasks_dir):
for filename in os.listdir(tasks_dir):
if filename.endswith('.yml') or filename.endswith('.json'):
task_path = os.path.join(tasks_dir, filename)
task = load_json(task_path) if filename.endswith('.json') else {}
if task.get('status') == 'in_progress':
active_tasks.append(task)
return active_tasks
def get_pending_tasks(workflow_dir: str) -> List[dict]:
"""Get pending tasks in priority order."""
tasks_dir = os.path.join(workflow_dir, 'tasks')
pending = []
if os.path.exists(tasks_dir):
for filename in os.listdir(tasks_dir):
if filename.endswith('.json'):
task_path = os.path.join(tasks_dir, filename)
task = load_json(task_path)
if task.get('status') == 'pending':
pending.append(task)
# Sort by layer, then by ID
pending.sort(key=lambda t: (t.get('layer', 999), t.get('id', '')))
return pending
# ============================================================================
# Context State Management
# ============================================================================
def capture_context_state(
workflow_dir: str,
context_percentage: float = 0.0,
active_work: Optional[dict] = None,
decisions: Optional[List[dict]] = None,
blockers: Optional[List[dict]] = None
) -> dict:
"""Capture current context state for later resume."""
workflow_state = load_workflow_state(workflow_dir)
active_tasks = load_active_tasks(workflow_dir)
pending_tasks = get_pending_tasks(workflow_dir)
modified_files = get_git_status()
# Determine active task
active_task = active_tasks[0] if active_tasks else None
# Build next actions from pending tasks
next_actions = []
for task in pending_tasks[:5]: # Top 5 pending
next_actions.append({
'action': task.get('type', 'implement'),
'target': task.get('title', task.get('id', 'unknown')),
'priority': len(next_actions) + 1,
'context_needed': [task.get('context', {}).get('context_snapshot_path', '')]
})
# Build context state
state = {
'session_id': f"compact_{datetime.now().strftime('%Y%m%d_%H%M%S')}",
'captured_at': datetime.now().isoformat(),
'context_usage': {
'tokens_used': 0, # Would need to be passed in
'tokens_max': 0,
'percentage': context_percentage,
'threshold_triggered': THRESHOLDS['save']
},
'workflow_position': {
'workflow_id': workflow_state.get('id', 'unknown'),
'current_phase': workflow_state.get('current_phase', 'UNKNOWN'),
'active_task_id': active_task.get('id') if active_task else None,
'layer': active_task.get('layer', 1) if active_task else 1
},
'active_work': active_work or {
'entity_id': active_task.get('entity_id', '') if active_task else '',
'entity_type': active_task.get('type', '') if active_task else '',
'action': 'implementing' if active_task else 'pending',
'file_path': None,
'progress_notes': ''
},
'next_actions': next_actions,
'modified_files': modified_files,
'decisions': decisions or [],
'blockers': blockers or []
}
return state
def save_context_state(workflow_dir: str, state: dict):
"""Save context state to file."""
state_path = os.path.join(workflow_dir, STATE_FILE)
save_json(state_path, state)
print(f"Saved context state to: {state_path}")
# Also save modified files separately for quick access
modified_path = os.path.join(workflow_dir, MODIFIED_FILES_FILE)
save_json(modified_path, state.get('modified_files', []))
def generate_resume_prompt(state: dict) -> str:
"""Generate human-readable resume prompt."""
lines = [
"## Context Recovery - Resuming Previous Session",
"",
"### Session Info",
f"- **Original Session**: {state.get('session_id', 'unknown')}",
f"- **Captured At**: {state.get('captured_at', 'unknown')}",
f"- **Context Usage**: {state.get('context_usage', {}).get('percentage', 0) * 100:.1f}%",
"",
"### Workflow Position",
f"- **Phase**: {state.get('workflow_position', {}).get('current_phase', 'UNKNOWN')}",
f"- **Active Task**: {state.get('workflow_position', {}).get('active_task_id', 'None')}",
f"- **Layer**: {state.get('workflow_position', {}).get('layer', 1)}",
"",
"### What Was Being Worked On",
]
active = state.get('active_work', {})
lines.extend([
f"- **Entity**: {active.get('entity_id', 'None')} ({active.get('entity_type', '')})",
f"- **Action**: {active.get('action', 'unknown')}",
f"- **File**: {active.get('file_path', 'None')}",
f"- **Progress**: {active.get('progress_notes', 'No notes')}",
"",
"### Next Actions (Priority Order)",
])
for action in state.get('next_actions', []):
context = ', '.join(action.get('context_needed', [])) or 'None'
lines.append(f"{action.get('priority', '?')}. **{action.get('action', '')}** {action.get('target', '')}")
lines.append(f" - Context needed: {context}")
if state.get('modified_files'):
lines.extend(["", "### Recent Changes"])
for f in state.get('modified_files', []):
lines.append(f"- `{f.get('path', '')}` - {f.get('action', '')}: {f.get('summary', '')}")
if state.get('decisions'):
lines.extend(["", "### Key Decisions Made"])
for d in state.get('decisions', []):
lines.append(f"- **{d.get('topic', '')}**: {d.get('decision', '')}")
if state.get('blockers'):
lines.extend(["", "### Current Blockers"])
for b in state.get('blockers', []):
lines.append(f"- {b.get('issue', '')} ({b.get('status', '')}): {b.get('notes', '')}")
lines.extend([
"",
"---",
"**Action Required**: Continue from the next action listed above.",
"",
"To load full context, read the following files:",
])
# Add context files to read
for action in state.get('next_actions', [])[:3]:
for ctx in action.get('context_needed', []):
if ctx:
lines.append(f"- `{ctx}`")
return '\n'.join(lines)
# ============================================================================
# Commands
# ============================================================================
def cmd_save(args):
"""Save current context state (pre-compact hook)."""
workflow_dir = args.workflow_dir
print("=" * 60)
print("PRE-COMPACT: Saving Context State")
print("=" * 60)
# Capture state
state = capture_context_state(
workflow_dir,
context_percentage=args.percentage / 100 if args.percentage else 0.80
)
# Save state
save_context_state(workflow_dir, state)
# Generate resume prompt
resume_prompt = generate_resume_prompt(state)
resume_path = os.path.join(workflow_dir, RESUME_PROMPT_FILE)
save_text(resume_path, resume_prompt)
print(f"Generated resume prompt: {resume_path}")
# Create git checkpoint if requested
if args.checkpoint:
create_checkpoint("WIP: Pre-compaction checkpoint")
print()
print("=" * 60)
print("State saved successfully!")
print(f" Session ID: {state['session_id']}")
print(f" Active Task: {state['workflow_position']['active_task_id']}")
print(f" Next Actions: {len(state['next_actions'])}")
print("=" * 60)
return 0
def cmd_resume(args):
"""Display resume prompt (post-compact hook)."""
workflow_dir = args.workflow_dir
# Load state
state_path = os.path.join(workflow_dir, STATE_FILE)
state = load_json(state_path)
if not state:
print("No saved context state found.")
print(f"Expected: {state_path}")
return 1
# Generate and display resume prompt
if args.json:
print(json.dumps(state, indent=2))
else:
resume_prompt = generate_resume_prompt(state)
print(resume_prompt)
return 0
def cmd_status(args):
"""Show current context state status."""
workflow_dir = args.workflow_dir
print("=" * 60)
print("CONTEXT STATE STATUS")
print("=" * 60)
# Check for saved state
state_path = os.path.join(workflow_dir, STATE_FILE)
if os.path.exists(state_path):
state = load_json(state_path)
print(f"\nSaved State Found:")
print(f" Session ID: {state.get('session_id', 'unknown')}")
print(f" Captured At: {state.get('captured_at', 'unknown')}")
print(f" Phase: {state.get('workflow_position', {}).get('current_phase', 'UNKNOWN')}")
print(f" Active Task: {state.get('workflow_position', {}).get('active_task_id', 'None')}")
else:
print(f"\nNo saved state at: {state_path}")
# Check workflow state
workflow_state = load_workflow_state(workflow_dir)
if workflow_state:
print(f"\nCurrent Workflow:")
print(f" ID: {workflow_state.get('id', 'unknown')}")
print(f" Phase: {workflow_state.get('current_phase', 'UNKNOWN')}")
# Check git status
modified = get_git_status()
if modified:
print(f"\nModified Files: {len(modified)}")
for f in modified[:5]:
print(f" - {f['action']}: {f['path']}")
if len(modified) > 5:
print(f" ... and {len(modified) - 5} more")
return 0
def cmd_clear(args):
"""Clear saved context state."""
workflow_dir = args.workflow_dir
files_to_remove = [
os.path.join(workflow_dir, STATE_FILE),
os.path.join(workflow_dir, RESUME_PROMPT_FILE),
os.path.join(workflow_dir, MODIFIED_FILES_FILE)
]
removed = 0
for filepath in files_to_remove:
if os.path.exists(filepath):
os.remove(filepath)
print(f"Removed: {filepath}")
removed += 1
if removed == 0:
print("No state files found to remove.")
else:
print(f"\nCleared {removed} state file(s).")
return 0
# ============================================================================
# Main CLI
# ============================================================================
def main():
parser = argparse.ArgumentParser(
description="Context Compaction Manager for Guardrail Orchestrator"
)
parser.add_argument(
'--workflow-dir', '-w',
default=DEFAULT_WORKFLOW_DIR,
help='Workflow directory path'
)
subparsers = parser.add_subparsers(dest='command', help='Commands')
# Save command
save_parser = subparsers.add_parser('save', help='Save context state (pre-compact)')
save_parser.add_argument('--percentage', '-p', type=float, help='Current context percentage (0-100)')
save_parser.add_argument('--checkpoint', '-c', action='store_true', help='Create git checkpoint')
# Resume command
resume_parser = subparsers.add_parser('resume', help='Display resume prompt (post-compact)')
resume_parser.add_argument('--json', '-j', action='store_true', help='Output as JSON')
# Status command
subparsers.add_parser('status', help='Show context state status')
# Clear command
subparsers.add_parser('clear', help='Clear saved context state')
args = parser.parse_args()
if args.command == 'save':
return cmd_save(args)
elif args.command == 'resume':
return cmd_resume(args)
elif args.command == 'status':
return cmd_status(args)
elif args.command == 'clear':
return cmd_clear(args)
else:
parser.print_help()
return 1
if __name__ == "__main__":
sys.exit(main())