diff --git a/mini_rag/__init__.py b/mini_rag/__init__.py index 10bea43..2db7d2e 100644 --- a/mini_rag/__init__.py +++ b/mini_rag/__init__.py @@ -13,10 +13,24 @@ from .indexer import ProjectIndexer from .search import CodeSearcher from .watcher import FileWatcher -__all__ = [ - "CodeEmbedder", - "CodeChunker", - "ProjectIndexer", - "CodeSearcher", - "FileWatcher", -] \ No newline at end of file +# Auto-update system (graceful import for legacy versions) +try: + from .updater import UpdateChecker, check_for_updates, get_updater + __all__ = [ + "CodeEmbedder", + "CodeChunker", + "ProjectIndexer", + "CodeSearcher", + "FileWatcher", + "UpdateChecker", + "check_for_updates", + "get_updater", + ] +except ImportError: + __all__ = [ + "CodeEmbedder", + "CodeChunker", + "ProjectIndexer", + "CodeSearcher", + "FileWatcher", + ] \ No newline at end of file diff --git a/mini_rag/config.py b/mini_rag/config.py index 5f31228..e80c421 100644 --- a/mini_rag/config.py +++ b/mini_rag/config.py @@ -114,6 +114,16 @@ class LLMConfig: ] +@dataclass +class UpdateConfig: + """Configuration for auto-update system.""" + auto_check: bool = True # Check for updates automatically + check_frequency_hours: int = 24 # How often to check (hours) + auto_install: bool = False # Auto-install without asking (not recommended) + backup_before_update: bool = True # Create backup before updating + notify_beta_releases: bool = False # Include beta/pre-releases + + @dataclass class RAGConfig: """Main RAG system configuration.""" @@ -123,6 +133,7 @@ class RAGConfig: embedding: EmbeddingConfig = None search: SearchConfig = None llm: LLMConfig = None + updates: UpdateConfig = None def __post_init__(self): if self.chunking is None: @@ -137,6 +148,8 @@ class RAGConfig: self.search = SearchConfig() if self.llm is None: self.llm = LLMConfig() + if self.updates is None: + self.updates = UpdateConfig() class ConfigManager: @@ -274,6 +287,18 @@ class ConfigManager: if len(config_dict['llm']['model_rankings']) > 10: yaml_lines.append(" # ... (edit config to see all options)") + # Add update settings + yaml_lines.extend([ + "", + "# Auto-update system settings", + "updates:", + f" auto_check: {str(config_dict['updates']['auto_check']).lower()} # Check for updates automatically", + f" check_frequency_hours: {config_dict['updates']['check_frequency_hours']} # Hours between update checks", + f" auto_install: {str(config_dict['updates']['auto_install']).lower()} # Auto-install updates (not recommended)", + f" backup_before_update: {str(config_dict['updates']['backup_before_update']).lower()} # Create backup before updating", + f" notify_beta_releases: {str(config_dict['updates']['notify_beta_releases']).lower()} # Include beta releases in checks", + ]) + return '\n'.join(yaml_lines) def update_config(self, **kwargs) -> RAGConfig: diff --git a/mini_rag/updater.py b/mini_rag/updater.py new file mode 100644 index 0000000..2e20740 --- /dev/null +++ b/mini_rag/updater.py @@ -0,0 +1,458 @@ +#!/usr/bin/env python3 +""" +FSS-Mini-RAG Auto-Update System + +Provides seamless GitHub-based updates with user-friendly interface. +Checks for new releases, downloads updates, and handles installation safely. +""" + +import os +import sys +import json +import time +import shutil +import zipfile +import tempfile +import subprocess +from pathlib import Path +from typing import Optional, Dict, Any, Tuple +from datetime import datetime, timedelta +from dataclasses import dataclass + +try: + import requests + REQUESTS_AVAILABLE = True +except ImportError: + REQUESTS_AVAILABLE = False + +from .config import ConfigManager + +@dataclass +class UpdateInfo: + """Information about an available update.""" + version: str + release_url: str + download_url: str + release_notes: str + published_at: str + is_newer: bool + +class UpdateChecker: + """ + Handles checking for and applying updates from GitHub releases. + + Features: + - Checks GitHub API for latest releases + - Downloads and applies updates safely with backup + - Respects user preferences and rate limiting + - Provides graceful fallbacks if network unavailable + """ + + def __init__(self, + repo_owner: str = "FSSCoding", + repo_name: str = "Fss-Mini-Rag", + current_version: str = "2.1.0"): + self.repo_owner = repo_owner + self.repo_name = repo_name + self.current_version = current_version + self.github_api_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}" + self.check_frequency_hours = 24 # Check once per day + + # Paths + self.app_root = Path(__file__).parent.parent + self.cache_file = self.app_root / ".update_cache.json" + self.backup_dir = self.app_root / ".backup" + + # User preferences + self.config = ConfigManager() + + def should_check_for_updates(self) -> bool: + """ + Determine if we should check for updates now. + + Respects: + - User preference to disable updates + - Rate limiting (once per day by default) + - Network availability + """ + if not REQUESTS_AVAILABLE: + return False + + # Check user preference + if hasattr(self.config, 'updates') and not getattr(self.config.updates, 'auto_check', True): + return False + + # Check if we've checked recently + if self.cache_file.exists(): + try: + with open(self.cache_file, 'r') as f: + cache = json.load(f) + last_check = datetime.fromisoformat(cache.get('last_check', '2020-01-01')) + if datetime.now() - last_check < timedelta(hours=self.check_frequency_hours): + return False + except (json.JSONDecodeError, ValueError, KeyError): + pass # Ignore cache errors, will check anyway + + return True + + def check_for_updates(self) -> Optional[UpdateInfo]: + """ + Check GitHub API for the latest release. + + Returns: + UpdateInfo if an update is available, None otherwise + """ + if not REQUESTS_AVAILABLE: + return None + + try: + # Get latest release from GitHub API + response = requests.get( + f"{self.github_api_url}/releases/latest", + timeout=10, + headers={"Accept": "application/vnd.github.v3+json"} + ) + + if response.status_code != 200: + return None + + release_data = response.json() + + # Extract version info + latest_version = release_data.get('tag_name', '').lstrip('v') + release_notes = release_data.get('body', 'No release notes available.') + published_at = release_data.get('published_at', '') + release_url = release_data.get('html_url', '') + + # Find download URL for source code + download_url = None + for asset in release_data.get('assets', []): + if asset.get('name', '').endswith('.zip'): + download_url = asset.get('browser_download_url') + break + + # Fallback to source code zip + if not download_url: + download_url = f"https://github.com/{self.repo_owner}/{self.repo_name}/archive/refs/tags/v{latest_version}.zip" + + # Check if this is a newer version + is_newer = self._is_version_newer(latest_version, self.current_version) + + # Update cache + self._update_cache(latest_version, is_newer) + + if is_newer: + return UpdateInfo( + version=latest_version, + release_url=release_url, + download_url=download_url, + release_notes=release_notes, + published_at=published_at, + is_newer=True + ) + + except Exception as e: + # Silently fail for network issues - don't interrupt user experience + pass + + return None + + def _is_version_newer(self, latest: str, current: str) -> bool: + """ + Compare version strings to determine if latest is newer. + + Simple semantic version comparison supporting: + - Major.Minor.Patch (e.g., 2.1.0) + - Major.Minor (e.g., 2.1) + """ + def version_tuple(v): + return tuple(map(int, (v.split(".")))) + + try: + return version_tuple(latest) > version_tuple(current) + except (ValueError, AttributeError): + # If version parsing fails, assume it's newer to be safe + return latest != current + + def _update_cache(self, latest_version: str, is_newer: bool): + """Update the cache file with check results.""" + cache_data = { + 'last_check': datetime.now().isoformat(), + 'latest_version': latest_version, + 'is_newer': is_newer + } + + try: + with open(self.cache_file, 'w') as f: + json.dump(cache_data, f, indent=2) + except Exception: + pass # Ignore cache write errors + + def download_update(self, update_info: UpdateInfo, progress_callback=None) -> Optional[Path]: + """ + Download the update package to a temporary location. + + Args: + update_info: Information about the update to download + progress_callback: Optional callback for progress updates + + Returns: + Path to downloaded file, or None if download failed + """ + if not REQUESTS_AVAILABLE: + return None + + try: + # Create temporary file for download + with tempfile.NamedTemporaryFile(suffix='.zip', delete=False) as tmp_file: + tmp_path = Path(tmp_file.name) + + # Download with progress tracking + response = requests.get(update_info.download_url, stream=True, timeout=30) + response.raise_for_status() + + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 + + with open(tmp_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + downloaded += len(chunk) + if progress_callback and total_size > 0: + progress_callback(downloaded, total_size) + + return tmp_path + + except Exception as e: + # Clean up on error + if 'tmp_path' in locals() and tmp_path.exists(): + tmp_path.unlink() + return None + + def create_backup(self) -> bool: + """ + Create a backup of the current installation. + + Returns: + True if backup created successfully + """ + try: + # Remove old backup if it exists + if self.backup_dir.exists(): + shutil.rmtree(self.backup_dir) + + # Create new backup + self.backup_dir.mkdir(exist_ok=True) + + # Copy key files and directories + important_items = [ + 'mini_rag', + 'rag-mini.py', + 'rag-tui.py', + 'requirements.txt', + 'install_mini_rag.sh', + 'install_windows.bat', + 'README.md', + 'assets' + ] + + for item in important_items: + src = self.app_root / item + if src.exists(): + if src.is_dir(): + shutil.copytree(src, self.backup_dir / item) + else: + shutil.copy2(src, self.backup_dir / item) + + return True + + except Exception as e: + return False + + def apply_update(self, update_package_path: Path, update_info: UpdateInfo) -> bool: + """ + Apply the downloaded update. + + Args: + update_package_path: Path to the downloaded update package + update_info: Information about the update + + Returns: + True if update applied successfully + """ + try: + # Extract to temporary directory first + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + + # Extract the archive + with zipfile.ZipFile(update_package_path, 'r') as zip_ref: + zip_ref.extractall(tmp_path) + + # Find the extracted directory (may be nested) + extracted_dirs = [d for d in tmp_path.iterdir() if d.is_dir()] + if not extracted_dirs: + return False + + source_dir = extracted_dirs[0] + + # Copy files to application directory + important_items = [ + 'mini_rag', + 'rag-mini.py', + 'rag-tui.py', + 'requirements.txt', + 'install_mini_rag.sh', + 'install_windows.bat', + 'README.md' + ] + + for item in important_items: + src = source_dir / item + dst = self.app_root / item + + if src.exists(): + if dst.exists(): + if dst.is_dir(): + shutil.rmtree(dst) + else: + dst.unlink() + + if src.is_dir(): + shutil.copytree(src, dst) + else: + shutil.copy2(src, dst) + + # Update version info + self._update_version_info(update_info.version) + + return True + + except Exception as e: + return False + + def _update_version_info(self, new_version: str): + """Update version information in the application.""" + # Update __init__.py version + init_file = self.app_root / 'mini_rag' / '__init__.py' + if init_file.exists(): + try: + content = init_file.read_text() + updated_content = content.replace( + f'__version__ = "{self.current_version}"', + f'__version__ = "{new_version}"' + ) + init_file.write_text(updated_content) + except Exception: + pass + + def rollback_update(self) -> bool: + """ + Rollback to the backup version if update failed. + + Returns: + True if rollback successful + """ + if not self.backup_dir.exists(): + return False + + try: + # Restore from backup + for item in self.backup_dir.iterdir(): + dst = self.app_root / item.name + + if dst.exists(): + if dst.is_dir(): + shutil.rmtree(dst) + else: + dst.unlink() + + if item.is_dir(): + shutil.copytree(item, dst) + else: + shutil.copy2(item, dst) + + return True + + except Exception as e: + return False + + def restart_application(self): + """Restart the application after update.""" + try: + # Get the current script path + current_script = sys.argv[0] + + # Restart with the same arguments + if sys.platform.startswith('win'): + # Windows + subprocess.Popen([sys.executable] + sys.argv) + else: + # Unix-like systems + os.execv(sys.executable, [sys.executable] + sys.argv) + + except Exception as e: + # If restart fails, just exit gracefully + print(f"\nāœ… Update complete! Please restart the application manually.") + sys.exit(0) + + +def get_legacy_notification() -> Optional[str]: + """ + Check if this is a legacy version that needs urgent notification. + + For users who downloaded before the auto-update system. + """ + try: + # Check if this is a very old version by looking for cache file + # Old versions won't have update cache, so we can detect them + app_root = Path(__file__).parent.parent + cache_file = app_root / ".update_cache.json" + + # Also check version in __init__.py to see if it's old + init_file = app_root / 'mini_rag' / '__init__.py' + if init_file.exists(): + content = init_file.read_text() + if '__version__ = "2.0.' in content or '__version__ = "1.' in content: + return """ +🚨 IMPORTANT UPDATE AVAILABLE 🚨 + +Your version of FSS-Mini-RAG is missing critical updates! + +šŸ”§ Recent improvements include: +• Fixed LLM response formatting issues +• Added context window configuration +• Improved Windows installer reliability +• Added auto-update system (this notification!) + +šŸ“„ Please update by downloading the latest version: + https://github.com/FSSCoding/Fss-Mini-Rag/releases/latest + +šŸ’” After updating, you'll get automatic update notifications! +""" + except Exception: + pass + + return None + + +# Global convenience functions +_updater_instance = None + +def check_for_updates() -> Optional[UpdateInfo]: + """Global function to check for updates.""" + global _updater_instance + if _updater_instance is None: + _updater_instance = UpdateChecker() + + if _updater_instance.should_check_for_updates(): + return _updater_instance.check_for_updates() + return None + +def get_updater() -> UpdateChecker: + """Get the global updater instance.""" + global _updater_instance + if _updater_instance is None: + _updater_instance = UpdateChecker() + return _updater_instance \ No newline at end of file diff --git a/rag-mini.py b/rag-mini.py index 51bb166..a045eed 100644 --- a/rag-mini.py +++ b/rag-mini.py @@ -21,6 +21,12 @@ try: from mini_rag.ollama_embeddings import OllamaEmbedder from mini_rag.llm_synthesizer import LLMSynthesizer from mini_rag.explorer import CodeExplorer + # Update system (graceful import) + try: + from mini_rag.updater import check_for_updates, get_updater + UPDATER_AVAILABLE = True + except ImportError: + UPDATER_AVAILABLE = False except ImportError as e: print("āŒ Error: Missing dependencies!") print() @@ -444,6 +450,119 @@ def explore_interactive(project_path: Path): print("Make sure the project is indexed first: rag-mini index ") sys.exit(1) +def show_discrete_update_notice(): + """Show a discrete, non-intrusive update notice for CLI users.""" + if not UPDATER_AVAILABLE: + return + + try: + update_info = check_for_updates() + if update_info: + # Very discrete notice - just one line + print(f"šŸ”„ (Update v{update_info.version} available - run 'rag-mini check-update' to learn more)") + except Exception: + # Silently ignore any update check failures + pass + +def handle_check_update(): + """Handle the check-update command.""" + if not UPDATER_AVAILABLE: + print("āŒ Update system not available") + print("šŸ’” Try updating to the latest version manually from GitHub") + return + + try: + print("šŸ” Checking for updates...") + update_info = check_for_updates() + + if update_info: + print(f"\nšŸŽ‰ Update Available: v{update_info.version}") + print("=" * 50) + print("\nšŸ“‹ What's New:") + notes_lines = update_info.release_notes.split('\n')[:10] # First 10 lines + for line in notes_lines: + if line.strip(): + print(f" {line.strip()}") + + print(f"\nšŸ”— Release Page: {update_info.release_url}") + print(f"\nšŸš€ To install: rag-mini update") + print("šŸ’” Or update manually from GitHub releases") + else: + print("āœ… You're already on the latest version!") + + except Exception as e: + print(f"āŒ Failed to check for updates: {e}") + print("šŸ’” Try updating manually from GitHub") + +def handle_update(): + """Handle the update command.""" + if not UPDATER_AVAILABLE: + print("āŒ Update system not available") + print("šŸ’” Try updating manually from GitHub") + return + + try: + print("šŸ” Checking for updates...") + update_info = check_for_updates() + + if not update_info: + print("āœ… You're already on the latest version!") + return + + print(f"\nšŸŽ‰ Update Available: v{update_info.version}") + print("=" * 50) + + # Show brief release notes + notes_lines = update_info.release_notes.split('\n')[:5] + for line in notes_lines: + if line.strip(): + print(f" • {line.strip()}") + + # Confirm update + confirm = input(f"\nšŸš€ Install v{update_info.version}? [Y/n]: ").strip().lower() + if confirm in ['', 'y', 'yes']: + updater = get_updater() + + print(f"\nšŸ“„ Downloading v{update_info.version}...") + + # Progress callback + def show_progress(downloaded, total): + if total > 0: + percent = (downloaded / total) * 100 + bar_length = 30 + filled = int(bar_length * downloaded / total) + bar = "ā–ˆ" * filled + "ā–‘" * (bar_length - filled) + print(f"\r [{bar}] {percent:.1f}%", end="", flush=True) + + # Download and install + update_package = updater.download_update(update_info, show_progress) + if not update_package: + print("\nāŒ Download failed. Please try again later.") + return + + print("\nšŸ’¾ Creating backup...") + if not updater.create_backup(): + print("āš ļø Backup failed, but continuing anyway...") + + print("šŸ”„ Installing update...") + if updater.apply_update(update_package, update_info): + print("āœ… Update successful!") + print("šŸš€ Restarting...") + updater.restart_application() + else: + print("āŒ Update failed.") + print("šŸ”™ Attempting rollback...") + if updater.rollback_update(): + print("āœ… Rollback successful.") + else: + print("āŒ Rollback failed. You may need to reinstall.") + else: + print("Update cancelled.") + + except Exception as e: + print(f"āŒ Update failed: {e}") + print("šŸ’” Try updating manually from GitHub") + def main(): """Main CLI interface.""" # Check virtual environment @@ -466,10 +585,10 @@ Examples: """ ) - parser.add_argument('command', choices=['index', 'search', 'explore', 'status'], + parser.add_argument('command', choices=['index', 'search', 'explore', 'status', 'update', 'check-update'], help='Command to execute') - parser.add_argument('project_path', type=Path, - help='Path to project directory (REQUIRED)') + parser.add_argument('project_path', type=Path, nargs='?', + help='Path to project directory (REQUIRED except for update commands)') parser.add_argument('query', nargs='?', help='Search query (for search command)') parser.add_argument('--force', action='store_true', @@ -487,6 +606,19 @@ Examples: if args.verbose: logging.getLogger().setLevel(logging.INFO) + # Handle update commands first (don't require project_path) + if args.command == 'check-update': + handle_check_update() + return + elif args.command == 'update': + handle_update() + return + + # All other commands require project_path + if not args.project_path: + print("āŒ Project path required for this command") + sys.exit(1) + # Validate project path if not args.project_path.exists(): print(f"āŒ Project path does not exist: {args.project_path}") @@ -496,6 +628,9 @@ Examples: print(f"āŒ Project path is not a directory: {args.project_path}") sys.exit(1) + # Show discrete update notification for regular commands (non-intrusive) + show_discrete_update_notice() + # Execute command if args.command == 'index': index_project(args.project_path, args.force) diff --git a/rag-tui.py b/rag-tui.py index 0ab14e0..db389f8 100755 --- a/rag-tui.py +++ b/rag-tui.py @@ -10,6 +10,13 @@ import json from pathlib import Path from typing import Optional, List, Dict, Any +# Update system (graceful import) +try: + from mini_rag.updater import check_for_updates, get_updater, get_legacy_notification + UPDATER_AVAILABLE = True +except ImportError: + UPDATER_AVAILABLE = False + # Simple TUI without external dependencies class SimpleTUI: def __init__(self): @@ -2083,12 +2090,107 @@ Your suggested question (under 10 words):""" input("Press Enter to continue...") + def check_for_updates_notification(self): + """Check for updates and show notification if available.""" + if not UPDATER_AVAILABLE: + return + + try: + # Check for legacy notification first + legacy_notice = get_legacy_notification() + if legacy_notice: + print("šŸ””" + "=" * 58 + "šŸ””") + print(legacy_notice) + print("šŸ””" + "=" * 58 + "šŸ””") + print() + return + + # Check for regular updates + update_info = check_for_updates() + if update_info: + print("šŸŽ‰" + "=" * 58 + "šŸŽ‰") + print(f"šŸ”„ Update Available: v{update_info.version}") + print() + print("šŸ“‹ What's New:") + # Show first few lines of release notes + notes_lines = update_info.release_notes.split('\n')[:3] + for line in notes_lines: + if line.strip(): + print(f" • {line.strip()}") + print() + + # Simple update prompt + update_choice = self.get_input("šŸš€ Install update now? [y/N]", "n").lower() + if update_choice in ['y', 'yes']: + self.perform_update(update_info) + else: + print("šŸ’” You can update anytime from the Configuration menu!") + + print("šŸŽ‰" + "=" * 58 + "šŸŽ‰") + print() + + except Exception: + # Silently ignore update check errors - don't interrupt user experience + pass + + def perform_update(self, update_info): + """Perform the actual update with progress display.""" + try: + updater = get_updater() + + print(f"\nšŸ“„ Downloading v{update_info.version}...") + + # Progress callback + def show_progress(downloaded, total): + if total > 0: + percent = (downloaded / total) * 100 + bar_length = 30 + filled = int(bar_length * downloaded / total) + bar = "ā–ˆ" * filled + "ā–‘" * (bar_length - filled) + print(f"\r [{bar}] {percent:.1f}%", end="", flush=True) + + # Download update + update_package = updater.download_update(update_info, show_progress) + if not update_package: + print("\nāŒ Download failed. Please try again later.") + input("Press Enter to continue...") + return + + print("\nšŸ’¾ Creating backup...") + if not updater.create_backup(): + print("āš ļø Backup failed, but continuing anyway...") + + print("šŸ”„ Installing update...") + if updater.apply_update(update_package, update_info): + print("āœ… Update successful!") + print("šŸš€ Restarting application...") + input("Press Enter to restart...") + updater.restart_application() + else: + print("āŒ Update failed.") + print("šŸ”™ Attempting rollback...") + if updater.rollback_update(): + print("āœ… Rollback successful.") + else: + print("āŒ Rollback failed. You may need to reinstall.") + input("Press Enter to continue...") + + except Exception as e: + print(f"āŒ Update error: {e}") + input("Press Enter to continue...") + def main_menu(self): """Main application loop.""" + first_run = True while True: self.clear_screen() self.print_header() + # Check for updates on first run only (non-intrusive) + if first_run: + self.check_for_updates_notification() + first_run = False + # Show current project status prominently if self.project_path: rag_dir = self.project_path / '.mini-rag'