236 lines
8.2 KiB
Python
236 lines
8.2 KiB
Python
#!/usr/bin/env python3
|
|
"""auto_detect.py — Auto-detection layer for umbrella updates.
|
|
|
|
Watches for file changes and automatically triggers updates to
|
|
all dependent components across all umbrellas.
|
|
|
|
No manual trigger needed. Build something → system detects what
|
|
changed → system knows what depends on it → system updates everything.
|
|
|
|
Usage:
|
|
auto_detect.py watch # Watch for changes (continuous)
|
|
auto_detect.py check <file> # Check what would update
|
|
auto_detect.py trigger <file> # Trigger update for a file
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
|
|
SCRIPTS_DIR = os.path.expanduser("~/.config/opencode/scripts")
|
|
SHARED_DIR = os.path.expanduser("~/.config/opencode/shared")
|
|
SNAPSHOT_FILE = os.path.expanduser("~/.config/opencode/memory/file_snapshot.json")
|
|
|
|
sys.path.insert(0, SHARED_DIR)
|
|
sys.path.insert(0, SCRIPTS_DIR)
|
|
|
|
|
|
def get_file_hash(filepath: str) -> str:
|
|
"""Get hash of a file for change detection."""
|
|
try:
|
|
with open(filepath, "rb") as f:
|
|
return hashlib.md5(f.read()).hexdigest()
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
def snapshot_directory(directory: str) -> dict:
|
|
"""Take a snapshot of all .py files in a directory."""
|
|
snapshot = {}
|
|
for f in os.listdir(directory):
|
|
if f.endswith(".py"):
|
|
path = os.path.join(directory, f)
|
|
snapshot[f] = {
|
|
"hash": get_file_hash(path),
|
|
"mtime": os.path.getmtime(path),
|
|
}
|
|
return snapshot
|
|
|
|
|
|
def load_snapshot() -> dict:
|
|
"""Load the last snapshot from disk."""
|
|
if os.path.exists(SNAPSHOT_FILE):
|
|
try:
|
|
with open(SNAPSHOT_FILE) as f:
|
|
return json.load(f)
|
|
except json.JSONDecodeError:
|
|
return {}
|
|
return {}
|
|
|
|
|
|
def save_snapshot(snapshot: dict):
|
|
"""Save snapshot to disk."""
|
|
os.makedirs(os.path.dirname(SNAPSHOT_FILE), exist_ok=True)
|
|
with open(SNAPSHOT_FILE, "w") as f:
|
|
json.dump(snapshot, f, indent=2)
|
|
|
|
|
|
def detect_changes(old_snapshot: dict, new_snapshot: dict) -> list:
|
|
"""Detect which files changed between snapshots."""
|
|
changes = []
|
|
for filename, new_info in new_snapshot.items():
|
|
old_info = old_snapshot.get(filename)
|
|
if not old_info:
|
|
changes.append({"file": filename, "type": "added"})
|
|
elif old_info["hash"] != new_info["hash"]:
|
|
changes.append({"file": filename, "type": "modified"})
|
|
for filename in old_snapshot:
|
|
if filename not in new_snapshot:
|
|
changes.append({"file": filename, "type": "deleted"})
|
|
return changes
|
|
|
|
|
|
def get_affected_umbrellas(changed_file: str) -> list:
|
|
"""Get all umbrellas affected by a file change."""
|
|
try:
|
|
import dependency_tracker
|
|
dependents = dependency_tracker.get_dependents(changed_file)
|
|
except Exception:
|
|
dependents = []
|
|
|
|
# Map files to umbrellas
|
|
umbrella_map = {
|
|
"memory": ["memory_store", "memory_consolidation", "warm_router", "graph_memory",
|
|
"warm_neuron_store", "abstraction", "cache", "recall_router",
|
|
"recall_uplift", "dedupe_policy", "memory_env", "drift_core",
|
|
"drift_cache", "tier_manager", "shard_manager", "query_memory"],
|
|
"governance": ["drift_core", "drift_cache", "drift_compression", "drift_observability",
|
|
"audit_ledger", "curator", "claim_audit", "grading", "worthiness",
|
|
"lexicon_evolution"],
|
|
"identity": ["persona_definition", "persona_check", "persona_gate",
|
|
"identity_anchors", "voice_hybrid", "voice_sync", "delivery_adapter",
|
|
"prompt_sync", "behavior"],
|
|
"growth": ["growth_delta", "growth_simulator", "lessons", "skill_library",
|
|
"self_improve", "subconscious_pass", "absorb_persona", "socratic"],
|
|
"perception": ["warm_router", "capability_broker", "witness",
|
|
"context_pressure", "auto_context"],
|
|
"infrastructure": ["microkernel", "hook_engine", "event_bus", "config_audit",
|
|
"system_registry", "bootstrap", "dependency_tracker"],
|
|
}
|
|
|
|
affected = set()
|
|
changed_stem = changed_file.replace(".py", "")
|
|
|
|
for umbrella, files in umbrella_map.items():
|
|
if changed_stem in files:
|
|
affected.add(umbrella)
|
|
|
|
# Also check dependents
|
|
for dep in dependents:
|
|
dep_stem = dep.replace(".py", "")
|
|
for umbrella, files in umbrella_map.items():
|
|
if dep_stem in files:
|
|
affected.add(umbrella)
|
|
|
|
return sorted(affected)
|
|
|
|
|
|
def auto_trigger(changed_file: str, dry_run: bool = False) -> dict:
|
|
"""Auto-trigger updates for a changed file."""
|
|
umbrellas = get_affected_umbrellas(changed_file)
|
|
|
|
result = {
|
|
"changed_file": changed_file,
|
|
"affected_umbrellas": umbrellas,
|
|
"triggered": [],
|
|
"skipped": [],
|
|
}
|
|
|
|
for umbrella in umbrellas:
|
|
if dry_run:
|
|
result["triggered"].append({"umbrella": umbrella, "action": "would trigger"})
|
|
else:
|
|
# Trigger umbrella update
|
|
try:
|
|
proc = subprocess.run(
|
|
[sys.executable, os.path.join(SHARED_DIR, "umbrella_update.py"),
|
|
umbrella, "--skip-commit"],
|
|
capture_output=True, text=True, timeout=300
|
|
)
|
|
if proc.returncode == 0:
|
|
result["triggered"].append({"umbrella": umbrella, "action": "updated"})
|
|
else:
|
|
result["skipped"].append({"umbrella": umbrella, "reason": proc.stderr[:100]})
|
|
except Exception as e:
|
|
result["skipped"].append({"umbrella": umbrella, "reason": str(e)[:100]})
|
|
|
|
return result
|
|
|
|
|
|
def main():
|
|
import argparse
|
|
parser = argparse.ArgumentParser(description="Auto-detection layer")
|
|
sub = parser.add_subparsers(dest="cmd")
|
|
|
|
sub.add_parser("watch", help="Watch for changes (continuous)")
|
|
|
|
p_check = sub.add_parser("check", help="Check what would update")
|
|
p_check.add_argument("file")
|
|
|
|
p_trigger = sub.add_parser("trigger", help="Trigger update for a file")
|
|
p_trigger.add_argument("file")
|
|
p_trigger.add_argument("--dry-run", action="store_true")
|
|
|
|
sub.add_parser("snapshot", help="Take a snapshot of current state")
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not args.cmd:
|
|
parser.print_help()
|
|
sys.exit(1)
|
|
|
|
if args.cmd == "snapshot":
|
|
snapshot = {}
|
|
for d in [SCRIPTS_DIR, SHARED_DIR]:
|
|
snapshot.update(snapshot_directory(d))
|
|
save_snapshot(snapshot)
|
|
print(f"Snapshot saved: {len(snapshot)} files")
|
|
|
|
elif args.cmd == "check":
|
|
umbrellas = get_affected_umbrellas(args.file)
|
|
print(f"File: {args.file}")
|
|
print(f"Affected umbrellas: {', '.join(umbrellas)}")
|
|
|
|
elif args.cmd == "trigger":
|
|
result = auto_trigger(args.file, dry_run=args.dry_run)
|
|
print(f"File: {result['changed_file']}")
|
|
print(f"Affected: {', '.join(result['affected_umbrellas'])}")
|
|
for t in result["triggered"]:
|
|
print(f" ✓ {t['umbrella']}: {t['action']}")
|
|
for s in result["skipped"]:
|
|
print(f" ✗ {s['umbrella']}: {s['reason']}")
|
|
|
|
elif args.cmd == "watch":
|
|
print("Watching for changes... (Ctrl+C to stop)")
|
|
# Take initial snapshot
|
|
snapshot = {}
|
|
for d in [SCRIPTS_DIR, SHARED_DIR]:
|
|
snapshot.update(snapshot_directory(d))
|
|
save_snapshot(snapshot)
|
|
|
|
while True:
|
|
time.sleep(30) # Check every 30 seconds
|
|
new_snapshot = {}
|
|
for d in [SCRIPTS_DIR, SHARED_DIR]:
|
|
new_snapshot.update(snapshot_directory(d))
|
|
|
|
changes = detect_changes(snapshot, new_snapshot)
|
|
if changes:
|
|
print(f"\nDetected {len(changes)} changes:")
|
|
for change in changes:
|
|
print(f" {change['type']}: {change['file']}")
|
|
result = auto_trigger(change["file"])
|
|
for t in result["triggered"]:
|
|
print(f" → {t['umbrella']}: {t['action']}")
|
|
|
|
snapshot = new_snapshot
|
|
save_snapshot(snapshot)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import subprocess
|
|
main()
|