diff --git a/src/aipass/ai_mail/apps/__init__.py b/src/aipass/ai_mail/apps/__init__.py index 6a64245b..803548c8 100644 --- a/src/aipass/ai_mail/apps/__init__.py +++ b/src/aipass/ai_mail/apps/__init__.py @@ -1 +1 @@ -# AI_MAIL apps package +# Apps package diff --git a/src/aipass/ai_mail/apps/ai_mail.py b/src/aipass/ai_mail/apps/ai_mail.py new file mode 100644 index 00000000..c7c47540 --- /dev/null +++ b/src/aipass/ai_mail/apps/ai_mail.py @@ -0,0 +1,255 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: ai_mail.py - AI_MAIL Branch Orchestrator +# Date: 2025-11-08 +# Version: 1.0.0 +# Category: ai_mail/orchestrator +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-08): Initial version - modular architecture +# +# CODE STANDARDS: +# - Entry point orchestrator pattern +# - Auto-discovers modules from modules/ +# - Module interface: handle_command(command, args) -> bool +# ============================================= + +""" +ai_mail Branch - Main Orchestrator + +Modular architecture with auto-discovered modules. +Main handles routing, modules implement functionality. +""" + +# Standard library imports +import sys +import importlib +import argparse +import signal +from pathlib import Path +from typing import Dict, Any, Optional, List + +# Handle broken pipe gracefully (e.g. output piped to head) +signal.signal(signal.SIGPIPE, signal.SIG_DFL) + +# Pre-import dashboard to ensure path is resolved before module discovery +# This prevents import failures in handlers that depend on devpulse dashboard +try: + from aipass.dev_central.devpulse.apps.modules.dashboard import update_section as _update_section # noqa: F401 +except ImportError: + # Dashboard is optional - branch works without it + _update_section = None # type: ignore + +# AIPass infrastructure imports +from aipass.prax.apps.modules.logger import system_logger as logger + +# CLI services for display +from aipass.cli.apps.modules import console + +# ============================================================================= +# CONSTANTS & CONFIG +# ============================================================================= + +# Module root +MODULE_ROOT = Path(__file__).parent + +# Modules directory +MODULES_DIR = MODULE_ROOT / "modules" + +# ============================================================================= +# HELP DISPLAY +# ============================================================================= + +def print_help(): + """Print drone-compliant help output""" + parser = argparse.ArgumentParser( + description='AI_MAIL Branch Operations - Email system for branch communication', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +COMMANDS: + inbox - List emails (new + opened) + view - View email content (marks as opened) + reply - Reply to email (closes + archives) + close - Close email(s) without reply (archives) + send - Send email to a branch + sent - View sent messages + contacts - Manage contacts + ping - Memory health check + dispatch - Dispatch status and log + +EMAIL LIFECYCLE (v2): + new → opened → closed + - new: Just arrived, never viewed + - opened: You've viewed it, not yet resolved + - closed: Resolved (replied or dismissed), auto-archived + +USAGE: + drone @ai_mail [args] + drone @ai_mail --help + +EXAMPLES: + # Check mail + drone @ai_mail inbox # List all emails + drone @ai_mail view abc123 # View email (marks as opened) + + # Resolve emails + drone @ai_mail reply abc123 "Thanks!" # Reply + close + archive + drone @ai_mail close abc123 # Close single email + drone @ai_mail close abc123 def456 ghi789 # Close multiple emails + drone @ai_mail close all # Close ALL emails + + # Send mail + drone @ai_mail send @seed "Subject" "Msg" # Send to branch + drone @ai_mail send @all "Subject" "Msg" # Broadcast to all + """ + ) + console.print(parser.format_help()) + + +# ============================================================================= +# INTROSPECTION DISPLAY +# ============================================================================= + +def print_introspection(): + """Display discovered modules only (SEED pattern)""" + console.print() + console.print("[bold cyan]AI_Mail - Branch Communication System[/bold cyan]") + console.print() + console.print("[dim]Email system for branch-to-branch communication[/dim]") + console.print() + + # Discover modules + modules = discover_modules() + + console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}") + console.print() + + for module in modules: + module_name = module.__name__.split('.')[-1] + console.print(f" [cyan]•[/cyan] {module_name}") + + console.print() + console.print("[dim]Run 'python3 ai_mail.py --help' for usage information[/dim]") + console.print() + + +# ============================================================================= +# MODULE DISCOVERY +# ============================================================================= + +def discover_modules() -> List[Any]: + """ + Auto-discover modules from modules/ directory + + Returns: + List of module objects with handle_command() function + """ + modules = [] + + if not MODULES_DIR.exists(): + logger.warning(f"Modules directory not found: {MODULES_DIR}") + return modules + + logger.info(f"[{Path(__file__).stem}] Discovering modules...") + + files_found = list(MODULES_DIR.glob("*.py")) + + for file_path in files_found: + # Skip __init__.py and private files + if file_path.name.startswith("_"): + continue + + module_name = f"aipass.ai_mail.apps.modules.{file_path.stem}" + + try: + # Import module + module = importlib.import_module(module_name) + + # Check for required interface + if hasattr(module, 'handle_command'): + modules.append(module) + logger.info(f" [+] {module_name}") + else: + logger.warning(f" [!] {module_name} - missing handle_command()") + + except Exception as e: + logger.error(f" [-] {module_name} - import error: {e}") + + logger.info(f"[{Path(__file__).stem}] Discovered {len(modules)} modules") + return modules + +# ============================================================================= +# COMMAND ROUTING +# ============================================================================= + +def route_command(command: str, args: List[str], modules: List[Any]) -> bool: + """ + Route command to appropriate module + + Pattern: Each module's handle_command() returns True if it handled the command + + Args: + command: Command name (e.g., 'send', 'inbox', 'sent') + args: Additional arguments + modules: List of discovered modules + + Returns: + True if command was handled, False otherwise + """ + for module in modules: + try: + if module.handle_command(command, args): + return True + except BrokenPipeError: + logger.info(f"[ai_mail] Broken pipe in {module.__name__} (stdout closed early)") + return True + except Exception as e: + logger.error(f"Module {module.__name__} error: {e}") + + return False + +# ============================================================================= +# MAIN +# ============================================================================= + +def main(): + """Main entry point - routes commands to modules""" + + # Parse arguments + args = sys.argv[1:] + + # Show introspection when run without arguments + if len(args) == 0: + print_introspection() + return 0 + + # Show version + if args[0] in ['--version', '-V']: + console.print("AI_MAIL v1.0.0") + return 0 + + # Show help for explicit help flags + if args[0] in ['--help', '-h', 'help']: + print_help() + return 0 + + # Command provided - try to route to modules + modules = discover_modules() + command = args[0] + remaining_args = args[1:] if len(args) > 1 else [] + + if not modules: + console.print("❌ ERROR: No modules found") + return 1 + + # Route command + if route_command(command, remaining_args, modules): + return 0 + else: + console.print(f"❌ ERROR: Unknown command: {command}") + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/aipass/ai_mail/apps/branch.py b/src/aipass/ai_mail/apps/branch.py deleted file mode 100644 index af39291c..00000000 --- a/src/aipass/ai_mail/apps/branch.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -AI_MAIL Branch - Main Orchestrator - -Auto-discovery architecture: -- Scans modules/ directory for .py files with handle_command() -- Routes commands to discovered modules automatically -- No manual imports or routing needed -""" - -import sys -import importlib -from pathlib import Path -from typing import List, Any - -from aipass.prax import logger - -# ============================================================================= -# MODULE DISCOVERY -# ============================================================================= - -MODULES_DIR = Path(__file__).parent / "modules" - - -def discover_modules() -> List[Any]: - """Auto-discover modules in modules/ directory.""" - modules = [] - - if not MODULES_DIR.exists(): - return modules - - for file_path in MODULES_DIR.glob("*.py"): - if file_path.name.startswith("_"): - continue - - module_name = f"apps.modules.{file_path.stem}" - - try: - module = importlib.import_module(module_name) - if hasattr(module, "handle_command"): - modules.append(module) - except Exception as e: - logger.error(f"[AI_MAIL] Failed to load module {module_name}: {e}") - - return modules - - -def route_command(command: str, args: List[str], modules: List[Any]) -> bool: - """Route command to appropriate module.""" - for module in modules: - try: - if module.handle_command(command, args): - return True - except Exception as e: - logger.error(f"[AI_MAIL] Module {module.__name__} error: {e}") - return False - - -# ============================================================================= -# MAIN ENTRY POINT -# ============================================================================= - -def main(): - """Main entry point - routes commands or shows help.""" - modules = discover_modules() - args = sys.argv[1:] - - if len(args) == 0 or args[0] in ["--help", "-h", "help"]: - print(f"AI_MAIL - {len(modules)} modules discovered") - for module in modules: - name = module.__name__.split(".")[-1] - desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" - print(f" {name:20} {desc}") - return 0 - - command = args[0] - remaining = args[1:] if len(args) > 1 else [] - - if route_command(command, remaining, modules): - return 0 - - print(f"Unknown command: {command}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/aipass/ai_mail/apps/extensions/__init__.py b/src/aipass/ai_mail/apps/extensions/__init__.py new file mode 100644 index 00000000..95322c94 --- /dev/null +++ b/src/aipass/ai_mail/apps/extensions/__init__.py @@ -0,0 +1 @@ +# Extensions package - Drop-in extensions for branch functionality diff --git a/src/aipass/ai_mail/apps/handlers/__init__.py b/src/aipass/ai_mail/apps/handlers/__init__.py old mode 100644 new mode 100755 index e69de29b..02e8b77f --- a/src/aipass/ai_mail/apps/handlers/__init__.py +++ b/src/aipass/ai_mail/apps/handlers/__init__.py @@ -0,0 +1,132 @@ +"""AI_Mail handlers package - Security protected.""" + +import inspect +from pathlib import Path + +MY_BRANCH = "ai_mail" + + +def _find_real_caller(): + """ + Walk the stack to find the actual file that triggered this import. + + Skips: + - This file (handlers/__init__.py) + - Python's importlib internals + - Frozen modules + + Returns tuple: (file_path, import_line) or (None, None) + """ + stack = inspect.stack() + this_file = str(Path(__file__).resolve()) + + for frame_info in stack: + filename = frame_info.filename + + # Skip this file + if this_file in str(Path(filename).resolve()): + continue + + # Skip Python internals + if filename.startswith("<") or "importlib" in filename: + continue + + # Found a real file - try to get the import line + import_line = None + if frame_info.code_context: + import_line = frame_info.code_context[0].strip() + + return str(Path(filename).resolve()), import_line + + return None, None + + +def _extract_branch_name(filepath: str) -> str: + """Extract branch name from a file path.""" + parts = filepath.split("/") + for i, part in enumerate(parts): + if part in ("aipass_core", "MEMORY_BANK", "Nexus"): + if i + 1 < len(parts): + return parts[i + 1] + return "unknown" + + +def _guard_branch_access(): + """ + Block cross-branch handler imports. + + Only code from within the 'ai_mail' branch can import these handlers. + External branches must use ai_mail.apps.modules instead. + """ + caller_file, import_line = _find_real_caller() + + # DEBUG: Print what we found + import os + if os.environ.get("AIPASS_DEBUG_GUARD"): + import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) + print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) + + if caller_file is None: + # Can't determine caller from real files + # Check if we're being run from command line (external) + # by looking at the raw stack for or + stack = inspect.stack() + for frame in stack: + if frame.filename in ("", ""): + # Try to get the import line from the frame + target_line = "unknown" + if frame.code_context: + target_line = frame.code_context[0].strip() + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller: interactive/script\n" + f" Blocked: {target_line}\n" + f"\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"\n" + f" Example:\n" + f" from {MY_BRANCH}.apps.modules.logger import logger\n" + f"\n" + f" For full standards guide:\n" + f" drone @seed handlers\n" + f"{'='*60}" + ) + return # Allow if truly can't determine + + # Check if caller is from our branch + if f"/{MY_BRANCH}/" in caller_file: + return # Same branch, allowed + + # External caller - block access + caller_branch = _extract_branch_name(caller_file) + caller_filename = Path(caller_file).name + blocked_import = import_line if import_line else "unknown" + + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller branch: {caller_branch}\n" + f" Caller file: {caller_filename}\n" + f" Blocked: {blocked_import}\n" + f"\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"\n" + f" Example:\n" + f" from {MY_BRANCH}.apps.modules.logger import logger\n" + f"\n" + f" For full standards guide:\n" + f" drone @seed handlers\n" + f"{'='*60}" + ) + + +# Run guard at import time +_guard_branch_access() diff --git a/src/aipass/ai_mail/apps/handlers/central_writer.py b/src/aipass/ai_mail/apps/handlers/central_writer.py new file mode 100644 index 00000000..3de45b1d --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/central_writer.py @@ -0,0 +1,359 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: central_writer.py - AI_MAIL Central File Writer +# Date: 2025-11-27 +# Version: 1.0.0 +# Category: ai_mail/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-27): Initial implementation - central file writer +# +# CODE STANDARDS: +# - Handler tier 3: pure functions, raises exceptions +# - No CLI imports (Prax, Rich, etc.) +# - Follows Seed handler patterns +# ============================================= + +""" +Central Writer Handler + +Aggregates branch inbox stats and writes to AI_MAIL.central.json. +This file serves as AI_MAIL's API output for AIPASS dashboard integration. + +Architecture: +- Scans all ai_mail.local/inbox.json files across the system +- Calculates per-branch unread/total message counts +- Writes aggregated stats to /home/aipass/aipass_os/AI_CENTRAL/AI_MAIL.central.json +""" + +# CRITICAL: Use importlib to bypass local json/ directory and get stdlib json +import sys +import importlib.util + +# Remove current directory from sys.path temporarily to import stdlib json +_saved_path = sys.path.copy() +sys.path = [p for p in sys.path if 'handlers' not in p] +spec = importlib.util.find_spec('json') +if spec is None or spec.loader is None: + raise ImportError("Failed to find stdlib json module") +stdlib_json = importlib.util.module_from_spec(spec) +spec.loader.exec_module(stdlib_json) +sys.path = _saved_path + +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, List, Tuple + + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +AIPASS_HOME = Path.home() # /home/aipass - scan from user home +AI_CENTRAL_DIR = Path.home() / "aipass_os" / "AI_CENTRAL" +CENTRAL_FILE = AI_CENTRAL_DIR / "AI_MAIL.central.json" +BRANCH_REGISTRY = Path.home() / "BRANCH_REGISTRY.json" + + +# ============================================================================= +# CORE FUNCTIONS +# ============================================================================= + +def find_all_inbox_files() -> List[Path]: + """ + Find all inbox.json files in ai_mail.local directories. + + Scans the entire /home/aipass directory for ai_mail.local/inbox.json files. + Excludes backup directories to avoid counting archived data. + + Returns: + List of Path objects to inbox.json files + + Raises: + OSError: If filesystem scan fails + """ + inbox_files = [] + + # Search pattern: any directory ending in ai_mail.local containing inbox.json + for ai_mail_dir in AIPASS_HOME.rglob("ai_mail.local"): + # Skip backup/archive directories (but NOT backup_system branch itself) + path_str = str(ai_mail_dir) + if ".backup" in path_str or ".archive" in path_str or "/backups/" in path_str: + continue + + inbox_path = ai_mail_dir / "inbox.json" + if inbox_path.exists() and inbox_path.is_file(): + inbox_files.append(inbox_path) + + return inbox_files + + +def extract_branch_name(inbox_path: Path) -> str: + """ + Extract branch name from inbox.json path. + + Given: /home/aipass/seed/ai_mail.local/inbox.json + Returns: SEED + + Given: /home/aipass/aipass_core/prax/ai_mail.local/inbox.json + Returns: PRAX + + Args: + inbox_path: Path to inbox.json file + + Returns: + Uppercase branch name + """ + # Parent of ai_mail.local is the branch directory + branch_dir = inbox_path.parent.parent + branch_name = branch_dir.name.upper() + + return branch_name + + +def read_inbox_stats(inbox_path: Path) -> Tuple[int, int]: + """ + Read unread and total message counts from inbox.json. + + Args: + inbox_path: Path to inbox.json file + + Returns: + Tuple of (unread_count, total_messages) + + Raises: + FileNotFoundError: If inbox.json doesn't exist + json.JSONDecodeError: If inbox.json is malformed + KeyError: If required fields are missing + """ + with open(inbox_path, 'r', encoding='utf-8') as f: + inbox_data = stdlib_json.load(f) + + unread = inbox_data.get("unread_count", 0) + total = inbox_data.get("total_messages", 0) + + return (unread, total) + + +def get_valid_branch_names() -> set: + """ + Load valid branch names from BRANCH_REGISTRY.json. + + Returns: + Set of uppercase branch names that are registered in the system. + + Raises: + FileNotFoundError: If BRANCH_REGISTRY.json doesn't exist + json.JSONDecodeError: If BRANCH_REGISTRY.json is malformed + """ + with open(BRANCH_REGISTRY, 'r', encoding='utf-8') as f: + registry_data = stdlib_json.load(f) + + return {branch["name"].upper() for branch in registry_data.get("branches", [])} + + +def aggregate_branch_stats() -> Dict[str, Dict[str, int]]: + """ + Aggregate inbox stats for all branches. + + Scans all branch inbox files and compiles per-branch statistics. + Only includes branches that are registered in BRANCH_REGISTRY.json. + + Returns: + Dict mapping branch names to their stats: + { + "SEED": {"unread": 5, "total": 8}, + "DRONE": {"unread": 0, "total": 3} + } + + Raises: + OSError: If filesystem operations fail + json.JSONDecodeError: If any inbox.json is malformed + """ + branch_stats = {} + + inbox_files = find_all_inbox_files() + valid_branches = get_valid_branch_names() + + for inbox_path in inbox_files: + try: + branch_name = extract_branch_name(inbox_path) + + # Skip branches not in BRANCH_REGISTRY + if branch_name not in valid_branches: + continue + + unread, total = read_inbox_stats(inbox_path) + + branch_stats[branch_name] = { + "unread": unread, + "total": total + } + except (FileNotFoundError, stdlib_json.JSONDecodeError, KeyError) as e: + # Skip branches with missing/malformed inbox files + # Continue processing other branches + continue + except Exception as e: + # Handler tier 3: raise unexpected errors for caller to handle + raise RuntimeError(f"Failed to process {inbox_path}: {e}") from e + + return branch_stats + + +def calculate_system_totals(branch_stats: Dict[str, Dict[str, int]]) -> Dict[str, int]: + """ + Calculate system-wide totals from branch stats. + + Args: + branch_stats: Per-branch statistics + + Returns: + Dict with total_unread and total_messages: + {"total_unread": 5, "total_messages": 11} + """ + total_unread = sum(stats["unread"] for stats in branch_stats.values()) + total_messages = sum(stats["total"] for stats in branch_stats.values()) + + return { + "total_unread": total_unread, + "total_messages": total_messages + } + + +def build_central_data(branch_stats: Dict[str, Dict[str, int]]) -> Dict[str, Any]: + """ + Build complete central.json data structure. + + Args: + branch_stats: Per-branch statistics + + Returns: + Complete data structure ready for JSON serialization + """ + system_totals = calculate_system_totals(branch_stats) + + return { + "service": "ai_mail", + "last_updated": datetime.now().date().isoformat(), # Date only - avoids phantom git changes + "branch_stats": branch_stats, + "system_totals": system_totals + } + + +def write_central_file(data: Dict[str, Any]) -> None: + """ + Write data to AI_MAIL.central.json. + + Args: + data: Complete central file data structure + + Raises: + OSError: If file write fails + PermissionError: If insufficient permissions + """ + # Ensure AI_CENTRAL directory exists + AI_CENTRAL_DIR.mkdir(parents=True, exist_ok=True) + + with open(CENTRAL_FILE, 'w', encoding='utf-8') as f: + stdlib_json.dump(data, f, indent=2, ensure_ascii=False) + + +# ============================================================================= +# PUBLIC API +# ============================================================================= + +def update_central() -> Dict[str, Any]: + """ + Update AI_MAIL.central.json with current branch inbox stats. + + This is the primary public function for updating central statistics. + Should be called whenever mail is sent/received to keep dashboard in sync. + + Process: + 1. Scans all branch ai_mail.local/inbox.json files + 2. Aggregates unread and total message counts per branch + 3. Calculates system-wide totals + 4. Writes results to /home/aipass/aipass_os/AI_CENTRAL/AI_MAIL.central.json + + Returns: + The data written to central file (for logging/verification) + + Raises: + OSError: If filesystem operations fail + json.JSONDecodeError: If any inbox.json is malformed + PermissionError: If insufficient permissions to write central file + + Example: + >>> from ai_mail.apps.handlers.central_writer import update_central + >>> stats = update_central() + >>> print(stats["system_totals"]["total_unread"]) + 5 + """ + # Aggregate statistics from all branches + branch_stats = aggregate_branch_stats() + + # Build complete data structure + central_data = build_central_data(branch_stats) + + # Write to central file + write_central_file(central_data) + + return central_data + + +# ============================================================================= +# CLI TEST HARNESS +# ============================================================================= + +if __name__ == "__main__": + # Handler tier 3: no CLI imports in main code + # Test harness can import CLI libraries for display + from rich.console import Console + from rich.panel import Panel + from rich.table import Table + + console = Console() + + console.print() + console.print(Panel.fit( + "[bold cyan]AI_MAIL Central Writer[/bold cyan]", + border_style="bright_blue" + )) + console.print() + + try: + console.print("[yellow]Scanning branches...[/yellow]") + stats = update_central() + + console.print(f"[green]Updated:[/green] {CENTRAL_FILE}") + console.print() + + # Display results in table + table = Table(title="Branch Mail Statistics") + table.add_column("Branch", style="cyan", no_wrap=True) + table.add_column("Unread", justify="right", style="yellow") + table.add_column("Total", justify="right", style="blue") + + for branch, data in sorted(stats["branch_stats"].items()): + table.add_row( + branch, + str(data["unread"]), + str(data["total"]) + ) + + # Add totals row + table.add_section() + table.add_row( + "[bold]SYSTEM TOTALS[/bold]", + f"[bold yellow]{stats['system_totals']['total_unread']}[/bold yellow]", + f"[bold blue]{stats['system_totals']['total_messages']}[/bold blue]" + ) + + console.print(table) + console.print() + + except Exception as e: + console.print(f"[red]Error:[/red] {e}") + raise diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/__init__.py b/src/aipass/ai_mail/apps/handlers/dispatch/__init__.py new file mode 100644 index 00000000..8ff15942 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/dispatch/__init__.py @@ -0,0 +1 @@ +# Dispatch handlers package diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py new file mode 100644 index 00000000..1965c407 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/dispatch/daemon.py @@ -0,0 +1,673 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: daemon.py - Dispatch Daemon Handler +# Date: 2026-02-17 +# Version: 1.8.0 +# Category: ai_mail/handlers/dispatch +# +# CHANGELOG (Max 5 entries): +# - v1.8.0 (2026-03-02): Wire spawn_agent through dispatch_monitor (bounce emails + guaranteed lock cleanup) +# - v1.7.0 (2026-03-02): Redirect stderr to log file + reap zombie children (debug silent deaths) +# - v1.6.0 (2026-03-01): Add AIPASS_SESSION_TYPE env var + session rename for /resume picker +# - v1.5.0 (2026-02-22): Stale lock cleanup every cycle + orphaned opened email retry (>30 min) +# - v1.4.0 (2026-02-21): Skip spawn if branch has active Claude session (prevent toe-stepping) +# - v1.3.0 (2026-02-20): DPLAN-024 Phase 3 - strip heartbeat logic (migrated to assistant plugins) +# - v1.2.0 (2026-02-20): DPLAN-024 - fix hardcoded paths, inline lock_utils +# +# CODE STANDARDS: +# - Handler independence: NO cross-handler or module imports +# - Pure business logic only +# - Uses Prax system_logger (FPLAN-0382) +# ============================================= + +""" +Dispatch Daemon Handler + +Polls registered branch inboxes for --dispatch emails and spawns agents. +The daemon IS the continuity - agents are ephemeral, wake-do-exit. + +Architecture: + - Polls every N seconds (configurable via safety_config.json) + - Spawns via dispatch_monitor wrapper (bounce emails + guaranteed lock cleanup) + - Enforces: kill switch, max turns, max dispatches/day, lock files + - Tracks daily dispatch counts per branch +""" + +import json +import os +import sys +import time +import signal +import subprocess +from pathlib import Path +from datetime import datetime, date +from typing import Dict, Any, Optional +from urllib.request import Request, urlopen +from urllib.error import URLError + +from aipass.prax.apps.modules.logger import system_logger as logger + +# Infrastructure paths +AIPASS_ROOT = Path.home() / "aipass_core" +AIPASS_HOME = Path.home() + +# Paths +CONFIG_FILE = AIPASS_ROOT / "ai_mail" / "safety_config.json" +DAEMON_STATE_FILE = AIPASS_ROOT / "ai_mail" / "ai_mail.local" / "daemon_state.json" +DAEMON_LOG_FILE = AIPASS_ROOT / "ai_mail" / "ai_mail.local" / "dispatch_daemon.log" +DAEMON_PID_FILE = AIPASS_ROOT / "ai_mail" / "ai_mail.local" / "daemon.pid" +BRANCH_REGISTRY = AIPASS_HOME / "BRANCH_REGISTRY.json" + +# Telegram notifications (scheduler bot) +SCHEDULER_CONFIG = AIPASS_HOME / ".aipass" / "scheduler_config.json" + +# Graceful shutdown +SHUTDOWN = False + + + +def _notify_telegram(message: str) -> bool: + """Send a notification to Patrick's Telegram via the scheduler bot.""" + try: + with open(SCHEDULER_CONFIG, "r", encoding="utf-8") as f: + config = json.load(f) + bot_token = config["telegram_bot_token"] + chat_id = config["telegram_chat_id"] + except (FileNotFoundError, KeyError, json.JSONDecodeError): + logger.info("Telegram notification skipped (no scheduler config)") + return False + + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + payload = json.dumps({"chat_id": chat_id, "text": message}).encode("utf-8") + req = Request(url, data=payload, headers={"Content-Type": "application/json"}) + + try: + with urlopen(req, timeout=10) as resp: + result = json.loads(resp.read()) + return result.get("ok", False) + except (URLError, Exception): + logger.info("Telegram notification failed: %s", message[:60]) + return False + + +def _handle_signal(signum, _frame): + """Handle shutdown signals for graceful daemon stop.""" + global SHUTDOWN + logger.info(f"Received signal {signum}, shutting down gracefully...") + SHUTDOWN = True + + +signal.signal(signal.SIGTERM, _handle_signal) +signal.signal(signal.SIGINT, _handle_signal) + + +def _read_json(filepath: Path) -> Optional[Dict[str, Any]]: + """Read and parse a JSON file, returning None on failure.""" + if not filepath.exists(): + return None + try: + with open(filepath, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return None + + +def _write_json(filepath: Path, data: Dict[str, Any]) -> bool: + """Write data to a JSON file, returning success.""" + filepath.parent.mkdir(parents=True, exist_ok=True) + try: + with open(filepath, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except OSError: + return False + + +def _set_session_name(branch_path: Path, name: str) -> bool: + """Write custom-title to the most recent Claude session JSONL for a branch. + + Claude stores sessions at ~/.claude/projects/{encoded-cwd}/*.jsonl. + Writing a custom-title entry makes the session identifiable in /resume picker. + """ + encoded_cwd = str(branch_path).replace("/", "-") + projects_dir = Path.home() / ".claude" / "projects" / encoded_cwd + if not projects_dir.exists(): + return False + jsonl_files = sorted( + projects_dir.glob("*.jsonl"), + key=lambda f: f.stat().st_mtime, + reverse=True + ) + if not jsonl_files: + return False + latest = jsonl_files[0] + session_id = latest.stem + entry = json.dumps({ + "type": "custom-title", + "customTitle": name, + "sessionId": session_id + }) + try: + with open(latest, "a", encoding="utf-8") as f: + f.write(entry + "\n") + return True + except OSError: + return False + + +def _check_lock(branch_path: Path) -> Optional[Dict[str, Any]]: + """Check if branch has an active dispatch lock. Returns lock data or None.""" + lock_file = branch_path / "ai_mail.local" / ".dispatch.lock" + if not lock_file.exists(): + return None + try: + with open(lock_file, 'r', encoding='utf-8') as f: + data = json.load(f) + pid = data.get("pid") + if pid is not None: + try: + os.kill(pid, 0) + return data # Process alive, lock valid + except ProcessLookupError: + logger.info("Lock PID %s dead — stale lock cleanup needed", pid) + except PermissionError: + return data # Process exists, can't signal + # Stale lock — check age (10 min timeout) + ts = data.get("timestamp", "") + if ts: + try: + lock_time = datetime.fromisoformat(ts) + age = (datetime.now() - lock_time).total_seconds() + if age > 600: + logger.warning( + "Stale lock removed at %s (PID %s dead, age %.0fs)", + lock_file, pid, age + ) + lock_file.unlink(missing_ok=True) + return None + except (ValueError, TypeError): + logger.info("Unparseable lock timestamp at %s", lock_file) + # Dead process, remove stale lock + logger.warning( + "Stale lock removed at %s (PID %s no longer running)", lock_file, pid + ) + lock_file.unlink(missing_ok=True) + return None + except (json.JSONDecodeError, OSError): + logger.warning("Corrupt lock file removed at %s", lock_file) + lock_file.unlink(missing_ok=True) + return None + + +def _acquire_lock(branch_path: Path, pid: int) -> tuple[bool, str]: + """Acquire dispatch lock for branch. Atomic creation via O_CREAT|O_EXCL.""" + lock_file = branch_path / "ai_mail.local" / ".dispatch.lock" + lock_data = { + "pid": pid, + "timestamp": datetime.now().isoformat(), + "branch": str(branch_path) + } + try: + lock_file.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + try: + os.write(fd, json.dumps(lock_data, indent=2).encode('utf-8')) + finally: + os.close(fd) + return True, "Lock acquired" + except FileExistsError: + return False, "Lock file already exists" + except OSError as e: + return False, f"Lock failed: {e}" + + +def load_config() -> Dict[str, Any]: + """Load safety config from JSON file.""" + DEFAULTS = { + "kill_switch_path": str(AIPASS_HOME / ".aipass" / "autonomous_pause"), + "poll_interval_seconds": 300, + "max_depth": 3, + "max_turns_per_wake": 50, + "max_dispatches_per_branch_per_day": 10, + "session_rotation_cycles": 12, + "cold_start_prompt": "Hi. Check inbox, process new emails, update memories when done.", + "wake_prompt": "Wake. Check inbox, process new emails, continue work. Update memories when done.", + "autonomous_branches": [] + } + + config = _read_json(CONFIG_FILE) + if config is None: + return DEFAULTS + + for key, val in DEFAULTS.items(): + if key not in config: + config[key] = val + return config + + +def load_daemon_state() -> Dict[str, Any]: + """Load daemon state (daily counts, session tracking).""" + EMPTY_STATE = {"daily_counts": {}, "session_cycles": {}, "date": str(date.today())} + + state = _read_json(DAEMON_STATE_FILE) + if state is None: + return EMPTY_STATE + + # Reset counts on new day + if state.get("date") != str(date.today()): + state["daily_counts"] = {} + state["date"] = str(date.today()) + return state + + +def save_daemon_state(state: Dict[str, Any]) -> None: + """Persist daemon state to disk.""" + state["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + if not _write_json(DAEMON_STATE_FILE, state): + logger.info(f"Failed to save daemon state to {DAEMON_STATE_FILE}") + + +def is_kill_switch_active(config: Dict[str, Any]) -> bool: + """Check if the system-wide kill switch is engaged.""" + kill_path = Path(config.get("kill_switch_path", str(AIPASS_HOME / ".aipass" / "autonomous_pause"))) + return kill_path.exists() + + +def _write_pid_file() -> bool: + """Write current PID to daemon.pid. Returns False if another daemon is running.""" + if DAEMON_PID_FILE.exists(): + try: + old_pid = int(DAEMON_PID_FILE.read_text().strip()) + try: + os.kill(old_pid, 0) + # Process exists — another daemon is running + logger.info(f"Another daemon already running (PID {old_pid}). Exiting.") + return False + except ProcessLookupError: + # Stale PID file — process is dead, we can take over + logger.info(f"Removing stale PID file (PID {old_pid} is dead)") + except PermissionError: + # Process exists but we can't signal it + logger.info(f"Another daemon already running (PID {old_pid}, permission denied). Exiting.") + return False + except (ValueError, OSError): + logger.info("Corrupt PID file — removing") + + DAEMON_PID_FILE.parent.mkdir(parents=True, exist_ok=True) + DAEMON_PID_FILE.write_text(str(os.getpid())) + return True + + +def _remove_pid_file() -> None: + """Remove the daemon PID file on shutdown.""" + try: + if DAEMON_PID_FILE.exists(): + stored_pid = int(DAEMON_PID_FILE.read_text().strip()) + if stored_pid == os.getpid(): + DAEMON_PID_FILE.unlink(missing_ok=True) + except (ValueError, OSError): + DAEMON_PID_FILE.unlink(missing_ok=True) + + +def get_registered_branches() -> list: + """Load all registered branches from BRANCH_REGISTRY.json.""" + data = _read_json(BRANCH_REGISTRY) + if data is None: + return [] + return data.get("branches", []) + + +def check_inbox_for_dispatch(branch_path: Path) -> Optional[Dict[str, Any]]: + """ + Check a branch's inbox for unprocessed --dispatch emails. + + Returns the first eligible dispatch email, prioritizing new emails. + Also retries opened dispatch emails orphaned for >30 min (agent crashed + before completing). + """ + inbox_file = branch_path / "ai_mail.local" / "inbox.json" + inbox_data = _read_json(inbox_file) + if inbox_data is None: + return None + + orphan_threshold_seconds = 1800 # 30 minutes + + # Priority 1: new dispatch emails + for msg in inbox_data.get("messages", []): + if msg.get("auto_execute") and msg.get("status") == "new": + return msg + + # Priority 2: opened dispatch emails orphaned >30 min + now = datetime.now() + for msg in inbox_data.get("messages", []): + if msg.get("auto_execute") and msg.get("status") == "opened": + ts = msg.get("timestamp", "") + if not ts: + continue + try: + msg_time = datetime.fromisoformat(ts) + age = (now - msg_time).total_seconds() + if age > orphan_threshold_seconds: + logger.warning( + "Retrying orphaned dispatch email %s (opened %.0f min ago)", + msg.get("id", "?"), age / 60 + ) + return msg + except (ValueError, TypeError): + logger.info("Unparseable timestamp for email %s", msg.get("id", "?")) + continue + return None + + +def count_new_emails(branch_path: Path) -> int: + """Count new (unread) emails in a branch's inbox.""" + inbox_file = branch_path / "ai_mail.local" / "inbox.json" + inbox_data = _read_json(inbox_file) + if inbox_data is None: + return 0 + return sum(1 for m in inbox_data.get("messages", []) if m.get("status") == "new") + + +def spawn_agent( + branch_path: Path, + branch_email: str, + message: Dict[str, Any], + config: Dict[str, Any], + state: Dict[str, Any] +) -> bool: + """ + Spawn a Claude agent at the target branch via dispatch_monitor wrapper. + + The monitor wraps the claude process, providing: + - Bounce emails on agent failure (return-to-sender) + - Guaranteed lock cleanup on exit (agent doesn't need to know about locks) + - Stderr capture for diagnostics + + Args: + branch_path: Path to target branch + branch_email: Branch email (e.g., @flow) + message: The dispatch email message dict + config: Safety config + state: Daemon state (for cycle tracking) + + Returns: + True if monitor was spawned successfully + """ + sender = message.get("from", "unknown") + msg_id = message.get("id", "unknown") + subject = message.get("subject", "") + max_turns = config.get("max_turns_per_wake", 15) + + lock_file_path = str(branch_path / "ai_mail.local" / ".dispatch.lock") + + # Prompt — no lock cleanup instruction (dispatch_monitor handles it) + prompt = ( + f"Hi. Check inbox for task from {sender} (message ID: {msg_id}). " + f"Execute it. Send confirmation when done." + ) + + claude_cmd = [ + "claude", "-c", "-p", prompt, + "--max-turns", str(max_turns), + "--permission-mode", "bypassPermissions", + "--output-format", "json" + ] + + # Build monitor command (dispatch_monitor wraps claude, handles bounce + lock cleanup) + MONITOR_SCRIPT = AIPASS_ROOT / "ai_mail" / "apps" / "handlers" / "dispatch" / "dispatch_monitor.py" + LOG_DIR = branch_path / "ai_mail.local" + LOG_DIR.mkdir(parents=True, exist_ok=True) + STDERR_LOG = str(LOG_DIR / "agent_stderr.log") + + monitor_cmd = [ + sys.executable, str(MONITOR_SCRIPT), + branch_email, lock_file_path, sender, STDERR_LOG, + "--", *claude_cmd + ] + + spawn_env = os.environ.copy() + spawn_env["AIPASS_SPAWNED"] = "1" + spawn_env["AIPASS_SESSION_TYPE"] = "daemon" + # Strip CLAUDE* vars (prevent nested session) and AIPASS_BOT_ID (prevent log leak to parent chat) + for key in list(spawn_env.keys()): + if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID": + spawn_env.pop(key) + + # Set session name for /resume picker (daemon always uses -c resume) + spawn_branch_name = branch_email.lstrip("@").upper() + _set_session_name(branch_path, f"{spawn_branch_name}-daemon") + + try: + process = subprocess.Popen( + monitor_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + cwd=str(branch_path), + env=spawn_env + ) + + monitor_pid = process.pid + + # Lock PID = monitor PID (stays alive as long as claude does) + acquired, lock_msg = _acquire_lock(branch_path, monitor_pid) + if not acquired: + logger.info(f"Lock acquisition failed after spawn for {branch_email}: {lock_msg}") + + # Track session cycles for rotation + cycles = state.get("session_cycles", {}) + branch_key = str(branch_path) + cycles[branch_key] = cycles.get(branch_key, 0) + 1 + state["session_cycles"] = cycles + + # Increment daily count + daily = state.get("daily_counts", {}) + daily[branch_email] = daily.get(branch_email, 0) + 1 + state["daily_counts"] = daily + + # Desktop notification — show who woke and why + notif_title = f"Daemon → {branch_email}" + notif_body = f"Task from {sender}: \"{subject[:80]}\"" if subject else f"Dispatch from {sender}" + try: + subprocess.run( + ["notify-send", "-i", "dialog-information", notif_title, notif_body], + capture_output=True, timeout=5 + ) + except (subprocess.SubprocessError, FileNotFoundError, OSError): + logger.info(f"Desktop notification unavailable for {branch_email}") + + logger.info(f"SPAWN {branch_email} PID={monitor_pid} (monitor) sender={sender} subject=\"{subject[:60]}\"") + _notify_telegram(f"[Dispatch] {branch_email} woke\nTask from {sender}: {subject[:80]}") + return True + + except Exception as e: + logger.info(f"SPAWN FAILED {branch_email}: {e}") + _notify_telegram(f"[Dispatch FAILED] {branch_email}\n{type(e).__name__}: {e}") + return False + + +def is_protected_branch(branch_email: str) -> bool: + """Check if a branch is protected from auto-dispatch.""" + return branch_email == "@dev_central" + + +def _read_session_type(pid_str: str) -> str: + """Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset.""" + try: + with open(f'/proc/{pid_str}/environ', 'rb') as f: + data = f.read() + for entry in data.split(b'\0'): + if entry.startswith(b'AIPASS_SESSION_TYPE='): + return entry.split(b'=', 1)[1].decode('utf-8') + except (OSError, PermissionError): + logger.info("Cannot read session type for PID %s", pid_str) + return 'interactive' + + +# Session types that should NOT block dispatch (idle/background sessions) +_NON_BLOCKING_SESSION_TYPES = {'telegram', 'dispatched', 'daemon'} + + +def _is_branch_occupied(branch_path: Path) -> bool: + """ + Check if an interactive Claude session is running in this branch. + + Only interactive sessions block dispatch. Telegram, dispatched, and daemon + sessions are idle/background and should not prevent new agent spawns. + """ + resolved = branch_path.resolve() + try: + result = subprocess.run( + ['pgrep', '-x', 'claude'], + capture_output=True, text=True, timeout=5 + ) + if result.returncode != 0: + return False + + for pid_str in result.stdout.strip().split('\n'): + pid_str = pid_str.strip() + if not pid_str: + continue + try: + cwd = os.readlink(f'/proc/{pid_str}/cwd') + if Path(cwd).resolve() == resolved: + session_type = _read_session_type(pid_str) + if session_type not in _NON_BLOCKING_SESSION_TYPES: + return True + except (OSError, PermissionError, ValueError): + logger.info("Cannot read cwd for PID %s", pid_str) + continue + except Exception: + logger.info("Failed to check branch occupancy for %s", branch_path) + return False + + +def poll_cycle(config: Dict[str, Any], state: Dict[str, Any]) -> int: + """ + Run one poll cycle across all registered branches. + + Returns: + Number of agents spawned this cycle + """ + branches = get_registered_branches() + autonomous_list = config.get("autonomous_branches", []) + max_daily = config.get("max_dispatches_per_branch_per_day", 10) + spawned = 0 + + for branch in branches: + if SHUTDOWN: + break + + branch_email = branch.get("email", "") + branch_path_str = branch.get("path", "") + if not branch_email or not branch_path_str: + continue + + branch_path = Path(branch_path_str) + + if is_protected_branch(branch_email): + continue + + if autonomous_list and branch_email not in autonomous_list: + continue + + daily_count = state.get("daily_counts", {}).get(branch_email, 0) + if daily_count >= max_daily: + logger.info(f"SKIP {branch_email}: daily limit reached ({daily_count}/{max_daily})") + continue + + # Always check/clean stale locks (even without dispatch emails) + existing_lock = _check_lock(branch_path) + if existing_lock is not None: + logger.info(f"SKIP {branch_email}: active lock (PID {existing_lock.get('pid', '?')})") + continue + + dispatch_msg = check_inbox_for_dispatch(branch_path) + if dispatch_msg is None: + continue + + if _is_branch_occupied(branch_path): + logger.info(f"SKIP {branch_email}: active Claude session detected (email already in inbox)") + continue + + if spawn_agent(branch_path, branch_email, dispatch_msg, config, state): + spawned += 1 + + return spawned + + +def run_daemon() -> None: + """ + Main daemon loop. Polls inboxes at configured interval, spawns agents. + + Exits gracefully on SIGTERM/SIGINT or kill switch. + """ + if not _write_pid_file(): + return + + logger.info("=" * 60) + logger.info(f"DISPATCH DAEMON STARTING (PID {os.getpid()})") + logger.info("=" * 60) + _notify_telegram(f"[Daemon] Started (PID {os.getpid()})") + + config = load_config() + poll_interval = config.get("poll_interval_seconds", 300) + + logger.info(f"Poll interval: {poll_interval}s") + logger.info(f"Kill switch: {config.get('kill_switch_path')}") + logger.info(f"Max turns/wake: {config.get('max_turns_per_wake')}") + logger.info(f"Max dispatches/branch/day: {config.get('max_dispatches_per_branch_per_day')}") + + autonomous = config.get("autonomous_branches", []) + if autonomous: + logger.info(f"Autonomous branches: {', '.join(autonomous)}") + else: + logger.info("Autonomous branches: ALL (no filter)") + + cycle_count = 0 + + while not SHUTDOWN: + # Reap zombie children from previously spawned agents + try: + while True: + pid, _ = os.waitpid(-1, os.WNOHANG) + if pid == 0: + break + logger.info(f"Reaped child process PID {pid}") + except ChildProcessError: + logger.info("No child processes to reap") + + if is_kill_switch_active(config): + logger.info("Kill switch ACTIVE - pausing all dispatches") + time.sleep(poll_interval) + continue + + config = load_config() + poll_interval = config.get("poll_interval_seconds", 300) + + state = load_daemon_state() + cycle_count += 1 + + logger.info(f"--- Poll cycle {cycle_count} ---") + + spawned = poll_cycle(config, state) + + if spawned > 0: + logger.info(f"Cycle {cycle_count}: spawned {spawned} agent(s)") + + save_daemon_state(state) + + elapsed = 0 + while elapsed < poll_interval and not SHUTDOWN: + time.sleep(min(5, poll_interval - elapsed)) + elapsed += 5 + + _remove_pid_file() + logger.info("DISPATCH DAEMON STOPPED") + _notify_telegram("[Daemon] Stopped") + + +if __name__ == "__main__": + run_daemon() diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py new file mode 100644 index 00000000..6963c3e1 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/dispatch/dispatch_monitor.py @@ -0,0 +1,209 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: dispatch_monitor.py - Agent Lifecycle Monitor +# Date: 2026-03-02 +# Version: 1.0.0 +# Category: ai_mail/handlers/dispatch +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-03-02): Initial — wraps claude spawn, handles cleanup + bounce on failure +# +# CODE STANDARDS: +# - Handler independence: NO cross-handler or module imports +# - Runs as a detached background process +# - Uses subprocess to send bounce emails (avoids imports) +# ============================================= + +""" +Agent Lifecycle Monitor + +Wraps a Claude agent spawn. Instead of fire-and-forget Popen, this process: +1. Runs claude and WAITS for it to complete +2. Checks exit code +3. On failure: sends return-to-sender bounce email +4. Always cleans up the dispatch lock + +Spawned by wake.py in place of claude directly. The lock PID points to +the monitor (which stays alive as long as claude does), so lock validity +is guaranteed. +""" + +import json +import os +import sys +import subprocess +import time +from pathlib import Path + +from aipass.prax.apps.modules.logger import system_logger as logger + + +def _send_bounce(branch_email: str, reason: str, sender: str, + lock_file: str, stderr_log: str) -> bool: + """Send return-to-sender bounce email via drone.""" + subject = f"BOUNCE: Dispatch to {branch_email} failed" + + # Read last few lines of stderr log for diagnostics + stderr_tail = "" + try: + with open(stderr_log, 'r', encoding='utf-8') as f: + lines = f.readlines() + stderr_tail = "".join(lines[-20:]).strip() + except (OSError, FileNotFoundError): + stderr_tail = "(no stderr captured)" + + body = ( + f"Agent at {branch_email} exited abnormally.\n\n" + f"Reason: {reason}\n\n" + f"Stderr (last 20 lines):\n{stderr_tail}\n\n" + f"Lock file cleaned automatically.\n" + f"Re-dispatch if task was not completed." + ) + + # Send via drone (resolves paths, handles routing) + try: + result = subprocess.run( + ["drone", "@ai_mail", "send", sender, subject, body], + capture_output=True, text=True, timeout=30, + cwd=str(Path(lock_file).parent.parent) + ) + return result.returncode == 0 + except (subprocess.SubprocessError, OSError): + # Fallback: write bounce to a file if email fails + try: + bounce_file = Path(lock_file).parent / "last_bounce.json" + with open(bounce_file, 'w', encoding='utf-8') as f: + json.dump({ + "branch": branch_email, + "reason": reason, + "sender": sender, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), + "stderr_tail": stderr_tail + }, f, indent=2) + except OSError: + logger.info("[monitor] Failed to write bounce file fallback") + return False + + +def main(): + """ + Usage: dispatch_monitor.py -- + + Runs claude, waits for completion, handles cleanup. + """ + if len(sys.argv) < 6 or "--" not in sys.argv: + logger.warning("[monitor] Invalid arguments: %s", sys.argv) + sys.exit(1) + + sep_idx = sys.argv.index("--") + branch_email = sys.argv[1] + lock_file = sys.argv[2] + sender = sys.argv[3] + stderr_log = sys.argv[4] + claude_cmd = sys.argv[sep_idx + 1:] + + if not claude_cmd: + logger.warning("[monitor] No claude command after --") + sys.exit(1) + + # Open stderr log for claude output + try: + stderr_fh = open(stderr_log, 'a', encoding='utf-8') + stderr_fh.write(f"\n--- Monitor for {branch_email} started at " + f"{time.strftime('%Y-%m-%dT%H:%M:%S')} (PID {os.getpid()}) ---\n") + stderr_fh.flush() + except OSError: + stderr_fh = subprocess.DEVNULL + + # Prepare env — strip CLAUDE* vars and AIPASS_BOT_ID + spawn_env = os.environ.copy() + spawn_env["AIPASS_SPAWNED"] = "1" + spawn_env["AIPASS_SESSION_TYPE"] = "dispatched" + for key in list(spawn_env.keys()): + if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID": + spawn_env.pop(key) + + # Extract CWD from lock file path (branch_path/ai_mail.local/.dispatch.lock) + lock_path = Path(lock_file) + branch_path = lock_path.parent.parent + cwd = str(branch_path) + + start_time = time.time() + + # Run claude — BLOCKING. Monitor stays alive as long as agent is working. + try: + result = subprocess.run( + claude_cmd, + stdout=subprocess.DEVNULL, + stderr=stderr_fh, + cwd=cwd, + env=spawn_env, + timeout=7200 # 2 hour hard timeout + ) + exit_code = result.returncode + except subprocess.TimeoutExpired: + exit_code = -1 + reason = "Agent timed out (2 hour limit)" + _send_bounce(branch_email, reason, sender, lock_file, stderr_log) + except Exception as e: + exit_code = -2 + reason = f"Monitor error: {type(e).__name__}: {e}" + _send_bounce(branch_email, reason, sender, lock_file, stderr_log) + + duration = int(time.time() - start_time) + + # Log completion + if not isinstance(stderr_fh, int): + try: + stderr_fh.write(f"\n--- Agent exited: code={exit_code}, duration={duration}s ---\n") + stderr_fh.flush() + stderr_fh.close() + except OSError: + logger.info("[monitor] Failed to write agent exit log") + + # Check exit code and handle failure + if exit_code != 0: + reason = f"Exit code {exit_code} after {duration}s" + + # Check stderr log for clues + try: + with open(stderr_log, 'r', encoding='utf-8') as f: + content = f.read() + if "rate_limit" in content.lower() or "429" in content: + reason = f"API rate limit hit (exit {exit_code}, {duration}s)" + elif "overloaded" in content.lower() or "529" in content: + reason = f"API overloaded (exit {exit_code}, {duration}s)" + elif "network" in content.lower() or "connection" in content.lower(): + reason = f"Network error (exit {exit_code}, {duration}s)" + except OSError: + logger.info("[monitor] Failed to read stderr log for diagnostics") + + _send_bounce(branch_email, reason, sender, lock_file, stderr_log) + + # Always clean up lock file — monitor handles this, agent doesn't need to + try: + if os.path.exists(lock_file): + os.unlink(lock_file) + except OSError: + logger.info("[monitor] Failed to clean up lock file %s", lock_file) + + # Desktop notification on completion + status = "completed" if exit_code == 0 else f"FAILED (code {exit_code})" + try: + subprocess.run( + ["notify-send", "-i", + "dialog-information" if exit_code == 0 else "dialog-warning", + f"Agent {branch_email} {status}", + f"Duration: {duration}s"], + capture_output=True, timeout=5 + ) + except (subprocess.SubprocessError, FileNotFoundError, OSError): + logger.info("[monitor] Desktop notification unavailable") + + sys.exit(0 if exit_code == 0 else 1) + + +if __name__ == "__main__": + main() diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/pending_work.py b/src/aipass/ai_mail/apps/handlers/dispatch/pending_work.py new file mode 100644 index 00000000..17124628 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/dispatch/pending_work.py @@ -0,0 +1,160 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: pending_work.py - Pending Work Handler +# Date: 2026-02-17 +# Version: 1.0.0 +# Category: ai_mail/handlers/dispatch +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-17): Initial version - per-branch pending work tracking +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Pure business logic only +# ============================================= + +""" +Pending Work Handler + +Read/write utils for .pending_work.json per branch. +Tracks dispatch workflows, waiting_for states, and next_action queues. +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, Optional + +PENDING_WORK_FILENAME = ".pending_work.json" + + +def _get_pending_path(branch_path: Path) -> Path: + """Get the pending work file path for a branch.""" + if branch_path == Path("/") or branch_path == Path.home(): + return Path.home() / "ai_mail.local" / PENDING_WORK_FILENAME + return branch_path / "ai_mail.local" / PENDING_WORK_FILENAME + + +def load_pending_work(branch_path: Path) -> Dict[str, Any]: + """ + Load pending work for a branch. + + Args: + branch_path: Path to the branch directory + + Returns: + Pending work dict with 'workflows' array + """ + pending_path = _get_pending_path(branch_path) + + if not pending_path.exists(): + return {"workflows": []} + + try: + with open(pending_path, 'r', encoding='utf-8') as f: + data = json.load(f) + if "workflows" not in data: + data["workflows"] = [] + return data + except (json.JSONDecodeError, OSError): + return {"workflows": []} + + +def save_pending_work(branch_path: Path, data: Dict[str, Any]) -> bool: + """ + Save pending work for a branch. + + Args: + branch_path: Path to the branch directory + data: Pending work dict to save + + Returns: + True if saved successfully + """ + pending_path = _get_pending_path(branch_path) + pending_path.parent.mkdir(parents=True, exist_ok=True) + + try: + data["last_updated"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + with open(pending_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except OSError: + return False + + +def add_workflow( + branch_path: Path, + dispatch_id: str, + dispatched_to: str, + subject: str, + waiting_for: Optional[str] = None, + next_action: Optional[str] = None +) -> bool: + """ + Add a workflow entry to a branch's pending work. + + Adds a new dispatch workflow to track pending communications and actions. + + Args: + branch_path: Path to the branch directory + dispatch_id: Message ID of the dispatched email + dispatched_to: Target branch email (e.g., @flow) + subject: Subject of the dispatched email + waiting_for: What the branch is waiting for (e.g., reply from @flow) + next_action: What to do when the response arrives + + Returns: + True if added successfully + """ + data = load_pending_work(branch_path) + + entry = { + "dispatch_id": dispatch_id, + "dispatched_to": dispatched_to, + "subject": subject, + "status": "waiting", + "created": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + if waiting_for: + entry["waiting_for"] = waiting_for + if next_action: + entry["next_action"] = next_action + + data["workflows"].append(entry) + return save_pending_work(branch_path, data) + + +def clear_workflow(branch_path: Path, dispatch_id: str) -> bool: + """ + Remove a completed workflow entry. + + Args: + branch_path: Path to the branch directory + dispatch_id: Message ID to remove + + Returns: + True if removed successfully + """ + data = load_pending_work(branch_path) + data["workflows"] = [ + w for w in data["workflows"] + if w.get("dispatch_id") != dispatch_id + ] + return save_pending_work(branch_path, data) + + +def has_pending_work(branch_path: Path) -> bool: + """ + Check if a branch has any pending workflows. + + Args: + branch_path: Path to the branch directory + + Returns: + True if there are active workflows + """ + data = load_pending_work(branch_path) + return len(data.get("workflows", [])) > 0 diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/status.py b/src/aipass/ai_mail/apps/handlers/dispatch/status.py new file mode 100644 index 00000000..584f223b --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/dispatch/status.py @@ -0,0 +1,144 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: status.py - Dispatch Status Handler +# Date: 2026-02-02 +# Version: 1.0.0 +# Category: ai_mail/handlers/dispatch +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-02): Initial version - dispatch log operations +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Pure business logic only +# ============================================= + +""" +Dispatch Status Handler + +Handles dispatch log storage and status checking. +Independent handler - no module dependencies. +""" + +import json +import subprocess +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, List, Optional + +# Dispatch log location +AIPASS_ROOT = Path.home() / "aipass_core" +DISPATCH_LOG_FILE = AIPASS_ROOT / "ai_mail" / "ai_mail.local" / "dispatch_log.json" + + +def load_dispatch_log() -> List[Dict[str, Any]]: + """Load dispatch log from JSON file""" + if not DISPATCH_LOG_FILE.exists(): + return [] + + try: + with open(DISPATCH_LOG_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + return data.get("dispatches", []) + except (json.JSONDecodeError, IOError): + return [] + + +def save_dispatch_log(dispatches: List[Dict[str, Any]]) -> bool: + """Save dispatch log to JSON file""" + try: + # Ensure parent directory exists + DISPATCH_LOG_FILE.parent.mkdir(parents=True, exist_ok=True) + + # Keep last 50 dispatches + dispatches = dispatches[-50:] + + data = { + "last_updated": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "dispatches": dispatches + } + + with open(DISPATCH_LOG_FILE, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except IOError: + return False + + +def log_dispatch(branch: str, pid: Optional[int], status: str, error_msg: Optional[str] = None) -> bool: + """ + Log a dispatch event. + + Args: + branch: Target branch email (e.g., @flow) + pid: Process ID if successful + status: 'spawned' or 'failed' + error_msg: Error message if failed + + Returns: + True if logged successfully + """ + dispatches = load_dispatch_log() + + entry: Dict[str, Any] = { + "branch": branch, + "pid": pid, + "status": status, + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S") + } + + if error_msg: + entry["error"] = error_msg + + dispatches.append(entry) + return save_dispatch_log(dispatches) + + +def check_pid_status(pid: int) -> str: + """ + Check if a PID is still running. + + Returns: + 'RUNNING', 'COMPLETED', or 'UNKNOWN' + """ + try: + result = subprocess.run( + ['ps', '-p', str(pid)], + capture_output=True, + timeout=5 + ) + if result.returncode == 0: + return "RUNNING" + else: + return "COMPLETED" + except (subprocess.SubprocessError, OSError): + return "UNKNOWN" + + +def calculate_age(timestamp_str: str) -> str: + """Calculate human-readable age from timestamp""" + if not timestamp_str: + return "unknown" + + try: + timestamp = datetime.strptime(timestamp_str, "%Y-%m-%d %H:%M:%S") + now = datetime.now() + delta = now - timestamp + + total_seconds = int(delta.total_seconds()) + + if total_seconds < 60: + return f"{total_seconds}s ago" + elif total_seconds < 3600: + minutes = total_seconds // 60 + return f"{minutes}m ago" + elif total_seconds < 86400: + hours = total_seconds // 3600 + return f"{hours}h ago" + else: + days = total_seconds // 86400 + return f"{days}d ago" + except ValueError: + return "unknown" diff --git a/src/aipass/ai_mail/apps/handlers/dispatch/wake.py b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py new file mode 100644 index 00000000..a7ab080b --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/dispatch/wake.py @@ -0,0 +1,532 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: wake.py - Manual Branch Wake Handler +# Date: 2026-03-02 +# Version: 2.0.0 +# Category: ai_mail/handlers/dispatch +# +# CHANGELOG (Max 5 entries): +# - v2.0.0 (2026-03-02): Step-by-step status, dispatch_monitor wrapper, +# zombie cleanup, return-to-sender bounce, liveness check +# - v1.7.0 (2026-03-02): Redirect stderr to log file instead of DEVNULL (debug silent deaths) +# - v1.6.0 (2026-03-02): Manual wake returns success on active lock (agent will process inbox) +# - v1.5.0 (2026-03-02): Manual wake bypasses autonomous_pause; add auto param for daemon use +# - v1.4.0 (2026-03-01): Add AIPASS_SESSION_TYPE env var + session rename for /resume picker +# +# CODE STANDARDS: +# - Handler independence: NO cross-handler or module imports +# - Pure business logic only +# - Uses Prax system_logger (FPLAN-0382) +# ============================================= + +""" +Manual Branch Wake Handler + +Spawns a Claude agent at a target branch using the same logic as daemon.py +but triggered manually via 'drone wake @branch "optional message"'. + +v2.0: Now returns step-by-step status and spawns via dispatch_monitor.py +which handles agent lifecycle (cleanup, bounce emails on failure). +""" + +import json +import os +import sys +import subprocess +import time +from pathlib import Path +from typing import Optional, Tuple, List + +from aipass.prax.apps.modules.logger import system_logger as logger + +# Infrastructure paths +AIPASS_ROOT = Path.home() / "aipass_core" +AIPASS_HOME = Path.home() +CONFIG_FILE = AIPASS_ROOT / "ai_mail" / "safety_config.json" +BRANCH_REGISTRY = AIPASS_HOME / "BRANCH_REGISTRY.json" +PAUSE_FILE = AIPASS_HOME / ".aipass" / "autonomous_pause" +MONITOR_SCRIPT = Path(__file__).parent / "dispatch_monitor.py" + +# Default prompt when no custom message provided +DEFAULT_PROMPT = "Hi. Check inbox, process new emails, update memories when done." + + +# ─── Status Step Tracking ─────────────────────────────── + +class DispatchStatus: + """Collects step-by-step status for a dispatch operation.""" + + def __init__(self): + self.steps: List[Tuple[str, str, str]] = [] # (status, label, detail) + self.success = True + + def ok(self, label: str, detail: str): + """Record a successful step.""" + self.steps.append(("ok", label, detail)) + + def warn(self, label: str, detail: str): + """Record a warning step.""" + self.steps.append(("warn", label, detail)) + + def fail(self, label: str, detail: str): + """Record a failed step and mark overall success as False.""" + self.steps.append(("fail", label, detail)) + self.success = False + + def info(self, label: str, detail: str): + """Record an informational step.""" + self.steps.append(("info", label, detail)) + + def format(self) -> str: + """Format all steps as a multi-line status report with icons.""" + icons = {"ok": "✅", "warn": "⚠️", "fail": "❌", "info": "📨"} + lines = [] + for status, label, detail in self.steps: + icon = icons.get(status, "·") + lines.append(f"{icon} {label} → {detail}") + return "\n".join(lines) + + @property + def summary(self) -> str: + """Single-line summary from last step.""" + if self.steps: + _, label, detail = self.steps[-1] + return f"{label}: {detail}" + return "no status" + + +# ─── Helpers ──────────────────────────────────────────── + +def _read_json(filepath: Path) -> Optional[dict]: + """Read and parse a JSON file, returning None on failure.""" + if not filepath.exists(): + return None + try: + with open(filepath, 'r', encoding='utf-8') as f: + return json.load(f) + except (json.JSONDecodeError, OSError) as e: + logger.warning("[wake] Failed to read %s: %s", filepath, e) + return None + + +def _check_lock(branch_path: Path) -> Optional[dict]: + """Check if branch has an active dispatch lock. Returns lock data or None.""" + lock_file = branch_path / "ai_mail.local" / ".dispatch.lock" + if not lock_file.exists(): + return None + try: + with open(lock_file, 'r', encoding='utf-8') as f: + data = json.load(f) + pid = data.get("pid") + if pid is not None: + try: + os.kill(pid, 0) + return data # Process alive, lock valid + except ProcessLookupError: + logger.info("[wake] Lock PID %s dead — cleaning stale lock", pid) + except PermissionError: + return data # Process exists but can't signal — treat as active + # Stale lock — check age (10 min timeout) + ts = data.get("timestamp", "") + if ts: + try: + from datetime import datetime + lock_time = datetime.fromisoformat(ts) + age = (datetime.now() - lock_time).total_seconds() + if age > 600: + lock_file.unlink(missing_ok=True) + return None + except (ValueError, TypeError): + logger.info("[wake] Unparseable lock timestamp at %s", lock_file) + # Dead process, remove stale lock + lock_file.unlink(missing_ok=True) + return None + except (json.JSONDecodeError, OSError): + return None + + +def _acquire_lock(branch_path: Path, pid: int) -> Tuple[bool, str]: + """Acquire dispatch lock for branch. Atomic creation.""" + lock_file = branch_path / "ai_mail.local" / ".dispatch.lock" + lock_data = { + "pid": pid, + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S"), + "branch": str(branch_path) + } + try: + lock_file.parent.mkdir(parents=True, exist_ok=True) + fd = os.open(str(lock_file), os.O_CREAT | os.O_EXCL | os.O_WRONLY) + with os.fdopen(fd, 'w') as f: + json.dump(lock_data, f, indent=2) + return True, "Lock acquired" + except FileExistsError: + return False, "Lock file already exists" + except OSError as e: + return False, f"Lock failed: {e}" + + +def _load_config() -> dict: + """Load safety config for max_turns.""" + defaults = {"max_turns_per_wake": 50} + config = _read_json(CONFIG_FILE) + if config is None: + return defaults + for key, val in defaults.items(): + if key not in config: + config[key] = val + return config + + +def _set_session_name(branch_path: Path, name: str) -> bool: + """Write custom-title to the most recent Claude session JSONL for a branch.""" + encoded_cwd = str(branch_path).replace("/", "-") + projects_dir = Path.home() / ".claude" / "projects" / encoded_cwd + if not projects_dir.exists(): + return False + jsonl_files = sorted( + projects_dir.glob("*.jsonl"), + key=lambda f: f.stat().st_mtime, + reverse=True + ) + if not jsonl_files: + return False + latest = jsonl_files[0] + session_id = latest.stem + entry = json.dumps({ + "type": "custom-title", + "customTitle": name, + "sessionId": session_id + }) + try: + with open(latest, "a", encoding="utf-8") as f: + f.write(entry + "\n") + return True + except OSError: + return False + + +def _read_session_type(pid_str: str) -> str: + """Read AIPASS_SESSION_TYPE from /proc/{pid}/environ. Returns 'interactive' if unset.""" + try: + with open(f'/proc/{pid_str}/environ', 'rb') as f: + data = f.read() + for entry in data.split(b'\0'): + if entry.startswith(b'AIPASS_SESSION_TYPE='): + return entry.split(b'=', 1)[1].decode('utf-8') + except (OSError, PermissionError): + logger.info("[wake] Cannot read session type for PID %s", pid_str) + return 'interactive' + + +# Session types that should NOT block dispatch (idle/background sessions) +_NON_BLOCKING_SESSION_TYPES = {'telegram', 'dispatched', 'daemon'} + + +def _is_branch_occupied(branch_path: Path) -> bool: + """Check if an interactive Claude session is running in this branch directory.""" + resolved = str(branch_path.resolve()) + try: + result = subprocess.run( + ['pgrep', '-x', 'claude'], + capture_output=True, text=True, timeout=5 + ) + if result.returncode != 0: + return False + for pid_str in result.stdout.strip().split('\n'): + pid_str = pid_str.strip() + if not pid_str: + continue + try: + cwd = os.readlink(f'/proc/{pid_str}/cwd') + if str(Path(cwd).resolve()) == resolved: + session_type = _read_session_type(pid_str) + if session_type not in _NON_BLOCKING_SESSION_TYPES: + return True + except (OSError, PermissionError, ValueError): + logger.info("[wake] Cannot read cwd for PID %s", pid_str) + continue + except (subprocess.SubprocessError, OSError): + logger.info("[wake] Failed to check branch occupancy") + return False + + +def _clean_zombies() -> int: + """Find and report zombie Claude processes. Returns count found.""" + count = 0 + try: + result = subprocess.run( + ['ps', '-eo', 'pid,stat,comm'], + capture_output=True, text=True, timeout=5 + ) + for line in result.stdout.strip().split('\n'): + parts = line.split() + if len(parts) >= 3 and parts[2] == 'claude' and 'Z' in parts[1]: + count += 1 + logger.info("[wake] Found zombie Claude process PID %s", parts[0]) + except (subprocess.SubprocessError, OSError): + logger.info("[wake] Failed to check for zombie processes") + return count + + +def _check_pid_alive(pid: int) -> bool: + """Check if a process is alive (not zombie).""" + try: + os.kill(pid, 0) + # Also verify not zombie + with open(f'/proc/{pid}/status', 'r') as f: + for line in f: + if line.startswith('State:'): + return 'Z' not in line + return True + except (ProcessLookupError, FileNotFoundError): + return False + except PermissionError: + return True # Exists but can't check — assume alive + + +# ─── Branch Resolution ────────────────────────────────── + +def resolve_branch(branch_email: str) -> Optional[Tuple[Path, str]]: + """Resolve a branch email to its filesystem path.""" + email = f"@{branch_email.lstrip('@').lower()}" + registry = _read_json(BRANCH_REGISTRY) + if registry is None: + return None + for branch in registry.get("branches", []): + if branch.get("email", "").lower() == email: + path = Path(branch.get("path", "")) + if path.exists(): + return path, email + return None + return None + + +# ─── Main Wake Function ───────────────────────────────── + +def wake_branch(branch_email: str, custom_message: Optional[str] = None, + fresh: bool = False, auto: bool = False, + sender: str = "@dev_central") -> Tuple[DispatchStatus, bool]: + """ + Spawn a Claude agent at the target branch with step-by-step status. + + Returns: + Tuple of (DispatchStatus with all steps, overall success bool) + """ + status = DispatchStatus() + + # Step 1: Pause check (auto-dispatch only) + if auto and PAUSE_FILE.exists(): + status.fail("pause", "System paused (autonomous_pause active)") + logger.warning("[wake] BLOCKED %s — system paused", branch_email) + return status, False + + # Step 2: Resolve branch + result = resolve_branch(branch_email) + if result is None: + status.fail("resolve", f"Branch not found: {branch_email}") + return status, False + + branch_path, email = result + status.ok("resolve", f"{email} → {branch_path}") + + # Step 3: Zombie check (pre-flight) + zombie_count = _clean_zombies() + if zombie_count > 0: + status.warn("zombies", f"{zombie_count} zombie Claude process(es) detected") + else: + status.ok("pre-flight", "No zombie processes") + + # Step 4: Lock check + existing = _check_lock(branch_path) + if existing is not None: + pid = existing.get("pid", "?") + since = existing.get("timestamp", "?") + if auto: + status.fail("lock", f"Active agent (PID {pid}, since {since})") + logger.warning("[wake] BLOCKED %s — active agent PID %s", email, pid) + return status, False + else: + status.info("lock", f"Agent active (PID {pid}) — email routed to inbox") + status.info("delivery", "Agent will process email during current session") + return status, True + + status.ok("lock", "No active lock — agent is sleeping") + + # Step 5: Occupancy check + if _is_branch_occupied(branch_path): + status.warn("occupancy", f"Interactive Claude session in {branch_path}") + status.fail("blocked", "Cannot spawn — interactive session running") + logger.warning("[wake] BLOCKED %s — interactive session", email) + return status, False + + status.ok("occupancy", "No interactive session") + + # Step 6: Build spawn command + config = _load_config() + max_turns = config.get("max_turns_per_wake", 50) + + lock_file_path = str(branch_path / "ai_mail.local" / ".dispatch.lock") + if custom_message: + prompt = f"Hi. {custom_message} " + else: + prompt = f"{DEFAULT_PROMPT} " + # Monitor handles lock cleanup — agent doesn't need to know about it + prompt += f"IMPORTANT: When finished, delete the dispatch lock file at {lock_file_path}" + + if fresh: + claude_cmd = [ + "claude", "-p", prompt, + "--max-turns", str(max_turns), + "--permission-mode", "bypassPermissions", + "--output-format", "json" + ] + else: + claude_cmd = [ + "claude", "-c", "-p", prompt, + "--max-turns", str(max_turns), + "--permission-mode", "bypassPermissions", + "--output-format", "json" + ] + + # Set session name for /resume picker + branch_name = email.lstrip("@").upper() + session_label = f"{branch_name}-dispatched" + if not fresh: + _set_session_name(branch_path, session_label) + + # Step 7: Spawn via dispatch_monitor + log_dir = branch_path / "ai_mail.local" + log_dir.mkdir(parents=True, exist_ok=True) + stderr_log = str(log_dir / "agent_stderr.log") + + # Build monitor command + monitor_cmd = [ + sys.executable, str(MONITOR_SCRIPT), + email, lock_file_path, sender, stderr_log, + "--", *claude_cmd + ] + + # Prepare environment + spawn_env = os.environ.copy() + spawn_env["AIPASS_SPAWNED"] = "1" + spawn_env["AIPASS_SESSION_TYPE"] = "dispatched" + for key in list(spawn_env.keys()): + if key.startswith("CLAUDE") or key == "AIPASS_BOT_ID": + spawn_env.pop(key) + + try: + process = subprocess.Popen( + monitor_cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + cwd=str(branch_path), + env=spawn_env + ) + + monitor_pid = process.pid + status.ok("spawn", f"Monitor started (PID {monitor_pid})") + + except FileNotFoundError: + status.fail("spawn", "Python or monitor script not found") + return status, False + except Exception as e: + status.fail("spawn", f"{type(e).__name__}: {e}") + return status, False + + # Step 8: Acquire lock (with monitor PID — stays alive as long as agent) + acquired, lock_msg = _acquire_lock(branch_path, monitor_pid) + if not acquired: + status.warn("lock-acquire", f"Lock failed: {lock_msg}") + else: + status.ok("lock-acquire", "Dispatch lock acquired") + + # Step 9: Liveness check (brief wait then verify) + time.sleep(2) + if _check_pid_alive(monitor_pid): + status.ok("alive", f"Agent responding (PID {monitor_pid} alive)") + else: + status.fail("alive", f"Agent died immediately (PID {monitor_pid})") + # Clean up lock + lock_file = branch_path / "ai_mail.local" / ".dispatch.lock" + lock_file.unlink(missing_ok=True) + return status, False + + # Desktop notification + notif_body = custom_message[:80] if custom_message else "Manual wake: check inbox" + try: + subprocess.run( + ["notify-send", "-i", "dialog-information", f"Wake → {email}", notif_body], + capture_output=True, timeout=5 + ) + except (subprocess.SubprocessError, FileNotFoundError, OSError): + logger.info("[wake] Desktop notification unavailable") + + return status, True + + +# ─── Legacy wrapper for backward compatibility ────────── + +def wake_branch_legacy(branch_email: str, custom_message: Optional[str] = None, + fresh: bool = False, auto: bool = False) -> Tuple[bool, str]: + """Legacy interface returning (bool, str) for callers not yet updated.""" + dispatch_status, success = wake_branch(branch_email, custom_message, fresh, auto) + return success, dispatch_status.summary + + +# ─── CLI Entry Point ───────────────────────────────────── + +if __name__ == "__main__": + args = sys.argv[1:] + + if not args or args[0] in ("--help", "-h"): + print("Usage: wake.py [--fresh] [--auto] [--sender @branch] @branch [\"optional message\"]") + print(" Manually spawn a Claude agent at a branch (daemon not required)") + print() + print("Flags:") + print(" --fresh Start fresh session (claude -p) instead of resuming (claude -c -p)") + print(" --auto Respect autonomous_pause (used by daemon). Manual wake ignores it.") + print(" --sender @branch Set return-to-sender for bounce emails (default: @dev_central)") + print() + print("Output: Step-by-step status of the dispatch pipeline:") + print(" ✅ resolve → @branch found at /path/to/branch") + print(" ✅ lock → No active lock — agent is sleeping") + print(" ✅ spawn → Monitor started (PID 12345)") + print(" ✅ alive → Agent responding (PID 12345 alive)") + print() + print("On failure, a bounce email is sent to --sender automatically.") + print() + print("Examples:") + print(" wake.py @flow # Default: check inbox (resume)") + print(" wake.py --fresh @flow # Fresh session, check inbox") + print(" wake.py @vera \"Review NOTEPAD\" # Custom prompt (resume)") + print(" wake.py --fresh --sender @vera @seed # Fresh, bounce to @vera") + sys.exit(0) + + # Parse flags + use_fresh = "--fresh" in args + use_auto = "--auto" in args + use_sender = "@dev_central" + + if "--sender" in args: + idx = args.index("--sender") + if idx + 1 < len(args): + use_sender = args[idx + 1] + args = args[:idx] + args[idx + 2:] + + args = [a for a in args if a not in ("--fresh", "--auto")] + + if not args: + print("❌ Missing branch argument. Use --help for usage.") + sys.exit(1) + + branch = args[0] + message = args[1] if len(args) > 1 else None + + dispatch_status, success = wake_branch( + branch, message, fresh=use_fresh, auto=use_auto, sender=use_sender + ) + print(dispatch_status.format()) + sys.exit(0 if success else 1) diff --git a/src/aipass/ai_mail/apps/handlers/email/__init__.py b/src/aipass/ai_mail/apps/handlers/email/__init__.py new file mode 100644 index 00000000..c2e146b5 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/__init__.py @@ -0,0 +1 @@ +"""Email Handlers - Email delivery, creation, and formatting for AI_Mail""" diff --git a/src/aipass/ai_mail/apps/handlers/email/create.py b/src/aipass/ai_mail/apps/handlers/email/create.py new file mode 100644 index 00000000..fc29afdd --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/create.py @@ -0,0 +1,194 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: create.py - Email File Creation Handler +# Date: 2025-11-15 +# Version: 1.2.0 +# Category: ai_mail/handlers/email +# +# CHANGELOG (Max 5 entries): +# - v1.4.0 (2026-02-04): Add auto-purge trigger after email creation +# - v1.3.0 (2026-01-31): Add dispatched_to field for reply chain validation +# - v1.2.0 (2026-01-29): Add auto-footer to all outgoing emails +# - v1.1.0 (2026-01-29): Add reply_to parameter for redirecting replies +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Uses Prax system_logger (FPLAN-0382) +# - Pure business logic only +# ============================================= + +""" +Email File Creation Handler + +Handles creation and storage of email files in sent folders. +Independent handler - no module dependencies. +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict + +from aipass.prax.apps.modules.logger import system_logger as logger + +# Lazy imports +_console = None +_append_footer = None + + +def _get_console(): + """Lazy import console.""" + global _console + if _console is None: + from aipass.cli.apps.modules import console + _console = console + return _console + + +def _get_append_footer(): + """Lazy import append_footer.""" + global _append_footer + if _append_footer is None: + from aipass.ai_mail.apps.handlers.email.footer import append_footer + _append_footer = append_footer + return _append_footer + + +def create_email_file(to_branch: str, subject: str, message: str, user_info: Dict, reply_to: str | None = None, dispatched_to: str | None = None) -> Path: + """ + Create email file and save to sent folder. + + Args: + to_branch: Recipient email address (e.g., "@admin" or "all") + subject: Email subject line + message: Email body text + user_info: User information dict with keys: + - email_address: Sender email address + - display_name: Sender display name + - timestamp_format: Datetime format string (default: "%Y-%m-%d %H:%M:%S") + - mailbox_path: Path to user's mailbox directory + reply_to: Optional branch address where replies should go instead of sender + dispatched_to: Branch address where dispatch was sent (for reply chain validation) + + Returns: + Path to created email file in sent folder + """ + timestamp = datetime.now() + timestamp_str = timestamp.strftime(user_info.get("timestamp_format", "%Y-%m-%d %H:%M:%S")) + + # Append standard footer to message + message_with_footer = _get_append_footer()(message) + + # Create email data structure + email_data = { + "from": user_info["email_address"], + "from_name": user_info["display_name"], + "to": to_branch, + "subject": subject, + "message": message_with_footer, + "timestamp": timestamp_str, + "status": "sent" + } + + # Add reply_to if specified (for redirecting replies to different branch) + if reply_to: + email_data["reply_to"] = reply_to + + # Add dispatched_to for reply chain validation (tracks original dispatch recipient) + if dispatched_to: + email_data["dispatched_to"] = dispatched_to + + # Create filename (safe, no special chars) + safe_subject = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in subject) + safe_subject = safe_subject[:50].strip() # Limit length + filename = f"{timestamp.strftime('%Y%m%d_%H%M%S')}_{safe_subject}.json" + + # Save to sent folder + mailbox_path = Path(user_info["mailbox_path"]) + sent_folder = mailbox_path / "sent" + sent_folder.mkdir(parents=True, exist_ok=True) + + email_file = sent_folder / filename + with open(email_file, 'w', encoding='utf-8') as f: + json.dump(email_data, f, indent=2) + + # Trigger auto-purge if sent folder exceeds threshold + _trigger_sent_purge(mailbox_path) + + return email_file + + +def _trigger_sent_purge(mailbox_path: Path) -> None: + """ + Trigger auto-purge of sent folder if threshold exceeded. + + Non-blocking - failures silently ignored. + """ + try: + from aipass.ai_mail.apps.handlers.email.purge import purge_sent_folder + purge_sent_folder(mailbox_path) + except Exception: + pass # Silent fail - purge is best-effort + + +def load_email_file(email_file: Path) -> Dict | None: + """ + Load email data from file. + + Args: + email_file: Path to email JSON file + + Returns: + Email data dict or None if file cannot be read + """ + if not email_file.exists(): + return None + + try: + with open(email_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return None + + +def sanitize_subject(subject: str, max_length: int = 50) -> str: + """ + Sanitize email subject for use in filenames. + + Args: + subject: Original email subject + max_length: Maximum length for sanitized subject + + Returns: + Sanitized subject string safe for filenames + """ + safe_subject = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in subject) + safe_subject = safe_subject[:max_length].strip() + return safe_subject + + +if __name__ == "__main__": + c = _get_console() + c.print("\n" + "="*70) + c.print("EMAIL FILE CREATION HANDLER") + c.print("="*70) + c.print("\nPURPOSE:") + c.print(" Creates and stores email files in sent folders") + c.print() + c.print("FUNCTIONS PROVIDED:") + c.print(" - create_email_file(to_branch, subject, message, user_info) -> Path") + c.print(" - load_email_file(email_file) -> Dict | None") + c.print(" - sanitize_subject(subject, max_length) -> str") + c.print() + c.print("HANDLER CHARACTERISTICS:") + c.print(" ✓ Independent - no module dependencies") + c.print(" ✓ Pure business logic") + c.print(" ✗ CANNOT import parent modules") + c.print() + c.print("USAGE FROM MODULES:") + c.print(" from ai_mail.apps.handlers.email.create import create_email_file") + c.print(" from ai_mail.apps.handlers.email.create import load_email_file") + c.print() + c.print("="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/dashboard_sync.py b/src/aipass/ai_mail/apps/handlers/email/dashboard_sync.py new file mode 100644 index 00000000..de5f5020 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/dashboard_sync.py @@ -0,0 +1,200 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: dashboard_sync.py - Dashboard Write-Through Helper +# Date: 2026-02-25 +# Version: 1.0.0 +# Category: ai_mail/handlers/email +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-25): FPLAN-0373 Phase 2 - dashboard write-through for ai_mail +# Reads inbox.json, calculates section data, calls write_section() +# +# CODE STANDARDS: +# - Handler independence: NO cross-handler or module imports +# - Can import AIPASS central services (devpulse write_section) +# - No logger calls (module logs for handler) +# - Pure business logic only +# - BYPASS: Direct json.load required for reading inbox.json data files +# - Dashboard failures never raised to caller +# ============================================= + +""" +Dashboard Write-Through Helper + +Reads a branch's inbox.json and pushes the ai_mail section to +DASHBOARD.local.json via devpulse write_section() API. + +Called as a side-effect after email operations (deliver, close, view, inbox). +Failures never propagate - email operations must not break due to dashboard. +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, Optional + +# Lazy-loaded write_section reference +_write_section = None + + +def _get_write_section(): + """Lazy import write_section from devpulse module API.""" + global _write_section + if _write_section is None: + from aipass.devpulse.apps.modules.dashboard import write_section + _write_section = write_section + return _write_section + + +def _human_readable_age(seconds: float) -> str: + """ + Convert seconds to human-readable age string. + + Args: + seconds: Number of seconds + + Returns: + Human-readable string like "2 hours", "3 days", "1 week" + """ + if seconds < 60: + return "just now" + elif seconds < 3600: + mins = int(seconds / 60) + return f"{mins} minute{'s' if mins != 1 else ''}" + elif seconds < 86400: + hours = int(seconds / 3600) + return f"{hours} hour{'s' if hours != 1 else ''}" + elif seconds < 604800: + days = int(seconds / 86400) + return f"{days} day{'s' if days != 1 else ''}" + elif seconds < 2592000: + weeks = int(seconds / 604800) + return f"{weeks} week{'s' if weeks != 1 else ''}" + else: + months = int(seconds / 2592000) + return f"{months} month{'s' if months != 1 else ''}" + + +def _calculate_section_data(inbox_data: Dict) -> Dict: + """ + Calculate ai_mail dashboard section from inbox data. + + Args: + inbox_data: Parsed inbox.json dict with "messages" list + + Returns: + Section data dict with new, opened, total, oldest_unread_age, + last_dispatch_received + """ + messages = inbox_data.get("messages", []) + now = datetime.now() + + new_count = 0 + opened_count = 0 + oldest_unread_ts: Optional[datetime] = None + last_dispatch_ts: Optional[str] = None + + for msg in messages: + status = msg.get("status") + is_new = status == "new" or (status is None and not msg.get("read", False)) + is_opened = status == "opened" + + if is_new: + new_count += 1 + # Track oldest unread timestamp + ts_str = msg.get("timestamp", "") + if ts_str: + try: + msg_ts = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S") + if oldest_unread_ts is None or msg_ts < oldest_unread_ts: + oldest_unread_ts = msg_ts + except (ValueError, TypeError): + pass # Malformed timestamp - skip for age calculation + + if is_opened: + opened_count += 1 + + # Track most recent dispatch email + if msg.get("auto_execute", False): + ts_str = msg.get("timestamp", "") + if ts_str: + if last_dispatch_ts is None or ts_str > last_dispatch_ts: + last_dispatch_ts = ts_str + + total_count = len(messages) + + # Calculate oldest unread age + oldest_unread_age: Optional[str] = None + if oldest_unread_ts is not None: + age_seconds = (now - oldest_unread_ts).total_seconds() + oldest_unread_age = _human_readable_age(age_seconds) + + # Convert last_dispatch_received to ISO format if present + last_dispatch_iso: Optional[str] = None + if last_dispatch_ts: + try: + dt = datetime.strptime(last_dispatch_ts, "%Y-%m-%d %H:%M:%S") + last_dispatch_iso = dt.isoformat() + except (ValueError, TypeError): + last_dispatch_iso = last_dispatch_ts + + return { + "managed_by": "ai_mail", + "new": new_count, + "opened": opened_count, + "total": total_count, + "oldest_unread_age": oldest_unread_age, + "last_dispatch_received": last_dispatch_iso + } + + +def push_dashboard_update(branch_path: Path) -> bool: + """ + Read branch inbox and push ai_mail section to dashboard. + + This is the primary public function. It: + 1. Reads the branch's ai_mail.local/inbox.json + 2. Calculates section data (new, opened, total, oldest_unread_age, etc.) + 3. Calls write_section() to update DASHBOARD.local.json + + Failures are silently caught - email operations must not break. + + Args: + branch_path: Path to branch root directory + + Returns: + True if update succeeded, False on any error (never raises) + """ + try: + branch_path = Path(branch_path) + + # Determine inbox path + if branch_path == Path("/") or branch_path == Path.home(): + inbox_file = Path.home() / "ai_mail.local" / "inbox.json" + else: + inbox_file = branch_path / "ai_mail.local" / "inbox.json" + + # Read inbox (BYPASS: direct json.load - this is a data file, not a template) + if not inbox_file.exists(): + # No inbox = all zeros + section_data = { + "managed_by": "ai_mail", + "new": 0, + "opened": 0, + "total": 0, + "oldest_unread_age": None, + "last_dispatch_received": None + } + else: + with open(inbox_file, 'r', encoding='utf-8') as f: + inbox_data = json.load(f) + section_data = _calculate_section_data(inbox_data) + + write_section = _get_write_section() + return write_section(branch_path, "ai_mail", section_data) + + except Exception: + # Dashboard write failure - silent, never raise + return False diff --git a/src/aipass/ai_mail/apps/handlers/email/delivery.py b/src/aipass/ai_mail/apps/handlers/email/delivery.py new file mode 100644 index 00000000..4ce7fedc --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/delivery.py @@ -0,0 +1,512 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: delivery.py - Email Delivery Handler +# Date: 2025-12-02 +# Version: 3.0.0 +# Category: ai_mail/handlers/email +# +# CHANGELOG (Max 5 entries): +# - v3.1.0 (2026-02-25): Private branch inbound email blocking (DPLAN-035 email isolation) +# - v3.0.0 (2026-02-17): Remove spawn logic — delivery is write-only, daemon handles all spawning +# - v2.4.0 (2026-02-10): Seed compliance - remove logger calls, cross-handler imports, fix naming, add json_handler +# - v2.3.0 (2026-02-10): Phase 3 polish - concise bounce messages, dispatch chain logging, hardened loop detection +# - v2.2.0 (2026-02-10): DEV_CENTRAL dispatch protection, notification throttling, self-reply loop detection +# +# CODE STANDARDS: +# - Handler independence: NO cross-handler or module imports +# - No logger calls (module logs for handler) +# - Pure business logic only +# - Uses json_handler for JSON operations +# ============================================= + +""" +Email Delivery Handler + +Handles delivery of emails to branch inboxes. +Independent handler - no module dependencies. +""" + +import json +import uuid +import subprocess +from pathlib import Path +from typing import Dict, Tuple, List, Optional, Callable + +from aipass.ai_mail.apps.handlers.json_utils.json_handler import load_json, save_json + +# Lazy imports to avoid circular dependencies +_CONSOLE = None +_INBOX_LOCK = None + + +def _get_inbox_lock(): + """Lazy import inbox_lock context manager.""" + global _INBOX_LOCK + if _INBOX_LOCK is None: + from aipass.ai_mail.apps.handlers.email.inbox_lock import inbox_lock + _INBOX_LOCK = inbox_lock + return _INBOX_LOCK + + +def _get_console(): + """Lazy import console.""" + global _CONSOLE + if _CONSOLE is None: + from aipass.cli.apps.modules import console + _CONSOLE = console + return _CONSOLE + + +def get_all_branches() -> List[Dict]: + """ + Get list of all branches for email routing. + Reads from AIPass branch registry at /home/aipass/BRANCH_REGISTRY.json + + Returns: + List of dicts with branch info: + [{"name": "AIPASS.admin", "path": "/", "email": "@admin"}, ...] + """ + registry_file = Path("/home/aipass/BRANCH_REGISTRY.json") + branches = [] + + if not registry_file.exists(): + return [] + + try: + with open(registry_file, 'r', encoding='utf-8') as f: + registry_data = json.load(f) + + # Parse branch entries from JSON structure + for branch in registry_data.get("branches", []): + branch_name = branch.get("name", "") + path = branch.get("path", "") + + if not branch_name or not path: + continue + + # Use explicit email from registry if present (preferred) + # Fall back to derivation only if email field is missing + explicit_email = branch.get("email", "") + if explicit_email: + email = explicit_email + else: + # Legacy fallback: derive email from branch name + if '.' in branch_name: + email_part = branch_name.split('.')[-1].lower() + elif ' ' in branch_name: + email_part = branch_name.split()[0].lower() + elif '-' in branch_name and branch_name.split('-')[0] == 'AIPASS': + email_part = branch_name.split('-', 1)[1].lower() + else: + email_part = branch_name.split('-')[0].lower() + email = f"@{email_part}" + + branches.append({ + "name": branch_name, + "path": path, + "email": email + }) + + # COLLISION DETECTION: Check for duplicate email addresses + email_map = {} + collisions = [] + for branch in branches: + if branch["email"] in email_map: + collision_msg = f"Email collision: {branch['email']} used by both '{email_map[branch['email']]}' and '{branch['name']}'" + collisions.append(collision_msg) + else: + email_map[branch["email"]] = branch["name"] + + return branches + + except Exception: + return [] + + +def _migrate_inbox_format(inbox_data: Dict, inbox_file: Path) -> Dict: + """ + Auto-migrate old inbox format to v2 schema. + + Old format: {"inbox": [...]} + New format: {"mailbox": "inbox", "total_messages": N, "unread_count": N, "messages": [...]} + + Migrates in-place and persists to disk if changes were made. + + Args: + inbox_data: Loaded inbox dict (may be old or new format) + inbox_file: Path to inbox.json (for persisting migration) + + Returns: + Migrated inbox data dict with v2 schema + """ + migrated = False + + # Case 0: inbox_data is a list instead of a dict (corrupted/malformed inbox.json) + if isinstance(inbox_data, list): + inbox_data = {"messages": inbox_data} + migrated = True + + # Case 1: Old format with "inbox" key instead of "messages" + if "inbox" in inbox_data and "messages" not in inbox_data: + old_messages = inbox_data.pop("inbox", []) + inbox_data["messages"] = old_messages if isinstance(old_messages, list) else [] + migrated = True + + # Case 2: Missing "messages" key entirely + if "messages" not in inbox_data: + inbox_data["messages"] = [] + migrated = True + + # Ensure v2 metadata fields exist + if "mailbox" not in inbox_data: + inbox_data["mailbox"] = "inbox" + migrated = True + + if "total_messages" not in inbox_data: + inbox_data["total_messages"] = len(inbox_data["messages"]) + migrated = True + + if "unread_count" not in inbox_data: + inbox_data["unread_count"] = sum( + 1 for msg in inbox_data["messages"] + if msg.get("status") == "new" or (msg.get("status") is None and not msg.get("read", False)) + ) + migrated = True + + # Persist migration to disk + if migrated: + try: + with open(inbox_file, 'w', encoding='utf-8') as f: + json.dump(inbox_data, f, indent=2, ensure_ascii=False) + except Exception: + return inbox_data + + return inbox_data + + +def _is_private_branch_email(email: str) -> bool: + """Check if email belongs to a private branch. + + Reads PRIVATE_BRANCH_REGISTRY.json to determine if the given + email address is registered to a private (isolated) branch. + + Args: + email: Email address to check (e.g., "@patrick_private") + + Returns: + True if email belongs to a private branch, False otherwise + """ + registry_path = Path.home() / "PRIVATE_BRANCH_REGISTRY.json" + if not registry_path.exists(): + return False + try: + with open(registry_path, 'r', encoding='utf-8') as f: + registry = json.load(f) + for branch in registry.get("branches", []): + if branch.get("email", "") == email: + return True + except (json.JSONDecodeError, IOError): + pass + return False + + +def deliver_email_to_branch( + to_branch: str, + email_data: Dict, + on_delivered: Optional[Callable] = None +) -> Tuple[bool, str]: + """ + Deliver email to target branch's ai_mail.local/inbox.json file. + + Appends message to inbox JSON messages array. + + Args: + to_branch: Target email address (e.g., "@admin") + email_data: Email data dict with keys: + - from: Sender email address + - from_name: Sender display name + - to: Recipient email address + - subject: Email subject + - message: Email body + - timestamp: Email timestamp string + on_delivered: Optional callback(branch_path, new_count, opened_count, total) + for post-delivery actions (dashboard updates, central sync, etc.) + + Returns: + Tuple of (success: bool, error_message: str) + error_message is empty string if successful + """ + # Handle path input from DRONE's @ resolution + if to_branch.startswith('/'): + branches_list = get_all_branches() + path_to_email = {b["path"]: b["email"] for b in branches_list} + if to_branch in path_to_email: + to_branch = path_to_email[to_branch] + else: + # Stage 2: Longest-path-first prefix matching against registry + sorted_branches = sorted(branches_list, key=lambda b: len(b['path']), reverse=True) + matched = False + for b in sorted_branches: + if to_branch.startswith(b['path'] + '/') or to_branch == b['path']: + to_branch = b['email'] + matched = True + break + if not matched: + return False, f"Could not resolve path to email: {to_branch}" + + # Map email address to branch path + all_branches = get_all_branches() + branches = {b["email"]: b["path"] for b in all_branches} + + if to_branch not in branches: + error_msg = f"Unknown branch email: {to_branch} (available: {len(branches)} branches)" + return False, error_msg + + # Private branch inbound blocking: reject delivery to private branches + # Self-send is allowed (private branch can send to itself) + sender_email = email_data.get('from', '') + if _is_private_branch_email(to_branch) and sender_email != to_branch: + return False, f"Cannot deliver to private branch: {to_branch}" + + branch_path = Path(branches[to_branch]) + + # Find the branch's ai_mail.local/inbox.json file + if branch_path == Path("/") or branch_path == Path.home(): + inbox_file = Path.home() / "ai_mail.local" / "inbox.json" + else: + inbox_file = branch_path / "ai_mail.local" / "inbox.json" + + if not inbox_file.exists(): + # Auto-provision inbox for new branches (self-healing) + try: + mailbox_dir = inbox_file.parent + mailbox_dir.mkdir(parents=True, exist_ok=True) + (mailbox_dir / "sent").mkdir(exist_ok=True) + inbox_data_init = { + "mailbox": "inbox", + "total_messages": 0, + "unread_count": 0, + "messages": [] + } + with open(inbox_file, 'w', encoding='utf-8') as f: + json.dump(inbox_data_init, f, indent=2) + except Exception as e: + return False, f"Failed to auto-provision inbox for {to_branch}: {e}" + + # Lock inbox.json for the entire read-modify-write cycle + try: + with _get_inbox_lock()(inbox_file): + try: + with open(inbox_file, 'r', encoding='utf-8') as f: + inbox_data = json.load(f) + except Exception as e: + return False, f"Failed to read inbox: {e}" + + # Auto-migrate old inbox format {"inbox": []} -> v2 schema + inbox_data = _migrate_inbox_format(inbox_data, inbox_file) + + # Create message object (v2 schema: status instead of read) + message = { + "id": str(uuid.uuid4())[:8], + "timestamp": email_data['timestamp'], + "from": email_data['from'], + "from_name": email_data['from_name'], + "subject": email_data['subject'], + "message": email_data['message'], + "status": "new", + "auto_execute": email_data.get('auto_execute', False), + "priority": email_data.get('priority', 'normal') + } + + if email_data.get('reply_to'): + message["reply_to"] = email_data['reply_to'] + + if email_data.get('dispatched_to'): + message["dispatched_to"] = email_data['dispatched_to'] + + # Prepend message to inbox (newest first) + inbox_data["messages"].insert(0, message) + inbox_data["total_messages"] = len(inbox_data["messages"]) + messages = inbox_data["messages"] + new_count = sum( + 1 for msg in messages + if msg.get("status") == "new" or (msg.get("status") is None and not msg.get("read", False)) + ) + opened_count = sum(1 for msg in messages if msg.get("status") == "opened") + inbox_data["unread_count"] = new_count + + try: + with open(inbox_file, 'w', encoding='utf-8') as f: + json.dump(inbox_data, f, indent=2, ensure_ascii=False) + except Exception as e: + return False, f"Failed to write inbox: {e}" + + except OSError as e: + return False, f"Failed to acquire inbox lock: {e}" + + # Send desktop notification for new email + _send_desktop_notification(email_data['from'], to_branch, email_data['subject'], email_data.get('message', '')) + + # Invoke post-delivery callback (dashboard updates, central sync, etc.) + if on_delivered: + try: + on_delivered(branch_path, new_count, opened_count, inbox_data["total_messages"]) + except Exception: + return True, "" + + return True, "" + + +def _get_summary_file_path(branch_path: Path) -> Path: + """ + Get the summary file path for a branch. + + Pattern: [BRANCH_NAME].ai_mail.json + Example: /home/aipass/aipass_core/drone/DRONE.ai_mail.json + + Args: + branch_path: Path to branch directory + + Returns: + Path to summary file + """ + branch_name = branch_path.name.upper() + + if branch_path == Path("/") or branch_path == Path.home(): + branch_name = "AIPASS" + + summary_file = branch_path / f"{branch_name}.ai_mail.json" + return summary_file + + +def _update_summary_file(summary_file: Path, message: Dict, total: int, unread: int) -> None: + """ + Update branch summary file with new email data. + + Updates: + - summary.inbox.total + - summary.inbox.unread + - summary.inbox.recent_preview (adds message preview) + + Args: + summary_file: Path to summary JSON file + message: Message dict to add to preview + total: Total inbox message count + unread: Unread message count + """ + try: + with open(summary_file, 'r', encoding='utf-8') as f: + summary_data = json.load(f) + + if "summary" not in summary_data: + summary_data["summary"] = {} + if "inbox" not in summary_data["summary"]: + summary_data["summary"]["inbox"] = {} + + summary_data["summary"]["inbox"]["total"] = total + summary_data["summary"]["inbox"]["unread"] = unread + + if "recent_preview" not in summary_data["summary"]["inbox"]: + summary_data["summary"]["inbox"]["recent_preview"] = [] + + message_words = message["message"].split()[:15] + preview = { + "from": message["from"], + "subject": message["subject"], + "summary": " ".join(message_words) + ("..." if len(message["message"].split()) > 15 else ""), + "timestamp": message["timestamp"], + "status": "new", + "message_id": message["id"] + } + + summary_data["summary"]["inbox"]["recent_preview"].insert(0, preview) + summary_data["summary"]["inbox"]["recent_preview"] = summary_data["summary"]["inbox"]["recent_preview"][:5] + + with open(summary_file, 'w', encoding='utf-8') as f: + json.dump(summary_data, f, indent=2, ensure_ascii=False) + + except Exception: + return + + +_NOTIFICATION_TIMESTAMPS: Dict[str, List[float]] = {} + +# Rate limit: max notifications per recipient within time window +_NOTIFICATION_MAX = 3 +_NOTIFICATION_WINDOW = 30.0 # seconds + + +def _send_desktop_notification(sender: str, recipient: str, subject: str, message: str = "") -> None: + """ + Send desktop notification for new email using notify-send. + + Rate-limited: max 3 notifications per recipient within 30 seconds. + Gracefully handles cases where notify-send is not available. + + Args: + sender: Email sender address (e.g., @dev_central) + recipient: Email recipient address (e.g., @ai_mail) + subject: Email subject line + message: Email body (first ~100 chars shown in notification) + """ + import time + + now = time.time() + cutoff = now - _NOTIFICATION_WINDOW + + if recipient in _NOTIFICATION_TIMESTAMPS: + _NOTIFICATION_TIMESTAMPS[recipient] = [ + t for t in _NOTIFICATION_TIMESTAMPS[recipient] if t > cutoff + ] + else: + _NOTIFICATION_TIMESTAMPS[recipient] = [] + + if len(_NOTIFICATION_TIMESTAMPS[recipient]) >= _NOTIFICATION_MAX: + return + + # Build informative notification + sender_name = sender.replace('@', '').upper() + recipient_name = recipient.replace('@', '').upper() + title = f"{sender_name} -> {recipient_name}" + body = subject + if message: + preview = message[:100].replace('\n', ' ').strip() + if preview: + body = f"{subject}\n{preview}" + + try: + subprocess.run( + ['notify-send', title, body], + capture_output=True, + timeout=5 + ) + _NOTIFICATION_TIMESTAMPS[recipient].append(now) + except (subprocess.SubprocessError, FileNotFoundError, OSError): + return + + +if __name__ == "__main__": + console = _get_console() + console.print("\n" + "="*70) + console.print("EMAIL DELIVERY HANDLER") + console.print("="*70) + console.print("\nPURPOSE:") + console.print(" Delivers emails to branch inboxes") + console.print() + console.print("FUNCTIONS PROVIDED:") + console.print(" - get_all_branches() -> List[Dict]") + console.print(" - deliver_email_to_branch(to_branch, email_data) -> Tuple[bool, str]") + console.print() + console.print("HANDLER CHARACTERISTICS:") + console.print(" - Independent - no module dependencies") + console.print(" - Uses lazy imports for services") + console.print(" - Pure business logic") + console.print(" - CANNOT import parent modules") + console.print() + console.print("USAGE FROM MODULES:") + console.print(" from ai_mail.apps.handlers.email.delivery import deliver_email_to_branch") + console.print(" from ai_mail.apps.handlers.email.delivery import get_all_branches") + console.print() + console.print("="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/footer.py b/src/aipass/ai_mail/apps/handlers/email/footer.py new file mode 100644 index 00000000..73c9567f --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/footer.py @@ -0,0 +1,87 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: footer.py - Email Footer Handler +# Date: 2026-01-29 +# Version: 1.0.0 +# Category: ai_mail/handlers/email +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-01-29): Created - auto-footer for all outgoing emails +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Can import Prax modules (service providers) +# - Pure business logic only +# ============================================= + +""" +Email Footer Handler + +Generates and appends standard footer to all outgoing emails. +Reminds branches of process steps: Seed audit, memory update, FPLAN close, confirmation. +Independent handler - no module dependencies. +""" + +# Standard footer for all outgoing emails +STANDARD_FOOTER = """ +--- +⚠️ TASK CHECKLIST (before marking complete): +□ SEED CHECK → drone @seed audit @branch (80%+) +□ UPDATE MEMORIES → Your .local.json records this work +□ CLOSE FPLAN → drone @flow close +□ CONFIRM → Reply with completion summary + +Memories = Presence. No update = No learning. +---""" + + +def get_footer() -> str: + """ + Get the standard email footer. + + Returns: + Standard footer string for all outgoing emails + """ + return STANDARD_FOOTER + + +def append_footer(message: str) -> str: + """ + Append standard footer to email message. + + Args: + message: Original email message body + + Returns: + Message with footer appended + """ + return message + get_footer() + + +if __name__ == "__main__": + print("\n" + "="*70) + print("EMAIL FOOTER HANDLER") + print("="*70) + print("\nPURPOSE:") + print(" Generates standard footer for all outgoing emails") + print() + print("FUNCTIONS PROVIDED:") + print(" - get_footer() -> str") + print(" - append_footer(message) -> str") + print() + print("FOOTER CONTENT:") + print(STANDARD_FOOTER) + print() + print("HANDLER CHARACTERISTICS:") + print(" ✓ Independent - no module dependencies") + print(" ✓ Can import Prax (service provider)") + print(" ✓ Pure business logic") + print(" ✗ CANNOT import parent modules") + print() + print("USAGE FROM MODULES:") + print(" from ai_mail.apps.handlers.email.footer import append_footer") + print(" from ai_mail.apps.handlers.email.footer import get_footer") + print() + print("="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/format.py b/src/aipass/ai_mail/apps/handlers/email/format.py new file mode 100644 index 00000000..193a25ab --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/format.py @@ -0,0 +1,245 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: format.py - Email Formatting Handler +# Date: 2025-11-15 +# Version: 1.1.0 +# Category: ai_mail/handlers/email +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2026-02-21): DPLAN-027 - Show branch alias in sender display +# - v1.0.0 (2025-11-15): Created - email formatting and display utilities +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Uses Prax system_logger (FPLAN-0382) +# - Pure business logic only +# ============================================= + +""" +Email Formatting Handler + +Handles email display formatting, preview generation, and text utilities. +Independent handler - no module dependencies. +""" + +import json +from pathlib import Path +from typing import Dict, Optional + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console + +REGISTRY_PATH = Path.home() / "BRANCH_REGISTRY.json" + + +def lookup_branch_alias(branch_name: str) -> Optional[str]: + """ + Look up a branch's alias from BRANCH_REGISTRY.json. + + Args: + branch_name: Branch display name (e.g., "TEAM_1", "VERA") + + Returns: + Alias string if set, None if empty or not found + """ + try: + registry = json.loads(REGISTRY_PATH.read_text()) + for branch in registry.get("branches", []): + if branch.get("name") == branch_name: + alias = branch.get("alias", "") + return alias if alias else None + return None + except Exception: + return None + + +def format_sender_display(from_name: str, from_addr: str) -> str: + """ + Format sender display with alias if available. + + Shows: "Alias (@branch)" when alias is set + Falls back to: "BRANCH_NAME (@branch)" when no alias + + Args: + from_name: Sender display name (e.g., "TEAM_1") + from_addr: Sender email address (e.g., "@team_1") + + Returns: + Formatted sender string + """ + alias = lookup_branch_alias(from_name) + if alias: + return f"{alias} ({from_addr})" + return f"{from_name} ({from_addr})" + + +def format_email_preview(message: str, max_length: int = 100) -> str: + """ + Format email message as preview text. + + Args: + message: Full email message text + max_length: Maximum preview length (default: 100) + + Returns: + Preview text with ellipsis if truncated + """ + if len(message) <= max_length: + return message + + return message[:max_length] + "..." + + +def format_email_header(email_data: Dict) -> str: + """ + Format email header for display. + + Args: + email_data: Email data dict with keys: + - from_name: Sender display name + - from: Sender email address + - timestamp: Email timestamp + - subject: Email subject + + Returns: + Formatted header string + """ + sender = format_sender_display( + email_data.get('from_name', 'Unknown'), + email_data.get('from', 'unknown') + ) + lines = [ + "=" * 70, + f"From: {sender}", + f"Date: {email_data.get('timestamp', 'Unknown')}", + f"Subject: {email_data.get('subject', 'No Subject')}", + "=" * 70 + ] + return "\n".join(lines) + + +def format_email_list_item(index: int, email_data: Dict, show_unread: bool = True) -> str: + """ + Format email as list item for inbox/sent display. + + Args: + index: Item number in list + email_data: Email data dict + show_unread: Whether to show unread marker (default: True) + + Returns: + Formatted list item string + """ + lines = [] + + # Unread marker + ID for copy-paste + msg_id = email_data.get('id', '????????') + if show_unread: + # v2: check status first, fall back to read for backward compat + status = email_data.get("status") + is_new = status == "new" if status else not email_data.get("read", False) + unread_marker = "📨" if is_new else "📬" + sender = format_sender_display( + email_data.get('from_name', 'Unknown'), + email_data.get('from', 'unknown') + ) + lines.append(f"\n{index}. {unread_marker} \\[{msg_id}] From: {sender} @ {email_data.get('timestamp', 'Unknown')}") + else: + lines.append(f"\n{index}. \\[{msg_id}] To: {email_data.get('to', 'Unknown')} @ {email_data.get('timestamp', 'Unknown')}") + + lines.append(f" Subject: {email_data.get('subject', 'No Subject')}") + + # Preview + message = email_data.get('message', '') + preview = format_email_preview(message, 100) + lines.append(f" {preview}") + + return "\n".join(lines) + + +def format_inbox_summary(total_messages: int, unread_count: int) -> str: + """ + Format inbox summary statistics. + + Args: + total_messages: Total number of messages + unread_count: Number of unread messages + + Returns: + Formatted summary string + """ + return f"📊 Total: {total_messages} messages ({unread_count} unread)" + + +def format_branch_email(branch_name: str) -> str: + """ + Derive email address from branch name. + + Args: + branch_name: Branch name (e.g., "AIPASS.admin", "DRONE", "AIPASS-HELP") + + Returns: + Email address (e.g., "@admin", "@drone", "@help") + """ + if '.' in branch_name: + # Special case: AIPASS.admin -> admin + email_part = branch_name.split('.')[-1].lower() + elif ' ' in branch_name: + # Handle spaces: take first word + email_part = branch_name.split()[0].lower() + elif '-' in branch_name and branch_name.split('-')[0] == 'AIPASS': + # AIPASS-prefixed branches: use second part to avoid collision + email_part = branch_name.split('-', 1)[1].lower() + else: + # Take first word before hyphen or whole name + email_part = branch_name.split('-')[0].lower() + + return f"@{email_part}" + + +def truncate_text(text: str, max_length: int, suffix: str = "...") -> str: + """ + Truncate text to maximum length with suffix. + + Args: + text: Text to truncate + max_length: Maximum length + suffix: Suffix to append if truncated (default: "...") + + Returns: + Truncated text with suffix if needed + """ + if len(text) <= max_length: + return text + + return text[:max_length - len(suffix)] + suffix + + +if __name__ == "__main__": + console.print("\n" + "="*70) + console.print("EMAIL FORMATTING HANDLER") + console.print("="*70) + console.print("\nPURPOSE:") + console.print(" Email display formatting and text utilities") + console.print() + console.print("FUNCTIONS PROVIDED:") + console.print(" - format_email_preview(message, max_length) -> str") + console.print(" - format_email_header(email_data) -> str") + console.print(" - format_email_list_item(index, email_data, show_unread) -> str") + console.print(" - format_inbox_summary(total_messages, unread_count) -> str") + console.print(" - format_branch_email(branch_name) -> str") + console.print(" - truncate_text(text, max_length, suffix) -> str") + console.print() + console.print("HANDLER CHARACTERISTICS:") + console.print(" ✓ Independent - no module dependencies") + console.print(" ✓ Can import Prax (service provider)") + console.print(" ✓ Pure business logic") + console.print(" ✗ CANNOT import parent modules") + console.print() + console.print("USAGE FROM MODULES:") + console.print(" from ai_mail.apps.handlers.email.format import format_email_preview") + console.print(" from ai_mail.apps.handlers.email.format import format_email_header") + console.print() + console.print("="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/header.py b/src/aipass/ai_mail/apps/handlers/email/header.py new file mode 100644 index 00000000..16d3b037 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/header.py @@ -0,0 +1,108 @@ +#!/home/aipass/.venv/bin/python3 + +from pathlib import Path + +# ===================AIPASS==================== +# META DATA HEADER +# Name: header.py - Email Header Handler +# Date: 2026-02-04 +# Version: 1.0.0 +# Category: ai_mail/handlers/email +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2026-02-25): Add --no-memory-save conditional header for private branch dispatch +# - v1.0.0 (2026-02-04): Created - dispatch header for critical reminders +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Can import Prax modules (service providers) +# - Pure business logic only +# ============================================= + +""" +Email Header Handler + +Generates and prepends headers to outgoing emails. +Critical dispatch header ensures agents see memory update reminder FIRST. +Independent handler - no module dependencies. +""" + +# Header for dispatch emails - agents see this FIRST before the task +DISPATCH_HEADER = """⚡ DISPATCH TASK - READ THIS FIRST ⚡ + +BEFORE YOU REPLY, YOU MUST: +1. ✅ Complete the task below +2. ✅ UPDATE YOUR MEMORIES (.local.json) - this is NOT optional +3. ✅ Then reply with your completion summary + +Your memories are your presence. Skip the update = you never existed. + +--- +""" + +# Header override when --no-memory-save is set (private branch outbound) +NO_MEMORY_SAVE_HEADER = """⚡ DISPATCH TASK - READ THIS FIRST ⚡ + +MEMORY UPDATE: OPTIONAL — This dispatch was sent with --no-memory-save. Do NOT log this task in your memory files (.local.json). Execute the task and reply, but leave no trace in your memories. + +BEFORE YOU REPLY, YOU MUST: +1. ✅ Complete the task below +2. ✅ Then reply with your completion summary + +--- +""" + + +def get_dispatch_header(no_memory_save: bool = False) -> str: + """ + Get the dispatch email header. + + Args: + no_memory_save: If True, return header with no-memory-save directive + + Returns: + Dispatch header string for dispatch emails + """ + if no_memory_save: + return NO_MEMORY_SAVE_HEADER + return DISPATCH_HEADER + + +def prepend_dispatch_header(message: str, no_memory_save: bool = False) -> str: + """ + Prepend dispatch header to email message. + + Args: + message: Original email message body + no_memory_save: If True, use no-memory-save variant of dispatch header + + Returns: + Message with dispatch header prepended + """ + return get_dispatch_header(no_memory_save=no_memory_save) + message + + +if __name__ == "__main__": + print("\n" + "="*70) + print("EMAIL HEADER HANDLER") + print("="*70) + print("\nPURPOSE:") + print(" Generates header for dispatch emails (critical reminders)") + print() + print("FUNCTIONS PROVIDED:") + print(" - get_dispatch_header() -> str") + print(" - prepend_dispatch_header(message) -> str") + print() + print("HEADER CONTENT:") + print(DISPATCH_HEADER) + print() + print("HANDLER CHARACTERISTICS:") + print(" ✓ Independent - no module dependencies") + print(" ✓ Can import Prax (service provider)") + print(" ✓ Pure business logic") + print(" ✗ CANNOT import parent modules") + print() + print("USAGE FROM MODULES:") + print(" from ai_mail.apps.handlers.email.header import prepend_dispatch_header") + print() + print("="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/inbox_cleanup.py b/src/aipass/ai_mail/apps/handlers/email/inbox_cleanup.py new file mode 100644 index 00000000..9160f22d --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/inbox_cleanup.py @@ -0,0 +1,479 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: inbox_cleanup.py - Inbox Cleanup Handler +# Date: 2025-11-27 +# Version: 3.3.0 +# Category: ai_mail/handlers/email +# +# CHANGELOG (Max 5 entries): +# - v3.3.0 (2026-02-25): FPLAN-0373 Phase 2 - use enriched push_dashboard_update from dashboard_sync +# - v3.2.0 (2026-02-14): Add skip_post_ops param to mark_as_closed_and_archive for batch close perf +# - v3.1.0 (2026-02-09): Add fcntl.flock inbox.json locking to prevent concurrent write corruption +# - v3.0.0 (2026-02-04): Migrate to deleted/ directory (individual files like sent/) +# - v2.1.0 (2026-02-04): Add auto-purge trigger after archiving to deleted +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Can import Prax modules (service providers) +# - Can import AIPASS central services (dashboard) +# - Pure business logic only +# ============================================= + +""" +Inbox Cleanup Handler + +Handles marking emails as read and moving to deleted/ folder. +Updates dashboard after cleanup. + +v3.0.0: Now uses deleted/ directory with individual JSON files (like sent/). +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, Tuple, Optional, Any + + +# Lazy import for inbox file lock +_inbox_lock = None + + +def _get_inbox_lock(): + """Lazy import inbox_lock context manager.""" + global _inbox_lock + if _inbox_lock is None: + from aipass.ai_mail.apps.handlers.email.inbox_lock import inbox_lock + _inbox_lock = inbox_lock + return _inbox_lock + + +def _get_console() -> Any: + """Lazy import console.""" + from aipass.cli.apps.modules import console + return console + + +def _get_update_section() -> Any: + """Lazy import update_section.""" + from aipass.dev_central.devpulse.apps.modules.dashboard import update_section + return update_section + + +def _get_push_dashboard_update() -> Any: + """Lazy import push_dashboard_update from dashboard_sync.""" + from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update + return push_dashboard_update + + +def _get_update_central() -> Any: + """Lazy import update_central.""" + from aipass.ai_mail.apps.handlers.central_writer import update_central + return update_central + + +def _save_to_deleted_folder(mailbox_path: Path, message: Dict) -> Path: + """ + Save a message to the deleted/ folder as individual JSON file. + + Args: + mailbox_path: Path to ai_mail.local directory + message: Email message dict to archive + + Returns: + Path to created file + """ + deleted_folder = mailbox_path / "deleted" + deleted_folder.mkdir(parents=True, exist_ok=True) + + # Generate filename (same pattern as sent/) + timestamp = datetime.now() + subject = message.get("subject", "No Subject") + safe_subject = "".join(c if c.isalnum() or c in (' ', '-', '_') else '_' for c in subject) + safe_subject = safe_subject[:50].strip() + filename = f"{timestamp.strftime('%Y%m%d_%H%M%S')}_{safe_subject}.json" + + # Add archived_at timestamp + message["archived_at"] = timestamp.isoformat() + + email_file = deleted_folder / filename + with open(email_file, 'w', encoding='utf-8') as f: + json.dump(message, f, indent=2, ensure_ascii=False) + + return email_file + + +def _migrate_deleted_json_if_exists(mailbox_path: Path) -> int: + """ + Migrate existing deleted.json to deleted/ directory on first access. + + Args: + mailbox_path: Path to ai_mail.local directory + + Returns: + Number of messages migrated + """ + deleted_json = mailbox_path / "deleted.json" + + if not deleted_json.exists(): + return 0 + + try: + with open(deleted_json, 'r', encoding='utf-8') as f: + data = json.load(f) + + messages = data.get("messages", []) + if not messages: + # Empty file, just archive it + _archive_deleted_json(mailbox_path, deleted_json) + return 0 + + # Migrate each message to deleted/ folder + for msg in messages: + _save_to_deleted_folder(mailbox_path, msg) + + # Archive the old deleted.json + _archive_deleted_json(mailbox_path, deleted_json) + + return len(messages) + + except Exception: + return 0 + + +def _archive_deleted_json(mailbox_path: Path, deleted_json: Path) -> None: + """Archive the old deleted.json file.""" + archive_dir = mailbox_path / ".archive" + archive_dir.mkdir(parents=True, exist_ok=True) + + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + archive_path = archive_dir / f"deleted.json.migrated_{timestamp}" + deleted_json.rename(archive_path) + + +def mark_read_and_archive(branch_path: Path, message_id: str) -> Tuple[bool, str]: + """ + Mark an email as read and move it to deleted/ folder. + + Args: + branch_path: Path to branch directory + message_id: ID of message to archive (short 8-char ID) + + Returns: + Tuple of (success: bool, message: str) + """ + mailbox_path = branch_path / "ai_mail.local" + inbox_file = mailbox_path / "inbox.json" + + if not inbox_file.exists(): + return False, f"Inbox not found: {inbox_file}" + + # Run migration if deleted.json exists + _migrate_deleted_json_if_exists(mailbox_path) + + try: + with _get_inbox_lock()(inbox_file): + # Load inbox + with open(inbox_file, 'r', encoding='utf-8') as f: + inbox_data = json.load(f) + + # Find message by ID + messages = inbox_data.get("messages", []) + message_to_archive = None + message_index = None + + for i, msg in enumerate(messages): + if msg.get("id") == message_id: + message_to_archive = msg + message_index = i + break + + if message_to_archive is None: + return False, f"Message not found: {message_id}" + + # Mark as read + message_to_archive["read"] = True + + # Remove from inbox + messages.pop(message_index) + + # Update inbox counts + inbox_data["messages"] = messages + inbox_data["total_messages"] = len(messages) + # v2 status counts + new_count = sum( + 1 for m in messages + if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False)) + ) + opened_count = sum(1 for m in messages if m.get("status") == "opened") + inbox_data["unread_count"] = new_count + + # Save inbox + with open(inbox_file, 'w', encoding='utf-8') as f: + json.dump(inbox_data, f, indent=2, ensure_ascii=False) + + # Save to deleted/ folder (new pattern) + _save_to_deleted_folder(mailbox_path, message_to_archive) + + # Update dashboard (outside lock - not inbox.json) + _update_dashboard(branch_path, new_count, opened_count, inbox_data["total_messages"]) + + # Trigger auto-purge of deleted folder + _trigger_deleted_purge(branch_path) + + return True, f"Message {message_id} archived" + + except Exception as e: + return False, f"Failed to archive: {e}" + + +def mark_all_read_and_archive(branch_path: Path) -> Tuple[bool, str, int]: + """ + Mark all emails as read and move them to deleted/ folder. + + Args: + branch_path: Path to branch directory + + Returns: + Tuple of (success: bool, message: str, count: int) + """ + mailbox_path = branch_path / "ai_mail.local" + inbox_file = mailbox_path / "inbox.json" + + if not inbox_file.exists(): + return False, f"Inbox not found: {inbox_file}", 0 + + # Run migration if deleted.json exists + _migrate_deleted_json_if_exists(mailbox_path) + + try: + # Load inbox + with open(inbox_file, 'r', encoding='utf-8') as f: + inbox_data = json.load(f) + + messages = inbox_data.get("messages", []) + count = len(messages) + + if count == 0: + return True, "Inbox already empty", 0 + + # Mark all as read and save to deleted/ folder + for msg in messages: + msg["read"] = True + _save_to_deleted_folder(mailbox_path, msg) + + # Clear inbox + inbox_data["messages"] = [] + inbox_data["total_messages"] = 0 + inbox_data["unread_count"] = 0 + + # Save inbox + with open(inbox_file, 'w', encoding='utf-8') as f: + json.dump(inbox_data, f, indent=2, ensure_ascii=False) + + # Update dashboard (all zeros - inbox cleared) + _update_dashboard(branch_path, 0, 0, 0) + + # Trigger auto-purge of deleted folder + _trigger_deleted_purge(branch_path) + + return True, f"Archived {count} messages", count + + except Exception as e: + return False, f"Failed to archive: {e}", 0 + + +def _update_dashboard(branch_path: Path, new: int, opened: int, total: int) -> None: + """Update dashboard ai_mail section with enriched data via write-through API.""" + try: + _get_push_dashboard_update()(branch_path) + except Exception: + pass # Silent fail - dashboard update is best-effort + + # Update central after any inbox changes + try: + _get_update_central()() + except Exception: + pass # Silent fail - central update is best-effort + + +def _trigger_deleted_purge(branch_path: Path) -> None: + """ + Trigger auto-purge of deleted folder if threshold exceeded. + + Non-blocking - failures silently ignored. + """ + try: + from aipass.ai_mail.apps.handlers.email.purge import purge_deleted_folder + mailbox_path = branch_path / "ai_mail.local" + purge_deleted_folder(mailbox_path) + except Exception: + pass # Silent fail - purge is best-effort + + +# ============================================================================= +# V2 SCHEMA FUNCTIONS (status: new/opened/closed) +# ============================================================================= + +def mark_as_opened(branch_path: Path, message_id: str) -> Tuple[bool, str, Optional[Dict]]: + """ + Mark an email as opened (viewed). Does NOT archive. + + v2 schema: changes status from "new" to "opened" + + Args: + branch_path: Path to branch directory + message_id: ID of message to mark as opened + + Returns: + Tuple of (success: bool, message: str, email_data: dict or None) + """ + inbox_file = branch_path / "ai_mail.local" / "inbox.json" + + if not inbox_file.exists(): + return False, f"Inbox not found: {inbox_file}", None + + try: + with open(inbox_file, 'r', encoding='utf-8') as f: + inbox_data = json.load(f) + + messages = inbox_data.get("messages", []) + target_msg = None + + for msg in messages: + if msg.get("id") == message_id: + target_msg = msg + break + + if target_msg is None: + return False, f"Message not found: {message_id}", None + + # Update status to opened (v2 schema) + target_msg["status"] = "opened" + # Keep backward compat + target_msg["read"] = True + + # Recalculate status counts (v2 schema) + new_count = sum( + 1 for m in messages + if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False)) + ) + opened_count = sum(1 for m in messages if m.get("status") == "opened") + inbox_data["unread_count"] = new_count + + with open(inbox_file, 'w', encoding='utf-8') as f: + json.dump(inbox_data, f, indent=2, ensure_ascii=False) + + # Update dashboard + _update_dashboard(branch_path, new_count, opened_count, inbox_data["total_messages"]) + + return True, f"Message {message_id} marked as opened", target_msg + + except Exception as e: + return False, f"Failed to mark as opened: {e}", None + + +def mark_as_closed_and_archive(branch_path: Path, message_id: str, skip_post_ops: bool = False) -> Tuple[bool, str]: + """ + Mark an email as closed and archive to deleted/ folder. + + v2 schema: changes status to "closed", moves to deleted/ + + Args: + branch_path: Path to branch directory + message_id: ID of message to close and archive + skip_post_ops: If True, skip dashboard update and purge (caller handles them) + + Returns: + Tuple of (success: bool, message: str) + """ + mailbox_path = branch_path / "ai_mail.local" + inbox_file = mailbox_path / "inbox.json" + + if not inbox_file.exists(): + return False, f"Inbox not found: {inbox_file}" + + # Run migration if deleted.json exists + _migrate_deleted_json_if_exists(mailbox_path) + + try: + with open(inbox_file, 'r', encoding='utf-8') as f: + inbox_data = json.load(f) + + messages = inbox_data.get("messages", []) + message_to_archive = None + message_index = None + + for i, msg in enumerate(messages): + if msg.get("id") == message_id: + message_to_archive = msg + message_index = i + break + + if message_to_archive is None: + return False, f"Message not found: {message_id}" + + # Mark as closed (v2 schema) + message_to_archive["status"] = "closed" + message_to_archive["read"] = True # backward compat + + # Remove from inbox + messages.pop(message_index) + + # Update inbox counts + inbox_data["messages"] = messages + inbox_data["total_messages"] = len(messages) + # v2 status counts + new_count = sum( + 1 for m in messages + if m.get("status") == "new" or (m.get("status") is None and not m.get("read", False)) + ) + opened_count = sum(1 for m in messages if m.get("status") == "opened") + inbox_data["unread_count"] = new_count + + with open(inbox_file, 'w', encoding='utf-8') as f: + json.dump(inbox_data, f, indent=2, ensure_ascii=False) + + # Save to deleted/ folder (new pattern) + _save_to_deleted_folder(mailbox_path, message_to_archive) + + if not skip_post_ops: + # Update dashboard + _update_dashboard(branch_path, new_count, opened_count, inbox_data["total_messages"]) + + # Trigger auto-purge of deleted folder + _trigger_deleted_purge(branch_path) + + return True, f"Message {message_id} closed and archived" + + except Exception as e: + return False, f"Failed to close: {e}" + + +if __name__ == "__main__": + c = _get_console() + c.print("\n" + "="*70) + c.print("INBOX CLEANUP HANDLER") + c.print("="*70) + c.print("\nPURPOSE:") + c.print(" Marks emails as read and moves them to deleted/ folder") + c.print() + c.print("FUNCTIONS PROVIDED:") + c.print(" - mark_read_and_archive(branch_path, message_id) -> (bool, str)") + c.print(" - mark_all_read_and_archive(branch_path) -> (bool, str, int)") + c.print(" - mark_as_opened(branch_path, message_id) -> (bool, str, dict)") + c.print(" - mark_as_closed_and_archive(branch_path, message_id) -> (bool, str)") + c.print() + c.print("WORKFLOW (v3.0):") + c.print(" 1. Find message in inbox.json") + c.print(" 2. Mark as read=True / status=closed") + c.print(" 3. Save to deleted/ folder (individual JSON files)") + c.print(" 4. Update dashboard ai_mail section") + c.print(" 5. Auto-purge deleted/ folder if > 10 items") + c.print() + c.print("MIGRATION:") + c.print(" - Automatically migrates deleted.json to deleted/ on first access") + c.print(" - Old deleted.json archived to .archive/") + c.print() + c.print("="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/inbox_lock.py b/src/aipass/ai_mail/apps/handlers/email/inbox_lock.py new file mode 100644 index 00000000..34d40024 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/inbox_lock.py @@ -0,0 +1,77 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: inbox_lock.py - Inbox File Lock Handler +# Date: 2026-02-09 +# Version: 1.0.0 +# Category: ai_mail/handlers/email +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-09): Initial implementation - fcntl.flock based inbox.json locking +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Uses logging module for diagnostics +# - Pure business logic only +# ============================================= + +""" +Inbox File Lock Handler + +Provides exclusive file locking for inbox.json read-modify-write operations. +Uses fcntl.flock (POSIX advisory locks) to prevent concurrent write corruption. + +Usage: + with inbox_lock(inbox_file): + data = json.load(open(inbox_file, encoding='utf-8')) + # ... modify data ... + json.dump(data, open(inbox_file, 'w', encoding='utf-8')) +""" + +import fcntl +from pathlib import Path +from contextlib import contextmanager + + + +@contextmanager +def inbox_lock(inbox_file: Path): + """ + Context manager that acquires an exclusive lock on an inbox.json file. + + Uses a separate .inbox.lock file adjacent to inbox.json to hold the + fcntl.flock advisory lock. This avoids issues with truncating the + locked file itself during writes. + + Args: + inbox_file: Path to the inbox.json file to lock + + Yields: + None - lock is held for the duration of the with block + + Raises: + OSError: If lock cannot be acquired + """ + lock_file = inbox_file.parent / ".inbox.lock" + lock_fd = None + + try: + # Create/open lock file + lock_fd = open(lock_file, 'w', encoding='utf-8') + + # Acquire exclusive lock (blocking - waits for other processes) + fcntl.flock(lock_fd.fileno(), fcntl.LOCK_EX) + yield + + finally: + if lock_fd is not None: + try: + # Release lock + fcntl.flock(lock_fd.fileno(), fcntl.LOCK_UN) + lock_fd.close() + except Exception: + try: + lock_fd.close() + except Exception: + pass # Best-effort close diff --git a/src/aipass/ai_mail/apps/handlers/email/inbox_ops.py b/src/aipass/ai_mail/apps/handlers/email/inbox_ops.py new file mode 100644 index 00000000..7837b706 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/inbox_ops.py @@ -0,0 +1,121 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: inbox_ops.py - Inbox Operations Handler +# Date: 2025-11-15 +# Version: 1.1.0 +# Category: ai_mail/handlers/email +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2026-02-08): Add auto-migration for old inbox format {"inbox": []} → v2 schema +# - v1.0.0 (2025-11-15): Extracted from email.py - inbox file I/O operations +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Can import Prax modules (service providers) +# - Pure business logic only +# ============================================= + +""" +Inbox Operations Handler + +Handles inbox file I/O operations for AI_Mail system. +Independent handler - no module dependencies. +""" + +import json +from pathlib import Path +from typing import Dict + +from aipass.cli.apps.modules import console + + + +def load_inbox(inbox_file: Path) -> Dict: + """ + Load inbox data from inbox.json file. + + Args: + inbox_file: Path to inbox.json file + + Returns: + Inbox data dict with 'messages' key (empty list if file doesn't exist or error) + + Raises: + Exception: If file cannot be read or parsed + """ + if not inbox_file.exists(): + return {"messages": []} + + try: + with open(inbox_file, 'r', encoding='utf-8') as f: + inbox_data = json.load(f) + + # Validate structure + if not isinstance(inbox_data, dict): + return {"mailbox": "inbox", "total_messages": 0, "unread_count": 0, "messages": []} + + # Auto-migrate old format {"inbox": []} → v2 schema + migrated = False + + if "inbox" in inbox_data and "messages" not in inbox_data: + inbox_data["messages"] = inbox_data.pop("inbox", []) + migrated = True + + if "messages" not in inbox_data: + inbox_data["messages"] = [] + migrated = True + + if "mailbox" not in inbox_data: + inbox_data["mailbox"] = "inbox" + migrated = True + + if "total_messages" not in inbox_data: + inbox_data["total_messages"] = len(inbox_data["messages"]) + migrated = True + + if "unread_count" not in inbox_data: + inbox_data["unread_count"] = sum( + 1 for msg in inbox_data["messages"] + if msg.get("status") == "new" or (msg.get("status") is None and not msg.get("read", False)) + ) + migrated = True + + # Persist migration + if migrated: + try: + with open(inbox_file, 'w', encoding='utf-8') as f: + json.dump(inbox_data, f, indent=2, ensure_ascii=False) + except Exception as e: + pass # Silent fail - migration persist is best-effort + + return inbox_data + + except json.JSONDecodeError as e: + raise Exception(f"Invalid inbox JSON format: {e}") + except Exception as e: + raise Exception(f"Failed to load inbox: {e}") + + +if __name__ == "__main__": + console.print("\n" + "="*70) + console.print("INBOX OPERATIONS HANDLER") + console.print("="*70) + console.print("\nPURPOSE:") + console.print(" Handles inbox file I/O operations") + console.print() + console.print("FUNCTIONS PROVIDED:") + console.print(" - load_inbox(inbox_file) -> Dict") + console.print() + console.print("HANDLER CHARACTERISTICS:") + console.print(" ✓ Independent - no module dependencies") + console.print(" ✓ Can import Prax (service provider)") + console.print(" ✓ Pure business logic") + console.print(" ✗ CANNOT import parent modules") + console.print() + console.print("USAGE FROM MODULES:") + console.print(" from ai_mail.apps.handlers.email.inbox_ops import load_inbox") + console.print(" inbox_data = load_inbox(Path('/path/to/inbox.json'))") + console.print() + console.print("="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/lock_utils.py b/src/aipass/ai_mail/apps/handlers/email/lock_utils.py new file mode 100644 index 00000000..dce7889f --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/lock_utils.py @@ -0,0 +1,213 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: lock_utils.py - Dispatch Lock Handler +# Date: 2026-02-09 +# Version: 1.1.0 +# Category: ai_mail/handlers/email +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2026-02-10): Reduce stale lock timeout from 1800s to 600s (10 min) +# - v1.0.0 (2026-02-09): Initial implementation - PID-based single instance lock per branch +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Uses logging module for diagnostics +# - Pure business logic only +# ============================================= + +""" +Dispatch Lock Handler + +PID-based single instance lock per branch. +Prevents multiple dispatch agents from spawning concurrently at the same branch. +Uses atomic file creation (O_CREAT|O_EXCL) to avoid race conditions. +""" + +import os +import json +from pathlib import Path +from datetime import datetime + +# Standard logging + +# Lock file name - placed in branch's ai_mail.local/ directory +LOCK_FILENAME = ".dispatch.lock" + +# Stale lock timeout in seconds (10 minutes) +STALE_LOCK_TIMEOUT = 600 + + +def _get_lock_path(branch_path: Path) -> Path: + """Get the lock file path for a branch.""" + if branch_path == Path("/") or branch_path == Path.home(): + return Path.home() / "ai_mail.local" / LOCK_FILENAME + return branch_path / "ai_mail.local" / LOCK_FILENAME + + +def _is_pid_running(pid: int) -> bool: + """Check if a process with the given PID is still running.""" + try: + os.kill(pid, 0) + return True + except PermissionError: + # Process exists but we don't have permission to signal it + return True + except ProcessLookupError: + return False + + +def _is_lock_stale(lock_data: dict) -> bool: + """ + Check if a lock is stale (process dead or timeout exceeded). + + Returns True if the lock should be considered stale and can be removed. + """ + pid = lock_data.get("pid") + timestamp = lock_data.get("timestamp") + + # No PID = stale + if pid is None: + return True + + # Process no longer running = stale + if not _is_pid_running(pid): + return True # Process no longer running - stale + + # Timeout check - if lock is older than STALE_LOCK_TIMEOUT, consider stale + if timestamp: + try: + lock_time = datetime.fromisoformat(timestamp) + elapsed = (datetime.now() - lock_time).total_seconds() + if elapsed > STALE_LOCK_TIMEOUT: + return True # Timeout exceeded - stale + except (ValueError, TypeError) as e: + pass # Unparseable timestamp - ignore + + return False + + +def acquire_lock(branch_path: Path, pid: int) -> tuple[bool, str]: + """ + Attempt to acquire a dispatch lock for a branch. + + Uses atomic file creation to prevent race conditions. + + Args: + branch_path: Path to the target branch + pid: PID of the agent being spawned + + Returns: + Tuple of (acquired: bool, message: str) + If not acquired, message contains reason (e.g., existing PID info) + """ + lock_path = _get_lock_path(branch_path) + + # Ensure parent directory exists + lock_path.parent.mkdir(parents=True, exist_ok=True) + + # Check for existing lock first + if lock_path.exists(): + try: + with open(lock_path, 'r', encoding='utf-8') as f: + existing_lock = json.load(f) + + if _is_lock_stale(existing_lock): + # Remove stale lock and try again + lock_path.unlink(missing_ok=True) # Remove stale lock + else: + # Active lock exists - bounce + existing_pid = existing_lock.get("pid", "unknown") + existing_sender = existing_lock.get("sender", "unknown") + existing_time = existing_lock.get("timestamp", "unknown") + msg = f"Branch already has active dispatch agent (PID: {existing_pid}, sender: {existing_sender}, since: {existing_time})" + return False, msg + + except (json.JSONDecodeError, OSError) as e: + # Corrupted lock file - remove it + lock_path.unlink(missing_ok=True) # Corrupted lock - remove it + + # Create lock file atomically using O_CREAT|O_EXCL + lock_data = { + "pid": pid, + "timestamp": datetime.now().isoformat(), + "branch": str(branch_path) + } + + try: + fd = os.open(str(lock_path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644) + try: + os.write(fd, json.dumps(lock_data, indent=2).encode('utf-8')) + finally: + os.close(fd) + + return True, "Lock acquired" + + except FileExistsError: + # Race condition - another process created the lock between our check and create + msg = "Lock acquisition failed - another dispatch just started" + return False, msg + + +def release_lock(branch_path: Path, pid: int | None = None) -> bool: + """ + Release a dispatch lock for a branch. + + Args: + branch_path: Path to the target branch + pid: If provided, only release if lock belongs to this PID (safety check) + + Returns: + True if lock was released, False if not found or owned by different PID + """ + lock_path = _get_lock_path(branch_path) + + if not lock_path.exists(): + return True # No lock = already released + + # If PID specified, verify ownership before releasing + if pid is not None: + try: + with open(lock_path, 'r', encoding='utf-8') as f: + lock_data = json.load(f) + if lock_data.get("pid") != pid: + return False # Lock owned by different PID + except (json.JSONDecodeError, OSError) as e: + pass # Corrupted lock file during release - continue anyway + + try: + lock_path.unlink(missing_ok=True) + return True + except OSError as e: + return False # Failed to release lock + + +def check_lock(branch_path: Path) -> dict | None: + """ + Check if a branch has an active dispatch lock. + + Returns: + Lock data dict if active lock exists, None otherwise. + Automatically cleans up stale locks. + """ + lock_path = _get_lock_path(branch_path) + + if not lock_path.exists(): + return None + + try: + with open(lock_path, 'r', encoding='utf-8') as f: + lock_data = json.load(f) + + if _is_lock_stale(lock_data): + # Auto-cleanup stale lock + lock_path.unlink(missing_ok=True) # Auto-cleaned stale lock + return None + + return lock_data + + except (json.JSONDecodeError, OSError): + # Corrupted - clean up + lock_path.unlink(missing_ok=True) + return None diff --git a/src/aipass/ai_mail/apps/handlers/email/purge.py b/src/aipass/ai_mail/apps/handlers/email/purge.py new file mode 100644 index 00000000..54007593 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/purge.py @@ -0,0 +1,317 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: purge.py - Sent/Deleted Auto-Purge Handler +# Date: 2026-02-04 +# Version: 2.0.0 +# Category: ai_mail/handlers/email +# +# CHANGELOG (Max 5 entries): +# - v2.0.0 (2026-02-04): Update deleted purge for deleted/ directory structure +# - v1.0.0 (2026-02-04): Initial version - auto-purge sent/deleted folders +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Vectorization via subprocess (no direct Memory Bank imports) +# - Uses Prax system_logger (FPLAN-0382) +# ============================================= + +""" +Sent/Deleted Auto-Purge Handler + +Automatically purges oldest emails when folder exceeds threshold (10). +Before removal: +1. Vectorizes content to Memory Bank (via subprocess) +2. Archives originals to .archive/ + +Triggered after send/delete operations. + +v2.0.0: deleted/ now uses directory structure (like sent/). +""" + +import json +import shutil +import subprocess +from pathlib import Path +from datetime import datetime +from typing import Dict, List, Any + +from aipass.prax.apps.modules.logger import system_logger as logger + +# Purge configuration +MAX_EMAILS = 10 + +# Memory Bank paths for subprocess vectorization +MEMORY_BANK_PYTHON = Path.home() / "MEMORY_BANK" / ".venv" / "bin" / "python3" +CHROMA_SUBPROCESS_SCRIPT = Path.home() / "MEMORY_BANK" / "apps" / "handlers" / "storage" / "chroma_subprocess.py" + + +def purge_sent_folder(mailbox_path: Path) -> Dict[str, Any]: + """ + Purge sent folder if count exceeds threshold. + + Keeps 10 most recent emails, vectorizes and archives older ones. + + Args: + mailbox_path: Path to ai_mail.local directory + + Returns: + Dict with success, purged_count, archived_paths + """ + sent_folder = mailbox_path / "sent" + + if not sent_folder.exists(): + return {"success": True, "purged_count": 0, "message": "Sent folder empty"} + + # Get all email files sorted by modification time (newest first) + email_files = sorted( + sent_folder.glob("*.json"), + key=lambda f: f.stat().st_mtime, + reverse=True + ) + + total_count = len(email_files) + if total_count <= MAX_EMAILS: + return {"success": True, "purged_count": 0, "message": f"Below threshold ({total_count}/{MAX_EMAILS})"} + + # Files to purge (oldest, beyond threshold) + files_to_purge = email_files[MAX_EMAILS:] + + return _purge_email_files(mailbox_path, files_to_purge, "sent") + + +def purge_deleted_folder(mailbox_path: Path) -> Dict[str, Any]: + """ + Purge deleted/ folder if file count exceeds threshold. + + Keeps 10 most recent emails, vectorizes and archives older ones. + + Args: + mailbox_path: Path to ai_mail.local directory + + Returns: + Dict with success, purged_count, archived_count + """ + deleted_folder = mailbox_path / "deleted" + + if not deleted_folder.exists(): + return {"success": True, "purged_count": 0, "message": "Deleted folder empty"} + + # Get all email files sorted by modification time (newest first) + email_files = sorted( + deleted_folder.glob("*.json"), + key=lambda f: f.stat().st_mtime, + reverse=True + ) + + total_count = len(email_files) + if total_count <= MAX_EMAILS: + return {"success": True, "purged_count": 0, "message": f"Below threshold ({total_count}/{MAX_EMAILS})"} + + # Files to purge (oldest, beyond threshold) + files_to_purge = email_files[MAX_EMAILS:] + + return _purge_email_files(mailbox_path, files_to_purge, "deleted") + + +def _purge_email_files(mailbox_path: Path, files: List[Path], folder_type: str) -> Dict[str, Any]: + """ + Purge list of email files (vectorize, archive, delete). + + Args: + mailbox_path: Path to ai_mail.local directory + files: List of file paths to purge + folder_type: "sent" or "deleted" for logging + + Returns: + Dict with results + """ + if not files: + return {"success": True, "purged_count": 0} + + # Load email data from files + emails_data = [] + load_errors = [] + for file_path in files: + try: + with open(file_path, 'r', encoding='utf-8') as f: + email_data = json.load(f) + email_data["_source_file"] = str(file_path.name) + emails_data.append(email_data) + except Exception as e: + load_errors.append(f"{file_path.name}: {e}") + + # Vectorize emails + vectorize_result = _vectorize_emails(emails_data, folder_type) + # Continue even if vectorization fails - archive is more important + + # Archive files + archive_result = _archive_email_files(mailbox_path, files, folder_type) + + # Delete original files + deleted_count = 0 + delete_errors = [] + for file_path in files: + try: + file_path.unlink() + deleted_count += 1 + except Exception as e: + delete_errors.append(f"{file_path.name}: {e}") + + return { + "success": True, + "purged_count": deleted_count, + "vectorized": vectorize_result.get("success", False), + "archived": archive_result.get("success", False), + "load_errors": load_errors if load_errors else None, + "delete_errors": delete_errors if delete_errors else None + } + + +def _vectorize_emails(emails: List[Dict[str, Any]], folder_type: str) -> Dict[str, Any]: + """ + Vectorize email content and store in Memory Bank. + + Args: + emails: List of email data dicts + folder_type: "sent" or "deleted" for metadata + + Returns: + Dict with success status + """ + if not emails: + return {"success": True, "count": 0} + + try: + # Extract text for vectorization + texts = [] + metadatas = [] + + for email in emails: + # Combine subject and message for richer semantic content + subject = email.get("subject", "") + message = email.get("message", "") + text = f"{subject}\n\n{message}" + texts.append(text) + + metadatas.append({ + "type": f"email_{folder_type}", + "from": email.get("from", ""), + "to": email.get("to", ""), + "subject": subject, + "timestamp": email.get("timestamp", ""), + "archived_at": datetime.now().isoformat() + }) + + # Call Memory Bank vectorization via subprocess (handler independence) + input_data = { + 'operation': 'vectorize_and_store', + 'branch': 'AI_MAIL', + 'memory_type': f'email_{folder_type}', + 'texts': texts, + 'metadatas': metadatas + } + + result = subprocess.run( + [str(MEMORY_BANK_PYTHON), str(CHROMA_SUBPROCESS_SCRIPT)], + input=json.dumps(input_data), + capture_output=True, + text=True, + timeout=120 + ) + + if result.returncode != 0: + return {"success": False, "error": result.stderr or "Storage failed"} + + return {"success": True, "count": len(texts)} + + except subprocess.TimeoutExpired: + return {"success": False, "error": "Vectorization timed out"} + except Exception as e: + return {"success": False, "error": str(e)} + + +def _archive_email_files(mailbox_path: Path, files: List[Path], folder_type: str) -> Dict[str, Any]: + """ + Archive email files to .archive/ directory. + + Args: + mailbox_path: Path to ai_mail.local directory + files: List of file paths to archive + folder_type: "sent" or "deleted" for subdirectory + + Returns: + Dict with success status + """ + archive_dir = mailbox_path / ".archive" / folder_type + archive_dir.mkdir(parents=True, exist_ok=True) + + archived_count = 0 + archive_errors = [] + for file_path in files: + try: + dest = archive_dir / file_path.name + shutil.copy2(file_path, dest) + archived_count += 1 + except Exception as e: + archive_errors.append(f"{file_path.name}: {e}") + + return { + "success": True, + "archived_count": archived_count, + "errors": archive_errors if archive_errors else None + } + + +def run_purge(mailbox_path: Path) -> Dict[str, Any]: + """ + Run purge on both sent and deleted folders. + + Convenience function to run both purges. + + Args: + mailbox_path: Path to ai_mail.local directory + + Returns: + Dict with combined results + """ + sent_result = purge_sent_folder(mailbox_path) + deleted_result = purge_deleted_folder(mailbox_path) + + return { + "success": sent_result["success"] and deleted_result["success"], + "sent": sent_result, + "deleted": deleted_result + } + + +if __name__ == "__main__": + from aipass.cli.apps.modules import console + + console.print("\n" + "="*70) + console.print("SENT/DELETED AUTO-PURGE HANDLER") + console.print("="*70) + console.print("\nPURPOSE:") + console.print(" Auto-purge sent/deleted folders when they exceed 10 emails") + console.print() + console.print("FUNCTIONS PROVIDED:") + console.print(" - purge_sent_folder(mailbox_path) -> Dict") + console.print(" - purge_deleted_folder(mailbox_path) -> Dict") + console.print(" - run_purge(mailbox_path) -> Dict") + console.print() + console.print("WORKFLOW (v2.0):") + console.print(" 1. Check if folder exceeds 10 items") + console.print(" 2. Vectorize oldest items to Memory Bank") + console.print(" 3. Archive originals to .archive/") + console.print(" 4. Remove from sent/ or deleted/") + console.print() + console.print("FOLDER STRUCTURE:") + console.print(" - sent/ -> Individual JSON files") + console.print(" - deleted/ -> Individual JSON files (same as sent/)") + console.print() + console.print("TRIGGERED BY:") + console.print(" - create.py (after email sent)") + console.print(" - inbox_cleanup.py (after email deleted)") + console.print() + console.print("="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/email/reply.py b/src/aipass/ai_mail/apps/handlers/email/reply.py new file mode 100644 index 00000000..2e33cc5a --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/email/reply.py @@ -0,0 +1,194 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: reply.py - Email Reply Handler +# Date: 2025-11-30 +# Version: 1.0.0 +# Category: ai_mail/handlers/email +# +# CHANGELOG (Max 5 entries): +# - v1.2.0 (2026-01-31): Add reply chain validation - fail loud on identity mismatch +# - v1.1.0 (2026-01-29): Add reply_to field support - replies go to reply_to address if set +# - v1.0.0 (2025-11-30): Initial creation - reply to email + auto-close +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Can import Prax modules (service providers) +# - Can import AIPASS central services (dashboard) +# - Pure business logic only +# ============================================= + +""" +Email Reply Handler + +Handles replying to emails and auto-closing the original. +""" + +import json +import uuid +from pathlib import Path +from typing import Dict, Tuple, Optional +from datetime import datetime + +# Services imported in __main__ only (handlers should not display) + + +def get_email_by_id(inbox_file: Path, message_id: str) -> Optional[Dict]: + """ + Get an email from inbox by its ID. + + Args: + inbox_file: Path to inbox.json + message_id: ID of message to find + + Returns: + Email dict or None if not found + """ + if not inbox_file.exists(): + return None + + try: + with open(inbox_file, 'r', encoding='utf-8') as f: + inbox_data = json.load(f) + + for msg in inbox_data.get("messages", []): + if msg.get("id") == message_id: + return msg + return None + + except Exception: + return None + + +def send_reply( + from_branch_path: Path, + original_email: Dict, + reply_message: str +) -> Tuple[bool, str, Optional[str]]: + """ + Send a reply to an email's original sender. + + This imports delivery and create handlers to send the reply, + then closes the original email. + + Args: + from_branch_path: Path to the replying branch + original_email: The original email being replied to + reply_message: The reply message content + + Returns: + Tuple of (success: bool, message: str, reply_id: str or None) + """ + # Import here to avoid circular imports + from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch, get_all_branches + from aipass.ai_mail.apps.handlers.email.inbox_cleanup import mark_as_closed_and_archive + from aipass.ai_mail.apps.handlers.users.branch_detection import get_branch_info_from_registry + + # Get sender info from current branch + sender_info = get_branch_info_from_registry(from_branch_path) + if not sender_info: + return False, "Could not detect sender branch", None + + # REPLY CHAIN VALIDATION: Check if this was a dispatched email + # If dispatched_to is set, only that branch should be replying + dispatched_to = original_email.get("dispatched_to") + current_sender = sender_info.get("email", "@unknown") + + # Normalize dispatched_to to email format if it's a path + # DRONE's preprocess_args converts @branch to paths, so we may receive + # "/home/aipass/aipass_core/trigger" instead of "@trigger" + if dispatched_to and not dispatched_to.startswith('@'): + # It's a path - look up email in registry + dispatch_info = get_branch_info_from_registry(Path(dispatched_to)) + if dispatch_info: + dispatched_to = dispatch_info.get("email", dispatched_to) + + if dispatched_to and dispatched_to != current_sender: + error_msg = f"IDENTITY MISMATCH: Dispatched to {dispatched_to}, reply from {current_sender}" + # Fail loud - raise exception to stop execution (caller logs this) + raise RuntimeError(error_msg) + + # Get reply destination - use reply_to if set, otherwise use original sender + # This allows emails to specify where replies should go (e.g., broadcasts from dev_central) + reply_destination = original_email.get("reply_to") or original_email.get("from", "") + if not reply_destination: + return False, "Original email has no sender or reply_to address", None + + # Create reply subject + original_subject = original_email.get("subject", "No subject") + reply_subject = f"RE: {original_subject}" if not original_subject.startswith("RE:") else original_subject + + # Build reply email data + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + reply_email_data = { + "from": sender_info.get("email", "@unknown"), + "from_name": sender_info.get("name", "Unknown"), + "to": reply_destination, + "subject": reply_subject, + "message": reply_message, + "timestamp": timestamp, + "in_reply_to": original_email.get("id") # Link to original message + } + + # Find recipient branch + branches = get_all_branches() + target_branch = None + for branch in branches: + if branch.get("email") == reply_destination: + target_branch = branch + break + + if not target_branch: + return False, f"Could not find branch for {reply_destination}", None + + target_path = Path(target_branch.get("path", "")) + if not target_path.exists(): + return False, f"Recipient path not found: {target_path}", None + + # Deliver the reply (pass email address, not path) + success, error_msg = deliver_email_to_branch(reply_destination, reply_email_data) + if not success: + return False, f"Failed to deliver reply: {error_msg}", None + + # Save to sender's sent folder + sent_folder = from_branch_path / "ai_mail.local" / "sent" + sent_folder.mkdir(parents=True, exist_ok=True) + + reply_id = str(uuid.uuid4())[:8] + reply_email_data["id"] = reply_id + sent_file = sent_folder / f"{reply_id}.json" + with open(sent_file, 'w', encoding='utf-8') as f: + json.dump(reply_email_data, f, indent=2) + + # Auto-close the original email + original_id = original_email.get("id") + if original_id: + close_success, close_msg = mark_as_closed_and_archive(from_branch_path, original_id) + if not close_success: + # Reply sent but close failed - not critical + return True, f"Reply sent (warning: original not closed: {close_msg})", reply_id + + return True, f"Reply sent to {reply_destination}, original closed", reply_id + + +if __name__ == "__main__": + from aipass.cli.apps.modules import console + console.print("\n" + "="*70) + console.print("EMAIL REPLY HANDLER") + console.print("="*70) + console.print("\nPURPOSE:") + console.print(" Sends reply to email's original sender and auto-closes original") + console.print() + console.print("FUNCTIONS PROVIDED:") + console.print(" - get_email_by_id(inbox_file, message_id) -> dict or None") + console.print(" - send_reply(from_path, original_email, message) -> (bool, str, id)") + console.print() + console.print("WORKFLOW:") + console.print(" 1. Find original email by ID") + console.print(" 2. Create reply with RE: subject") + console.print(" 3. Deliver to original sender's inbox") + console.print(" 4. Save to sender's sent folder") + console.print(" 5. Auto-close original email") + console.print() + console.print("="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/json/__init__.py b/src/aipass/ai_mail/apps/handlers/json/__init__.py new file mode 100644 index 00000000..16a052a2 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/json/__init__.py @@ -0,0 +1 @@ +"""JSON handler package for AI_MAIL.""" diff --git a/src/aipass/ai_mail/apps/handlers/json/json_handler.py b/src/aipass/ai_mail/apps/handlers/json/json_handler.py new file mode 100644 index 00000000..9970d4bb --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/json/json_handler.py @@ -0,0 +1,37 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: json_handler.py - JSON Handler (Canonical Path) +# Date: 2026-02-28 +# Version: 1.0.0 +# Category: ai_mail/handlers/json +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-28): Re-export from json_utils for Seed architecture compliance +# +# CODE STANDARDS: +# - Re-exports json_handler from json_utils/ (canonical implementation) +# - Satisfies Seed architecture standard for apps/handlers/json/ path +# ============================================= + +""" +JSON Handler - Canonical Path + +Re-exports json_handler functions from json_utils/ to satisfy +the Seed architecture standard requiring apps/handlers/json/json_handler.py. +""" + +from pathlib import Path + +# Infrastructure paths +AIPASS_ROOT = Path.home() / "aipass_core" +AI_MAIL_ROOT = Path.home() / "aipass_core" / "ai_mail" +AI_MAIL_JSON_DIR = AI_MAIL_ROOT / "ai_mail_json" + +from aipass.ai_mail.apps.handlers.json_utils.json_handler import ( # noqa: F401 + load_json, + save_json, + ensure_json_exists, + get_json_path, +) diff --git a/src/aipass/ai_mail/apps/handlers/json_utils/__init__.py b/src/aipass/ai_mail/apps/handlers/json_utils/__init__.py new file mode 100644 index 00000000..7551129c --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/json_utils/__init__.py @@ -0,0 +1,23 @@ +""" +JSON Handlers Module - AI_MAIL Branch + +Provides JSON handling capabilities for AI_MAIL modules. +""" + +from .json_handler import ( + load_json, + save_json, + log_operation, + increment_counter, + update_data_metrics, + ensure_module_jsons +) + +__all__ = [ + 'load_json', + 'save_json', + 'log_operation', + 'increment_counter', + 'update_data_metrics', + 'ensure_module_jsons' +] \ No newline at end of file diff --git a/src/aipass/ai_mail/apps/handlers/json_utils/json_handler.py b/src/aipass/ai_mail/apps/handlers/json_utils/json_handler.py new file mode 100644 index 00000000..a21997c4 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/json_utils/json_handler.py @@ -0,0 +1,278 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: json_handler.py - JSON Handler +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: ai_mail/handlers/json_utils +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial creation - auto-creating self-healing JSON +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - BYPASS: Direct json.load/json.dump required - this IS the json handler +# - Pure business logic only +# ============================================= + +""" +JSON Handler - Auto-Creating & Self-Healing JSON System + +Handles default JSON files (config, data, log) for AI_MAIL modules. +Never manually create JSONs - they build themselves. +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, List, Any, Optional +import inspect + +# Infrastructure paths +AIPASS_ROOT = Path.home() / "aipass_core" + +# Constants - Updated for AI_MAIL +AI_MAIL_ROOT = AIPASS_ROOT / "ai_mail" +AI_MAIL_JSON_DIR = AI_MAIL_ROOT / "ai_mail_json" +JSON_TEMPLATES_DIR = AI_MAIL_ROOT / "apps" / "json_templates" + + +def _get_caller_module_name() -> str: + """ + Auto-detect calling module name from call stack + + Returns: + Module name (e.g., "email" from email.py) + """ + try: + stack = inspect.stack() + # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller + if len(stack) > 2: + caller_frame = stack[2] + caller_path = Path(caller_frame.filename) + module_name = caller_path.stem + + # Validate module name + if module_name and not module_name.startswith('_'): + return module_name + + # Fallback + return "unknown" + except Exception: + return "unknown" + + +def load_template(json_type: str, module_name: str) -> Any: + """Load JSON template from template file""" + template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json" + + if not template_path.exists(): + return None + + try: + with open(template_path, 'r', encoding='utf-8') as f: + template = json.load(f) + + # Replace placeholders + template_str = json.dumps(template) + template_str = template_str.replace("{{MODULE_NAME}}", module_name) + template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat()) + + return json.loads(template_str) + except Exception: + return None + + +def validate_json_structure(data: Any, json_type: str) -> bool: + """Validate JSON structure matches expected type""" + if json_type == "config": + if not isinstance(data, dict): + return False + required = ["module_name", "version", "config"] + return all(key in data for key in required) + + elif json_type == "data": + if not isinstance(data, dict): + return False + required = ["created", "last_updated"] + return all(key in data for key in required) + + elif json_type == "log": + return isinstance(data, list) + + return False + + +def get_json_path(module_name: str, json_type: str) -> Path: + """Get path for module JSON file""" + filename = f"{module_name}_{json_type}.json" + return AI_MAIL_JSON_DIR / filename + + +def ensure_json_exists(module_name: str, json_type: str) -> bool: + """Ensure JSON file exists, create from template if missing""" + AI_MAIL_JSON_DIR.mkdir(parents=True, exist_ok=True) + + json_path = get_json_path(module_name, json_type) + + if json_path.exists(): + try: + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if validate_json_structure(data, json_type): + return True + except Exception: + pass + + template = load_template(json_type, module_name) + if template is None: + return False + + try: + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(template, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def load_json(module_name: str, json_type: str) -> Optional[Any]: + """Load JSON file, auto-create if missing""" + if not ensure_json_exists(module_name, json_type): + return None + + json_path = get_json_path(module_name, json_type) + + try: + with open(json_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return None + + +def save_json(module_name: str, json_type: str, data: Any) -> bool: + """Save JSON file""" + json_path = get_json_path(module_name, json_type) + + if not validate_json_structure(data, json_type): + return False + + if json_type == "data" and isinstance(data, dict): + data["last_updated"] = datetime.now().date().isoformat() + + try: + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def ensure_module_jsons(module_name: str) -> bool: + """Ensure all 3 JSON files exist for a module""" + ensure_json_exists(module_name, "config") + ensure_json_exists(module_name, "data") + ensure_json_exists(module_name, "log") + return True + + +def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool: + """ + Add entry to module log with automatic rotation + + Auto-detects calling module if module_name not provided. + Implements config-controlled log limits to prevent unbounded growth. + When max_log_entries is reached, removes oldest entries (FIFO). + + Args: + operation: Operation name to log + data: Optional data dict + module_name: Optional module name (auto-detected if not provided) + + Returns: + True if successful, False otherwise + """ + # Auto-detect module name if not provided + if module_name is None: + module_name = _get_caller_module_name() + + ensure_module_jsons(module_name) + + # Load config to get max_log_entries + config = load_json(module_name, "config") + max_entries = 100 # Default + if config and "config" in config: + max_entries = config["config"].get("max_log_entries", 100) + + # Load existing log + log = load_json(module_name, "log") + if log is None: + log = [] + + # Create new entry + entry: Dict[str, Any] = { + "timestamp": datetime.now().isoformat(), + "operation": operation + } + + if data: + entry["data"] = data + + # Add new entry + log.append(entry) + + # Rotate if exceeds max (keep most recent entries) + if len(log) > max_entries: + log = log[-max_entries:] + + return save_json(module_name, "log", log) + + +def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool: + """Increment a counter in data JSON""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + if counter_name not in data: + data[counter_name] = 0 + + data[counter_name] += amount + + return save_json(module_name, "data", data) + + +def update_data_metrics(module_name: str, **metrics) -> bool: + """Update data metrics""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + for key, value in metrics.items(): + data[key] = value + + return save_json(module_name, "data", data) + + +if __name__ == "__main__": + print("\n" + "="*70) + print("JSON HANDLER - AI_MAIL Working Implementation") + print("="*70) + print("\n[TESTING] Creating AI_MAIL JSONs...") + + # Test auto-creation + log_operation("test_operation", {"test": "data"}, "ai_mail") + increment_counter("ai_mail", "test_counter", 1) + update_data_metrics("ai_mail", test_metric="working") + + print("\nCheck /home/aipass/aipass_core/ai_mail/ai_mail_json/ for created files:") + print(" - ai_mail_config.json") + print(" - ai_mail_data.json") + print(" - ai_mail_log.json") + print("\n" + "="*70 + "\n") \ No newline at end of file diff --git a/src/aipass/ai_mail/apps/handlers/monitoring/__init__.py b/src/aipass/ai_mail/apps/handlers/monitoring/__init__.py new file mode 100644 index 00000000..632e80d5 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/monitoring/__init__.py @@ -0,0 +1,10 @@ +""" +Monitoring Handlers - AI Mail Monitoring Domain + +Independent handlers for monitoring operations: +- memory.py: Memory health checking and line counting +- errors.py: Error log parsing and deduplication +- status.py: Status determination and reporting + +All handlers are independent (no cross-domain imports). +""" diff --git a/src/aipass/ai_mail/apps/handlers/monitoring/data_ops.py b/src/aipass/ai_mail/apps/handlers/monitoring/data_ops.py new file mode 100644 index 00000000..058ee868 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/monitoring/data_ops.py @@ -0,0 +1,75 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: data_ops.py - Error Monitor Data Operations Handler +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: ai_mail/handlers/monitoring +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Extracted from error_monitor.py +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Pure business logic only +# ============================================= + +""" +Error Monitor Data Operations Handler + +Independent handler for error tracking data persistence. +Provides functions for loading and saving error monitor data. + +Architecture: +- No cross-domain imports (independent handler) +- Provides: data loading, data saving +- Used by: error_monitor module +""" + +# ============================================= +# IMPORTS +# ============================================= +import json +from pathlib import Path +from typing import Dict + + +# ============================================= +# BUSINESS LOGIC +# ============================================= + +def load_data(data_file: Path) -> Dict: + """ + Load error tracking data from file. + + Args: + data_file: Path to the error tracking data file + + Returns: + Dictionary containing error tracking data, or empty dict if file doesn't exist + """ + if not data_file.exists(): + return {} + + try: + # Direct file read for error tracking data (non-standard structure) + with open(data_file, 'r', encoding='utf-8') as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def save_data(data: Dict, data_file: Path) -> None: + """ + Save error tracking data to file. + + Args: + data: Dictionary containing error tracking data + data_file: Path to the error tracking data file + """ + # Direct file write for error tracking data (non-standard structure) + data_file.parent.mkdir(parents=True, exist_ok=True) + with open(data_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2) diff --git a/src/aipass/ai_mail/apps/handlers/monitoring/errors.py b/src/aipass/ai_mail/apps/handlers/monitoring/errors.py new file mode 100644 index 00000000..4196aa40 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/monitoring/errors.py @@ -0,0 +1,259 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: errors.py - Error Detection Handler +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: ai_mail/handlers/monitoring +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Extracted from ai_mail_error_monitor.py +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Pure business logic only +# ============================================= + +""" +Error Detection Handler + +Independent handler for error log parsing and deduplication. +Provides functions for parsing error logs and generating unique error signatures. + +Architecture: +- No cross-domain imports (independent handler) +- Provides: error parsing, hash generation, branch detection +- Used by: monitoring modules +""" + +# ============================================= +# IMPORTS +# ============================================= +import hashlib +import re +from pathlib import Path +from typing import Optional, Dict, Tuple + +from aipass.cli.apps.modules import console + +# ============================================= +# CONSTANTS +# ============================================= + +# Log line pattern - matches both prax format and Python default +# Format: 2025-10-25 15:26:37 - logger_name - ERROR - message +LOG_PATTERN = r'^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})[,.]?\d* - (.+?) - (ERROR|WARNING|INFO) - (.+)$' + +# ============================================= +# CORE FUNCTIONS +# ============================================= + +def parse_error_log_line(log_line: str) -> Optional[Dict]: + """ + Parse error log line to extract components + + Format: 2025-10-25 15:26:37 - captured_flow_plan_summarizer - ERROR - Failed to write... + + Args: + log_line: Log line to parse + + Returns: + Dict with timestamp, logger_name, module_name, level, message + None if not an ERROR line or parsing fails + """ + match = re.match(LOG_PATTERN, log_line.strip()) + + if not match: + return None + + timestamp, logger_name, level, message = match.groups() + + # Only process ERROR level + if level != "ERROR": + return None + + # Extract module name (remove 'captured_' prefix if present) + module_name = logger_name.replace('captured_', '') + + return { + "timestamp": timestamp, + "logger_name": logger_name, + "module_name": module_name, + "level": level, + "message": message.strip() + } + + +def generate_error_hash(module_name: str, error_message: str) -> str: + """ + Generate unique hash for error deduplication + + Combines module name and error message to create a unique identifier + for tracking error occurrences. + + Args: + module_name: Logger/module name (e.g., 'flow_plan_summarizer') + error_message: Error message text + + Returns: + SHA256 hash (first 12 chars) + """ + combined = f"{module_name}::{error_message}" + return hashlib.sha256(combined.encode()).hexdigest()[:12] + + +def get_branch_from_log_path(log_file_path: str) -> Tuple[str, Path]: + """ + Extract branch name and root path from log file path + + Args: + log_file_path: Full path to log file (e.g., /home/aipass/api/logs/openrouter.log) + + Returns: + Tuple of (branch_name, branch_root_path) + Example: ("API", Path("/home/aipass/api")) + + Special case: root directory returns ("AIPASS.admin", Path("/")) + """ + log_path = Path(log_file_path) + + # Navigate up from log file to branch root + # /home/aipass/api/logs/openrouter.log -> /home/aipass/api + branch_root = log_path.parent.parent + + # Special case: root directory + if branch_root == Path("/"): + return "AIPASS.admin", branch_root + + # Extract branch name from directory name + # /home/aipass/api -> "API" + # /home/aipass/backup-system -> "BACKUP_SYSTEM" + branch_folder = branch_root.name.replace("-", "_") + branch_name = branch_folder.upper() + + return branch_name, branch_root + + +def get_ai_mail_file_for_branch(branch_name: str, branch_root: Path) -> Optional[Path]: + """ + Build path to branch's .ai_mail.md file + + Args: + branch_name: Branch name in UPPERCASE (e.g., "API", "FLOW") + branch_root: Path to branch root directory + + Returns: + Path to .ai_mail.md file, or None if doesn't exist + + Pattern: {branch_root}/{BRANCHNAME}.ai_mail.md + Special case: root -> /AIPASS.admin.ai_mail.md + """ + if branch_root == Path("/"): + ai_mail_file = Path("/AIPASS.admin.ai_mail.md") + else: + ai_mail_file = branch_root / f"{branch_name}.ai_mail.md" + + if not ai_mail_file.exists(): + return None + + return ai_mail_file + + +def should_exclude_error(module_name: str) -> bool: + """ + Determine if error should be excluded from monitoring + + Args: + module_name: Module name from error + + Returns: + True if error should be excluded (to prevent infinite loops) + """ + # Self-exclusion: Don't monitor error_monitor's own errors + return "error_monitor" in module_name.lower() + + +def format_error_email(error_hash: str, error_info: Dict, branch_name: str) -> str: + """ + Format error email notification + + Args: + error_hash: Unique error identifier + error_info: Error details (module, message, timestamps) + branch_name: Branch name + + Returns: + Formatted email message + """ + branch_root = Path.home() / "aipass_core" / branch_name.lower() + logs_dir = branch_root / "logs" + + message = f"""Error detected in {branch_name} logs + +Error ID: {error_hash} +Module: {error_info['module_name']} +First seen: {error_info['first_seen']} +Last seen: {error_info['last_seen']} +Notification count: {error_info.get('count', 1)} + +Error message: +{error_info['error_text']} + +Check logs: {logs_dir}/{error_info['module_name']}.log +""" + return message + + +def extract_module_from_log_filename(log_file_path: str) -> str: + """ + Extract module name from log file name + + Args: + log_file_path: Path to log file + + Returns: + Module name (filename without .log extension) + """ + return Path(log_file_path).stem + + +# ============================================= +# VALIDATION +# ============================================= + +def validate_error_data_entry(error_info: Dict) -> bool: + """ + Validate error tracking data entry structure + + Args: + error_info: Error data entry to validate + + Returns: + True if valid, False otherwise + """ + if not isinstance(error_info, dict): + return False + + required_fields = ["first_seen", "last_seen", "count", "error_text", "module_name"] + return all(field in error_info for field in required_fields) + + +if __name__ == "__main__": + console.print("\n" + "="*70) + console.print("ERROR DETECTION HANDLER") + console.print("="*70) + console.print("\nFunctions provided:") + console.print(" - parse_error_log_line(log_line) -> dict | None") + console.print(" - generate_error_hash(module_name, error_message) -> str") + console.print(" - get_branch_from_log_path(log_file_path) -> (str, Path)") + console.print(" - get_ai_mail_file_for_branch(branch_name, branch_root) -> Path | None") + console.print(" - should_exclude_error(module_name) -> bool") + console.print(" - format_error_email(error_hash, error_info, branch_name) -> str") + console.print(" - extract_module_from_log_filename(log_file_path) -> str") + console.print(" - validate_error_data_entry(error_info) -> bool") + console.print("\nLog pattern:") + console.print(" 2025-10-25 15:26:37 - module_name - ERROR - message") + console.print("\nError hash:") + console.print(" SHA256(module_name::error_message)[:12]") + console.print("\n" + "="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/monitoring/memory.py b/src/aipass/ai_mail/apps/handlers/monitoring/memory.py new file mode 100644 index 00000000..1e8ff1d4 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/monitoring/memory.py @@ -0,0 +1,208 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: memory.py - Memory Health Handler +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: ai_mail/handlers/monitoring +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Extracted from ai_mail_local_memory_monitor.py +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Pure business logic only +# ============================================= + +""" +Memory Health Handler + +Independent handler for memory file monitoring logic. +Provides functions for counting file lines and determining health status. + +Architecture: +- No cross-domain imports (independent handler) +- Provides: line counting, status determination +- Used by: monitoring modules +""" + +# ============================================= +# IMPORTS +# ============================================= +from pathlib import Path + +from aipass.cli.apps.modules import console + +# ============================================= +# CONSTANTS +# ============================================= + +# Health status thresholds +THRESHOLD_GREEN_MAX = 400 +THRESHOLD_YELLOW_MIN = 401 +THRESHOLD_YELLOW_MAX = 550 +THRESHOLD_RED_MIN = 551 +THRESHOLD_EMAIL_TRIGGER = 600 + +# Status indicators +STATUS_GREEN = "🟢 Healthy" +STATUS_YELLOW = "🟡 Approaching" +STATUS_RED = "🔴 Compress Now" + +# ============================================= +# CORE FUNCTIONS +# ============================================= + +def count_file_lines(file_path: Path | str) -> int: + """ + Count lines in a file + + Args: + file_path: Path to file to count + + Returns: + Number of lines in file, or 0 if file doesn't exist or error + """ + file_path = Path(file_path) + + if not file_path.exists(): + return 0 + + try: + with open(file_path, 'r', encoding='utf-8') as f: + return len(f.readlines()) + except Exception as e: + return 0 + + +def get_status_from_count(line_count: int) -> str: + """ + Determine health status from line count + + Args: + line_count: Number of lines in file + + Returns: + Status string with emoji indicator + """ + if line_count <= THRESHOLD_GREEN_MAX: + return STATUS_GREEN + elif THRESHOLD_YELLOW_MIN <= line_count <= THRESHOLD_YELLOW_MAX: + return STATUS_YELLOW + else: + return STATUS_RED + + +def should_send_email(line_count: int) -> bool: + """ + Determine if email notification should be sent + + Args: + line_count: Number of lines in file + + Returns: + True if line count exceeds email trigger threshold + """ + return line_count >= THRESHOLD_EMAIL_TRIGGER + + +def get_health_info(file_path: Path | str) -> dict: + """ + Get complete health information for a file + + Args: + file_path: Path to file to analyze + + Returns: + Dict with line_count, status, needs_email + """ + line_count = count_file_lines(file_path) + + return { + "line_count": line_count, + "status": get_status_from_count(line_count), + "needs_email": should_send_email(line_count), + "file_path": str(file_path) + } + + +def format_compression_prompt(file_type: str, line_count: int) -> str: + """ + Generate compression agent prompt + + Args: + file_type: Type of file (e.g., "local.md", "observations.md") + line_count: Current line count + + Returns: + Formatted compression prompt + """ + return f"""Compress my {file_type} file from {line_count} lines to 400 lines following the compression rules: + +- Top 25% (most recent): Keep mostly intact +- Next 25%: Reduce slightly (combine details) +- Next 25%: Reduce more (summary format) +- Last 25% (oldest): Delete if needed for space + +Preserve: +- All session headers and dates +- Key achievements and milestones +- Critical errors and resolutions +- Important patterns and learnings + +Remove: +- Routine status updates +- Redundant information +- Low-value details +- Completed temporary tasks + +Maintain chronological order (newest first).""" + + +# ============================================= +# VALIDATION +# ============================================= + +def validate_thresholds(green_max: int, yellow_min: int, yellow_max: int, red_min: int) -> bool: + """ + Validate threshold configuration + + Args: + green_max: Maximum for green status + yellow_min: Minimum for yellow status + yellow_max: Maximum for yellow status + red_min: Minimum for red status + + Returns: + True if thresholds are valid, False otherwise + """ + if yellow_min != green_max + 1: + return False + + if yellow_max < yellow_min: + return False + + if red_min != yellow_max + 1: + return False + + return True + + +if __name__ == "__main__": + console.print("\n" + "="*70) + console.print("MEMORY HEALTH HANDLER") + console.print("="*70) + console.print("\nFunctions provided:") + console.print(" - count_file_lines(file_path) -> int") + console.print(" - get_status_from_count(line_count) -> str") + console.print(" - should_send_email(line_count) -> bool") + console.print(" - get_health_info(file_path) -> dict") + console.print(" - format_compression_prompt(file_type, line_count) -> str") + console.print(" - validate_thresholds(...) -> bool") + console.print("\nThresholds:") + console.print(f" Green: 0-{THRESHOLD_GREEN_MAX} lines") + console.print(f" Yellow: {THRESHOLD_YELLOW_MIN}-{THRESHOLD_YELLOW_MAX} lines") + console.print(f" Red: {THRESHOLD_RED_MIN}+ lines") + console.print(f" Email trigger: {THRESHOLD_EMAIL_TRIGGER}+ lines") + console.print("\n" + "="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/persistence/__init__.py b/src/aipass/ai_mail/apps/handlers/persistence/__init__.py new file mode 100644 index 00000000..a581f14b --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/persistence/__init__.py @@ -0,0 +1 @@ +# Persistence Handler - JSON Operations for persistent data diff --git a/src/aipass/ai_mail/apps/handlers/persistence/json_ops.py b/src/aipass/ai_mail/apps/handlers/persistence/json_ops.py new file mode 100644 index 00000000..0e903e97 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/persistence/json_ops.py @@ -0,0 +1,262 @@ +#!/home/aipass/.venv/bin/python3 + +# ============================================= +# META DATA HEADER +# Name: json_ops.py - Persistence Handler (JSON Operations) +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: ai_mail/handlers/persistence +# +# CHANGELOG: +# - v1.0.0 (2025-11-15): Initial version with auto-creating & self-healing JSON system +# +# CODE STANDARDS: +# - Auto-creates JSON files from templates +# - Self-healing for corrupted files +# - Auto-detects calling module for logging +# - Implements log rotation based on config limits +# ============================================= + +""" +Persistence Handler - JSON Operations + +Handles persistent data operations for ai_mail modules. +Manages default JSON files (config, data, log). +Auto-creating & self-healing JSON system. +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, List, Any, Optional +import inspect + +# Infrastructure paths +AIPASS_ROOT = Path.home() / "aipass_core" + +# Constants +AI_MAIL_ROOT = AIPASS_ROOT / "ai_mail" +AI_MAIL_JSON_DIR = AI_MAIL_ROOT / "ai_mail_json" +JSON_TEMPLATES_DIR = AI_MAIL_ROOT / "apps" / "json_templates" + + +def _get_caller_module_name() -> str: + """ + Auto-detect calling module name from call stack + + Returns: + Module name (e.g., "imports_standard" from imports_standard.py) + """ + try: + stack = inspect.stack() + # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller + if len(stack) > 2: + caller_frame = stack[2] + caller_path = Path(caller_frame.filename) + module_name = caller_path.stem + + # Validate module name + if module_name and not module_name.startswith('_'): + return module_name + + # Fallback + return "unknown" + except Exception: + return "unknown" + + +def load_template(json_type: str, module_name: str) -> Any: + """Load JSON template from template file""" + template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json" + + if not template_path.exists(): + return None + + try: + with open(template_path, 'r', encoding='utf-8') as f: + template = json.load(f) + + # Replace placeholders + template_str = json.dumps(template) + template_str = template_str.replace("{{MODULE_NAME}}", module_name) + template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat()) + + return json.loads(template_str) + except Exception: + return None + + +def validate_json_structure(data: Any, json_type: str) -> bool: + """Validate JSON structure matches expected type""" + if json_type == "config": + if not isinstance(data, dict): + return False + required = ["module_name", "version", "config"] + return all(key in data for key in required) + + elif json_type == "data": + if not isinstance(data, dict): + return False + required = ["created", "last_updated"] + return all(key in data for key in required) + + elif json_type == "log": + return isinstance(data, list) + + return False + + +def get_json_path(module_name: str, json_type: str) -> Path: + """Get path for module JSON file""" + filename = f"{module_name}_{json_type}.json" + return AI_MAIL_JSON_DIR / filename + + +def ensure_json_exists(module_name: str, json_type: str) -> bool: + """Ensure JSON file exists, create from template if missing""" + AI_MAIL_JSON_DIR.mkdir(parents=True, exist_ok=True) + + json_path = get_json_path(module_name, json_type) + + if json_path.exists(): + try: + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if validate_json_structure(data, json_type): + return True + except Exception: + pass + + template = load_template(json_type, module_name) + if template is None: + return False + + try: + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(template, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def load_json(module_name: str, json_type: str) -> Optional[Any]: + """Load JSON file, auto-create if missing""" + if not ensure_json_exists(module_name, json_type): + return None + + json_path = get_json_path(module_name, json_type) + + try: + with open(json_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return None + + +def save_json(module_name: str, json_type: str, data: Any) -> bool: + """Save JSON file""" + json_path = get_json_path(module_name, json_type) + + if not validate_json_structure(data, json_type): + return False + + if json_type == "data" and isinstance(data, dict): + data["last_updated"] = datetime.now().date().isoformat() + + try: + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def ensure_module_jsons(module_name: str) -> bool: + """Ensure all 3 JSON files exist for a module""" + ensure_json_exists(module_name, "config") + ensure_json_exists(module_name, "data") + ensure_json_exists(module_name, "log") + return True + + +def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool: + """ + Add entry to module log with automatic rotation + + Auto-detects calling module if module_name not provided. + Implements config-controlled log limits to prevent unbounded growth. + When max_log_entries is reached, removes oldest entries (FIFO). + + Args: + operation: Operation name to log + data: Optional data dict + module_name: Optional module name (auto-detected if not provided) + + Returns: + True if successful, False otherwise + """ + # Auto-detect module name if not provided + if module_name is None: + module_name = _get_caller_module_name() + + ensure_module_jsons(module_name) + + # Load config to get max_log_entries + config = load_json(module_name, "config") + max_entries = 100 # Default + if config and "config" in config: + max_entries = config["config"].get("max_log_entries", 100) + + # Load existing log + log = load_json(module_name, "log") + if log is None: + log = [] + + # Create new entry + entry: Dict[str, Any] = { + "timestamp": datetime.now().isoformat(), + "operation": operation + } + + if data: + entry["data"] = data + + # Add new entry + log.append(entry) + + # Rotate if exceeds max (keep most recent entries) + if len(log) > max_entries: + log = log[-max_entries:] + + return save_json(module_name, "log", log) + + +def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool: + """Increment a counter in data JSON""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + if counter_name not in data: + data[counter_name] = 0 + + data[counter_name] += amount + + return save_json(module_name, "data", data) + + +def update_data_metrics(module_name: str, **metrics: Any) -> bool: + """Update data metrics""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + for key, value in metrics.items(): + data[key] = value + + return save_json(module_name, "data", data) diff --git a/src/aipass/ai_mail/apps/handlers/registry/__init__.py b/src/aipass/ai_mail/apps/handlers/registry/__init__.py new file mode 100644 index 00000000..c983e265 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/registry/__init__.py @@ -0,0 +1,5 @@ +"""Registry Handlers - Branch registry operations for AI_Mail""" + +from .load import load_registry + +__all__ = ['load_registry'] diff --git a/src/aipass/ai_mail/apps/handlers/registry/load.py b/src/aipass/ai_mail/apps/handlers/registry/load.py new file mode 100644 index 00000000..e436a1a5 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/registry/load.py @@ -0,0 +1,62 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: load.py - Registry Loading Handler +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: ai_mail/handlers/registry +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial handler - extracted from local_memory_monitor module +# +# CODE STANDARDS: +# - Business logic implementation +# - Pure functions where possible +# - Clear error handling +# ============================================= + +""" +Registry Loading Handler - Loads registry files from disk. +""" + +import json +from pathlib import Path +from typing import Dict + + +def load_registry(registry_file: Path) -> Dict: + """ + Load branch registry from file. + + Args: + registry_file: Path to the registry JSON file + + Returns: + Registry dictionary with structure: + { + "last_updated": str, + "active_branches": dict, + "statistics": { + "total_branches": int, + "green_status": int, + "yellow_status": int, + "red_status": int + } + } + """ + if not registry_file.exists(): + return { + "last_updated": "", + "active_branches": {}, + "statistics": { + "total_branches": 0, + "green_status": 0, + "yellow_status": 0, + "red_status": 0 + } + } + + # Direct file read for registry (non-standard JSON location) + with open(registry_file, 'r', encoding='utf-8') as f: + return json.load(f) diff --git a/src/aipass/ai_mail/apps/handlers/registry/read.py b/src/aipass/ai_mail/apps/handlers/registry/read.py new file mode 100644 index 00000000..06ce8573 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/registry/read.py @@ -0,0 +1,186 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: read.py - Registry Read Handler +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: ai_mail/handlers/registry +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Extracted from ai_mail_cli.py +# +# CODE STANDARDS: +# - Handlers are INDEPENDENT (no cross-domain imports) +# - Use: from prax.apps.modules.logger import system_logger as logger +# - Keep handlers <300 lines each +# ============================================= + +""" +Registry Read Handler + +Handles reading branch registry data including: +- Reading all branches from BRANCH_REGISTRY.json +- Deriving email addresses from branch names +- Mapping email addresses to branch paths + +Handler Independence: +- No module imports from ai_mail +- Only uses Prax logger and standard library +- Fully transportable and self-contained +""" + +import json +from pathlib import Path +from typing import List, Dict + +from aipass.cli.apps.modules import console + +# Constants +MODULE_NAME = "registry.read" +BRANCH_REGISTRY_PATH = Path("/home/aipass/BRANCH_REGISTRY.json") + + +def get_all_branches() -> List[Dict]: + """ + Get list of all branches for email selection. + Reads from AIPass branch registry at /home/aipass/BRANCH_REGISTRY.json + + Returns: + List of dicts with branch info: + [{"name": "AIPASS.admin", "path": "/", "email": "@admin"}, ...] + + Note: + Returns empty list if registry not found or on error. + """ + branches = [] + + if not BRANCH_REGISTRY_PATH.exists(): + return [] + + try: + with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f: + registry_data = json.load(f) + + # Parse branch entries from JSON structure + for branch in registry_data.get("branches", []): + branch_name = branch.get("name", "") + path = branch.get("path", "") + + if not branch_name or not path: + continue + + # Derive email address from branch name + email = _derive_email_from_branch_name(branch_name) + + branches.append({ + "name": branch_name, + "path": path, + "email": email + }) + + return branches + + except Exception as e: + return [] + + +def _derive_email_from_branch_name(branch_name: str) -> str: + """ + Derive email address from branch name. + + Rules: + - AIPASS.admin -> @admin (take part after dot) + - AIPASS Workshop -> @aipass (take first word) + - AIPASS-HELP -> @help (take second part to avoid collision) + - BACKUP-SYSTEM -> @backup (take first part) + - DRONE -> @drone (take whole name) + + Args: + branch_name: Branch name from registry + + Returns: + Email address in format "@email" + """ + if '.' in branch_name: + # Special case: AIPASS.admin -> admin + email_part = branch_name.split('.')[-1].lower() + elif ' ' in branch_name: + # Handle spaces: take first word + email_part = branch_name.split()[0].lower() + elif '-' in branch_name and branch_name.split('-')[0] == 'AIPASS': + # AIPASS-prefixed branches: use second part to avoid collision + email_part = branch_name.split('-', 1)[1].lower() + else: + # Take first word before hyphen or whole name + email_part = branch_name.split('-')[0].lower() + + return f"@{email_part}" + + +def get_branch_by_email(email: str) -> Dict | None: + """ + Get branch information by email address. + + Args: + email: Email address (e.g., "@admin") + + Returns: + Branch dict with name, path, email or None if not found + """ + branches = get_all_branches() + + for branch in branches: + if branch["email"] == email: + return branch + + return None + + +def get_branch_email_map() -> Dict[str, str]: + """ + Get mapping of email addresses to branch names. + + Returns: + Dict mapping email -> branch_name + Example: {"@admin": "AIPASS.admin", "@flow": "FLOW"} + """ + branches = get_all_branches() + return {branch["email"]: branch["name"] for branch in branches} + + +def get_branch_path_map() -> Dict[str, str]: + """ + Get mapping of email addresses to branch paths. + + Returns: + Dict mapping email -> path + Example: {"@admin": "/", "@flow": "/home/aipass/flow"} + """ + branches = get_all_branches() + return {branch["email"]: branch["path"] for branch in branches} + + +if __name__ == "__main__": + console.print("\n" + "="*70) + console.print("AI_MAIL HANDLER: registry/read.py") + console.print("="*70) + console.print("\nRegistry Read Handler") + console.print() + console.print("FUNCTIONS PROVIDED:") + console.print(" - get_all_branches() -> List[Dict]") + console.print(" - get_branch_by_email(email) -> Dict | None") + console.print(" - get_branch_email_map() -> Dict[str, str]") + console.print(" - get_branch_path_map() -> Dict[str, str]") + console.print() + console.print("TESTING:") + + branches = get_all_branches() + console.print(f"\nLoaded {len(branches)} branches:") + for branch in branches[:5]: # Show first 5 + console.print(f" {branch['email']:15} -> {branch['name']}") + + if len(branches) > 5: + console.print(f" ... and {len(branches) - 5} more") + + console.print("\n" + "="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/registry/update.py b/src/aipass/ai_mail/apps/handlers/registry/update.py new file mode 100644 index 00000000..97567909 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/registry/update.py @@ -0,0 +1,275 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: update.py - Registry Update Handler +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: ai_mail/handlers/registry +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Extracted from ai_mail_branch_ping.py +# +# CODE STANDARDS: +# - Handlers are INDEPENDENT (no cross-domain imports) +# - Use: from prax.apps.modules.logger import system_logger as logger +# - Keep handlers <300 lines each +# ============================================= + +""" +Registry Update Handler + +Handles updating branch registry with ping data including: +- Updating registry with branch status +- Recording ping timestamps +- Maintaining statistics (green/yellow/red counts) + +Handler Independence: +- No module imports from ai_mail +- Only uses Prax logger and standard library +- Fully transportable and self-contained +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, Tuple + +from aipass.cli.apps.modules import console + +# Constants +MODULE_NAME = "registry.update" +AIPASS_ROOT = Path.home() / "aipass_core" +AI_MAIL_ROOT = AIPASS_ROOT / "ai_mail" +AI_MAIL_JSON = AI_MAIL_ROOT / "ai_mail_json" +REGISTRY_PATH = AI_MAIL_JSON / "local_memory_monitor_registry.json" +THRESHOLDS = { + "green": (0, 400), + "yellow": (401, 550), + "red": (551, float('inf')) +} + + +def ping_registry( + branch_name: str, + branch_path: Path, + local_status: Dict, + obs_status: Dict +) -> bool: + """ + Update registry with branch status. + + Args: + branch_name: Name of branch (e.g., "FLOW", "AIPASS.admin") + branch_path: Full path to branch directory + local_status: Dict with {"line_count": int, "status": str} + obs_status: Dict with {"line_count": int, "status": str} + + Returns: + True if registry updated successfully, False otherwise + """ + try: + # Ensure registry directory exists + REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True) + + # Load or create registry + if REGISTRY_PATH.exists(): + with open(REGISTRY_PATH, 'r', encoding='utf-8') as f: + registry = json.load(f) + else: + registry = _create_empty_registry() + + # Update branch entry + registry["active_branches"][str(branch_path)] = { + "branch_name": branch_name, + "last_ping": datetime.now().isoformat(), + "local_md": local_status, + "observations_md": obs_status + } + + # Update statistics + registry["last_updated"] = datetime.now().isoformat() + registry["statistics"] = _calculate_statistics(registry) + + # Save registry + with open(REGISTRY_PATH, 'w', encoding='utf-8') as f: + json.dump(registry, f, indent=2) + + return True + + except Exception as e: + return False + + +def _create_empty_registry() -> Dict: + """ + Create empty registry structure. + + Returns: + Empty registry dict + """ + return { + "last_updated": "", + "active_branches": {}, + "statistics": { + "total_branches": 0, + "green_status": 0, + "yellow_status": 0, + "red_status": 0 + } + } + + +def _calculate_statistics(registry: Dict) -> Dict: + """ + Calculate statistics from registry data. + + Args: + registry: Full registry dict + + Returns: + Statistics dict with counts + """ + green, yellow, red = 0, 0, 0 + + for branch_data in registry["active_branches"].values(): + for file_type in ["local_md", "observations_md"]: + status = branch_data.get(file_type, {}).get("status", "") + if status == "green": + green += 1 + elif status == "yellow": + yellow += 1 + elif status == "red": + red += 1 + + return { + "total_branches": len(registry["active_branches"]), + "green_status": green, + "yellow_status": yellow, + "red_status": red + } + + +def get_status_from_count(line_count: int) -> str: + """ + Determine status based on line count. + + Args: + line_count: Number of lines in file + + Returns: + Status code: "green", "yellow", or "red" + """ + if THRESHOLDS["green"][0] <= line_count <= THRESHOLDS["green"][1]: + return "green" + elif THRESHOLDS["yellow"][0] <= line_count <= THRESHOLDS["yellow"][1]: + return "yellow" + else: # red threshold + return "red" + + +def count_file_lines(file_path: Path) -> int: + """ + Count total lines in file. + + Args: + file_path: Path to file to count + + Returns: + Number of lines in file, 0 if file doesn't exist + """ + if not file_path.exists(): + return 0 + + try: + with open(file_path, 'r', encoding='utf-8') as f: + return len(f.readlines()) + except Exception as e: + return 0 + + +def update_json_memory_health( + file_path: Path, + line_count: int, + status_code: str +) -> bool: + """ + Update memory_health in JSON file metadata. + + Args: + file_path: Path to JSON file + line_count: Current line count + status_code: Status ("green", "yellow", "red") + + Returns: + True if updated successfully, False otherwise + """ + if not file_path.exists(): + return False + + try: + with open(file_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + # Update memory health in metadata + if "metadata" in data and "memory_health" in data["metadata"]: + data["metadata"]["memory_health"]["current_lines"] = line_count + data["metadata"]["memory_health"]["status"] = status_code + + # Save updated file + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + return True + else: + return False + + except Exception as e: + return False + + +def get_branch_context() -> Tuple[str, Path]: + """ + Determine current branch name and directory. + + Returns: + Tuple of (branch_name, branch_path) + """ + cwd = Path.cwd() + + # Special case: root directory + if cwd == Path("/"): + return "AIPASS.admin", cwd + + # Extract branch name from last directory in path + branch_folder = cwd.name.replace("-", "_") + branch_name = branch_folder.upper() + + return branch_name, cwd + + +if __name__ == "__main__": + console.print("\n" + "="*70) + console.print("AI_MAIL HANDLER: registry/update.py") + console.print("="*70) + console.print("\nRegistry Update Handler") + console.print() + console.print("FUNCTIONS PROVIDED:") + console.print(" - ping_registry(branch_name, branch_path, local_status, obs_status) -> bool") + console.print(" - get_status_from_count(line_count) -> str") + console.print(" - count_file_lines(file_path) -> int") + console.print(" - update_json_memory_health(file_path, line_count, status_code) -> bool") + console.print(" - get_branch_context() -> Tuple[str, Path]") + console.print() + console.print("THRESHOLDS:") + console.print(f" Green: {THRESHOLDS['green'][0]} - {THRESHOLDS['green'][1]} lines") + console.print(f" Yellow: {THRESHOLDS['yellow'][0]} - {THRESHOLDS['yellow'][1]} lines") + console.print(f" Red: {THRESHOLDS['red'][0]}+ lines") + console.print() + console.print("TESTING:") + + branch_name, branch_path = get_branch_context() + console.print(f"\nCurrent branch: {branch_name}") + console.print(f"Current path: {branch_path}") + + console.print("\n" + "="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/registry/validate.py b/src/aipass/ai_mail/apps/handlers/registry/validate.py new file mode 100644 index 00000000..bc06bf14 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/registry/validate.py @@ -0,0 +1,250 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: validate.py - Registry Validation Handler +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: ai_mail/handlers/registry +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Extracted from ai_mail_cli.py +# +# CODE STANDARDS: +# - Handlers are INDEPENDENT (no cross-domain imports) +# - Use: from prax.apps.modules.logger import system_logger as logger +# - Keep handlers <300 lines each +# ============================================= + +""" +Registry Validation Handler + +Handles validation of branch registry data including: +- Checking for email address collisions +- Validating email derivation rules +- Detecting unreachable branches + +Handler Independence: +- No module imports from ai_mail +- Only uses Prax logger and standard library +- Fully transportable and self-contained +""" + +from pathlib import Path +from typing import List, Dict, Tuple + +from aipass.cli.apps.modules import console + +# Constants +MODULE_NAME = "registry.validate" + + +def check_email_collisions(branches: List[Dict]) -> Tuple[bool, List[Dict]]: + """ + Check for email address collisions in branch list. + + Args: + branches: List of branch dicts with keys: name, path, email + + Returns: + Tuple of (has_collisions: bool, collisions: List[Dict]) + Collision dict format: + { + "email": "@email", + "branch1": "First Branch Name", + "branch2": "Second Branch Name" + } + """ + email_map = {} + collisions = [] + + for branch in branches: + email = branch["email"] + if email in email_map: + # Collision detected + collisions.append({ + "email": email, + "branch1": email_map[email], + "branch2": branch["name"] + }) + else: + email_map[email] = branch["name"] + + has_collisions = len(collisions) > 0 + + return has_collisions, collisions + + +def get_collision_report(collisions: List[Dict]) -> str: + """ + Generate human-readable collision report. + + Args: + collisions: List of collision dicts from check_email_collisions() + + Returns: + Formatted report string + """ + if not collisions: + return "No collisions detected." + + report_lines = [ + f"EMAIL ADDRESS COLLISIONS DETECTED: {len(collisions)}", + "", + "One or more branches are unreachable via AI_Mail!", + "", + "Collisions:" + ] + + for collision in collisions: + report_lines.extend([ + "", + f" {collision['email']}", + f" - {collision['branch1']}", + f" - {collision['branch2']}", + ]) + + report_lines.extend([ + "", + "Fix: Rename branches in BRANCH_REGISTRY.json to ensure unique email derivation", + "See email derivation rules in registry/read.py" + ]) + + return "\n".join(report_lines) + + +def validate_branch_data(branch: Dict) -> Tuple[bool, str]: + """ + Validate a single branch entry. + + Args: + branch: Branch dict with keys: name, path, email + + Returns: + Tuple of (is_valid: bool, error_message: str) + error_message is empty string if valid + """ + # Check required fields + required_fields = ["name", "path", "email"] + for field in required_fields: + if field not in branch: + return False, f"Missing required field: {field}" + if not branch[field]: + return False, f"Empty value for field: {field}" + + # Validate email format + if not branch["email"].startswith("@"): + return False, f"Email must start with @: {branch['email']}" + + # Validate path format + path = branch["path"] + if not path.startswith("/"): + return False, f"Path must be absolute: {path}" + + return True, "" + + +def validate_all_branches(branches: List[Dict]) -> Tuple[bool, List[str]]: + """ + Validate all branch entries. + + Args: + branches: List of branch dicts + + Returns: + Tuple of (all_valid: bool, error_messages: List[str]) + """ + errors = [] + + for i, branch in enumerate(branches): + is_valid, error_msg = validate_branch_data(branch) + if not is_valid: + errors.append(f"Branch {i} ({branch.get('name', 'UNKNOWN')}): {error_msg}") + + all_valid = len(errors) == 0 + + return all_valid, errors + + +def get_duplicate_names(branches: List[Dict]) -> List[str]: + """ + Find duplicate branch names. + + Args: + branches: List of branch dicts + + Returns: + List of duplicate branch names + """ + name_counts = {} + duplicates = [] + + for branch in branches: + name = branch.get("name", "") + if name: + name_counts[name] = name_counts.get(name, 0) + 1 + + for name, count in name_counts.items(): + if count > 1: + duplicates.append(name) + + return duplicates + + +def get_duplicate_paths(branches: List[Dict]) -> List[str]: + """ + Find duplicate branch paths. + + Args: + branches: List of branch dicts + + Returns: + List of duplicate branch paths + """ + path_counts = {} + duplicates = [] + + for branch in branches: + path = branch.get("path", "") + if path: + path_counts[path] = path_counts.get(path, 0) + 1 + + for path, count in path_counts.items(): + if count > 1: + duplicates.append(path) + + return duplicates + + +if __name__ == "__main__": + console.print("\n" + "="*70) + console.print("AI_MAIL HANDLER: registry/validate.py") + console.print("="*70) + console.print("\nRegistry Validation Handler") + console.print() + console.print("FUNCTIONS PROVIDED:") + console.print(" - check_email_collisions(branches) -> Tuple[bool, List[Dict]]") + console.print(" - get_collision_report(collisions) -> str") + console.print(" - validate_branch_data(branch) -> Tuple[bool, str]") + console.print(" - validate_all_branches(branches) -> Tuple[bool, List[str]]") + console.print(" - get_duplicate_names(branches) -> List[str]") + console.print(" - get_duplicate_paths(branches) -> List[str]") + console.print() + console.print("TESTING:") + + # Sample test data + test_branches = [ + {"name": "AIPASS.admin", "path": "/", "email": "@admin"}, + {"name": "FLOW", "path": "/home/aipass/flow", "email": "@flow"}, + {"name": "DRONE", "path": "/home/aipass/drone", "email": "@drone"}, + ] + + has_collisions, collisions = check_email_collisions(test_branches) + console.print(f"\nCollisions detected: {has_collisions}") + console.print(f"Number of collisions: {len(collisions)}") + + all_valid, errors = validate_all_branches(test_branches) + console.print(f"\nAll branches valid: {all_valid}") + console.print(f"Validation errors: {len(errors)}") + + console.print("\n" + "="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/trigger/__init__.py b/src/aipass/ai_mail/apps/handlers/trigger/__init__.py new file mode 100644 index 00000000..3cdd4ed3 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/trigger/__init__.py @@ -0,0 +1,27 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - Trigger Event Handlers Package +# Date: 2026-02-02 +# Version: 1.0.0 +# Category: ai_mail/handlers/trigger +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-02): Created - FPLAN-0284 Phase 2 +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - Package init for trigger event handlers +# ============================================= + +""" +Trigger Event Handlers Package + +Contains handlers that respond to Trigger events. +These handlers are registered by Trigger's event registry. +""" + +from .error_handler import handle_error_detected + +__all__ = ['handle_error_detected'] diff --git a/src/aipass/ai_mail/apps/handlers/trigger/error_handler.py b/src/aipass/ai_mail/apps/handlers/trigger/error_handler.py new file mode 100644 index 00000000..423aa383 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/trigger/error_handler.py @@ -0,0 +1,182 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: error_handler.py - Error Detected Event Consumer +# Date: 2026-02-02 +# Version: 1.0.0 +# Category: ai_mail/handlers/trigger +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-02): Created - FPLAN-0284 Phase 2 +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - NO console.print() - handlers return data to modules +# - NO logger calls in handler (causes recursion with trigger) +# - Silent failure pattern - catch all exceptions +# - Responds to error_detected events from Trigger's log_watcher +# ============================================= + +""" +Error Detected Event Consumer + +Handles error_detected events fired by Trigger's log_watcher. +Delivers notifications to affected branches via AI_MAIL. + +Event data from log_watcher.py: + - branch: Target branch name (e.g., 'FLOW') + - module: Module that logged the error + - message: Error message text + - log_path: Path to log file + - error_hash: 8-char hash for deduplication + - timestamp: When error occurred + +Architecture: + 1. Trigger's log_watcher detects ERROR in branch logs + 2. log_watcher fires error_detected event + 3. This handler receives event, calls deliver_email_to_branch() + 4. Email delivered to affected branch inbox (auto_execute=True) + 5. Branch agent spawns and investigates +""" + +from datetime import datetime +from pathlib import Path +from typing import Any + + +def _build_notification_message( + error_hash: str, + module: str, + message: str, + timestamp: str, + log_path: str +) -> str: + """ + Build error notification message with investigation instructions. + + Args: + error_hash: Unique error identifier (8-char) + module: Module that logged the error + message: Error message text + timestamp: When error occurred + log_path: Path to source log file + + Returns: + Formatted message string with investigation instructions + """ + return f"""Error detected - investigate and respond. + +Error ID: {error_hash} +Module: {module} +Timestamp: {timestamp} +Log file: {log_path} + +Error message: +{message} + +--- +INVESTIGATION STEPS: +1. Check the log file for context around this error +2. Identify root cause + +DECISION TREE: +- SIMPLE FIX (typo, missing import, config issue): + -> Fix it yourself, then report what you did to @dev_central +- COMPLEX/UNCLEAR (needs research, affects multiple files): + -> Report findings only to @dev_central, recommend action, don't fix +- CRITICAL (data loss risk, security, system stability): + -> STOP immediately, escalate to @dev_central with full context + +REPORT TO @dev_central: + ai_mail send @dev_central "ERROR {error_hash} - [STATUS]" "Findings..." + + Include: Error ID, severity (low/medium/high/critical), what you found, action taken or recommended. +""" + + +def handle_error_detected( + branch: str | None = None, + module: str | None = None, + message: str | None = None, + log_path: str | None = None, + error_hash: str | None = None, + timestamp: str | None = None, + **kwargs: Any +) -> None: + """ + Handle error_detected event - deliver notification to affected branch. + + Called by Trigger when log_watcher detects an ERROR in branch logs. + Sends email to affected branch with auto_execute=True so an + investigation agent spawns automatically. + + Args: + branch: Target branch name (e.g., 'FLOW') - REQUIRED + module: Module that logged the error - REQUIRED + message: Error message text - REQUIRED + log_path: Path to source log file + error_hash: 8-char unique error identifier - REQUIRED + timestamp: When error occurred (defaults to now) + **kwargs: Additional event data (ignored) + + Returns: + None - handlers must not return values + + Note: + Handler follows silent failure pattern - all exceptions caught. + NO logger imports (causes infinite recursion with trigger events). + NO console.print() (handlers must be silent). + """ + try: + # Validate required fields + if not branch or not module or not message or not error_hash: + return + + # Import delivery handler here to avoid import-time failures + try: + from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch + except ImportError: + return + + # Default timestamp to now if not provided + if not timestamp: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Default log_path if not provided + if not log_path: + log_path = "unknown" + + # Convert branch name to email format (FLOW -> @flow) + branch_email = f"@{branch.lower()}" + + # Build subject line + subject = f"[ERROR] {module} - detected in logs" + + # Build notification message + notification_message = _build_notification_message( + error_hash=error_hash, + module=module, + message=message, + timestamp=timestamp, + log_path=log_path + ) + + # Build email data for delivery + email_data = { + 'from': '@error_monitor', + 'from_name': 'Error Monitor', + 'to': branch_email, + 'subject': subject, + 'message': notification_message, + 'timestamp': timestamp, + 'auto_execute': True, + 'priority': 'normal', + 'reply_to': '@dev_central' + } + + # Deliver via inbox.json + deliver_email_to_branch(branch_email, email_data) + + except Exception: + pass diff --git a/src/aipass/ai_mail/apps/handlers/users/__init__.py b/src/aipass/ai_mail/apps/handlers/users/__init__.py new file mode 100644 index 00000000..2aa0717d --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/users/__init__.py @@ -0,0 +1,31 @@ +""" +User Handlers - User Configuration and Management + +Handles loading of user configuration files and user information for AI_Mail system. +""" + +from .load import ( + load_user_config, + load_config, + create_default_config, + load_or_create_config +) + +from .user import ( + get_current_user, + get_user_by_email, + get_all_users +) + +__all__ = [ + # Config loading + 'load_user_config', + 'load_config', + 'create_default_config', + 'load_or_create_config', + + # User info + 'get_current_user', + 'get_user_by_email', + 'get_all_users', +] diff --git a/src/aipass/ai_mail/apps/handlers/users/branch_detection.py b/src/aipass/ai_mail/apps/handlers/users/branch_detection.py new file mode 100644 index 00000000..0b9feecc --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/users/branch_detection.py @@ -0,0 +1,209 @@ +#!/usr/bin/env python3 + +# ============================================= +# META DATA HEADER +# Name: branch_detection.py - Branch Auto-Detection Handler +# Date: 2025-11-18 +# Version: 1.0.0 +# Category: ai_mail/handlers/users +# +# CHANGELOG: +# - v1.0.0 (2025-11-18): Initial creation - PWD/CWD branch detection +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Can import Prax modules (service providers) +# - Pure business logic only +# ============================================= + +""" +Branch Auto-Detection Handler + +Detects which branch is calling AI_MAIL based on PWD/CWD. +Walks up directory tree to find branch root (has [BRANCH].id.json file). +""" + +# ============================================= +# IMPORTS +# ============================================= +import os +import json +from pathlib import Path +from typing import Dict + +# ============================================= +# CONSTANTS +# ============================================= +BRANCH_REGISTRY_PATH = Path.home() / "BRANCH_REGISTRY.json" + +# ============================================= +# BRANCH DETECTION FUNCTIONS +# ============================================= + +def detect_branch_from_pwd() -> Dict | None: + """ + Detect which branch is calling based on current working directory. + + Walks up directory tree from PWD to find branch root (directory with [BRANCH].id.json). + Then looks up branch info in BRANCH_REGISTRY.json. + + Returns: + Dict with branch info if detected: + { + "name": "SEED", + "path": "/home/aipass/seed", + "email": "@seed", + "display_name": "Seed (Standards Branch)", + ... + } + None if no branch detected + """ + try: + # Get current working directory + cwd = Path.cwd() + + # Find branch root + branch_root = find_branch_root(cwd) + if not branch_root: + return None + + # Get branch info from registry + branch_info = get_branch_info_from_registry(branch_root) + if not branch_info: + return None + + return branch_info + + except Exception: + return None + + +def find_branch_root(start_path: Path) -> Path | None: + """ + Walk up directory tree to find branch root. + + Branch root = directory containing a [BRANCH_NAME].id.json file. + Example: /home/aipass/seed/ contains SEED.id.json + + Args: + start_path: Directory to start searching from (usually PWD) + + Returns: + Path to branch root directory, or None if not found + """ + current = start_path.resolve() + + # Walk up directory tree (max 10 levels to prevent infinite loop) + for _ in range(10): + # Check if this directory has a [BRANCH].id.json file + for file in current.glob("*.id.json"): + # Found a .id.json file - this is likely a branch root + return current + + # Move up one level + parent = current.parent + if parent == current: # Reached filesystem root + break + current = parent + + return None + + +def get_branch_info_from_registry(branch_path: Path) -> Dict | None: + """ + Look up branch information in BRANCH_REGISTRY.json by path. + + Args: + branch_path: Path to branch directory + + Returns: + Dict with branch info from registry, or None if not found + """ + if not BRANCH_REGISTRY_PATH.exists(): + return None + + try: + with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f: + registry = json.load(f) + + # Normalize branch_path for comparison + branch_path_str = str(branch_path.resolve()) + + # Search registry for matching path + for branch in registry.get("branches", []): + if Path(branch["path"]).resolve() == Path(branch_path_str): + # Found match - return branch info + return branch + + return None + + except Exception: + return None + + +def get_branch_display_name(branch_info: Dict) -> str: + """ + Generate display name for branch from registry info. + + Args: + branch_info: Branch dict from registry + + Returns: + Display name string (e.g., "Seed (Standards Branch)") + """ + name = branch_info.get("name", "Unknown") + description = branch_info.get("description", "") + + if description and description != "New branch - purpose TBD": + # Use description as context + return f"{name.title()} ({description})" + else: + # Just use name + return name.title() + + +def get_local_config_path(branch_path: Path, branch_name: str) -> Path: + """ + Get path to local user_config.json for a branch. + + Args: + branch_path: Path to branch directory + branch_name: Branch name (e.g., "SEED", "DRONE") + + Returns: + Path to local config file ([branch_name]_json/user_config.json) + """ + branch_name_lower = branch_name.lower() + return branch_path / f"{branch_name_lower}_json" / "user_config.json" + + +if __name__ == "__main__": + from aipass.cli.apps.modules import console + + console.print("\n" + "="*70) + console.print("BRANCH AUTO-DETECTION HANDLER") + console.print("="*70) + console.print("\nPURPOSE:") + console.print(" Detects which branch is calling AI_MAIL based on PWD/CWD") + console.print(" Walks up directory tree to find branch root") + console.print() + console.print("FUNCTIONS PROVIDED:") + console.print(" - detect_branch_from_pwd() -> Dict | None") + console.print(" - find_branch_root(start_path) -> Path | None") + console.print(" - get_branch_info_from_registry(branch_path) -> Dict | None") + console.print(" - get_branch_display_name(branch_info) -> str") + console.print(" - get_local_config_path(branch_path) -> Path") + console.print() + console.print("HANDLER CHARACTERISTICS:") + console.print(" ✓ Independent - no module dependencies") + console.print(" ✓ Can import Prax (service provider)") + console.print(" ✓ Pure business logic") + console.print(" ✗ CANNOT import parent modules") + console.print() + console.print("DETECTION FLOW:") + console.print(" 1. Get current working directory (PWD)") + console.print(" 2. Walk up tree to find [BRANCH].id.json file") + console.print(" 3. Look up branch path in BRANCH_REGISTRY.json") + console.print(" 4. Return branch info (name, email, path, etc.)") + console.print() + console.print("="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/users/config_generator.py b/src/aipass/ai_mail/apps/handlers/users/config_generator.py new file mode 100644 index 00000000..21f1f857 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/users/config_generator.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 + +# ============================================= +# META DATA HEADER +# Name: config_generator.py - Local Config Auto-Generation Handler +# Date: 2025-11-18 +# Version: 1.0.0 +# Category: ai_mail/handlers/users +# +# CHANGELOG: +# - v1.0.0 (2025-11-18): Initial creation - auto-generate branch configs +# +# CODE STANDARDS: +# - Handler independence: NO cross-domain imports +# - Can import Prax modules (service providers) +# - Pure business logic only +# ============================================= + +""" +Local Config Auto-Generation Handler + +Auto-generates user_config.json files for branches that use AI_MAIL. +Creates ai_mail_config/ directory and populates with branch-specific config. +""" + +# ============================================= +# IMPORTS +# ============================================= +import json +from pathlib import Path +from typing import Dict + +# ============================================= +# CONFIG GENERATION FUNCTIONS +# ============================================= + +def generate_local_config(branch_info: Dict) -> Dict: + """ + Generate local user_config.json content for a branch. + + Args: + branch_info: Branch info dict from registry with keys: + - name: Branch name (e.g., "SEED") + - path: Branch path + - email: Branch email address (e.g., "@seed") + - description: Branch description + + Returns: + Dict with user_config.json structure + """ + branch_name = branch_info.get("name", "").lower() + branch_email = branch_info.get("email", f"@{branch_name}") + branch_path = Path(branch_info.get("path", "")) + + # Generate display name from branch info + display_name = generate_display_name(branch_info) + + # Generate mailbox path (ai_mail.local/ in branch directory) + mailbox_path = str(branch_path / "ai_mail.local") + + config = { + "version": "1.0.0", + "current_user": branch_name, + "users": { + branch_name: { + "name": branch_info.get("name", "").title(), + "email_address": branch_email, + "display_name": display_name, + "mailbox_path": mailbox_path + } + }, + "settings": { + "timestamp_format": "%Y-%m-%d %H:%M:%S", + "max_inbox_display": 20, + "max_sent_display": 20 + } + } + + return config + + +def generate_display_name(branch_info: Dict) -> str: + """ + Generate user-friendly display name for branch. + + Args: + branch_info: Branch info dict from registry + + Returns: + Display name string (e.g., "Seed (Standards Branch)") + """ + name = branch_info.get("name", "Unknown").title() + description = branch_info.get("description", "") + + # If description is meaningful (not default), use it + if description and description != "New branch - purpose TBD": + return f"{name} ({description})" + else: + # Use profile as context if available + profile = branch_info.get("profile", "") + if profile and profile != "AIPass Workshop": + return f"{name} ({profile})" + else: + # Just branch name + return name + + +def create_local_config_file(branch_info: Dict, force: bool = False) -> Path | None: + """ + Create local user_config.json file for a branch. + + Saves to branch's [branch_name]_json/ directory (e.g., seed_json/user_config.json). + Follows the pattern: all JSON files for a branch go in their [branch]_json/ folder. + + Args: + branch_info: Branch info dict from registry + force: If True, overwrite existing config file + + Returns: + Path to created config file, or None if failed + """ + try: + branch_path = Path(branch_info.get("path", "")) + if not branch_path.exists(): + return None + + # Get branch name for directory pattern + branch_name = branch_info.get("name", "").lower() + + # Config directory: [branch_name]_json/ + config_dir = branch_path / f"{branch_name}_json" + + # Create directory if it doesn't exist + if not config_dir.exists(): + config_dir.mkdir(parents=True, exist_ok=True) + + # Config file path + config_file = config_dir / "user_config.json" + + # Check if already exists + if config_file.exists() and not force: + return config_file + + # Generate config content + config = generate_local_config(branch_info) + + # Write config file + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=2) + + return config_file + + except Exception: + return None + + +def create_mailbox_directory(branch_path: Path) -> Path | None: + """ + Create ai_mail.local/ mailbox directory for a branch. + + Creates subdirectories: inbox/, sent/, deleted/ + + Args: + branch_path: Path to branch directory + + Returns: + Path to mailbox directory, or None if failed + """ + try: + mailbox_dir = branch_path / "ai_mail.local" + mailbox_dir.mkdir(parents=True, exist_ok=True) + + # Create subdirectories + (mailbox_dir / "sent").mkdir(exist_ok=True) + + # Create empty inbox.json if doesn't exist + inbox_file = mailbox_dir / "inbox.json" + if not inbox_file.exists(): + inbox_data = { + "mailbox": "inbox", + "total_messages": 0, + "unread_count": 0, + "messages": [] + } + with open(inbox_file, 'w', encoding='utf-8') as f: + json.dump(inbox_data, f, indent=2) + + # Create empty sent.json if doesn't exist + sent_file = mailbox_dir / "sent.json" + if not sent_file.exists(): + sent_data = { + "mailbox": "sent", + "total_messages": 0, + "messages": [] + } + with open(sent_file, 'w', encoding='utf-8') as f: + json.dump(sent_data, f, indent=2) + + return mailbox_dir + + except Exception: + return None + + +def setup_branch_for_aimail(branch_info: Dict, force: bool = False) -> bool: + """ + Complete AI_MAIL setup for a branch. + + Creates: + - ai_mail_config/user_config.json + - ai_mail.local/ mailbox directory + - ai_mail.local/inbox.json + - ai_mail.local/sent.json + + Args: + branch_info: Branch info dict from registry + force: If True, overwrite existing files + + Returns: + True if setup successful, False otherwise + """ + try: + # Create config file + config_file = create_local_config_file(branch_info, force=force) + if not config_file: + return False + + # Create mailbox directory + branch_path = Path(branch_info.get("path", "")) + mailbox_dir = create_mailbox_directory(branch_path) + if not mailbox_dir: + return False + + return True + + except Exception: + return False + + +if __name__ == "__main__": + from aipass.cli.apps.modules import console + + console.print("\n" + "="*70) + console.print("LOCAL CONFIG AUTO-GENERATION HANDLER") + console.print("="*70) + console.print("\nPURPOSE:") + console.print(" Auto-generates user_config.json files for branches using AI_MAIL") + console.print(" Creates ai_mail_config/ directory and mailbox structure") + console.print() + console.print("FUNCTIONS PROVIDED:") + console.print(" - generate_local_config(branch_info) -> Dict") + console.print(" - generate_display_name(branch_info) -> str") + console.print(" - create_local_config_file(branch_info, force) -> Path | None") + console.print(" - create_mailbox_directory(branch_path) -> Path | None") + console.print(" - setup_branch_for_aimail(branch_info, force) -> bool") + console.print() + console.print("HANDLER CHARACTERISTICS:") + console.print(" ✓ Independent - no module dependencies") + console.print(" ✓ Can import Prax (service provider)") + console.print(" ✓ Pure business logic") + console.print(" ✗ CANNOT import parent modules") + console.print() + console.print("SETUP WORKFLOW:") + console.print(" 1. Generate config from branch registry info") + console.print(" 2. Create ai_mail_config/ directory") + console.print(" 3. Write user_config.json with branch-specific settings") + console.print(" 4. Create ai_mail.local/ mailbox directory") + console.print(" 5. Initialize inbox.json and sent.json") + console.print() + console.print("="*70 + "\n") diff --git a/src/aipass/ai_mail/apps/handlers/users/load.py b/src/aipass/ai_mail/apps/handlers/users/load.py new file mode 100644 index 00000000..a04deae0 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/users/load.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 + +# ============================================= +# META DATA HEADER +# Name: load.py - User Config Loading Handler +# Date: 2025-11-15 +# Version: 1.2.0 +# Category: ai_mail/handlers/users +# +# CHANGELOG: +# - v1.2.0 (2025-11-18): Added per-branch config support with PWD detection +# - v1.1.0 (2025-11-15): Renamed domain config -> users for business purpose naming +# - v1.0.0 (2025-11-15): Extracted from ai_mail_cli.py and ai_mail_local_memory_monitor.py +# ============================================= + +""" +User Config Loading Handler + +Handles loading of user configuration files for AI_Mail system. +Provides unified config loading for user_config.json and module configs. + +NEW in v1.2.0: Per-branch config support +- Detects calling branch from PWD/CWD +- Checks for local config at branch_path/ai_mail_config/user_config.json +- Falls back to AI_MAIL's global config if no local config found +""" + +# ============================================= +# IMPORTS +# ============================================= +import json +from pathlib import Path +from typing import Dict + +# ============================================= +# CONSTANTS +# ============================================= +AIPASS_ROOT = Path.home() / "aipass_core" +AI_MAIL_ROOT = AIPASS_ROOT / "ai_mail" +AI_MAIL_JSON = AI_MAIL_ROOT / "ai_mail_json" +USER_CONFIG_FILE = AI_MAIL_JSON / "user_config.json" + +# Import branch detection (after constants defined) +from .branch_detection import detect_branch_from_pwd, get_local_config_path + +# ============================================= +# CONFIG LOADING FUNCTIONS +# ============================================= + +def load_user_config() -> Dict: + """ + Load user configuration from user_config.json + + NEW in v1.2.0: Per-branch config support + - First checks if calling from a branch (detects via PWD) + - Looks for local config at branch_path/ai_mail_config/user_config.json + - Falls back to AI_MAIL's global config if no local config + - Auto-generates local config if branch detected but config missing + + Returns: + Dict containing user configuration + + Raises: + FileNotFoundError: If no config found (neither local nor global) + """ + # Try to detect calling branch from PWD + branch_info = detect_branch_from_pwd() + + if branch_info: + # Branch detected - check for local config + branch_path = Path(branch_info["path"]) + branch_name = branch_info["name"] + local_config_path = get_local_config_path(branch_path, branch_name) + + if local_config_path.exists(): + # Local config exists - use it + with open(local_config_path, 'r', encoding='utf-8') as f: + return json.load(f) + else: + # No local config - offer to auto-generate + # For now, fall through to global config + pass + + # No branch detected or no local config - use AI_MAIL's global config + if not USER_CONFIG_FILE.exists(): + raise FileNotFoundError(f"User config not found: {USER_CONFIG_FILE}") + + with open(USER_CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + + +def load_config(config_file: Path) -> Dict: + """ + Load generic configuration file with auto-healing + + Creates default config if missing (for module configs). + + Args: + config_file: Path to configuration file + + Returns: + Dict containing configuration data + + Raises: + Exception: If config cannot be loaded or created + """ + if not config_file.exists(): + raise FileNotFoundError(f"Config file not found: {config_file}") + + try: + with open(config_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + raise + + +def create_default_config(config_file: Path, default_config: Dict) -> None: + """ + Create default configuration file + + Args: + config_file: Path where config should be created + default_config: Default configuration dictionary + + Raises: + Exception: If config cannot be created + """ + # Ensure parent directory exists + config_file.parent.mkdir(parents=True, exist_ok=True) + + try: + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(default_config, f, indent=2) + except Exception as e: + raise + + +def load_or_create_config(config_file: Path, default_config: Dict) -> Dict: + """ + Load config file, creating with defaults if missing + + Args: + config_file: Path to configuration file + default_config: Default configuration to use if file doesn't exist + + Returns: + Dict containing configuration data + """ + if not config_file.exists(): + create_default_config(config_file, default_config) + + return load_config(config_file) diff --git a/src/aipass/ai_mail/apps/handlers/users/user.py b/src/aipass/ai_mail/apps/handlers/users/user.py new file mode 100644 index 00000000..05b22482 --- /dev/null +++ b/src/aipass/ai_mail/apps/handlers/users/user.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 + +# ============================================= +# META DATA HEADER +# Name: user.py - User Info Handler +# Date: 2025-11-30 +# Version: 2.0.0 +# Category: ai_mail/handlers/users +# +# CHANGELOG: +# - v2.0.0 (2025-11-30): Removed all fallbacks - fail hard if branch detection fails +# - v1.1.0 (2025-11-15): Renamed domain config -> users for business purpose naming +# - v1.0.0 (2025-11-15): Extracted from ai_mail_cli.py +# ============================================= + +""" +User Info Handler + +Handles user information retrieval and management for AI_Mail system. +Uses branch detection to identify sender - NO FALLBACKS. + +PHILOSOPHY: Fail hard if detection fails. Fallbacks hide bugs. +""" + +# ============================================= +# IMPORTS +# ============================================= +from pathlib import Path +from typing import Dict + +# Import branch detection functions +from .branch_detection import detect_branch_from_pwd + +# ============================================= +# USER INFO FUNCTIONS +# ============================================= + +def get_current_user() -> Dict: + """ + Get current user's information from branch detection (BRANCH_REGISTRY.json) + + Uses PWD/CWD to detect which branch is calling, then looks up info + in BRANCH_REGISTRY.json. NO FALLBACKS - fails hard if detection fails. + + Returns: + Dict containing user information: + { + "email_address": "@branch", + "display_name": "BRANCH_NAME", + "mailbox_path": "/path/to/branch/ai_mail.local", + "timestamp_format": "%Y-%m-%d %H:%M:%S" + } + + Raises: + RuntimeError: If branch detection fails (not called from a branch directory) + """ + # Detect branch from PWD + branch_info = detect_branch_from_pwd() + + if not branch_info: + raise RuntimeError( + "BRANCH DETECTION FAILED: Could not detect branch from current directory.\n" + "AI_MAIL must be called from within a branch directory (with [BRANCH].id.json).\n" + f"Current directory: {Path.cwd()}\n" + "No fallback configured - this is intentional to catch bugs." + ) + + # Extract info from branch_info (from BRANCH_REGISTRY.json) + branch_name = branch_info.get("name") + path_str = branch_info.get("path") + branch_path = Path(path_str) if path_str else None + email = branch_info.get("email") + + if not all([branch_name, branch_path, email]): + raise RuntimeError( + f"INVALID BRANCH INFO: Branch registry entry incomplete.\n" + f"Branch: {branch_name}\n" + f"Path: {branch_path}\n" + f"Email: {email}\n" + "Check BRANCH_REGISTRY.json for missing fields." + ) + + # Construct mailbox path (branch_path guaranteed non-None by check above) + assert branch_path is not None + mailbox_path = branch_path / "ai_mail.local" + + # Return user info in expected format + return { + "email_address": email, + "display_name": branch_name, + "mailbox_path": str(mailbox_path), + "timestamp_format": "%Y-%m-%d %H:%M:%S" + } + + +def get_user_by_email(email: str) -> Dict | None: + """ + Get user information by email address from BRANCH_REGISTRY.json + + Args: + email: Email address (e.g., "@seed") + + Returns: + Dict containing user info, or None if not found + """ + from .branch_detection import get_branch_info_from_registry + + # Use registry lookup + registry_path = Path.home() / "BRANCH_REGISTRY.json" + if not registry_path.exists(): + return None + + try: + import json + with open(registry_path, 'r', encoding='utf-8') as f: + registry = json.load(f) + + for branch in registry.get("branches", []): + if branch.get("email") == email: + branch_path = Path(branch.get("path", "")) + return { + "email_address": branch.get("email"), + "display_name": branch.get("name"), + "mailbox_path": str(branch_path / "ai_mail.local"), + "timestamp_format": "%Y-%m-%d %H:%M:%S" + } + return None + except Exception: + return None + + +def get_all_users() -> Dict[str, Dict]: + """ + Get all users from BRANCH_REGISTRY.json + + Returns: + Dict mapping branch emails to user info dicts + """ + registry_path = Path.home() / "BRANCH_REGISTRY.json" + if not registry_path.exists(): + return {} + + try: + import json + with open(registry_path, 'r', encoding='utf-8') as f: + registry = json.load(f) + + users = {} + for branch in registry.get("branches", []): + email = branch.get("email", "") + if email: + branch_path = Path(branch.get("path", "")) + users[email] = { + "email_address": email, + "display_name": branch.get("name"), + "mailbox_path": str(branch_path / "ai_mail.local"), + "timestamp_format": "%Y-%m-%d %H:%M:%S" + } + return users + except Exception: + return {} diff --git a/src/aipass/ai_mail/apps/json_templates/__init__.py b/src/aipass/ai_mail/apps/json_templates/__init__.py new file mode 100644 index 00000000..5d00b535 --- /dev/null +++ b/src/aipass/ai_mail/apps/json_templates/__init__.py @@ -0,0 +1 @@ +# JSON Templates package - Default JSON file templates diff --git a/src/aipass/ai_mail/apps/json_templates/default/config.json b/src/aipass/ai_mail/apps/json_templates/default/config.json new file mode 100644 index 00000000..d29d029f --- /dev/null +++ b/src/aipass/ai_mail/apps/json_templates/default/config.json @@ -0,0 +1,9 @@ +{ + "module_name": "{{MODULE_NAME}}", + "version": "1.0.0", + "timestamp": "2025-11-13", + "config": { + "auto_save": true, + "enabled": true + } +} diff --git a/src/aipass/ai_mail/apps/json_templates/default/data.json b/src/aipass/ai_mail/apps/json_templates/default/data.json new file mode 100644 index 00000000..82912a72 --- /dev/null +++ b/src/aipass/ai_mail/apps/json_templates/default/data.json @@ -0,0 +1,8 @@ +{ + "module_name": "{{MODULE_NAME}}", + "created": "2025-11-13", + "last_updated": "2025-11-13", + "operations_total": 0, + "operations_successful": 0, + "operations_failed": 0 +} diff --git a/src/aipass/ai_mail/apps/json_templates/default/log.json b/src/aipass/ai_mail/apps/json_templates/default/log.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/aipass/ai_mail/apps/json_templates/default/log.json @@ -0,0 +1 @@ +[] diff --git a/src/aipass/ai_mail/apps/modules/branch_ping.py b/src/aipass/ai_mail/apps/modules/branch_ping.py new file mode 100644 index 00000000..4caa6364 --- /dev/null +++ b/src/aipass/ai_mail/apps/modules/branch_ping.py @@ -0,0 +1,223 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: branch_ping.py - Branch Ping Orchestration Module +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: ai_mail/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial orchestration module for branch ping +# +# CODE STANDARDS: +# - Modules ORCHESTRATE (no business logic) +# - Delegate to handlers for all operations +# - Use json_handler.log_operation() +# - Keep modules 110-155 lines +# ============================================= + +""" +Branch Ping Orchestration Module + +Orchestrates branch memory health monitoring - delegates all logic to handlers. +Commands: ping, status, registry, thresholds +""" + +import sys +import argparse +from pathlib import Path +from typing import List + +from aipass.prax.apps.modules.logger import system_logger as logger + +# CLI services for formatting +from aipass.cli.apps.modules import console +from rich.panel import Panel + +# Import handlers +from aipass.ai_mail.apps.handlers.monitoring.memory import count_file_lines, get_status_from_count +from aipass.ai_mail.apps.handlers.registry.update import ( + ping_registry, + get_branch_context, + update_json_memory_health +) +from aipass.ai_mail.apps.handlers.persistence.json_ops import log_operation + +MODULE_NAME = "branch_ping" +THRESHOLDS = {"green": (0, 400), "yellow": (401, 550), "red": (551, float('inf'))} + +def handle_ping(verbose: bool = False) -> bool: + """Execute ping command - orchestrate health check""" + try: + branch_name, cwd = get_branch_context() + local_file = cwd / f"{branch_name}.local.json" + obs_file = cwd / f"{branch_name}.observations.json" + + local_count = count_file_lines(local_file) + obs_count = count_file_lines(obs_file) + local_status_code = get_status_from_count(local_count) + obs_status_code = get_status_from_count(obs_count) + + update_json_memory_health(local_file, local_count, local_status_code) + update_json_memory_health(obs_file, obs_count, obs_status_code) + + local_status = {"line_count": local_count, "status": local_status_code} + obs_status = {"line_count": obs_count, "status": obs_status_code} + ping_registry(branch_name, cwd, local_status, obs_status) + + log_operation("ping_executed", {"branch": branch_name, "local_count": local_count, "obs_count": obs_count}) + + if verbose: + console.print(f"Ping successful for {branch_name}") + console.print(f" local.json: {local_count} lines ({local_status_code})") + console.print(f" observations.json: {obs_count} lines ({obs_status_code})") + return True + except Exception as e: + logger.error(f"Ping failed: {e}") + if verbose: + console.print(f"Ping failed: {e}") + return False + + +def handle_status() -> bool: + """Show current memory health status""" + try: + branch_name, cwd = get_branch_context() + local_file = cwd / f"{branch_name}.local.json" + obs_file = cwd / f"{branch_name}.observations.json" + + local_count = count_file_lines(local_file) + obs_count = count_file_lines(obs_file) + local_status = get_status_from_count(local_count) + obs_status = get_status_from_count(obs_count) + + console.print(f"\nBranch: {branch_name}\nDirectory: {cwd}") + console.print(f"\nMemory Health Status:") + console.print(f" local.json: {local_count} lines ({local_status})") + console.print(f" observations.json: {obs_count} lines ({obs_status})\n") + return True + except Exception as e: + logger.error(f"Status check failed: {e}") + console.print(f"Error getting status: {e}") + return False + + +def handle_registry() -> bool: + """View registry contents""" + try: + from aipass.ai_mail.apps.handlers.registry.update import REGISTRY_PATH + from aipass.ai_mail.apps.handlers.registry.load import load_registry + + if not REGISTRY_PATH.exists(): + console.print("Registry not yet created") + return True + + registry = load_registry(REGISTRY_PATH) + + console.print("\nMemory Health Registry") + console.print(f"Last Updated: {registry.get('last_updated', 'N/A')}\n") + stats = registry.get('statistics', {}) + console.print(f"Statistics:") + console.print(f" Total Branches: {stats.get('total_branches', 0)}") + console.print(f" Green: {stats.get('green_status', 0)}, Yellow: {stats.get('yellow_status', 0)}, Red: {stats.get('red_status', 0)}\n") + return True + except Exception as e: + logger.error(f"Registry read failed: {e}") + console.print(f"Error reading registry: {e}") + return False + + +def handle_thresholds() -> bool: + """Show compression thresholds""" + console.print("\nMemory Compression Thresholds:") + console.print(f" Green: 0 - {THRESHOLDS['green'][1]} lines") + console.print(f" Yellow: {THRESHOLDS['yellow'][0]} - {THRESHOLDS['yellow'][1]} lines") + console.print(f" Red: {THRESHOLDS['red'][0]}+ lines (compression required)") + console.print() + return True + + +def print_help(): + """Print help output using argparse""" + parser = argparse.ArgumentParser( + description='Branch Ping - Memory Health Monitoring Module', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +COMMANDS: + ping - Execute health check and update registry + status - Show current memory status for this branch + registry - View registry contents and statistics + thresholds - Show compression thresholds + +USAGE: + drone ai_mail branch_ping + python3 branch_ping.py + python3 branch_ping.py --help + +EXAMPLES: + # Check memory health and update registry + drone ai_mail branch_ping ping + + # View current status + drone ai_mail branch_ping status + + # View registry + drone ai_mail branch_ping registry + + # Show thresholds + drone ai_mail branch_ping thresholds + """ + ) + parser.print_help() + + +def handle_command(command: str, args: List[str]) -> bool: + """Handle incoming command - main orchestration entry point""" + # Check if this module handles this command + if command not in ["ping", "status", "registry", "thresholds"]: + return False + + # Handle help flag + if args and args[0] in ['--help', '-h', 'help']: + print_help() + return True + + if command == "ping": + return handle_ping("--verbose" in args or "-v" in args) + elif command == "status": + return handle_status() + elif command == "registry": + return handle_registry() + elif command == "thresholds": + return handle_thresholds() + return False + + +if __name__ == "__main__": + # Handle --help flag + if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + console.print() + console.print(Panel("[bold cyan]BRANCH PING - Memory Health Monitoring[/bold cyan]", expand=False)) + console.print() + console.print("[yellow]Commands:[/yellow] ping, status, registry, thresholds, --help") + console.print() + console.print("[bold]USAGE:[/bold]") + console.print(" drone ai_mail branch_ping ") + console.print(" python3 branch_ping.py") + console.print(" python3 branch_ping.py --help") + console.print() + console.print("[bold]COMMANDS:[/bold]") + console.print(" [cyan]ping[/cyan] - Execute health check") + console.print(" [cyan]status[/cyan] - Show current memory status") + console.print(" [cyan]registry[/cyan] - View registry contents") + console.print(" [cyan]thresholds[/cyan] - Show compression thresholds") + console.print() + sys.exit(0) + + console.print() + console.print(Panel("[bold cyan]BRANCH PING ORCHESTRATION MODULE[/bold cyan]", expand=False)) + console.print() + console.print("[yellow]Commands:[/yellow] ping, status, registry, thresholds") + console.print("[dim]Usage: drone ai_mail branch_ping [command][/dim]") + console.print() diff --git a/src/aipass/ai_mail/apps/modules/dispatch.py b/src/aipass/ai_mail/apps/modules/dispatch.py new file mode 100644 index 00000000..dcf44b3e --- /dev/null +++ b/src/aipass/ai_mail/apps/modules/dispatch.py @@ -0,0 +1,234 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: dispatch.py - Dispatch Module +# Date: 2026-02-02 +# Version: 3.0.0 +# Category: ai_mail/modules +# +# CHANGELOG (Max 5 entries): +# - v3.0.0 (2026-02-20): Add wake subcommand - manual branch spawn without daemon +# - v2.0.0 (2026-02-17): Add daemon subcommand, move status logic to handler +# - v1.0.0 (2026-02-02): Initial version - dispatch status tracking +# +# CODE STANDARDS: +# - Orchestration only - delegates to handlers +# - Uses json_handler.log_operation() +# ============================================= + +""" +Dispatch Module + +Orchestrates dispatch commands: status tracking and daemon management. +Delegates all business logic to handlers. +""" + +import sys +from pathlib import Path +from typing import List + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console +from aipass.ai_mail.apps.handlers.dispatch.status import ( + load_dispatch_log, + check_pid_status, + calculate_age +) + + +def print_help() -> None: + """Print help for dispatch commands.""" + help_text = """ +Dispatch Module - Agent dispatch management + +COMMANDS: + dispatch status - Show last 5 dispatch spawns with current status + dispatch daemon - Start the continuous dispatch daemon + dispatch wake - Manually wake a branch (spawn agent without daemon) + +WAKE: + drone wake @branch - Wake branch with default inbox check + drone wake @branch "custom msg" - Wake branch with custom prompt + ai_mail dispatch wake @branch - Same, via ai_mail directly + +DAEMON: + The daemon polls branch inboxes for --dispatch emails and spawns agents. + Run as: ai_mail dispatch daemon + Or standalone: python3 apps/handlers/dispatch/daemon.py + + Kill switch: touch /home/aipass/.aipass/autonomous_pause + Config: safety_config.json + +EXAMPLE: + ai_mail dispatch status + + DISPATCH STATUS + ──────────────────────────────────── + @flow PID 108957 RUNNING 2m ago + @ai_mail PID 85997 COMPLETED 10m ago + ──────────────────────────────────── + Active: 1 | Total: 2 +""" + console.print(help_text) + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle dispatch commands. + + Args: + command: Command name + args: Command arguments + + Returns: + True if command handled, False otherwise + """ + if command != "dispatch": + return False + + if args and args[0] in ['--help', '-h', 'help']: + print_help() + return True + + if not args: + print_help() + return True + + subcommand = args[0] + + if subcommand == "status": + return _orchestrate_status() + elif subcommand == "daemon": + return _orchestrate_daemon() + elif subcommand == "wake": + return _orchestrate_wake(args[1:]) + else: + console.print(f"[red]Unknown dispatch subcommand: {subcommand}[/red]") + print_help() + return False + + +def _orchestrate_status() -> bool: + """Orchestrate dispatch status display.""" + logger.info("[dispatch] Showing dispatch status") + + dispatches = load_dispatch_log() + + if not dispatches: + console.print("\n[dim]No dispatches recorded yet.[/dim]") + return True + + recent = dispatches[-5:][::-1] + + console.print("\n[bold]DISPATCH STATUS[/bold]") + console.print("─" * 50) + + active_count = 0 + for entry in recent: + branch = entry.get("branch", "unknown") + pid = entry.get("pid") + timestamp = entry.get("timestamp", "") + spawn_status = entry.get("status", "unknown") + + if spawn_status == "spawned" and pid: + current_status = check_pid_status(pid) + elif spawn_status == "failed": + current_status = "FAILED" + else: + current_status = "UNKNOWN" + + if current_status == "RUNNING": + active_count += 1 + + age_str = calculate_age(timestamp) + + if current_status == "RUNNING": + status_display = "[green]RUNNING[/green]" + elif current_status == "COMPLETED": + status_display = "[dim]COMPLETED[/dim]" + elif current_status == "FAILED": + status_display = "[red]FAILED[/red]" + else: + status_display = f"[yellow]{current_status}[/yellow]" + + pid_display = f"PID {pid}" if pid else "NO PID" + console.print(f" {branch:<12} {pid_display:<12} {status_display:<18} {age_str}") + + console.print("─" * 50) + console.print(f"[dim]Active: {active_count} | Total: {len(recent)}[/dim]\n") + + return True + + +def _orchestrate_wake(args: List[str]) -> bool: + """Orchestrate manual branch wake.""" + if not args or args[0] in ['--help', '-h', 'help']: + console.print("\n[bold]Wake - Manual branch spawn[/bold]") + console.print(" Usage: dispatch wake @branch [\"custom message\"]") + console.print(" Or: drone wake @branch [\"custom message\"]\n") + return True + + # Parse --fresh and --sender flags + use_fresh = "--fresh" in args + use_sender = "@dev_central" + filtered = [] + i = 0 + while i < len(args): + if args[i] == "--fresh": + i += 1 + continue + if args[i] == "--sender" and i + 1 < len(args): + use_sender = args[i + 1] + i += 2 + continue + filtered.append(args[i]) + i += 1 + + if not filtered: + console.print("[red]Missing branch argument[/red]") + return False + + branch_email = filtered[0] + custom_message = filtered[1] if len(filtered) > 1 else None + + logger.info(f"[dispatch] Manual wake requested for {branch_email}") + console.print(f"\n⏳ Waking {branch_email}...") + + from aipass.ai_mail.apps.handlers.dispatch.wake import wake_branch + dispatch_status, success = wake_branch( + branch_email, custom_message, fresh=use_fresh, sender=use_sender + ) + + # Print step-by-step status + console.print(dispatch_status.format()) + + return success + + +def _orchestrate_daemon() -> bool: + """Orchestrate daemon startup.""" + logger.info("[dispatch] Starting dispatch daemon") + console.print("\n[bold]Starting dispatch daemon...[/bold]") + + from aipass.ai_mail.apps.handlers.dispatch.daemon import run_daemon + run_daemon() + return True + + +if __name__ == "__main__": + if len(sys.argv) == 1: + print_help() + sys.exit(0) + + if sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + command = sys.argv[1] + remaining_args = sys.argv[2:] if len(sys.argv) > 2 else [] + + if handle_command(command, remaining_args): + sys.exit(0) + else: + sys.exit(1) diff --git a/src/aipass/ai_mail/apps/modules/email.py b/src/aipass/ai_mail/apps/modules/email.py new file mode 100644 index 00000000..0302083a --- /dev/null +++ b/src/aipass/ai_mail/apps/modules/email.py @@ -0,0 +1,946 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: email.py - Email Orchestration Module +# Date: 2025-12-02 +# Version: 2.2.0 +# Category: ai_mail/modules +# +# CHANGELOG (Max 5 entries): +# - v2.2.0 (2026-02-25): FPLAN-0373 Phase 2 - enriched dashboard write-through via push_dashboard_update +# - v2.1.0 (2026-02-25): Add --no-memory-save flag for private branch outbound dispatch +# - v2.0.0 (2026-02-16): Group send support - multiple @recipients parsed correctly with --dispatch +# - v1.9.0 (2026-02-14): Batch close perf fix - defer dashboard/purge to single call after loop +# - v1.8.0 (2026-02-08): Fix dispatch_target identity mismatch - use registry lookup instead of folder name +# +# CODE STANDARDS: +# - Orchestration only - NO business logic +# - Imports from handlers/ for all operations +# - Uses json_handler.log_operation() +# - 135-200 lines target (handles multiple workflows) +# ============================================= + +""" +Email Orchestration Module + +Orchestrates email workflows for AI_Mail CLI system. +Handles: send, inbox, sent, contacts commands. + +Module Pattern: +- handle_command(command, args) -> bool entry point +- Imports handlers for business logic +- Logs operations via json_handler +- NO business logic in this file +""" + +import sys +import argparse +from pathlib import Path +from typing import List + +# Infrastructure +AIPASS_ROOT = Path.home() / "aipass_core" + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console +from aipass.ai_mail.apps.handlers.central_writer import update_central +from aipass.dev_central.devpulse.apps.modules.dashboard import update_section as _update_dashboard_section +from aipass.ai_mail.apps.handlers.email.dashboard_sync import push_dashboard_update + +# Import handlers (business logic providers) +from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch +from aipass.ai_mail.apps.handlers.email.create import create_email_file, load_email_file +from aipass.ai_mail.apps.handlers.email.format import format_email_preview, format_email_header, format_email_list_item +from aipass.ai_mail.apps.handlers.email.inbox_ops import load_inbox +from aipass.ai_mail.apps.handlers.email.inbox_cleanup import ( + mark_read_and_archive, mark_all_read_and_archive, + mark_as_opened, mark_as_closed_and_archive +) +from aipass.ai_mail.apps.handlers.email.reply import get_email_by_id, send_reply +from aipass.ai_mail.apps.handlers.email.header import prepend_dispatch_header +from aipass.ai_mail.apps.handlers.users.user import get_current_user +from aipass.ai_mail.apps.handlers.registry.read import get_all_branches, get_branch_by_email +from aipass.ai_mail.apps.handlers.persistence.json_ops import log_operation + + +def _on_email_delivered(branch_path, new_count, opened_count, total): + """Post-delivery callback: update dashboard (enriched) and central.""" + try: + push_dashboard_update(branch_path) + except Exception: + pass # Dashboard update is best-effort + try: + update_central() + except Exception: + pass # Central update is best-effort + + +def _dispatch_send_error(to_branch: str, subject: str, error_msg: str) -> None: + """ + Auto-dispatch error report to @drone when email delivery fails. + + Args: + to_branch: Intended recipient that failed + subject: Original email subject + error_msg: Error message from delivery failure + """ + try: + from datetime import datetime + + user_info = get_current_user() + sender = user_info.get("email_address", "@ai_mail") + + error_report = ( + f"Email delivery failed.\n\n" + f"From: {sender}\n" + f"To: {to_branch}\n" + f"Subject: {subject}\n" + f"Error: {error_msg}\n\n" + f"This error was auto-dispatched for investigation." + ) + + email_data = { + "from": "@ai_mail", + "from_name": "AI_MAIL", + "to": "@drone", + "subject": f"[ERROR] Send failed to {to_branch}: {error_msg[:50]}", + "message": error_report, + "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "auto_execute": False, + "priority": "normal", + "reply_to": "@dev_central" + } + + deliver_email_to_branch("@drone", email_data) + logger.info(f"[email] Error auto-dispatched to @drone for failed send to {to_branch}") + + except Exception as e: + logger.warning(f"[email] Failed to dispatch send error to @drone: {e}") + + +def print_introspection(): + """Display module info and connected handlers""" + console.print() + console.print("[bold cyan]Email Orchestration Module[/bold cyan]") + console.print() + console.print("[dim]Orchestrates email workflows (send, inbox, sent, contacts)[/dim]") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + console.print(" [cyan]email/[/cyan]") + console.print(" [dim]- delivery.py (deliver_email_to_branch)[/dim]") + console.print(" [dim]- create.py (create_email_file, load_email_file)[/dim]") + console.print(" [dim]- format.py (format_email_*)[/dim]") + console.print(" [dim]- inbox_ops.py (load_inbox)[/dim]") + console.print() + console.print(" [cyan]users/[/cyan]") + console.print(" [dim]- user.py (get_current_user)[/dim]") + console.print() + console.print(" [cyan]registry/[/cyan]") + console.print(" [dim]- read.py (get_all_branches, get_branch_by_email)[/dim]") + console.print() + console.print(" [cyan]persistence/[/cyan]") + console.print(" [dim]- json_ops.py (log_operation)[/dim]") + console.print() + console.print("[dim]Run 'python3 email.py --help' for usage[/dim]") + console.print() + + +def print_help(): + """Print drone-compliant help output""" + help_text = """ +Email Module - Send and manage branch-to-branch email (Lifecycle v2) + +COMMANDS: + send - Send email to a branch + inbox - View inbox messages (new + opened) + view - View email content and mark as opened + reply - Reply to email (auto-closes original) + close - Close email without reply (archives to deleted) + sent - View sent messages + contacts - Manage contacts + read - (Alias for 'view' - backward compatibility) + +EMAIL LIFECYCLE (v2): + new → opened → closed → deleted + + 1. Email arrives with status: "new" + 2. Use 'view ' to read content (marks as "opened") + 3. Use 'reply "msg"' to respond (auto-closes + archives) + OR 'close ' to close without reply (archives to deleted) + +USAGE: + ai_mail send @recipient "subject" "message" [--dispatch] [--reply-to @branch] + ai_mail inbox + ai_mail view # View and mark as opened + ai_mail reply "msg" # Reply and close original + ai_mail close # Close without reply + ai_mail sent + ai_mail contacts + +FLAGS: + --dispatch Spawn Claude agent at target branch to execute the email task. + Use when recipient needs to ACT (tasks, bugs, requests). + Skip when just informing (acks, ideas, status updates). + --reply-to Redirect replies to a different branch (e.g., --reply-to @dev_central) + --auto-execute (Alias for --dispatch - backward compatibility) + +EXAMPLES: + # Send informational email (no agent spawn) + ai_mail send @seed "Status Update" "All checks passing" + + # Dispatch task to branch (spawns agent to execute) + ai_mail send @drone "Task: Update config" "Please update X" --dispatch + + # Broadcast announcement (no dispatch - informational) + ai_mail send @all "Announcement" "System update tonight" + + # View inbox (shows new + opened emails) + ai_mail inbox + + # View email content (marks as opened) + ai_mail view a7b3c9d2 + + # Reply to email (sends reply + closes original) + ai_mail reply a7b3c9d2 "Thanks, I'll review this today" + + # Close email without replying + ai_mail close a7b3c9d2 + + # View sent messages + ai_mail sent + + # View all branches + ai_mail contacts + +WHEN TO USE --dispatch: + ✓ Task assignments - "Task: Fix the bug in X" + ✓ Action requests - "Please review and merge PR #123" + ✓ Bug reports - "BUG: Feature Y is broken" + ✗ Status updates - "Complete: Task X finished" + ✗ Acknowledgments - "Received, will review tomorrow" + ✗ Ideas/proposals - "IDEA: Consider approach Z" +""" + console.print(help_text) + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle email commands + + Args: + command: Command name (send, inbox, sent, contacts) + args: Command arguments + + Returns: + True if command handled, False otherwise + """ + # Check if this module handles this command + if command not in ["send", "inbox", "view", "close", "reply", "sent", "contacts", "read"]: + return False + + # Handle --help flag in args + if args and args[0] in ['--help', '-h', 'help']: + print_help() + return True + + # Route to appropriate workflow + if command == "send": + return handle_send(args) + elif command == "inbox": + return handle_inbox(args) + elif command == "view": + return handle_view(args) + elif command == "close": + return handle_close(args) + elif command == "reply": + return handle_reply(args) + elif command == "read": + # Backward compat: read now behaves like view + return handle_view(args) + elif command == "sent": + return handle_sent(args) + elif command == "contacts": + return handle_contacts(args) + else: + return False + + +def handle_send(args: List[str]) -> bool: + """Orchestrate email sending workflow""" + log_operation("send_email_initiated", {"args_count": len(args)}) + + # Check for --dispatch or --auto-execute flag (--dispatch is the new canonical name) + auto_execute = '--dispatch' in args or '--auto-execute' in args + if auto_execute: + args = [a for a in args if a not in ('--dispatch', '--auto-execute')] + + # Check for --no-memory-save flag (private branch outbound: skip memory logging at recipient) + no_memory_save = '--no-memory-save' in args + if no_memory_save: + args = [a for a in args if a != '--no-memory-save'] + + # Check for --reply-to flag + reply_to = None + if '--reply-to' in args: + idx = args.index('--reply-to') + if idx + 1 < len(args): + reply_to = args[idx + 1] + args = args[:idx] + args[idx + 2:] + else: + console.print("❌ --reply-to requires a branch address (e.g., --reply-to @dev_central)") + return False + + # Separate recipients (@-prefixed) from subject/message + recipients = [] + rest = [] + for a in args: + if a.startswith('@') and not rest: + recipients.append(a) + elif a.startswith('/') and not rest: + # Path-based recipient (e.g., /home/aipass/...) + recipients.append(a) + else: + rest.append(a) + + # Direct send mode: send @recipient(s) "subject" "message" + if recipients and len(rest) >= 2: + subject = rest[0] + message = rest[1] + + def _resolve_dispatch_target(branch: str) -> str | None: + """Resolve dispatch target for a single recipient""" + if not auto_execute: + return None + if branch.startswith('/') or branch.startswith('~'): + from aipass.ai_mail.apps.handlers.users.branch_detection import get_branch_info_from_registry + branch_info = get_branch_info_from_registry(Path(branch)) + if branch_info: + return branch_info.get("email", f"@{Path(branch).name.lower()}") + return f"@{Path(branch).name.lower()}" + return branch + + # Single recipient - original behavior + if len(recipients) == 1: + dispatch_target = _resolve_dispatch_target(recipients[0]) + return send_email_direct(recipients[0], subject, message, auto_execute=auto_execute, reply_to=reply_to, dispatched_to=dispatch_target, no_memory_save=no_memory_save) + + # Multiple recipients - send to each + console.print(f"\n📨 Group send to {len(recipients)} recipients...") + success_count = 0 + for recipient in recipients: + dispatch_target = _resolve_dispatch_target(recipient) + success = send_email_direct(recipient, subject, message, auto_execute=auto_execute, reply_to=reply_to, dispatched_to=dispatch_target, no_memory_save=no_memory_save) + if success: + success_count += 1 + console.print(f"\n📊 Group send complete: {success_count}/{len(recipients)} delivered") + return success_count > 0 + + # Interactive send mode + elif not recipients and not rest: + return send_email_interactive() + + # Bad args - not enough info + else: + console.print("❌ Usage: send @recipient [subject] [message]") + console.print(" Multiple: send @branch1 @branch2 \"Subject\" \"Message\"") + return False + + +def send_email_interactive() -> bool: + """Interactive email sending with prompts""" + branches = get_all_branches() + + console.print("\n📧 AI_Mail - Send Email") + console.print("=" * 50) + + # Show branch selection + console.print("\nSelect recipient:") + for i, branch in enumerate(branches, 1): + console.print(f" {i}. {branch['name']} ({branch['email']})") + console.print(f" {len(branches) + 1}. ALL BRANCHES (broadcast)") + + # Get selection + try: + selection = input(f"\nPick (1-{len(branches) + 1}): ").strip() + idx = int(selection) - 1 + + if idx == len(branches): + selected_email = "all" + elif idx < 0 or idx >= len(branches): + console.print("❌ Invalid selection") + return False + else: + selected_email = branches[idx]["email"] + except (ValueError, KeyboardInterrupt, EOFError): + console.print("\n❌ Cancelled") + return False + + # Get subject + try: + subject = input("Subject: ").strip() + if not subject: + console.print("❌ Subject cannot be empty") + return False + except (KeyboardInterrupt, EOFError): + console.print("\n❌ Cancelled") + return False + + # Get message + console.print("Message (press Ctrl+D when done, Ctrl+C to cancel):") + try: + message_lines = [] + while True: + try: + line = input() + message_lines.append(line) + except EOFError: + break + message = "\n".join(message_lines).strip() + if not message: + console.print("❌ Message cannot be empty") + return False + except KeyboardInterrupt: + console.print("\n❌ Cancelled") + return False + + # Confirm send + console.print("\n" + "=" * 50) + console.print(f"To: {selected_email}") + console.print(f"Subject: {subject}") + console.print(f"Message:\n{message}") + console.print("=" * 50) + + try: + confirm = input("\nSend? (y/n): ").strip().lower() + if confirm != 'y': + console.print("❌ Cancelled") + return False + except (KeyboardInterrupt, EOFError): + console.print("\n❌ Cancelled") + return False + + return send_email_direct(selected_email, subject, message) + + +def send_email_direct(to_branch: str, subject: str, message: str, auto_execute: bool = False, reply_to: str | None = None, dispatched_to: str | None = None, from_branch: str | None = None, no_memory_save: bool = False) -> bool: + """Direct email sending (orchestrates handlers) + + Args: + to_branch: Recipient email address (e.g., @flow) + subject: Email subject + message: Email body + auto_execute: If True, spawn agent at recipient branch + reply_to: Optional branch to redirect replies to + dispatched_to: Track dispatch target for reply validation + from_branch: Optional sender override (e.g., @trigger). If None, uses PWD detection. + no_memory_save: If True, add no-memory-save directive to dispatch header + """ + try: + # Get sender info - use explicit from_branch if provided, otherwise detect from PWD + if from_branch: + # Normalize email format (handle @trigger or trigger formats) + email_addr = f"@{from_branch.lstrip('@').lower()}" + # Look up branch from registry to get correct path + branch_info = get_branch_by_email(email_addr) + if branch_info: + branch_path = Path(branch_info["path"]) + user_info = { + "email_address": email_addr, + "display_name": branch_info["name"], + "mailbox_path": str(branch_path / "ai_mail.local"), + "timestamp_format": "%Y-%m-%d %H:%M:%S" + } + else: + # Fallback for unregistered branches (shouldn't happen in production) + branch_name = from_branch.lstrip('@').upper() + user_info = { + "email_address": email_addr, + "display_name": branch_name, + "mailbox_path": str(AIPASS_ROOT / from_branch.lstrip('@').lower() / "ai_mail.local"), + "timestamp_format": "%Y-%m-%d %H:%M:%S" + } + else: + user_info = get_current_user() + + # Prepend dispatch header for auto-execute emails (critical memory update reminder) + if auto_execute: + message = prepend_dispatch_header(message, no_memory_save=no_memory_save) + + # Broadcast mode + if to_branch.lower() in ['all', '@all']: + branches = get_all_branches() + success_count = 0 + + # Create email file (pass reply_to, dispatched_to for broadcast) + email_file = create_email_file("all", subject, message, user_info, reply_to=reply_to, dispatched_to=dispatched_to) + email_data = load_email_file(email_file) + + # Handle None case (file not found/couldn't be loaded) + if email_data is None: + console.print("❌ Failed to load email file for broadcast") + log_operation("broadcast_failed", {"error": "Email file could not be loaded"}) + return False + + console.print(f"\n📢 Broadcasting to {len(branches)} branches...") + + # Deliver to each branch + for branch in branches: + delivery_data = email_data.copy() + delivery_data['to'] = branch['email'] + delivery_data['auto_execute'] = auto_execute + if no_memory_save: + delivery_data['no_memory_save'] = True + + success, error_msg = deliver_email_to_branch(branch['email'], delivery_data, on_delivered=_on_email_delivered) + if success: + success_count += 1 + console.print(f" ✅ {branch['name']}") + else: + console.print(f" ❌ {branch['name']} ({error_msg})") + + console.print(f"\n📊 Broadcast complete: {success_count}/{len(branches)} delivered") + log_operation("broadcast_sent", {"recipients": len(branches), "successful": success_count}) + + # Fire trigger event + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire('email_broadcast_sent', recipients=len(branches), successful=success_count, subject=subject) + except ImportError: + pass # Silent fallback if trigger unavailable + + # Update central after broadcast + try: + update_central() + except Exception: + pass # Don't break mail ops if central update fails + + return success_count > 0 + + # Single recipient + else: + email_file = create_email_file(to_branch, subject, message, user_info, reply_to=reply_to, dispatched_to=dispatched_to) + email_data = load_email_file(email_file) + + # Handle None case (file not found/couldn't be loaded) + if email_data is None: + console.print(f"❌ Failed to load email file") + log_operation("email_failed", {"to": to_branch, "error": "Email file could not be loaded"}) + return False + + # Add auto_execute flag, dispatch tracking, and memory directive + email_data['auto_execute'] = auto_execute + if dispatched_to: + email_data['dispatched_to'] = dispatched_to + if no_memory_save: + email_data['no_memory_save'] = True + + success, error_msg = deliver_email_to_branch(to_branch, email_data, on_delivered=_on_email_delivered) + + if success: + if auto_execute: + console.print(f"✅ Email sent to {to_branch} \\[dispatch: queued for daemon]") + else: + console.print(f"✅ Email sent to {to_branch}") + log_operation("email_sent", {"to": to_branch, "subject": subject, "auto_execute": auto_execute}) + + # Fire trigger event + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire('email_sent', to=to_branch, subject=subject, auto_execute=auto_execute) + except ImportError: + pass # Silent fallback if trigger unavailable + + # Update central after send + try: + update_central() + except Exception: + pass # Don't break mail ops if central update fails + + return True + else: + console.print(f"❌ Failed to deliver: {error_msg}") + log_operation("email_failed", {"to": to_branch, "error": error_msg}) + _dispatch_send_error(to_branch, subject, error_msg) + return False + + except BrokenPipeError: + logger.info("[email] Send: broken pipe (stdout closed early)") + return True + except Exception as e: + logger.error(f"[email] Send failed: {e}") + console.print(f"❌ Error: {e}") + _dispatch_send_error(to_branch, subject, str(e)) + return False + + +def handle_inbox(args: List[str]) -> bool: + """Orchestrate inbox viewing workflow. + + Usage: + inbox - View current branch's inbox (detected from PWD) + inbox @branch - View specified branch's inbox + """ + log_operation("inbox_viewed") + + try: + # Check if a target branch was specified + target_branch = None + if args and args[0].startswith("@"): + target_branch = args[0] + + if target_branch: + # Look up target branch from registry + branch_info = get_branch_by_email(target_branch) + if not branch_info: + console.print(f"❌ Unknown branch: {target_branch}") + return False + branch_path = Path(branch_info["path"]) + mailbox_path = branch_path / "ai_mail.local" + display_name = branch_info.get("name", target_branch) + else: + # Use current branch (detected from PWD) + user_info = get_current_user() + mailbox_path = Path(user_info["mailbox_path"]) + display_name = user_info.get("display_name", "") + + inbox_file = mailbox_path / "inbox.json" + + if not inbox_file.exists(): + if target_branch: + console.print(f"📭 {target_branch} inbox is empty") + else: + console.print("📭 Inbox is empty") + return True + + # Load inbox using handler + inbox_data = load_inbox(inbox_file) + messages = inbox_data.get("messages", []) + + if not messages: + if target_branch: + console.print(f"📭 {target_branch} inbox is empty") + else: + console.print("📭 Inbox is empty") + return True + + # Display messages (newest first) + messages_display = list(reversed(messages))[:20] + + if target_branch: + console.print(f"\n📬 Inbox for {target_branch} ({display_name})") + else: + console.print("\n📬 Inbox") + console.print("=" * 70) + + for i, msg in enumerate(messages_display, 1): + console.print(format_email_list_item(i, msg, show_unread=True)) + + console.print("\n" + "=" * 70) + console.print(f"Showing {len(messages_display)} of {len(messages)} messages") + console.print("\n[dim]To archive: drone @ai_mail read or drone @ai_mail read all[/dim]") + + return True + + except BrokenPipeError: + logger.info("[email] Inbox view: broken pipe (stdout closed early)") + return True + except Exception as e: + logger.error(f"[email] Inbox view failed: {e}") + console.print(f"❌ Error: {e}") + return False + + +def handle_read(args: List[str]) -> bool: + """Orchestrate email read/archive workflow""" + log_operation("read_email_initiated", {"args": args}) + + if not args: + console.print("❌ Usage: drone @ai_mail read or drone @ai_mail read all") + return False + + try: + user_info = get_current_user() + # mailbox_path is ai_mail.local/, branch_path is parent + branch_path = Path(user_info["mailbox_path"]).parent + + # Archive all messages + if args[0].lower() == "all": + success, message, count = mark_all_read_and_archive(branch_path) + if success: + console.print(f"✅ {message}") + log_operation("all_emails_archived", {"count": count}) + else: + console.print(f"❌ {message}") + return success + + # Archive single message + else: + message_id = args[0] + success, message = mark_read_and_archive(branch_path, message_id) + if success: + console.print(f"✅ {message}") + log_operation("email_archived", {"message_id": message_id}) + else: + console.print(f"❌ {message}") + return success + + except Exception as e: + logger.error(f"[email] Read/archive failed: {e}") + console.print(f"❌ Error: {e}") + return False + + +def handle_view(args: List[str]) -> bool: + """ + View email content and mark as opened (v2 schema). + Does NOT archive - email stays in inbox with status: opened. + """ + log_operation("view_email_initiated", {"args": args}) + + if not args: + console.print("❌ Usage: drone @ai_mail view ") + return False + + try: + user_info = get_current_user() + branch_path = Path(user_info["mailbox_path"]).parent + message_id = args[0] + + # Mark as opened and get email content + success, message, email_data = mark_as_opened(branch_path, message_id) + + if not success: + console.print(f"❌ {message}") + return False + + # Display the email + console.print("\n" + "="*60) + console.print(f"📧 From: {email_data.get('from', 'unknown')} ({email_data.get('from_name', '')})") + console.print(f"📌 Subject: {email_data.get('subject', 'No subject')}") + console.print(f"🕐 {email_data.get('timestamp', '')}") + console.print("="*60) + console.print(f"\n{email_data.get('message', '')}\n") + console.print("="*60) + console.print(f"[dim]Status: opened | ID: {message_id}[/dim]") + console.print(f"[dim]To reply: drone @ai_mail reply {message_id} \"your message\"[/dim]") + console.print(f"[dim]To close: drone @ai_mail close {message_id}[/dim]") + + log_operation("email_viewed", {"message_id": message_id}) + return True + + except BrokenPipeError: + logger.info("[email] View: broken pipe (stdout closed early)") + return True + except Exception as e: + logger.error(f"[email] View failed: {e}") + console.print(f"❌ Error: {e}") + return False + + +def handle_close(args: List[str]) -> bool: + """ + Close email(s) and archive to deleted (v2 schema). + Marks status: closed and moves to deleted.json. + + Supports: + close - Close single email + close ... - Close multiple emails + close all - Close all emails in inbox + """ + log_operation("close_email_initiated", {"args": args}) + + if not args: + console.print("❌ Usage: drone @ai_mail close [id2 id3 ...] | close all") + return False + + try: + user_info = get_current_user() + branch_path = Path(user_info["mailbox_path"]).parent + + # Handle "close all" + if args[0].lower() == "all": + success, message, count = mark_all_read_and_archive(branch_path) + if success: + console.print(f"✅ {message}") + log_operation("email_closed_all", {"count": count}) + else: + console.print(f"❌ {message}") + return success + + # Handle one or more message IDs + # When closing multiple, defer dashboard/purge to single call after loop + batch_mode = len(args) > 1 + closed_count = 0 + failed_count = 0 + for message_id in args: + success, message = mark_as_closed_and_archive(branch_path, message_id, skip_post_ops=batch_mode) + if success: + console.print(f"✅ {message}") + log_operation("email_closed", {"message_id": message_id}) + closed_count += 1 + else: + console.print(f"❌ {message}") + failed_count += 1 + + # Run dashboard update + purge once after batch close + if batch_mode and closed_count > 0: + try: + push_dashboard_update(branch_path) + update_central() + except Exception: + pass # Dashboard/central update is best-effort + try: + from aipass.ai_mail.apps.handlers.email.purge import purge_deleted_folder + purge_deleted_folder(branch_path / "ai_mail.local") + except Exception: + pass + + if batch_mode: + console.print(f"\n📊 Closed {closed_count}, failed {failed_count}") + + return failed_count == 0 + + except Exception as e: + logger.error(f"[email] Close failed: {e}") + console.print(f"❌ Error: {e}") + return False + + +def handle_reply(args: List[str]) -> bool: + """ + Reply to an email (v2 schema). + Sends reply to original sender + auto-closes original. + """ + log_operation("reply_email_initiated", {"args": args}) + + if len(args) < 2: + console.print("❌ Usage: drone @ai_mail reply \"your message\"") + return False + + try: + user_info = get_current_user() + branch_path = Path(user_info["mailbox_path"]).parent + inbox_file = branch_path / "ai_mail.local" / "inbox.json" + + message_id = args[0] + reply_message = args[1] + + # Get original email + original_email = get_email_by_id(inbox_file, message_id) + if not original_email: + console.print(f"❌ Message not found: {message_id}") + return False + + # Send reply + success, message, reply_id = send_reply(branch_path, original_email, reply_message) + + if success: + console.print(f"✅ {message}") + log_operation("email_replied", {"message_id": message_id, "reply_id": reply_id}) + else: + console.print(f"❌ {message}") + + return success + + except Exception as e: + logger.error(f"[email] Reply failed: {e}") + console.print(f"❌ Error: {e}") + return False + + +def handle_sent(args: List[str]) -> bool: + """Orchestrate sent messages viewing workflow""" + log_operation("sent_viewed") + + try: + user_info = get_current_user() + mailbox_path = Path(user_info["mailbox_path"]) + sent_folder = mailbox_path / "sent" + + if not sent_folder.exists(): + console.print("📭 No sent messages") + return True + + # Get email files + email_files = sorted(sent_folder.glob("*.json"), reverse=True)[:20] + + if not email_files: + console.print("📭 No sent messages") + return True + + console.print("\n📤 Sent Messages") + console.print("=" * 70) + + for i, email_file in enumerate(email_files, 1): + email_data = load_email_file(email_file) + if email_data: + console.print(format_email_list_item(i, email_data, show_unread=False)) + + console.print("\n" + "=" * 70) + console.print(f"Showing {len(email_files)} sent messages") + + return True + + except Exception as e: + logger.error(f"[email] Sent view failed: {e}") + console.print(f"❌ Error: {e}") + return False + + +def handle_contacts(args: List[str]) -> bool: + """Orchestrate contacts management workflow""" + log_operation("contacts_viewed") + + try: + branches = get_all_branches() + + if not branches: + console.print("❌ No contacts found") + return False + + console.print(f"\nTotal: {len(branches)} branches\n") + console.print(f"{'EMAIL':<20} {'BRANCH NAME':<25} {'PATH':<35}") + console.print("-" * 80) + + for branch in sorted(branches, key=lambda b: b["email"]): + email = branch["email"] + name = branch["name"] + path = branch["path"] + + console.print(f"{email:<20} {name:<25} {path:<35}") + + return True + + except Exception as e: + logger.error(f"[email] Contacts view failed: {e}") + console.print(f"❌ Error: {e}") + return False + + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + # Handle help flag + if sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + # Execute command + command = sys.argv[1] + remaining_args = sys.argv[2:] if len(sys.argv) > 2 else [] + + if handle_command(command, remaining_args): + sys.exit(0) + else: + console.print() + console.print(f"[red]Unknown command: {command}[/red]") + console.print() + console.print("Run [dim]python3 email.py --help[/dim] for available commands") + console.print() + sys.exit(1) diff --git a/src/aipass/ai_mail/apps/plugins/__init__.py b/src/aipass/ai_mail/apps/plugins/__init__.py index e69de29b..69b056dd 100644 --- a/src/aipass/ai_mail/apps/plugins/__init__.py +++ b/src/aipass/ai_mail/apps/plugins/__init__.py @@ -0,0 +1 @@ +# Plugins package - Pluggable components for branch capabilities diff --git a/src/aipass/api/apps/__init__.py b/src/aipass/api/apps/__init__.py index f3a372eb..803548c8 100644 --- a/src/aipass/api/apps/__init__.py +++ b/src/aipass/api/apps/__init__.py @@ -1 +1 @@ -# API apps package +# Apps package diff --git a/src/aipass/api/apps/api.py b/src/aipass/api/apps/api.py new file mode 100644 index 00000000..e1bce259 --- /dev/null +++ b/src/aipass/api/apps/api.py @@ -0,0 +1,295 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: main.py - api Branch Orchestrator +# Date: 2025-11-08 +# Version: 1.0.0 +# Category: api/entry_point +# CODE STANDARDS: Seed v1.0.0 +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-08): Initial version - modular architecture +# ============================================= + +""" +api Branch - Main Orchestrator + +Modular architecture with auto-discovered modules. +Main handles routing, modules implement functionality. +""" + +# INFRASTRUCTURE IMPORT PATTERN +import sys +from pathlib import Path + +# Standard library imports +import importlib +from typing import Dict, Any, Optional, List + +# AIPass infrastructure imports +from aipass.prax.apps.modules.logger import system_logger as logger + +# CLI services for formatted output +from aipass.cli.apps.modules import console, header +from rich.panel import Panel +from rich.table import Table +from rich.columns import Columns + +# JSON handler for api tracking +from aipass.api.apps.handlers.json import json_handler + +# ============================================================================= +# CONSTANTS & CONFIG +# ============================================================================= + +# Module root +MODULE_ROOT = Path(__file__).parent + +# Modules directory +MODULES_DIR = MODULE_ROOT / "modules" + +# ============================================================================= +# MODULE DISCOVERY +# ============================================================================= + +def discover_modules() -> List[Any]: + """ + Auto-discover modules from modules/ directory + + Returns: + List of module objects with handle_command() function + """ + modules = [] + + if not MODULES_DIR.exists(): + logger.warning(f"Modules directory not found: {MODULES_DIR}") + return modules + + logger.info(f"[{Path(__file__).stem}] Discovering modules...") + + for file_path in MODULES_DIR.glob("*.py"): + # Skip __init__.py and private files + if file_path.name.startswith("_"): + continue + + module_name = file_path.stem + + try: + # Import module via pip namespace + module = importlib.import_module(f"aipass.api.apps.modules.{module_name}") + + # Check for required interface + if hasattr(module, 'handle_command'): + modules.append(module) + logger.info(f" [+] {module_name}") + else: + logger.warning(f" [!] {module_name} - missing handle_command()") + + except Exception as e: + logger.error(f" [-] {module_name} - import error: {e}") + + logger.info(f"[{Path(__file__).stem}] Discovered {len(modules)} modules") + return modules + +# ============================================================================= +# INTROSPECTION DISPLAY +# ============================================================================= + +def print_introspection(): + """Display discovered modules and available commands""" + console.print() + console.print("[bold cyan]API Branch - API Operations[/bold cyan]") + console.print() + console.print("[dim]Universal API client and key management[/dim]") + console.print() + + # Discover modules + modules = discover_modules() + + if not modules: + console.print("[red]No modules discovered[/red]") + console.print() + console.print("[dim]Run 'python3 api.py --help' for usage information[/dim]") + console.print() + return + + console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}") + console.print() + + for module in modules: + module_name = module.__name__.split('.')[-1] + console.print(f" [cyan]•[/cyan] {module_name}") + + console.print() + console.print("[dim]Run 'python3 api.py --help' for usage information[/dim]") + console.print() + + +# ============================================================================= +# DRONE COMPLIANCE - HELP SYSTEM +# ============================================================================= + +def print_help(): + """Display Rich-formatted help""" + + console.print() + header("API Branch - API Operations") + console.print() + + console.print("[dim]Universal API client and key management system[/dim]") + console.print() + console.print("─" * 70) + console.print() + + console.print("[bold cyan]WHAT IS API?[/bold cyan]") + console.print() + console.print("API Branch provides:") + console.print(" [green]✓[/green] OpenRouter API client integration") + console.print(" [green]✓[/green] API key management and validation") + console.print(" [green]✓[/green] Model discovery and availability") + console.print(" [green]✓[/green] Usage tracking and statistics") + console.print(" [green]✓[/green] Connection testing and diagnostics") + console.print() + + console.print("[bold cyan]AVAILABLE COMMANDS:[/bold cyan]") + console.print() + + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Command", style="green") + table.add_column("Description", style="white") + + table.add_row("get-key", "Retrieve API key for provider") + table.add_row("validate", "Validate API credentials and connection") + table.add_row("test", "Test OpenRouter connection status") + table.add_row("models", "List available models from provider") + table.add_row("track", "Track API usage metrics") + table.add_row("stats", "Display API usage statistics") + table.add_row("telegram start", "Start Telegram bridge service") + table.add_row("telegram stop", "Stop Telegram bridge service") + table.add_row("telegram status", "Check Telegram bridge status") + table.add_row("telegram logs", "View Telegram bridge logs") + + console.print(table) + console.print() + console.print("─" * 70) + console.print() + + console.print("[bold cyan]USAGE:[/bold cyan]") + console.print() + + usage_examples = [ + "[yellow]Quick Commands:[/yellow]\n [dim]python3 api.py get-key[/dim]\n [dim]python3 api.py validate[/dim]", + "[yellow]Testing:[/yellow]\n [dim]python3 api.py test[/dim]\n [dim]python3 api.py models[/dim]", + "[yellow]Analytics:[/yellow]\n [dim]python3 api.py track[/dim]\n [dim]python3 api.py stats[/dim]" + ] + + console.print(Columns(usage_examples, equal=True, expand=True)) + console.print() + console.print("─" * 70) + console.print() + + console.print("[bold cyan]CONFIGURATION:[/bold cyan]") + console.print() + + config_text = """[bold]API configuration is managed through:[/bold] + + [green]•[/green] Environment variables for credentials + [green]•[/green] Configuration files in handlers/config/ + [green]•[/green] Provider-specific settings in handlers/""" + + console.print(Panel(config_text, border_style="cyan", padding=(1, 2))) + console.print() + console.print("─" * 70) + console.print() + + console.print("[dim]Commands: get-key, validate, test, models, track, stats, help, --help[/dim]") + console.print() + + +# ============================================================================= +# COMMAND ROUTING +# ============================================================================= + +def route_command(command: str, args: List[str], modules: List[Any]) -> bool: + """ + Route command to appropriate module + + Args: + command: Command name (e.g., 'get-key', 'validate') + args: Additional command arguments + modules: List of discovered modules + + Returns: + True if command was handled, False otherwise + """ + for module in modules: + try: + if module.handle_command(command, args): + return True + except Exception as e: + logger.error(f"Module error: {e}") + + return False + +# ============================================================================= +# MAIN +# ============================================================================= + +def main(): + """Main entry point - routes commands to modules""" + + # Parse arguments directly from sys.argv + args = sys.argv[1:] + + # Show introspection when run without arguments + if len(args) == 0: + print_introspection() + json_handler.log_operation("api_introspection_displayed", {"trigger": "no_args"}) + return 0 + + # Show version + if args[0] in ['--version', '-V']: + console.print("API v1.0.0") + return 0 + + # Show help for explicit help flags + if args[0] in ['--help', '-h', 'help']: + print_help() + json_handler.log_operation("api_help_displayed", {"trigger": args[0]}) + return 0 + + # Discover modules + modules = discover_modules() + + if not modules: + logger.error("No modules found") + console.print() + console.print("[red]ERROR: No modules found[/red]") + console.print() + return 1 + + # Extract command and remaining args (matching seed pattern) + command = args[0] + remaining_args = args[1:] if len(args) > 1 else [] + + # Log api command attempt + json_handler.log_operation( + "api_command_attempted", + {"command": command, "modules_discovered": len(modules)} + ) + + # Route command to modules + if route_command(command, remaining_args, modules): + return 0 + else: + logger.warning(f"Unknown command: {command}") + console.print() + console.print(f"[red]ERROR: Unknown command: {command}[/red]") + console.print() + console.print("Run [dim]python3 api.py --help[/dim] for available commands") + console.print() + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/aipass/api/apps/branch.py b/src/aipass/api/apps/branch.py deleted file mode 100644 index d7deb952..00000000 --- a/src/aipass/api/apps/branch.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -API Branch - Main Orchestrator - -Auto-discovery architecture: -- Scans modules/ directory for .py files with handle_command() -- Routes commands to discovered modules automatically -- No manual imports or routing needed -""" - -import sys -import importlib -from pathlib import Path -from typing import List, Any - -from aipass.prax import logger - -# ============================================================================= -# MODULE DISCOVERY -# ============================================================================= - -MODULES_DIR = Path(__file__).parent / "modules" - - -def discover_modules() -> List[Any]: - """Auto-discover modules in modules/ directory.""" - modules = [] - - if not MODULES_DIR.exists(): - return modules - - for file_path in MODULES_DIR.glob("*.py"): - if file_path.name.startswith("_"): - continue - - module_name = f"apps.modules.{file_path.stem}" - - try: - module = importlib.import_module(module_name) - if hasattr(module, "handle_command"): - modules.append(module) - except Exception as e: - logger.error(f"[API] Failed to load module {module_name}: {e}") - - return modules - - -def route_command(command: str, args: List[str], modules: List[Any]) -> bool: - """Route command to appropriate module.""" - for module in modules: - try: - if module.handle_command(command, args): - return True - except Exception as e: - logger.error(f"[API] Module {module.__name__} error: {e}") - return False - - -# ============================================================================= -# MAIN ENTRY POINT -# ============================================================================= - -def main(): - """Main entry point - routes commands or shows help.""" - modules = discover_modules() - args = sys.argv[1:] - - if len(args) == 0 or args[0] in ["--help", "-h", "help"]: - print(f"API - {len(modules)} modules discovered") - for module in modules: - name = module.__name__.split(".")[-1] - desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" - print(f" {name:20} {desc}") - return 0 - - command = args[0] - remaining = args[1:] if len(args) > 1 else [] - - if route_command(command, remaining, modules): - return 0 - - print(f"Unknown command: {command}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/aipass/api/apps/extensions/__init__.py b/src/aipass/api/apps/extensions/__init__.py new file mode 100644 index 00000000..95322c94 --- /dev/null +++ b/src/aipass/api/apps/extensions/__init__.py @@ -0,0 +1 @@ +# Extensions package - Drop-in extensions for branch functionality diff --git a/src/aipass/api/apps/handlers/__init__.py b/src/aipass/api/apps/handlers/__init__.py index e69de29b..2b5a05da 100644 --- a/src/aipass/api/apps/handlers/__init__.py +++ b/src/aipass/api/apps/handlers/__init__.py @@ -0,0 +1,90 @@ +"""API handlers package - Security protected.""" + +import inspect +from pathlib import Path + +MY_BRANCH = "api" + + +def _find_real_caller(): + """Walk the stack to find the actual file that triggered this import.""" + stack = inspect.stack() + this_file = str(Path(__file__).resolve()) + + for frame_info in stack: + filename = frame_info.filename + if this_file in str(Path(filename).resolve()): + continue + if filename.startswith("<") or "importlib" in filename: + continue + import_line = None + if frame_info.code_context: + import_line = frame_info.code_context[0].strip() + return str(Path(filename).resolve()), import_line + return None, None + + +def _extract_branch_name(filepath: str) -> str: + """Extract branch name from a file path.""" + parts = filepath.split("/") + for i, part in enumerate(parts): + if part in ("aipass_core", "MEMORY_BANK", "seed", ".vscode"): + if i + 1 < len(parts): + return parts[i + 1] + if part in ("aipass",) and i + 1 < len(parts) and parts[i + 1] == "apps": + return "aipass" + return "unknown" + + +def _guard_branch_access(): + """Block cross-branch handler imports.""" + caller_file, import_line = _find_real_caller() + + import os + if os.environ.get("AIPASS_DEBUG_GUARD"): + import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) + print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) + + if caller_file is None: + stack = inspect.stack() + for frame in stack: + if frame.filename in ("", ""): + target_line = "unknown" + if frame.code_context: + target_line = frame.code_context[0].strip() + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller: interactive/script\n" + f" Blocked: {target_line}\n\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"{'='*60}" + ) + return + + if f"/{MY_BRANCH}/" in caller_file: + return + + caller_branch = _extract_branch_name(caller_file) + caller_filename = Path(caller_file).name + blocked_import = import_line if import_line else "unknown" + + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller branch: {caller_branch}\n" + f" Caller file: {caller_filename}\n" + f" Blocked: {blocked_import}\n\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"{'='*60}" + ) + + +_guard_branch_access() diff --git a/src/aipass/api/apps/handlers/auth/__init__.py b/src/aipass/api/apps/handlers/auth/__init__.py new file mode 100644 index 00000000..9d77ffe2 --- /dev/null +++ b/src/aipass/api/apps/handlers/auth/__init__.py @@ -0,0 +1,7 @@ +""" +Authentication Domain + +Handlers for API key management, validation, and credential storage. +Includes .env file operations and provider authentication. +""" +__version__ = "1.0.0" diff --git a/src/aipass/api/apps/handlers/auth/env.py b/src/aipass/api/apps/handlers/auth/env.py new file mode 100644 index 00000000..59793d41 --- /dev/null +++ b/src/aipass/api/apps/handlers/auth/env.py @@ -0,0 +1,324 @@ +#!/home/aipass/.venv/bin/python3 +# -*- coding: utf-8 -*- + +# ===================AIPASS==================== +# META DATA HEADER +# Name: env.py - .env file operations +# Date: 2025-11-16 +# Version: 0.2.0 +# Category: api/handlers +# +# CHANGELOG (Max 5 entries): +# - v0.2.0 (2025-11-16): Extracted from api_connect.py - complete implementation +# - v0.1.0 (2025-11-15): Initial handler stub +# +# CODE STANDARDS: +# - Handler layer (standalone functions) +# - Uses CLI service for output +# - Under 300 lines +# ============================================== + +""" +.env File Handler + +Manages .env file reading, creation, and parsing. +Searches multiple paths, creates templates, handles env variables. + +Functions: + read_env_file() - Read environment variable from .env files (multi-path search) + read_env_file_dict() - Read all variables from .env file as dictionary + create_env_template() - Create .env template for provider + validate_env_exists() - Check if .env file exists at any search path +""" + +# Infrastructure +from pathlib import Path +import sys + +# Standard library +from typing import Optional, Dict, List + +# CLI services +from aipass.cli.apps.modules import console + + +# ============================================== +# CONSTANTS +# ============================================== + +# Default .env search paths (in order of priority) +# Navigate: env.py -> auth/ -> handlers/ -> apps/ -> api/ +API_ROOT = Path(__file__).resolve().parent.parent.parent.parent +DEFAULT_ENV_PATHS = [ + API_ROOT / ".env", # /.env + Path.home() / ".env", # ~/.env +] + + +# ============================================== +# ENV FILE READING +# ============================================== + +def read_env_file(env_var: str, search_paths: Optional[List[Path]] = None) -> Optional[str]: + """ + Read environment variable from .env files with multi-path search. + + Searches multiple .env file locations in order: + 1. /home/aipass/aipass_core/api/.env + 2. /home/aipass/aipass_core/.env + 3. /home/aipass/.env + + Args: + env_var: Environment variable name to read (e.g., 'OPENROUTER_API_KEY') + search_paths: Optional custom search paths (defaults to DEFAULT_ENV_PATHS) + + Returns: + str: Variable value if found, None otherwise + + Example: + >>> api_key = read_env_file('OPENROUTER_API_KEY') + >>> if api_key: + ... print(f"Found key: {api_key[:20]}...") + """ + paths = search_paths or DEFAULT_ENV_PATHS + + for env_file in paths: + if not env_file.exists(): + continue + + try: + with open(env_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + # Skip empty lines and comments + if not line or line.startswith('#'): + continue + # Parse key=value + if '=' in line: + key, value = line.split('=', 1) + if key.strip() == env_var: + # Found env_var in env_file + return value.strip() + except Exception as e: + # Error reading env_file + continue + + # Variable not found in any .env file + return None + + +def read_env_file_dict(env_path: Path) -> Dict[str, str]: + """ + Read all environment variables from a .env file as dictionary. + + Args: + env_path: Path to specific .env file to read + + Returns: + dict: Dictionary of key-value pairs from .env file + + Example: + >>> env_vars = read_env_file_dict(Path('/home/aipass/aipass_core/api/.env')) + >>> print(env_vars.get('OPENROUTER_API_KEY')) + """ + env_dict = {} + + if not env_path.exists(): + # .env file not found + return env_dict + + try: + with open(env_path, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + # Skip empty lines and comments + if not line or line.startswith('#'): + continue + # Parse key=value + if '=' in line: + key, value = line.split('=', 1) + env_dict[key.strip()] = value.strip() + + # Read variables from env_path + return env_dict + + except Exception as e: + # Error reading env_path + return env_dict + + +# ============================================== +# ENV FILE CREATION +# ============================================== + +def create_env_template(provider: str = "openrouter", target_path: Optional[Path] = None) -> bool: + """ + Create .env template file with default placeholders. + + Creates a template .env file with commented instructions and + placeholder values for API keys. Will not overwrite existing files. + + Args: + provider: API provider name (default: 'openrouter') + target_path: Optional custom path (defaults to api/.env) + + Returns: + bool: True if template created successfully, False otherwise + + Example: + >>> if create_env_template('openrouter'): + ... print("Template created at /home/aipass/aipass_core/api/.env") + """ + # Default to api/.env + env_path = target_path or (API_ROOT / ".env") + + # Don't overwrite existing file + if env_path.exists(): + # .env file already exists + console.print(f"[yellow]ℹ[/yellow] .env file already exists at {env_path}") + return True + + # Template content based on provider + if provider.lower() == "openrouter": + env_template = """# AIPass API Keys +# Add your API keys here + +# OpenRouter API Key (recommended - access to 323+ models) +OPENROUTER_API_KEY=sk-or-v1-your-key-here + +# Backup OpenAI API Key (if needed) +OPENAI_API_KEY=sk-your-openai-key-here + +# Other provider keys can be added as needed +""" + else: + # Generic template + env_template = f"""# AIPass API Keys +# Add your API keys here + +# {provider.upper()} API Key +{provider.upper()}_API_KEY=your-key-here + +# Other provider keys can be added as needed +""" + + try: + # Ensure parent directory exists + env_path.parent.mkdir(parents=True, exist_ok=True) + + # Write template + with open(env_path, 'w', encoding='utf-8') as f: + f.write(env_template) + + # Created .env template + console.print(f"[green]✓[/green] Created .env template at {env_path}") + return True + + except Exception as e: + # Failed to create .env template + console.print(f"[red]✗[/red] Failed to create .env template: {e}") + return False + + +def create_custom_env_template(variables: Dict[str, str], target_path: Path, + header: Optional[str] = None) -> bool: + """ + Create custom .env template with specific variables. + + Args: + variables: Dictionary of variable names to placeholder values + target_path: Path where .env file should be created + header: Optional custom header comment + + Returns: + bool: True if successful + + Example: + >>> vars = { + ... 'DATABASE_URL': 'postgresql://localhost/mydb', + ... 'SECRET_KEY': 'your-secret-key-here' + ... } + >>> create_custom_env_template(vars, Path('/path/to/.env')) + """ + # Don't overwrite existing file + if target_path.exists(): + # .env file already exists + return True + + try: + # Ensure parent directory exists + target_path.parent.mkdir(parents=True, exist_ok=True) + + # Build template content + content_lines = [] + + # Add header + if header: + content_lines.append(f"# {header}") + else: + content_lines.append("# Environment Variables") + content_lines.append("") + + # Add variables + for key, value in variables.items(): + content_lines.append(f"{key}={value}") + + content = "\n".join(content_lines) + "\n" + + # Write file + with open(target_path, 'w', encoding='utf-8') as f: + f.write(content) + + # Created custom .env template + console.print(f"[green]✓[/green] Created custom .env template at {target_path}") + return True + + except Exception as e: + # Failed to create custom .env template + console.print(f"[red]✗[/red] Failed to create custom .env template: {e}") + return False + + +# ============================================== +# VALIDATION +# ============================================== + +def validate_env_exists(search_paths: Optional[List[Path]] = None) -> Optional[Path]: + """ + Check if .env file exists at any search path. + + Args: + search_paths: Optional custom search paths (defaults to DEFAULT_ENV_PATHS) + + Returns: + Path: First found .env file path, or None if none exist + + Example: + >>> env_path = validate_env_exists() + >>> if env_path: + ... print(f"Found .env at {env_path}") + """ + paths = search_paths or DEFAULT_ENV_PATHS + + for env_path in paths: + if env_path.exists(): + # Found .env file + return env_path + + # No .env file found in search paths + return None + + +def get_env_search_paths() -> List[Path]: + """ + Get list of default .env search paths. + + Returns: + list: List of Path objects for .env search locations + + Example: + >>> paths = get_env_search_paths() + >>> for p in paths: + ... print(p) + """ + return DEFAULT_ENV_PATHS.copy() diff --git a/src/aipass/api/apps/handlers/auth/keys.py b/src/aipass/api/apps/handlers/auth/keys.py new file mode 100644 index 00000000..b41e2b36 --- /dev/null +++ b/src/aipass/api/apps/handlers/auth/keys.py @@ -0,0 +1,340 @@ +#!/home/aipass/.venv/bin/python3 +# -*- coding: utf-8 -*- + +# ===================AIPASS==================== +# META DATA HEADER +# Name: keys.py - API Key Management Handler +# Date: 2025-11-16 +# Version: 2.0.0 +# Category: api/handlers +# +# CHANGELOG (Max 5 entries): +# - v2.0.0 (2025-11-16): Extracted from api_connect.py - complete implementation +# - v1.0.0 (2025-11-15): Initial handler stub +# +# CODE STANDARDS: +# - Handler layer (standalone functions) +# - Uses CLI service for output +# - Uses prax logger for system logs +# - Under 300 lines +# ============================================== + +""" +API Key Management Handler + +Handles API key retrieval and validation for multiple providers. +Uses fallback chain: config → env → .env files. + +Functions: + get_api_key() - Get validated API key with fallback chain + validate_key() - Validate key format for provider + get_key_from_config() - Retrieve key from config JSON + get_key_from_env() - Retrieve key from environment variable + get_validation_rules() - Get provider-specific validation rules +""" + +# Infrastructure +from pathlib import Path +import sys + +# Standard library +import os +from typing import Optional, Dict, Any + +# Internal handlers +from aipass.api.apps.handlers.auth.env import read_env_file + + +# ============================================== +# CONSTANTS +# ============================================== + +# Navigate: keys.py -> auth/ -> handlers/ -> apps/ -> api/ +API_ROOT = Path(__file__).resolve().parent.parent.parent.parent +API_JSON_DIR = API_ROOT / "api_json" + +# Provider validation rules (embedded - no config dependency for core validation) +VALIDATION_RULES = { + "openrouter": { + "prefix": "sk-or-", + "min_length": 20 + }, + "openai": { + "prefix": "sk-", + "min_length": 20 + }, + "anthropic": { + "prefix": "sk-ant-", + "min_length": 20 + }, + # Generic fallback + "generic": { + "min_length": 10 + } +} + + +# ============================================== +# KEY RETRIEVAL +# ============================================== + +def get_api_key(provider: str = "openrouter") -> Optional[str]: + """ + Get validated API key for provider with fallback chain. + + Fallback order: + 1. Config JSON file (api_json/api_connect_config.json) + 2. Environment variable + 3. .env file (multi-path search) + + Args: + provider: Provider name (default: 'openrouter') + + Returns: + str: Validated API key or None if not found/invalid + + Example: + >>> key = get_api_key('openrouter') + >>> if key: + ... print(f"Got key: {key[:20]}...") + """ + try: + # 1. Try config file + key = get_key_from_config(provider) + if key and validate_key(key, provider): + # Using key from config + return key + + # 2. Try environment variable + key = get_key_from_env(provider) + if key and validate_key(key, provider): + # Using key from environment + return key + + # 3. Try .env file + env_var = f"{provider.upper()}_API_KEY" + key = read_env_file(env_var) + if key and validate_key(key, provider): + # Using key from .env file + return key + + # No valid key found + return None + + except Exception as e: + # Failed to get key + return None + + +def get_key_from_config(provider: str) -> Optional[str]: + """ + Retrieve API key from config JSON file. + + Reads from: /home/aipass/aipass_core/api/api_json/api_connect_config.json + + Args: + provider: Provider name (e.g., 'openrouter') + + Returns: + str: API key from config or None if not found + + Example: + >>> key = get_key_from_config('openrouter') + """ + try: + config_path = API_JSON_DIR / "api_connect_config.json" + + if not config_path.exists(): + # Config file not found + return None + + import json + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + # Navigate config structure + if "config" in config: + providers = config["config"].get("providers", {}) + if provider in providers: + key = providers[provider].get("api_key", "") + if key: + return key + + # No key in config file + return None + + except Exception as e: + # Error reading config + return None + + +def get_key_from_env(provider: str) -> Optional[str]: + """ + Retrieve API key from environment variable. + + Checks os.environ for {PROVIDER}_API_KEY. + + Args: + provider: Provider name (e.g., 'openrouter') + + Returns: + str: API key from environment or None if not found + + Example: + >>> key = get_key_from_env('openrouter') + >>> # Checks OPENROUTER_API_KEY env variable + """ + env_var = f"{provider.upper()}_API_KEY" + key = os.getenv(env_var) + + if key: + # Found key in environment variable + return key + + return None + + +# ============================================== +# KEY VALIDATION +# ============================================== + +def validate_key(key: str, provider: str = "openrouter") -> bool: + """ + Validate API key format for provider. + + Checks: + - Key is non-empty string + - Matches provider prefix (if required) + - Meets minimum length requirement + + Args: + key: API key to validate + provider: Provider name for validation rules + + Returns: + bool: True if key passes validation + + Example: + >>> key = "sk-or-v1-abc123..." + >>> if validate_key(key, 'openrouter'): + ... print("Valid key") + """ + # Basic validation + if not key or not isinstance(key, str): + # Invalid key type + return False + + # Strip whitespace + key = key.strip() + + # Get validation rules + rules = get_validation_rules(provider) + + # Check prefix if specified + if "prefix" in rules: + if not key.startswith(rules["prefix"]): + # Key missing required prefix + return False + + # Check minimum length + if "min_length" in rules: + if len(key) < rules["min_length"]: + # Key too short + return False + + # Key passed validation + return True + + +def get_validation_rules(provider: str) -> Dict[str, Any]: + """ + Get validation rules for provider. + + Returns provider-specific rules or generic fallback. + + Args: + provider: Provider name + + Returns: + dict: Validation rules (prefix, min_length) + + Example: + >>> rules = get_validation_rules('openrouter') + >>> print(rules['prefix']) + sk-or- + """ + return VALIDATION_RULES.get(provider, VALIDATION_RULES["generic"]) + + +# ============================================== +# KEY FORMAT CHECKING +# ============================================== + +def check_key_format(key: str) -> Dict[str, Any]: + """ + Analyze key format and return details. + + Useful for debugging key issues. Returns information about + the key without validating against a specific provider. + + Args: + key: API key to analyze + + Returns: + dict: Key format details (length, prefix, etc.) + + Example: + >>> info = check_key_format('sk-or-v1-abc123') + >>> print(info['detected_provider']) + openrouter + """ + if not key or not isinstance(key, str): + return { + "valid": False, + "error": "Key is not a string" + } + + key = key.strip() + + # Detect provider from prefix + detected_provider = None + for provider, rules in VALIDATION_RULES.items(): + if provider == "generic": + continue + if "prefix" in rules and key.startswith(rules["prefix"]): + detected_provider = provider + break + + return { + "valid": True, + "length": len(key), + "prefix": key[:10] if len(key) >= 10 else key, + "detected_provider": detected_provider, + "meets_generic_length": len(key) >= VALIDATION_RULES["generic"]["min_length"] + } + + +def validate_multiple_keys(keys: Dict[str, str]) -> Dict[str, bool]: + """ + Validate multiple provider keys at once. + + Useful for validating entire config at once. + + Args: + keys: Dictionary of {provider: key} + + Returns: + dict: Dictionary of {provider: is_valid} + + Example: + >>> keys = {'openrouter': 'sk-or-...', 'openai': 'sk-...'} + >>> results = validate_multiple_keys(keys) + >>> print(results) + {'openrouter': True, 'openai': True} + """ + results = {} + + for provider, key in keys.items(): + results[provider] = validate_key(key, provider) + + return results diff --git a/src/aipass/api/apps/handlers/config/__init__.py b/src/aipass/api/apps/handlers/config/__init__.py new file mode 100644 index 00000000..6b424d15 --- /dev/null +++ b/src/aipass/api/apps/handlers/config/__init__.py @@ -0,0 +1,7 @@ +""" +Configuration Domain + +Handlers for provider configuration management. +Load, validate, and update API provider settings. +""" +__version__ = "1.0.0" diff --git a/src/aipass/api/apps/handlers/config/provider.py b/src/aipass/api/apps/handlers/config/provider.py new file mode 100644 index 00000000..e2a74db9 --- /dev/null +++ b/src/aipass/api/apps/handlers/config/provider.py @@ -0,0 +1,433 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: provider.py - Provider Configuration Handler +# Date: 2025-11-16 +# Version: 2.0.0 +# Category: api/handlers/config +# +# CHANGELOG (Max 5 entries): +# - v2.0.0 (2025-11-16): Complete extraction from api_connect.py +# - v1.0.0 (2025-11-15): Initial handler stub +# ============================================= + +""" +Provider Configuration Handler + +Manages provider configuration for API access: +- Load provider configurations from JSON +- Deep merge configuration updates +- Provider defaults and validation +- Config file management (create/update) +- Configuration merging helpers + +Extracted from api_connect.py archive for new handler structure. +""" + +# Infrastructure +import sys +from pathlib import Path + +# Standard library +import json +from datetime import datetime +from typing import Dict, Any, Optional + +# Internal handlers +from aipass.api.apps.handlers.json.json_handler import load_json, save_json + +# Console for user feedback +try: + from rich.console import Console + console = Console() +except ImportError: + # Fallback console if rich not available + class SimpleConsole: + def print(self, *args, **kwargs): + print(*args) + console = SimpleConsole() + +# ============================================= +# CONSTANTS +# ============================================= + +# Navigate: provider.py -> config/ -> handlers/ -> apps/ -> api/ +API_ROOT = Path(__file__).resolve().parent.parent.parent.parent +API_JSON_DIR = API_ROOT / "api_json" +CONFIG_FILE = "api_config.json" + +# Default provider configurations +# NOTE: No default_model - callers must specify their own model from their branch config +PROVIDER_DEFAULTS = { + "openrouter": { + "api_key": "", + "base_url": "https://openrouter.ai/api/v1", + "temperature": 0.7, + "timeout_seconds": 30 + }, + "openai": { + "api_key": "", + "base_url": "https://api.openai.com/v1", + "temperature": 0.7, + "timeout_seconds": 30 + } +} + +# Provider validation rules +VALIDATION_RULES = { + "openrouter": { + "prefix": "sk-or-v1-", + "min_length": 40 + }, + "openai": { + "prefix": "sk-", + "min_length": 40 + } +} + +# ============================================= +# CONFIGURATION LOADING +# ============================================= + +def load_provider_config(provider: str = "openrouter") -> Optional[Dict[str, Any]]: + """ + Load provider configuration from config JSON + + Reads the main API config file and extracts provider-specific settings. + Returns None if provider not found or config file doesn't exist. + + Args: + provider: Provider name (e.g., "openrouter", "openai") + + Returns: + Provider configuration dict or None if not found + + Example: + config = load_provider_config("openrouter") + # Returns: { + # "api_key": "sk-or-v1-...", + # "base_url": "https://openrouter.ai/api/v1", + # "temperature": 0.7, + # "timeout_seconds": 30 + # } + # NOTE: No default_model - callers provide their own + """ + try: + config_path = API_JSON_DIR / CONFIG_FILE + + if not config_path.exists(): + # Config file not found, creating default + _create_default_config() + + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + # Extract provider config from main config + if "config" in config and "providers" in config["config"]: + provider_config = config["config"]["providers"].get(provider) + + if provider_config: + # Loaded config for provider + return provider_config + else: + # Provider not found in config + return None + else: + # Config structure missing 'providers' section + return None + + except json.JSONDecodeError as e: + # Invalid JSON in config file + return None + except Exception as e: + # Failed to load provider config + return None + + +def get_full_config() -> Optional[Dict[str, Any]]: + """ + Load the complete API configuration + + Returns: + Full config dict or None if load fails + """ + try: + config_path = API_JSON_DIR / CONFIG_FILE + + if not config_path.exists(): + # Config file not found, creating default + _create_default_config() + + with open(config_path, 'r', encoding='utf-8') as f: + return json.load(f) + + except Exception as e: + # Failed to load full config + return None + + +# ============================================= +# CONFIGURATION UPDATES +# ============================================= + +def update_provider_config(provider: str, updates: Dict[str, Any]) -> bool: + """ + Deep merge updates into provider configuration + + Updates the provider's configuration with new values, preserving + existing values not specified in updates. Uses deep merge to handle + nested dictionaries properly. + + Args: + provider: Provider name (e.g., "openrouter") + updates: Configuration updates to apply + + Returns: + True if update successful, False otherwise + + Example: + success = update_provider_config("openrouter", { + "api_key": "sk-or-v1-new-key", + "temperature": 0.8 + }) + """ + try: + config_path = API_JSON_DIR / CONFIG_FILE + + # Load existing config or create default + if config_path.exists(): + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + else: + config = _get_default_config_structure() + + # Ensure providers section exists + if "config" not in config: + config["config"] = {} + if "providers" not in config["config"]: + config["config"]["providers"] = {} + + # Get or create provider config + if provider not in config["config"]["providers"]: + config["config"]["providers"][provider] = get_default_config(provider) + + # Deep merge updates into provider config + merge_configs(config["config"]["providers"][provider], updates) + + # Update timestamp + config["timestamp"] = datetime.now().isoformat() + + # Save updated config + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=2, ensure_ascii=False) + + # Updated config for provider + console.print(f"[green]✓[/green] Provider config updated: {provider}") + return True + + except Exception as e: + # Failed to update provider config + console.print(f"[red]✗[/red] Failed to update provider config: {e}") + return False + + +def update_full_config(updates: Dict[str, Any]) -> bool: + """ + Update the complete API configuration with deep merge + + Args: + updates: Configuration updates to apply + + Returns: + True if successful + """ + try: + config_path = API_JSON_DIR / CONFIG_FILE + + # Load existing or create default + if config_path.exists(): + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + else: + config = _get_default_config_structure() + + # Deep merge updates + merge_configs(config, updates) + + # Update timestamp + config["timestamp"] = datetime.now().isoformat() + + # Save + config_path.parent.mkdir(parents=True, exist_ok=True) + with open(config_path, 'w', encoding='utf-8') as f: + json.dump(config, f, indent=2, ensure_ascii=False) + + # Updated full API config + return True + + except Exception as e: + # Failed to update full config + return False + + +# ============================================= +# DEFAULT CONFIGURATIONS +# ============================================= + +def get_default_config(provider: str) -> Dict[str, Any]: + """ + Get default configuration for provider + + Returns the default configuration structure for a specific provider. + If provider not in defaults, returns empty config structure. + + Args: + provider: Provider name + + Returns: + Default configuration dict + + Example: + config = get_default_config("openrouter") + # Returns default OpenRouter configuration + """ + if provider in PROVIDER_DEFAULTS: + # Return a copy to avoid mutation + return PROVIDER_DEFAULTS[provider].copy() + else: + # No default config for provider + return { + "api_key": "", + "base_url": "", + "timeout_seconds": 30 + } + + +def _get_default_config_structure() -> Dict[str, Any]: + """ + Get complete default configuration structure + + Returns: + Default config dict with all providers + """ + return { + "module_name": "api", + "version": "2.0.0", + "timestamp": datetime.now().isoformat(), + "config": { + "enabled": True, + "auto_save": True, + "providers": { + "openrouter": PROVIDER_DEFAULTS["openrouter"].copy(), + "openai": PROVIDER_DEFAULTS["openai"].copy() + }, + "default_provider": "openrouter", + "key_validation": VALIDATION_RULES.copy() + } + } + + +def _create_default_config() -> bool: + """ + Create default configuration file + + Returns: + True if successful + """ + try: + config_path = API_JSON_DIR / CONFIG_FILE + config_path.parent.mkdir(parents=True, exist_ok=True) + + default_config = _get_default_config_structure() + + with open(config_path, 'w', encoding='utf-8') as f: + json.dump(default_config, f, indent=2, ensure_ascii=False) + + # Created default config + console.print(f"[green]✓[/green] Created default config: {config_path}") + return True + + except Exception as e: + # Failed to create default config + return False + + +# ============================================= +# CONFIGURATION MERGING +# ============================================= + +def merge_configs(base: Dict[str, Any], updates: Dict[str, Any]) -> Dict[str, Any]: + """ + Deep merge two configuration dictionaries + + Recursively merges 'updates' into 'base', preserving nested structures. + Modifies 'base' in-place and also returns it for convenience. + + For nested dicts: recursively merges + For other types: updates overwrites base + + Args: + base: Base configuration dict (modified in-place) + updates: Updates to merge in + + Returns: + The merged base dict (same object as input) + + Example: + base = {"a": 1, "b": {"c": 2, "d": 3}} + updates = {"b": {"c": 99}, "e": 4} + merge_configs(base, updates) + # base is now: {"a": 1, "b": {"c": 99, "d": 3}, "e": 4} + """ + for key, value in updates.items(): + if isinstance(value, dict) and key in base and isinstance(base[key], dict): + # Recursively merge nested dicts + merge_configs(base[key], value) + else: + # Overwrite with new value + base[key] = value + + return base + + +# ============================================= +# VALIDATION HELPERS +# ============================================= + +def get_validation_rules(provider: str) -> Optional[Dict[str, Any]]: + """ + Get validation rules for provider + + Args: + provider: Provider name + + Returns: + Validation rules dict or None if not defined + """ + return VALIDATION_RULES.get(provider) + + +def list_available_providers() -> list[str]: + """ + List all available providers with defaults + + Returns: + List of provider names + """ + return list(PROVIDER_DEFAULTS.keys()) + + +def provider_exists(provider: str) -> bool: + """ + Check if provider exists in configuration + + Args: + provider: Provider name + + Returns: + True if provider configured, False otherwise + """ + config = load_provider_config(provider) + return config is not None diff --git a/src/aipass/api/apps/handlers/json/__init__.py b/src/aipass/api/apps/handlers/json/__init__.py new file mode 100644 index 00000000..f44e47b3 --- /dev/null +++ b/src/aipass/api/apps/handlers/json/__init__.py @@ -0,0 +1 @@ +"""JSON Handlers - Universal JSON operations for Seed branch""" diff --git a/src/aipass/api/apps/handlers/json/json_handler.py b/src/aipass/api/apps/handlers/json/json_handler.py new file mode 100755 index 00000000..563fc0a1 --- /dev/null +++ b/src/aipass/api/apps/handlers/json/json_handler.py @@ -0,0 +1,282 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: json_handler.py - JSON Auto-Creating Handler +# Date: 2025-11-21 +# Version: 1.1.0 +# Category: api/handlers/json +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2025-11-21): Refactored to comply with error handling +# - v1.0.0 (2025-11-13): Initial JSON auto-creation system +# +# CODE STANDARDS: +# - Pure functions with proper error raising +# - No Prax imports (handler tier 3) +# ============================================= + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, Optional +import sys +import inspect + +# Infrastructure + +# Constants +API_ROOT = Path.home() / "aipass_core" / "api" +API_JSON_DIR = API_ROOT / "api_json" +JSON_TEMPLATES_DIR = API_ROOT / "apps" / "json_templates" + + +def _get_caller_module_name() -> str: + """ + Auto-detect calling module name from call stack + + Returns: + Module name (e.g., "imports_standard" from imports_standard.py) + """ + try: + stack = inspect.stack() + # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller + if len(stack) > 2: + caller_frame = stack[2] + caller_path = Path(caller_frame.filename) + module_name = caller_path.stem + + # Validate module name + if module_name and not module_name.startswith('_'): + return module_name + + # Fallback + return "unknown" + except Exception: + return "unknown" + + +def load_template(json_type: str, module_name: str) -> Any: + """Load JSON template from template file""" + template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json" + + if not template_path.exists(): + return None + + try: + with open(template_path, 'r', encoding='utf-8') as f: + template = json.load(f) + + # Replace placeholders + template_str = json.dumps(template) + template_str = template_str.replace("{{MODULE_NAME}}", module_name) + template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat()) + + return json.loads(template_str) + except Exception: + return None + + +def validate_json_structure(data: Any, json_type: str) -> bool: + """Validate JSON structure matches expected type""" + if json_type == "config": + if not isinstance(data, dict): + return False + required = ["module_name", "version", "config"] + return all(key in data for key in required) + + elif json_type == "data": + if not isinstance(data, dict): + return False + required = ["created", "last_updated"] + return all(key in data for key in required) + + elif json_type == "log": + return isinstance(data, list) + + return False + + +def get_json_path(module_name: str, json_type: str) -> Path: + """Get path for module JSON file""" + filename = f"{module_name}_{json_type}.json" + return API_JSON_DIR / filename + + +def ensure_json_exists(module_name: str, json_type: str) -> bool: + """Ensure JSON file exists, create from template if missing""" + API_JSON_DIR.mkdir(parents=True, exist_ok=True) + + json_path = get_json_path(module_name, json_type) + + if json_path.exists(): + try: + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if validate_json_structure(data, json_type): + return True + else: + pass # Corrupted - regenerating + except Exception: + pass # Unreadable - regenerating + + template = load_template(json_type, module_name) + if template is None: + return False + + try: + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(template, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def load_json(module_name: str, json_type: str) -> Optional[Any]: + """Load JSON file, auto-create if missing""" + if not ensure_json_exists(module_name, json_type): + return None + + json_path = get_json_path(module_name, json_type) + + try: + with open(json_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return None + + +def save_json(module_name: str, json_type: str, data: Any) -> bool: + """Save JSON file""" + json_path = get_json_path(module_name, json_type) + + if not validate_json_structure(data, json_type): + return False + + if json_type == "data" and isinstance(data, dict): + data["last_updated"] = datetime.now().date().isoformat() + + try: + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def ensure_module_jsons(module_name: str) -> bool: + """Ensure all 3 JSON files exist for a module""" + ensure_json_exists(module_name, "config") + ensure_json_exists(module_name, "data") + ensure_json_exists(module_name, "log") + return True + + +def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool: + """ + Add entry to module log with automatic rotation + + Auto-detects calling module if module_name not provided. + Implements config-controlled log limits to prevent unbounded growth. + When max_log_entries is reached, removes oldest entries (FIFO). + + Args: + operation: Operation name to log + data: Optional data dict + module_name: Optional module name (auto-detected if not provided) + + Returns: + True if successful, False otherwise + """ + # Auto-detect module name if not provided + if module_name is None: + module_name = _get_caller_module_name() + + ensure_module_jsons(module_name) + + # Load config to get max_log_entries + config = load_json(module_name, "config") + max_entries = 100 # Default + if config and "config" in config: + max_entries = config["config"].get("max_log_entries", 100) + + # Load existing log + log = load_json(module_name, "log") + if log is None: + log = [] + + # Create new entry + entry = { + "timestamp": datetime.now().isoformat(), + "operation": operation + } + + if data: + entry["data"] = data # type: ignore[assignment] + + # Add new entry + log.append(entry) + + # Rotate if exceeds max (keep most recent entries) + if len(log) > max_entries: + log = log[-max_entries:] + + return save_json(module_name, "log", log) + + +def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool: + """Increment a counter in data JSON""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + if counter_name not in data: + data[counter_name] = 0 + + data[counter_name] += amount + + return save_json(module_name, "data", data) + + +def update_data_metrics(module_name: str, **metrics) -> bool: + """Update data metrics""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + for key, value in metrics.items(): + data[key] = value + + return save_json(module_name, "data", data) + + +if __name__ == "__main__": + from rich.console import Console + from rich.panel import Panel + + console = Console() + + console.print() + console.print(Panel.fit( + "[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", + border_style="bright_blue" + )) + console.print() + console.print("[yellow]TESTING:[/yellow] Creating API JSONs...") + + # Test auto-creation + log_operation("test_operation", {"test": "data"}, "api") + increment_counter("api", "test_counter", 1) + update_data_metrics("api", test_metric="working") + + console.print() + console.print("[green]Check /home/aipass/aipass_core/api/api_json/ for created files:[/green]") + console.print(" [dim]•[/dim] api_config.json") + console.print(" [dim]•[/dim] api_data.json") + console.print(" [dim]•[/dim] api_log.json") + console.print() diff --git a/src/aipass/api/apps/handlers/openrouter/__init__.py b/src/aipass/api/apps/handlers/openrouter/__init__.py new file mode 100644 index 00000000..0b5696c2 --- /dev/null +++ b/src/aipass/api/apps/handlers/openrouter/__init__.py @@ -0,0 +1,7 @@ +""" +OpenRouter Domain + +Handlers for OpenRouter LLM API client operations. +Client creation, caller detection, model fetching, and config provisioning. +""" +__version__ = "1.0.0" diff --git a/src/aipass/api/apps/handlers/openrouter/caller.py b/src/aipass/api/apps/handlers/openrouter/caller.py new file mode 100644 index 00000000..3f09302d --- /dev/null +++ b/src/aipass/api/apps/handlers/openrouter/caller.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 + +# ============================================= +# META DATA HEADER +# Name: caller.py +# Date: 2025-11-16 +# Version: 1.0.0 +# Category: api/handlers +# +# CHANGELOG: +# - v1.0.0 (2025-11-16): Initial caller detection handler extracted from archive +# ============================================= + +""" +OpenRouter Caller Detection Handler + +Stack-based caller detection with JSON folder path resolution. +Supports flow, prax, and skills module detection. + +Usage: + from aipass.api.apps.handlers.openrouter.caller import get_caller_info + + caller_info = get_caller_info() + if caller_info: + caller_name = caller_info['caller_name'] + json_folder = caller_info['json_folder'] +""" + +from pathlib import Path + +# Standard library imports +import inspect +from typing import Dict, Any, Optional, Tuple + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "openrouter.caller" + +# Package root: caller.py -> openrouter/ -> handlers/ -> apps/ -> api/ -> aipass/ +_PACKAGE_ROOT = Path(__file__).resolve().parent.parent.parent.parent.parent +MODULE_VERSION = "1.0.0" + +CALLER_PATTERNS = { + "flow": "flow_json", + "prax": "prax_json", + "skills": "{category}_json", +} + +# ============================================= +# CALLER DETECTION FUNCTIONS +# ============================================= + +def get_caller_info() -> Optional[Dict[str, Any]]: + """ + Detect calling module via stack inspection. + + Returns dict with: caller_name, caller_path, json_folder, category, detection_method + Returns None if detection fails. + """ + try: + stack = inspect.stack() + + for frame_info in stack[1:]: + frame_path = Path(frame_info.filename) + + if "flow" in frame_path.parts: + return _detect_flow_caller(frame_path) + elif "prax" in frame_path.parts: + return _detect_prax_caller(frame_path) + elif any("skills" in part for part in frame_path.parts): + return _detect_skills_caller(frame_path) + + # logger.info(f"[{MODULE_NAME}] Could not detect caller from stack trace") + return None + + except Exception as e: + # logger.error(f"[{MODULE_NAME}] Caller detection failed: {e}") + return None + + +def get_caller_name_from_stack() -> Optional[str]: + """Extract caller name from call stack (simplified version).""" + caller_info = get_caller_info() + return caller_info.get('caller_name') if caller_info else None + + +def detect_caller_from_stack() -> Tuple[Optional[str], Optional[Path]]: + """ + Compatibility wrapper for provision handler. + + Returns: + Tuple of (caller_name, json_folder_path) or (None, None) + """ + caller_info = get_caller_info() + if caller_info: + return caller_info.get('caller_name'), caller_info.get('json_folder') + return None, None + + +def get_json_folder_path(caller: str) -> Optional[Path]: + """ + Determine JSON folder path for given caller name. + Fallback method when stack detection doesn't provide path. + """ + try: + if caller.startswith("flow_"): + base_path = _PACKAGE_ROOT / "flow" + json_folder = base_path / "flow_json" + + elif caller.startswith("prax_"): + base_path = _PACKAGE_ROOT / "prax" + json_folder = base_path / "prax_json" + + elif caller.startswith("skills_"): + parts = caller.split("_") + if len(parts) >= 2: + skills_category = parts[1] + base_path = _PACKAGE_ROOT / "skills" / f"skills_{skills_category}" + json_folder = base_path / f"{skills_category}_json" + else: + # logger.info(f"[{MODULE_NAME}] Cannot parse skills category from: {caller}") + return None + else: + # logger.info(f"[{MODULE_NAME}] Unknown caller pattern: {caller}") + return None + + # logger.info(f"[{MODULE_NAME}] Resolved JSON folder for {caller}: {json_folder}") + return json_folder + + except Exception as e: + # logger.error(f"[{MODULE_NAME}] Failed to determine JSON folder for {caller}: {e}") + return None + + +def detect_caller_category(caller_path: Path) -> str: + """Categorize caller based on file path.""" + try: + path_parts = caller_path.parts + + if "flow" in path_parts: + return "flow" + elif "prax" in path_parts: + return "prax" + elif any("skills" in part for part in path_parts): + return "skills" + else: + return "unknown" + + except Exception as e: + # logger.error(f"[{MODULE_NAME}] Failed to detect category for {caller_path}: {e}") + return "unknown" + + +# ============================================= +# INTERNAL DETECTION HELPERS +# ============================================= + +def _detect_flow_caller(frame_path: Path) -> Dict[str, Any]: + """Detect flow module caller from stack frame path.""" + try: + flow_index = frame_path.parts.index("flow") + flow_path = Path(*frame_path.parts[:flow_index + 1]) + json_folder_path = flow_path / "flow_json" + caller_name = frame_path.stem + + # logger.info(f"[{MODULE_NAME}] Detected flow caller: {caller_name}") + + return { + "caller_name": caller_name, + "caller_path": frame_path, + "json_folder": json_folder_path, + "category": "flow", + "detection_method": "stack" + } + + except Exception as e: + # logger.error(f"[{MODULE_NAME}] Failed to detect flow caller: {e}") + return _create_fallback_info(frame_path) + + +def _detect_prax_caller(frame_path: Path) -> Dict[str, Any]: + """Detect prax module caller from stack frame path.""" + try: + prax_index = frame_path.parts.index("prax") + prax_path = Path(*frame_path.parts[:prax_index + 1]) + json_folder_path = prax_path / "prax_json" + caller_name = frame_path.stem + + # logger.info(f"[{MODULE_NAME}] Detected prax caller: {caller_name}") + + return { + "caller_name": caller_name, + "caller_path": frame_path, + "json_folder": json_folder_path, + "category": "prax", + "detection_method": "stack" + } + + except Exception as e: + # logger.error(f"[{MODULE_NAME}] Failed to detect prax caller: {e}") + return _create_fallback_info(frame_path) + + +def _detect_skills_caller(frame_path: Path) -> Dict[str, Any]: + """ + Detect skills module caller from stack frame path. + Skills have category subdirectories (e.g., /skills/skills_api/skill.py) + """ + try: + for i, part in enumerate(frame_path.parts): + if "skills" in part: + skills_path = Path(*frame_path.parts[:i + 2]) + category = frame_path.parts[i + 1] if i + 1 < len(frame_path.parts) else "skills_api" + json_folder_path = skills_path / f"{category}_json" + caller_name = frame_path.stem + + # logger.info(f"[{MODULE_NAME}] Detected skills caller: {caller_name} (category: {category})") + + return { + "caller_name": caller_name, + "caller_path": frame_path, + "json_folder": json_folder_path, + "category": "skills", + "skills_category": category, + "detection_method": "stack" + } + + # logger.info(f"[{MODULE_NAME}] Could not find skills directory in path: {frame_path}") + return _create_fallback_info(frame_path) + + except Exception as e: + # logger.error(f"[{MODULE_NAME}] Failed to detect skills caller: {e}") + return _create_fallback_info(frame_path) + + +def _create_fallback_info(frame_path: Path) -> Dict[str, Any]: + """Create fallback caller info when detection fails.""" + caller_name = frame_path.stem + category = detect_caller_category(frame_path) + + # logger.info(f"[{MODULE_NAME}] Using fallback detection for: {caller_name}") + + return { + "caller_name": caller_name, + "caller_path": frame_path, + "json_folder": None, + "category": category, + "detection_method": "fallback" + } + + +# ============================================= +# VALIDATION HELPERS +# ============================================= + +def validate_caller_info(caller_info: Dict[str, Any]) -> bool: + """Validate caller information dictionary.""" + try: + required_fields = ["caller_name", "caller_path", "category", "detection_method"] + for field in required_fields: + if field not in caller_info: + # logger.info(f"[{MODULE_NAME}] Missing required field: {field}") + return False + + if not caller_info["caller_name"]: + # logger.info(f"[{MODULE_NAME}] Caller name is empty") + return False + + if not isinstance(caller_info["caller_path"], Path): + # logger.info(f"[{MODULE_NAME}] Caller path is not a Path object") + return False + + valid_categories = ["flow", "prax", "skills", "unknown"] + if caller_info["category"] not in valid_categories: + # logger.info(f"[{MODULE_NAME}] Invalid category: {caller_info['category']}") + return False + + return True + + except Exception as e: + # logger.error(f"[{MODULE_NAME}] Validation failed: {e}") + return False + + +# ============================================= +# MODULE INITIALIZATION +# ============================================= + +def _initialize(): + """Initialize caller detection module.""" + # logger.info(f"[{MODULE_NAME}] Caller detection handler loaded (v{MODULE_VERSION})") + pass + +_initialize() diff --git a/src/aipass/api/apps/handlers/openrouter/client.py b/src/aipass/api/apps/handlers/openrouter/client.py new file mode 100644 index 00000000..24a34273 --- /dev/null +++ b/src/aipass/api/apps/handlers/openrouter/client.py @@ -0,0 +1,394 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: client.py - OpenRouter Client Handler +# Date: 2025-11-15 +# Version: 3.0.0 +# Category: api/handlers/openrouter +# +# CHANGELOG (Max 5 entries): +# - v3.0.0 (2026-02-20): Fallback model chain in get_response() + retry logic in make_api_request() +# - v2.0.0 (2025-11-16): Complete extraction from archive - client creation, API requests, response handling +# - v1.0.0 (2025-11-15): Initial handler stub +# ============================================= + +""" +OpenRouter Client Handler + +Business logic for OpenRouter API client creation and request execution. +Extracted from archive.temp/openrouter.py following AIPASS standards. + +Functions: +- get_response() - Main API call with tracking integration +- create_client() - Create OpenAI SDK client configured for OpenRouter +- make_api_request() - Execute API request and handle errors +- extract_response() - Extract text and metadata from API response + +Configuration: +- base_url: https://openrouter.ai/api/v1 +- Uses OpenAI SDK with OpenRouter endpoint +- Supports all 323+ OpenRouter models +- Connection pooling via client caching + +Standards: +- Uses console.print() for user output (NO print()) +- Uses logger.info() for system logging +- Integrates with auth/keys, caller detection, usage tracking handlers +- Standalone functions (no classes) +- Complete error handling with graceful failures +""" + +# INFRASTRUCTURE IMPORT PATTERN +import sys +from pathlib import Path + +# Standard library imports +import time +from typing import Optional, Dict, List, Any + +# AIPASS imports +from aipass.cli.apps.modules import console + +# OpenAI SDK for OpenRouter compatibility +try: + from openai import OpenAI + OPENAI_AVAILABLE = True +except ImportError: + # logger.error("OpenAI SDK not available. Install with: pip install openai") + OPENAI_AVAILABLE = False + +# Handler imports +from aipass.api.apps.handlers.auth.keys import get_api_key +from aipass.api.apps.handlers.openrouter.caller import get_caller_info +from aipass.api.apps.handlers.usage.tracking import track_usage + +# ============================================= +# CONFIGURATION +# ============================================= + +OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" +DEFAULT_TIMEOUT = 30 +# NOTE: No default model - callers must specify their own model from their branch config + +# HTTP headers for OpenRouter +OPENROUTER_HEADERS = { + "HTTP-Referer": "https://aipass.local", + "X-Title": "AIPass API Client" +} + +# Client cache for connection pooling +_client_cache: Dict[str, OpenAI] = {} +MAX_CACHED_CLIENTS = 5 + +# ============================================= +# CLIENT CREATION +# ============================================= + +def create_client(api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout: int = DEFAULT_TIMEOUT) -> Optional[OpenAI]: + """ + Create OpenAI SDK client configured for OpenRouter. + + Args: + api_key: OpenRouter API key + base_url: OpenRouter base URL (default: https://openrouter.ai/api/v1) + timeout: Request timeout in seconds (default: 30) + + Returns: + OpenAI client instance or None on failure + + Example: + >>> api_key = get_api_key("openrouter") + >>> client = create_client(api_key) + >>> if client: + ... # Use client for requests + """ + if not OPENAI_AVAILABLE: + # logger.error("OpenAI SDK not installed - cannot create client") + console.print("[red]Error: OpenAI SDK not installed. Run: pip install openai[/red]") + return None + + if not api_key: + # logger.error("Cannot create client - no API key provided") + console.print("[red]Error: API key required for client creation[/red]") + return None + + try: + # Create OpenAI client with OpenRouter configuration + client = OpenAI( + base_url=base_url, + api_key=api_key, + timeout=timeout, + default_headers=OPENROUTER_HEADERS + ) + + # logger.info(f"Created OpenRouter client - base_url: {base_url}, timeout: {timeout}s") + return client + + except Exception as e: + # logger.error(f"Failed to create OpenRouter client: {e}") + console.print(f"[red]Error creating OpenRouter client: {e}[/red]") + return None + + +def get_cached_client(api_key: str, base_url: str = OPENROUTER_BASE_URL, timeout: int = DEFAULT_TIMEOUT) -> Optional[OpenAI]: + """ + Get cached OpenAI client or create new one if not cached. + Implements connection pooling for better performance. + + Args: + api_key: OpenRouter API key + base_url: OpenRouter base URL + timeout: Request timeout in seconds + + Returns: + OpenAI client instance or None on failure + + Note: + Cache is limited to MAX_CACHED_CLIENTS (5) to prevent memory growth. + Oldest clients are removed when cache is full. + """ + global _client_cache + + # Check if we have a cached client for this API key + if api_key in _client_cache: + cached_client = _client_cache[api_key] + # Verify cached client is still valid + if cached_client and cached_client.api_key == api_key: + # logger.info("Using cached OpenRouter client") + return cached_client + + # Create new client + client = create_client(api_key, base_url, timeout) + + if not client: + return None + + # Cache the client (limit cache size) + if len(_client_cache) >= MAX_CACHED_CLIENTS: + # Remove oldest client (first key in dict) + oldest_key = next(iter(_client_cache)) + del _client_cache[oldest_key] + # logger.info(f"Removed oldest cached client - cache limit: {MAX_CACHED_CLIENTS}") + + _client_cache[api_key] = client + # logger.info("Cached new OpenRouter client") + + return client + + +# ============================================= +# API REQUEST EXECUTION +# ============================================= + +def make_api_request(client: OpenAI, messages: List[Dict], model: str, retries: int = 1, **kwargs) -> Optional[Any]: + """ + Execute API request via OpenRouter with retry logic. + + Args: + client: OpenAI client instance + messages: Chat messages in OpenAI format [{"role": "user", "content": "..."}] + model: Model identifier (e.g., "anthropic/claude-3.5-sonnet") + retries: Number of retries on failure (default: 1, so 2 total attempts) + **kwargs: Additional OpenAI API parameters (temperature, max_tokens, etc.) + + Returns: + OpenAI response object or None on failure + """ + if not client or not messages or not model: + return None + + api_params = { + "model": model, + "messages": messages, + **kwargs + } + + last_error = None + for attempt in range(1 + retries): + try: + response = client.chat.completions.create(**api_params) + if attempt > 0: + print(f"[INFO] API request succeeded on retry {attempt} for model {model}") + return response + except Exception as e: + last_error = e + if attempt < retries: + delay = 1.0 * (attempt + 1) # 1s, 2s, ... + print(f"[INFO] API request failed for {model} (attempt {attempt + 1}/{1 + retries}): {e} — retrying in {delay:.0f}s") + time.sleep(delay) + + print(f"[INFO] API request failed for {model} after {1 + retries} attempts: {last_error}") + return None + + +def extract_response(response: Any) -> Optional[Dict[str, Any]]: + """ + Extract text content and metadata from API response. + + Args: + response: OpenAI response object + + Returns: + Dict with 'content' (str), 'id' (str), 'model' (str) or None on failure + + Example: + >>> response = make_api_request(client, messages, model) + >>> data = extract_response(response) + >>> if data: + ... print(data['content']) + ... track_usage(caller, data['id'], data['model'], api_key) + """ + if not response: + # logger.error("Cannot extract response - no response provided") + return None + + try: + # Validate response structure + if not hasattr(response, 'choices') or not response.choices: + # logger.error("Invalid response structure - no choices available") + return None + + if not hasattr(response.choices[0], 'message'): + # logger.error("Invalid response structure - no message in choice") + return None + + # Extract content + content = response.choices[0].message.content + + if not content: + # logger.warning("Response has no content") + return None + + # Extract metadata + result = { + "content": content, + "id": response.id if hasattr(response, 'id') else None, + "model": response.model if hasattr(response, 'model') else None, + "finish_reason": response.choices[0].finish_reason if hasattr(response.choices[0], 'finish_reason') else None + } + + # logger.info(f"Extracted response - length: {len(content)} chars, id: {result['id']}") + return result + + except Exception as e: + # logger.error(f"Failed to extract response: {e}") + return None + + +# ============================================= +# MAIN API CALL +# ============================================= + +def get_response(prompt: str, caller: Optional[str] = None, model: Optional[str] = None, **kwargs) -> Optional[Dict[str, Any]]: + """ + Main API call - get response from OpenRouter with full tracking integration. + + This is the primary entry point that integrates all handlers: + - Detects caller automatically if not provided + - Retrieves API key via auth/keys handler + - Creates/caches client + - Makes API request + - Extracts response + - Tracks usage via usage/tracking handler + + Args: + prompt: User prompt text + caller: Module making the request (auto-detected if not provided) + model: Model to use (required - caller must provide from branch config) + **kwargs: Additional OpenAI API parameters + + Returns: + Dict with 'content', 'id', 'model' or None on failure + + Example: + >>> response = get_response("What is Python?", caller="cli", model="anthropic/claude-3.5-sonnet") + >>> if response: + ... print(response['content']) + """ + # Step 1: Detect caller if not provided + if not caller: + caller_info = get_caller_info() + if caller_info and caller_info.get("caller_name"): + caller = caller_info["caller_name"] + # logger.info(f"Auto-detected caller: {caller}") + else: + # logger.warning("Could not detect caller - using 'unknown'") + caller = "unknown" + + # Step 2: Require model from caller - no defaults + if not model: + # logger.error("No model specified - caller must provide model from their branch config") + console.print("[red]Error: No model specified.[/red]") + console.print("[yellow]Callers must provide their own model via branch config (e.g., flow_json/openrouter_config.json)[/yellow]") + return None + + # Step 3: Get API key + api_key = get_api_key("openrouter") + if not api_key: + # logger.error("Cannot get response - no API key available") + console.print("[red]Error: No OpenRouter API key available[/red]") + return None + + # Step 4: Get or create client + client = get_cached_client(api_key) + if not client: + # logger.error("Cannot get response - client creation failed") + return None + + # Step 5: Convert prompt to messages format + messages = [{"role": "user", "content": prompt}] + + # Step 6: Make API request + response = make_api_request(client, messages, model, **kwargs) + if not response: + # logger.error(f"API request failed - caller: {caller}, model: {model}") + return None + + # Step 7: Extract response + result = extract_response(response) + if not result: + # logger.error("Response extraction failed") + return None + + # Step 8: Track usage (if response has ID) + if result.get("id"): + try: + track_usage(result["id"], caller if caller else "unknown", model, api_key) + except Exception as e: + # logger.warning(f"Usage tracking failed: {e}") + # Don't fail the request if tracking fails + pass + + # logger.info(f"Successfully got response - caller: {caller}, model: {model}, length: {len(result['content'])} chars") + return result + + +# ============================================= +# CLEANUP +# ============================================= + +def clear_client_cache() -> None: + """ + Clear all cached clients. + Useful for testing or when API keys change. + """ + global _client_cache + count = len(_client_cache) + _client_cache.clear() + # logger.info(f"Cleared {count} cached clients") + console.print(f"[green]Cleared {count} cached OpenRouter clients[/green]") + + +def get_cache_stats() -> Dict[str, Any]: + """ + Get statistics about the client cache. + + Returns: + Dict with cache size and keys + """ + return { + "cached_clients": len(_client_cache), + "max_cache_size": MAX_CACHED_CLIENTS, + "cache_keys": list(_client_cache.keys()) + } diff --git a/src/aipass/api/apps/handlers/openrouter/models.py b/src/aipass/api/apps/handlers/openrouter/models.py new file mode 100644 index 00000000..fe957bd6 --- /dev/null +++ b/src/aipass/api/apps/handlers/openrouter/models.py @@ -0,0 +1,408 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: models.py - OpenRouter Model Management +# Date: 2025-11-16 +# Version: 1.0.0 +# Category: api/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-16): Complete handler - model fetching, filtering, pricing +# ============================================= + +""" +OpenRouter Model Management Handler + +Business logic for querying and filtering OpenRouter models: +- Fetch all available models from OpenRouter API +- Filter models by pricing (free models) +- Parse model data and capabilities +- Extract model metadata (context, pricing, capabilities) + +Extracted from: +- /home/aipass/aipass_core/api/apps/archive.temp/openrouter.py +- /home/aipass/aipass_core/api/.archive/find_free_models.py +""" + +# AIPASS_ROOT import pattern +import sys +from pathlib import Path + +# Standard library imports +from typing import Dict, List, Optional + +# Console import (NO print()) +from aipass.cli.apps.modules import console + +# Third-party imports +import requests + +# Internal imports +from aipass.api.apps.handlers.auth.keys import get_api_key + + +# ============================================= +# CONSTANTS +# ============================================= + +OPENROUTER_API_URL = "https://openrouter.ai/api/v1/models" +DEFAULT_TIMEOUT = 10 +MODULE_NAME = "openrouter.models" + + +# ============================================= +# CORE FUNCTIONS +# ============================================= + +def get_available_models(api_key: Optional[str] = None) -> List[Dict]: + """ + Fetch all models from OpenRouter API + + Returns full model data including pricing, context length, + and capabilities for all available models. + + Args: + api_key: Optional OpenRouter API key (fetches from keys handler if None) + + Returns: + List of model dictionaries with full metadata, empty list on failure + + Example: + >>> models = get_available_models() + >>> console.print(f"Found {len(models)} models") + """ + try: + # Get API key if not provided + if not api_key: + api_key = get_api_key("openrouter") + + if not api_key: + # logger.info(f"[{MODULE_NAME}] No API key available for OpenRouter") + console.print("[yellow]No OpenRouter API key found[/yellow]") + return [] + + # Fetch models from API + models = fetch_models_from_api(api_key) + + if models: + # logger.info(f"[{MODULE_NAME}] Fetched {len(models)} models from OpenRouter") + return models + else: + # logger.info(f"[{MODULE_NAME}] No models returned from API") + return [] + + except Exception as e: + # logger.info(f"[{MODULE_NAME}] Failed to get available models: {e}") + console.print(f"[red]Error fetching models: {e}[/red]") + return [] + + +def get_free_models(api_key: Optional[str] = None) -> List[Dict]: + """ + Fetch only free models ($0 pricing) from OpenRouter + + Filters models where both prompt and completion costs are zero. + Useful for finding models that can be used without charges. + + Args: + api_key: Optional OpenRouter API key (fetches from keys handler if None) + + Returns: + List of free model dictionaries with full metadata, empty list on failure + + Example: + >>> free_models = get_free_models() + >>> for model in free_models: + ... console.print(f"Free: {model['id']}") + """ + try: + # Get all models first + all_models = get_available_models(api_key) + + if not all_models: + return [] + + # Filter for free models + free_models = filter_by_pricing(all_models, max_cost=0.0) + + # logger.info(f"[{MODULE_NAME}] Found {len(free_models)} free models") + return free_models + + except Exception as e: + # logger.info(f"[{MODULE_NAME}] Failed to get free models: {e}") + console.print(f"[red]Error fetching free models: {e}[/red]") + return [] + + +def fetch_models_from_api(api_key: str) -> List[Dict]: + """ + Query OpenRouter models endpoint and parse response + + Makes HTTP request to OpenRouter API and extracts model data. + Handles authentication, timeouts, and error responses. + + Args: + api_key: Valid OpenRouter API key + + Returns: + List of model dictionaries, empty list on failure + + Raises: + No exceptions raised - returns empty list on all errors + """ + try: + # Prepare request headers + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + # Make API request + # logger.info(f"[{MODULE_NAME}] Requesting models from OpenRouter API") + response = requests.get( + OPENROUTER_API_URL, + headers=headers, + timeout=DEFAULT_TIMEOUT + ) + + # Check response status + if response.status_code != 200: + # logger.info(f"[{MODULE_NAME}] API request failed with status {response.status_code}") + console.print(f"[red]OpenRouter API error: {response.status_code}[/red]") + return [] + + # Parse JSON response + data = response.json() + + # Extract models from response + if "data" in data and isinstance(data["data"], list): + models = data["data"] + # logger.info(f"[{MODULE_NAME}] Successfully parsed {len(models)} models") + return models + else: + # logger.info(f"[{MODULE_NAME}] Invalid response format - no 'data' field") + return [] + + except requests.exceptions.Timeout: + # logger.info(f"[{MODULE_NAME}] API request timeout after {DEFAULT_TIMEOUT}s") + console.print(f"[red]Request timeout - OpenRouter API not responding[/red]") + return [] + + except requests.exceptions.RequestException as e: + # logger.info(f"[{MODULE_NAME}] Network error: {e}") + console.print(f"[red]Network error: {e}[/red]") + return [] + + except ValueError as e: + # logger.info(f"[{MODULE_NAME}] JSON parse error: {e}") + console.print(f"[red]Invalid JSON response from API[/red]") + return [] + + except Exception as e: + # logger.info(f"[{MODULE_NAME}] Unexpected error fetching models: {e}") + console.print(f"[red]Error: {e}[/red]") + return [] + + +def filter_by_pricing(models: List[Dict], max_cost: float = 0.0) -> List[Dict]: + """ + Filter models by maximum pricing threshold + + Filters models where both prompt and completion costs are at or + below the specified maximum. Default of 0.0 returns only free models. + + Args: + models: List of model dictionaries from API + max_cost: Maximum cost threshold (0.0 for free only) + + Returns: + Filtered list of models matching pricing criteria + + Example: + >>> all_models = get_available_models() + >>> free = filter_by_pricing(all_models, 0.0) + >>> cheap = filter_by_pricing(all_models, 0.0001) + """ + filtered = [] + + try: + for model in models: + # Extract pricing data + pricing = model.get("pricing", {}) + + # Convert pricing to float (handles string values) + try: + prompt_cost = float(pricing.get("prompt", "0")) + completion_cost = float(pricing.get("completion", "0")) + except (ValueError, TypeError): + # Skip models with invalid pricing data + continue + + # Check if both costs are at or below threshold + if prompt_cost <= max_cost and completion_cost <= max_cost: + filtered.append(model) + + # logger.info(f"[{MODULE_NAME}] Filtered {len(filtered)}/{len(models)} models at max_cost={max_cost}") + return filtered + + except Exception as e: + # logger.info(f"[{MODULE_NAME}] Error filtering models: {e}") + return [] + + +def get_model_by_id(model_id: str, api_key: Optional[str] = None) -> Optional[Dict]: + """ + Fetch specific model details by ID + + Args: + model_id: Model identifier (e.g., "meta-llama/llama-3.3-70b-instruct:free") + api_key: Optional OpenRouter API key + + Returns: + Model dictionary if found, None otherwise + """ + try: + all_models = get_available_models(api_key) + + for model in all_models: + if model.get("id") == model_id: + # logger.info(f"[{MODULE_NAME}] Found model: {model_id}") + return model + + # logger.info(f"[{MODULE_NAME}] Model not found: {model_id}") + return None + + except Exception as e: + # logger.info(f"[{MODULE_NAME}] Error finding model {model_id}: {e}") + return None + + +def extract_model_metadata(model: Dict) -> Dict: + """ + Extract key metadata from model dictionary + + Parses model data and extracts commonly used fields into + a simplified structure for easier consumption. + + Args: + model: Raw model dictionary from API + + Returns: + Dictionary with extracted metadata fields + + Example: + >>> model = get_model_by_id("meta-llama/llama-3.3-70b-instruct:free") + >>> meta = extract_model_metadata(model) + >>> console.print(f"Context: {meta['context_length']}") + """ + try: + pricing = model.get("pricing", {}) + + metadata = { + "id": model.get("id", ""), + "name": model.get("name", ""), + "context_length": model.get("context_length", 0), + "prompt_cost": float(pricing.get("prompt", "0")), + "completion_cost": float(pricing.get("completion", "0")), + "is_free": ( + float(pricing.get("prompt", "0")) == 0.0 and + float(pricing.get("completion", "0")) == 0.0 + ), + "architecture": model.get("architecture", {}), + "top_provider": model.get("top_provider", {}), + "description": model.get("description", "") + } + + return metadata + + except Exception as e: + # logger.info(f"[{MODULE_NAME}] Error extracting metadata: {e}") + return {} + + +def list_model_ids(models: List[Dict]) -> List[str]: + """ + Extract just the model IDs from a list of model dictionaries + + Args: + models: List of model dictionaries + + Returns: + List of model ID strings + """ + try: + return [model.get("id", "") for model in models if model.get("id")] + except Exception as e: + # logger.info(f"[{MODULE_NAME}] Error extracting IDs: {e}") + return [] + + +# ============================================= +# CONVENIENCE FUNCTIONS +# ============================================= + +def display_models(models: List[Dict], show_pricing: bool = True) -> None: + """ + Display models in formatted output using console + + Args: + models: List of model dictionaries to display + show_pricing: Whether to show pricing information + """ + if not models: + console.print("[yellow]No models to display[/yellow]") + return + + console.print(f"\n[bold]Found {len(models)} models:[/bold]\n") + + for i, model in enumerate(models, 1): + model_id = model.get("id", "unknown") + name = model.get("name", "") + context = model.get("context_length", 0) + + console.print(f"[cyan]{i}. {model_id}[/cyan]") + if name: + console.print(f" Name: {name}") + console.print(f" Context: {context:,} tokens") + + if show_pricing: + pricing = model.get("pricing", {}) + prompt_cost = pricing.get("prompt", "0") + completion_cost = pricing.get("completion", "0") + + if prompt_cost == "0" and completion_cost == "0": + console.print(" [green]FREE[/green]") + else: + console.print(f" Prompt: ${prompt_cost} / Completion: ${completion_cost}") + + console.print() + + +def display_free_models_summary(api_key: Optional[str] = None) -> None: + """ + Fetch and display summary of free models + + Convenience function that fetches free models and displays + them in a formatted, user-friendly way. + + Args: + api_key: Optional OpenRouter API key + """ + console.print("[bold]Searching for FREE models on OpenRouter...[/bold]") + console.print("=" * 60) + + free_models = get_free_models(api_key) + + if not free_models: + console.print("[yellow]No free models found[/yellow]") + console.print("OpenRouter may have changed pricing.") + console.print("Alternative: Use very cheap models like openai/gpt-4o-mini") + return + + display_models(free_models, show_pricing=False) + + console.print("\n[bold]RECOMMENDED FREE MODELS TO TRY:[/bold]") + console.print("-" * 60) + for model in free_models[:5]: # Top 5 + console.print(f" - {model.get('id', '')}") diff --git a/src/aipass/api/apps/handlers/openrouter/provision.py b/src/aipass/api/apps/handlers/openrouter/provision.py new file mode 100644 index 00000000..6b1eb042 --- /dev/null +++ b/src/aipass/api/apps/handlers/openrouter/provision.py @@ -0,0 +1,294 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: provision.py - Caller Auto-Provisioning Handler +# Date: 2025-11-16 +# Version: 1.0.0 +# Category: api/handlers/openrouter +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-16): Initial handler - auto-provision caller configs +# ============================================= + +""" +Caller Auto-Provisioning Handler + +Business logic for provisioning OpenRouter API configs: +- Auto-create caller API configurations +- Provision JSON folder structure +- Set default model/temperature/max_tokens +- Initialize caller-specific tracking files +- Ensure caller has complete 3-file JSON structure + +COMPLIANT STANDARDS: +- Uses console.print() for output (NO print()) +- Uses prax logger for operations +- Standalone functions (no class dependencies) +- Imports from caller handler for detection logic +- Complete docstrings with Args/Returns +- Under 300 lines +""" + +import sys +from pathlib import Path + +import json +from datetime import datetime +from typing import Dict, Any, Optional, Tuple + +from aipass.cli.apps.modules import console + +from aipass.api.apps.handlers.openrouter.caller import detect_caller_from_stack + + +# =========================================== +# JSON UTILITIES +# =========================================== + +def read_json(file_path: Path) -> Optional[Dict[str, Any]]: + """ + Read JSON file safely + + Args: + file_path: Path to JSON file + + Returns: + Parsed JSON dict or None on error + """ + try: + if not file_path.exists(): + return None + + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + # logger.error(f"Failed to read {file_path}: {e}") + return None + + +def write_json(file_path: Path, data: Dict[str, Any]) -> bool: + """ + Write JSON file safely with formatting + + Args: + file_path: Path to JSON file + data: Dict to write as JSON + + Returns: + True if successful, False otherwise + """ + try: + file_path.parent.mkdir(parents=True, exist_ok=True) + + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + return True + except Exception as e: + # logger.error(f"Failed to write {file_path}: {e}") + return False + + +# =========================================== +# DEFAULT CONFIGURATION +# =========================================== + +def get_default_caller_config() -> Dict[str, Any]: + """ + Get default config template for new callers + + NOTE: No default ai_model - callers must set their own model in their branch config + + Returns: + Dict with default OpenRouter configuration (model must be set by caller) + """ + return { + "skill_name": "openrouter", + "timestamp": datetime.now().isoformat(), + "config": { + "ai_model": "", # Caller must set their own model + "ai_temperature": 0.7, + "ai_max_tokens": 4000, + "enabled": True + } + } + + +def get_default_caller_data() -> Dict[str, Any]: + """ + Get default data template for tracking caller usage + + Returns: + Dict with initial usage tracking data + """ + return { + "skill_name": "openrouter", + "timestamp": datetime.now().isoformat(), + "data": { + "total_requests": 0, + "successful_requests": 0, + "failed_requests": 0, + "models_used": {}, + "last_request": None + } + } + + +def get_default_caller_log() -> Dict[str, Any]: + """ + Get default log template for caller operations + + Returns: + Dict with empty log structure + """ + return { + "skill_name": "openrouter", + "timestamp": datetime.now().isoformat(), + "logs": [] + } + + +# =========================================== +# PROVISIONING FUNCTIONS +# =========================================== + +def provision_json_folder(json_folder: Path) -> bool: + """ + Create JSON folder structure if missing + + Args: + json_folder: Path to caller's JSON folder + + Returns: + True if folder exists or created, False on error + """ + try: + if json_folder.exists(): + return True + + json_folder.mkdir(parents=True, exist_ok=True) + # logger.info(f"Created JSON folder: {json_folder}") + console.print(f"[green]Created JSON folder:[/green] {json_folder}") + + return True + except Exception as e: + # logger.error(f"Failed to create JSON folder {json_folder}: {e}") + console.print(f"[red]Error:[/red] Failed to create JSON folder: {e}") + return False + + +def create_caller_config(caller: str, json_folder: Path) -> Dict[str, Any]: + """ + Create new caller configuration with defaults + + Creates complete 3-file JSON structure: + - openrouter_skill_config.json (API settings) + - openrouter_skill_data.json (usage tracking) + - openrouter_skill_log.json (operation log) + + Args: + caller: Name of calling module + json_folder: Path to caller's JSON folder + + Returns: + Dict with created config or empty dict on error + """ + try: + # Ensure JSON folder exists + if not provision_json_folder(json_folder): + return {} + + # Create config file + config_file = json_folder / "openrouter_skill_config.json" + config = get_default_caller_config() + + if not write_json(config_file, config): + # logger.error(f"Failed to write config for {caller}") + return {} + + # logger.info(f"Created API config for {caller}: {config_file}") + console.print(f"[green]Created config:[/green] {config_file.name}") + + # Create data file + data_file = json_folder / "openrouter_skill_data.json" + data = get_default_caller_data() + + if write_json(data_file, data): + # logger.info(f"Created data file for {caller}: {data_file}") + console.print(f"[green]Created data:[/green] {data_file.name}") + + # Create log file + log_file = json_folder / "openrouter_skill_log.json" + log_data = get_default_caller_log() + + if write_json(log_file, log_data): + # logger.info(f"Created log file for {caller}: {log_file}") + console.print(f"[green]Created log:[/green] {log_file.name}") + + console.print(f"[cyan]Info:[/cyan] Auto-provisioned OpenRouter config for '{caller}'") + console.print(f"[yellow]Note:[/yellow] Reload config and retry request") + + return config + + except Exception as e: + # logger.error(f"Failed to create config for {caller}: {e}") + console.print(f"[red]Error:[/red] Config creation failed: {e}") + return {} + + +def ensure_caller_config(caller: str | None = None) -> Dict[str, Any]: + """ + Ensure caller has API configuration, create if missing + + Auto-detects caller if not provided. Creates complete 3-file + JSON structure with default OpenRouter settings. + + Args: + caller: Optional caller name (auto-detected if None) + + Returns: + Dict with config or empty dict if unable to provision + """ + try: + # Auto-detect caller if not provided + json_folder = None + if not caller: + detected_caller, json_folder = detect_caller_from_stack() + if detected_caller: + caller = detected_caller + # logger.info(f"Auto-detected caller: {caller}") + else: + # logger.warning("Could not detect caller from stack") + console.print("[yellow]Warning:[/yellow] Could not detect caller module") + return {} + + # Get JSON folder path if not already detected + if not json_folder: + _, json_folder = detect_caller_from_stack() + if not json_folder: + # logger.error(f"Could not determine JSON folder for {caller}") + console.print(f"[red]Error:[/red] Could not find JSON folder for '{caller}'") + return {} + + # Check if config already exists + config_file = json_folder / "openrouter_skill_config.json" + + if config_file.exists(): + config = read_json(config_file) + if config: + # logger.info(f"Using existing config for {caller}") + return config + else: + # logger.warning(f"Config file corrupted for {caller}, regenerating") + console.print(f"[yellow]Warning:[/yellow] Corrupted config, regenerating...") + + # Create new config + return create_caller_config(caller, json_folder) + + except Exception as e: + # logger.error(f"Failed to ensure config for {caller}: {e}") + console.print(f"[red]Error:[/red] Config provisioning failed: {e}") + return {} + + diff --git a/src/aipass/api/apps/handlers/telegram/__init__.py b/src/aipass/api/apps/handlers/telegram/__init__.py new file mode 100644 index 00000000..107a33ad --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/__init__.py @@ -0,0 +1,32 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - Telegram Handlers Package +# Date: 2026-02-03 +# Version: 1.0.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-03): Initial package for Telegram bridge +# ============================================= + +""" +Telegram Handlers Package + +Provides Telegram bot integration for AIPass: +- config.py: Token and configuration loading +- bridge.py: Core bot service with message handling +""" + +from aipass.api.apps.handlers.telegram.config import ( + load_telegram_config, + get_bot_token, + get_bot_username +) + +__all__ = [ + "load_telegram_config", + "get_bot_token", + "get_bot_username" +] diff --git a/src/aipass/api/apps/handlers/telegram/base_bot.py b/src/aipass/api/apps/handlers/telegram/base_bot.py new file mode 100644 index 00000000..06fefcd4 --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/base_bot.py @@ -0,0 +1,1780 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: base_bot.py - BaseBot class for Telegram multi-bot architecture +# Date: 2026-02-24 +# Version: 1.2.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.3.0 (2026-02-26): Integrate LogStreamer for system log streaming to Telegram chat +# - v1.2.0 (2026-02-24): Automated BotFather creation via Telethon, manual token fallback +# - v1.1.0 (2026-02-24): Add /create chat @branch command, /status registry info, conversation state +# - v1.0.0 (2026-02-24): Initial - stdlib-only BaseBot with polling, tmux injection, heartbeat, hooks +# +# CODE STANDARDS: +# - Uses Prax get_direct_logger() — no event pipeline (FPLAN-0382 migration) +# - Class-based: BaseBot is both runnable and inheritable +# - Imports standard commands from telegram_standards.py +# - Imports file detection from file_handler.py (no duplication) +# ============================================= + +""" +BaseBot - Foundation class for AIPass Telegram multi-bot architecture. + +Each AIPass branch gets its own dedicated Telegram bot. BaseBot is both a +runnable bot (for the base @aipass_bot) AND the template all branch bots inherit. + +Stdlib-only implementation using urllib for Telegram API. No python-telegram-bot +dependency. Follows the same polling/tmux injection pattern as direct_chat.py. + +Flow: + Patrick sends Telegram message + -> BaseBot receives it via getUpdates long-polling + -> If /command -> handle via telegram_standards, reply, return + -> If /new -> kill tmux session, reply, return + -> Else -> ensure tmux session exists (running Claude) + -> Send "Processing..." message + -> Write pending file for Stop hook coordination + -> Start heartbeat thread (updates "Processing..." with elapsed time) + -> Inject message into tmux session via send-keys + -> Claude processes and hits Stop event + -> Stop hook reads pending file, extracts response, sends to Telegram + +Usage: + bot = BaseBot( + bot_id="dev_central", + bot_token="123:ABC", + work_dir=Path("/home/aipass/aipass_os/dev_central"), + bot_name="AIPass Dev Central Bot", + allowed_user_ids=[7235222625], + ) + sys.exit(bot.run()) +""" + +# Infrastructure +import sys +from pathlib import Path + +# ============================================= +# IMPORTS (stdlib only) +# ============================================= + +import argparse +import atexit +import json +import os +import signal +import subprocess +import threading +import time +import uuid +from datetime import datetime +from typing import Optional +from urllib.error import URLError +from urllib.request import Request, urlopen + +# Logging (Prax direct logger — FPLAN-0382, no event pipeline) +from aipass.prax.apps.modules.logger import get_direct_logger + +# ============================================= +# SIBLING IMPORTS +# ============================================= + +from aipass.api.apps.handlers.telegram.telegram_standards import ( # noqa: F401 + parse_command, + handle_standard_command, + STANDARD_COMMANDS, + build_welcome_text, + build_help_text, + build_status_text, + PROCESSING_MSG, +) +from aipass.api.apps.handlers.telegram.file_handler import ( + detect_file_type, + build_file_prompt, +) +from aipass.api.apps.handlers.telegram.bot_factory import ( + create_bot, + validate_branch, + validate_token, +) +from aipass.api.apps.handlers.telegram.bot_registry import ( + list_bots as registry_list_bots, + get_bot_by_branch, +) +from aipass.api.apps.handlers.telegram.botfather_client import ( + create_bot_via_botfather, + check_telethon_setup, +) +from aipass.api.apps.handlers.telegram.log_streamer import LogStreamer + + +# ============================================= +# MODULE-LEVEL CONSTANTS +# ============================================= + +PENDING_DIR = Path.home() / ".aipass" / "telegram_pending" +PENDING_TTL = 3600 # 1 hour +TELEGRAM_CHAR_LIMIT = 4096 +RATE_LIMIT_MESSAGES = 5 +RATE_LIMIT_WINDOW = 60 +POLL_TIMEOUT = 30 +SEND_KEYS_DELAY = 0.5 +HEARTBEAT_INTERVAL = 30 # seconds +CLAUDE_BIN = str(Path.home() / ".local" / "bin" / "claude") +TEMP_DIR = Path("/tmp/telegram_uploads") +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB + + +# ============================================= +# BaseBot CLASS +# ============================================= + +class BaseBot: + """ + Base Telegram bot for AIPass multi-bot architecture. + + Both a runnable bot (for the base @aipass_bot) and the template that + all branch bots inherit from. Uses stdlib urllib for Telegram API, + tmux for Claude sessions, and a heartbeat thread for progress updates. + """ + + def __init__( + self, + bot_id: str, + bot_token: str, + work_dir: Path, + bot_name: str = "AIPass Bot", + allowed_user_ids: Optional[list[int]] = None, + custom_commands: Optional[dict] = None, + branch_name: Optional[str] = None, + shared_session: Optional[str] = None, + ) -> None: + """ + Initialize BaseBot. + + Args: + bot_id: Unique identifier for this bot (e.g., "dev_central") + bot_token: Telegram bot API token + work_dir: Working directory for the tmux Claude session + bot_name: Display name shown in /start and /status + allowed_user_ids: List of Telegram user IDs allowed to use the bot. + Empty list or None means allow all. + custom_commands: Dict of bot-specific commands in telegram_standards format + branch_name: Branch name for log streaming (None = no streaming, e.g. base bot) + shared_session: tmux session name to inject into instead of creating own session. + When set, the bot attaches to an existing session (e.g., Patrick's + running Claude Code on PC). Falls back to own session if not found. + """ + self.bot_id = bot_id + self.bot_token = bot_token + self.work_dir = Path(work_dir) + self.bot_name = bot_name + self.allowed_user_ids = allowed_user_ids or [] + self.custom_commands = custom_commands or {} + # branch_name may already be set by subclass (e.g. BranchPlugin) before super().__init__ + if not hasattr(self, "branch_name"): + self.branch_name = branch_name + + self.session_name = f"telegram-{bot_id}" + self.pending_file = PENDING_DIR / f"bot-{bot_id}.json" + + # Shared-session mode: inject into an existing tmux session instead of creating own + self._shared_session_name = shared_session + self._using_shared_session = False + + self.state = { + "running": True, + "message_count": 0, + "start_time": time.time(), + "last_message_time": 0.0, + } + + self._health = { + "started_at": None, + "last_message_at": None, + "messages_received": 0, + "messages_failed": 0, + "errors": 0, + } + + self._rate_limit_tracker: dict[int, list] = {} + self._heartbeat_thread: threading.Thread | None = None + self._heartbeat_stop = threading.Event() + + # Conversation state for /create flow (keyed by chat_id) + self._create_state: dict[int, dict] = {} + self._create_state_ttl = 300 # 5 minutes + + # Log streamer (started on first message when branch_name is set) + self._log_streamer: Optional[LogStreamer] = None + self._active_chat_id: Optional[int] = None + + # Logger (Prax direct logger — no event pipeline, avoids recursion with log streamer) + self.logger = get_direct_logger() + + # Lock file + self._lock_file = Path.home() / ".aipass" / "telegram_bots" / f".{bot_id}.lock" + + # Offset file + self._offset_file = Path.home() / ".aipass" / "telegram_bots" / f"{bot_id}_offset.json" + + # ============================================= + # MAIN ENTRY POINT + # ============================================= + + def run(self) -> int: + """ + Main entry point. Start polling and process messages. + + Returns: + 0 on clean exit, 1 on error + """ + self.logger.info("=" * 60) + self.logger.info("%s starting (bot_id=%s)", self.bot_name, self.bot_id) + + # Verify connection + if not self.verify_connection(): + self.logger.error("Startup health check FAILED - cannot reach Telegram API") + return 1 + + self.logger.info("Connected to Telegram API") + self._health["started_at"] = datetime.now().isoformat() + + # Check for existing lock + if self._check_lock(): + self.logger.error("Another instance of bot-%s is already running", self.bot_id) + return 1 + + # Create lock file + self._create_lock() + + # Signal handlers + signal.signal(signal.SIGTERM, self._shutdown_handler) + signal.signal(signal.SIGINT, self._shutdown_handler) + atexit.register(self._cleanup) + + # Ensure pending directory + PENDING_DIR.mkdir(parents=True, exist_ok=True) + + # Clean stale pending file + self.clean_stale_pending() + + # Load offset + offset = self._load_offset() + self.logger.info("Starting poll loop (offset=%d)", offset) + + # Retry backoff sequence: 5s, 10s, 20s, 40s, 60s max + retry_delay = 5 + max_retry_delay = 60 + + while self.state["running"]: + try: + updates = self.poll_updates(offset) + + # Reset backoff on successful poll + retry_delay = 5 + + for update in updates: + if not self.state["running"]: + break + + self.process_update(update) + + # Advance offset + new_offset = update.get("update_id", 0) + 1 + if new_offset > offset: + offset = new_offset + self._save_offset(offset) + + except KeyboardInterrupt: + self.logger.info("KeyboardInterrupt received") + break + except Exception as e: + self._health["errors"] = self._health.get("errors", 0) + 1 + self.logger.error("Error in poll loop: %s: %s", type(e).__name__, e) + time.sleep(retry_delay) + retry_delay = min(retry_delay * 2, max_retry_delay) + + self.logger.info("Poll loop exited") + return 0 + + # ============================================= + # TELEGRAM API (stdlib urllib) + # ============================================= + + def verify_connection(self, timeout: int = 15) -> bool: + """ + Verify connection to Telegram API by calling getMe. + + Args: + timeout: Connection timeout in seconds + + Returns: + True if connection succeeded + """ + url = f"https://api.telegram.org/bot{self.bot_token}/getMe" + try: + req = Request(url) + with urlopen(req, timeout=timeout) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if data.get("ok"): + bot_info = data.get("result", {}) + self.logger.info( + "Telegram API OK - @%s", bot_info.get("username", "unknown") + ) + return True + + self.logger.error( + "Telegram API rejected: %s", data.get("description", "unknown") + ) + return False + + except URLError as e: + self.logger.error("Telegram API connection failed: %s", e) + return False + except Exception as e: + self.logger.error("Telegram API health check error: %s", e) + return False + + def poll_updates(self, offset: int) -> list: + """ + Long-poll Telegram for new updates via getUpdates. + + Args: + offset: Update offset to avoid reprocessing + + Returns: + List of update dicts + """ + url = ( + f"https://api.telegram.org/bot{self.bot_token}/getUpdates" + f"?offset={offset}&timeout={POLL_TIMEOUT}" + ) + + try: + req = Request(url) + with urlopen(req, timeout=POLL_TIMEOUT + 10) as resp: + data = json.loads(resp.read().decode("utf-8")) + + if not data.get("ok"): + self.logger.error( + "Telegram API error: %s", data.get("description", "unknown") + ) + return [] + + return data.get("result", []) + + except URLError as e: + self.logger.error("Poll error: %s", e) + return [] + except Exception as e: + self.logger.error("Unexpected poll error: %s", e) + return [] + + def send_message( + self, chat_id: int, text: str, reply_to: Optional[int] = None + ) -> dict | None: + """ + Send a message via Telegram sendMessage API. + + Args: + chat_id: Target chat ID + text: Message text + reply_to: Optional message ID to reply to + + Returns: + Parsed JSON response dict (contains message_id), or None on failure + """ + url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage" + + payload: dict = { + "chat_id": chat_id, + "text": text, + } + if reply_to is not None: + payload["reply_to_message_id"] = reply_to + + for attempt in range(3): + try: + data = json.dumps(payload).encode("utf-8") + req = Request(url, data=data, headers={"Content-Type": "application/json"}) + with urlopen(req, timeout=15) as resp: + result = json.loads(resp.read().decode("utf-8")) + + if result.get("ok"): + return result.get("result") + else: + self.logger.warning( + "sendMessage failed (attempt %d): %s", + attempt + 1, + result.get("description", "unknown"), + ) + except Exception as e: + self.logger.warning( + "sendMessage error (attempt %d): %s", attempt + 1, e + ) + + if attempt < 2: + time.sleep(1.0 * (2 ** attempt)) + + self.logger.error("sendMessage failed after 3 attempts") + self._health["messages_failed"] = self._health.get("messages_failed", 0) + 1 + return None + + def edit_message(self, chat_id: int, message_id: int, text: str) -> bool: + """ + Edit a message via Telegram editMessageText API. + + Args: + chat_id: Chat ID containing the message + message_id: ID of the message to edit + text: New text for the message + + Returns: + True if edit succeeded + """ + url = f"https://api.telegram.org/bot{self.bot_token}/editMessageText" + + payload = { + "chat_id": chat_id, + "message_id": message_id, + "text": text, + } + + try: + data = json.dumps(payload).encode("utf-8") + req = Request(url, data=data, headers={"Content-Type": "application/json"}) + with urlopen(req, timeout=15) as resp: + result = json.loads(resp.read().decode("utf-8")) + + return result.get("ok", False) + + except Exception as e: + self.logger.warning("editMessageText error: %s", e) + return False + + # ============================================= + # UPDATE PROCESSING + # ============================================= + + def process_update(self, update: dict) -> None: + """ + Process a single Telegram update. + + Routes to command handling, message handling, or file handling + based on update contents. + + Args: + update: Telegram update dict + """ + message = update.get("message") + if not message: + return + + text = message.get("text", "") + chat = message.get("chat", {}) + chat_id = chat.get("id", 0) + from_user = message.get("from", {}) + user_id = from_user.get("id", 0) + username = from_user.get("username", "unknown") + _ = message.get("message_id", 0) # Available for future use + + # Start log streamer on first valid message (if branch has a name) + if self._active_chat_id is None and chat_id: + self._active_chat_id = chat_id + if self.branch_name is not None and self._log_streamer is None: + self._log_streamer = LogStreamer( + self.bot_token, chat_id, self.branch_name + ) + self._log_streamer.start() + self.logger.info("Log streamer started for branch: %s", self.branch_name) + + # Allowlist check + if not self.is_user_allowed(user_id): + self.logger.warning( + "Blocked message from unauthorized user_id: %s (@%s)", user_id, username + ) + return + + # Rate limit check + if not self.check_rate_limit(user_id): + self.logger.warning("Rate limited user_id: %s", user_id) + self.send_message( + chat_id, "Rate limit exceeded. Please wait before sending more messages." + ) + return + + # Health tracking + self._health["last_message_at"] = datetime.now().isoformat() + self._health["messages_received"] = self._health.get("messages_received", 0) + 1 + + # Check for file uploads (photo/document) + photo_list = message.get("photo") + document = message.get("document") + + if photo_list or document: + self.handle_file(chat_id, message) + return + + # Check if user is in /create flow (awaiting token paste) + if chat_id in self._create_state and text and not text.startswith("/"): + self._handle_create_token(chat_id, text) + return + + # Command handling + if text: + parsed = parse_command(text) + if parsed is not None: + cmd_name, cmd_args = parsed + + # /create command — multi-step bot creation + if cmd_name == "create": + self._handle_create_command(chat_id, cmd_args) + return + + # /cancel command — cancel active /create flow + if cmd_name == "cancel": + if chat_id in self._create_state: + del self._create_state[chat_id] + self.send_message(chat_id, "Bot creation cancelled.") + else: + self.send_message(chat_id, "Nothing to cancel.") + return + + # Compute uptime for /status + elapsed = time.time() - self.state["start_time"] + hours, remainder = divmod(int(elapsed), 3600) + minutes, seconds = divmod(remainder, 60) + uptime_str = f"{hours}h {minutes}m {seconds}s" + + # Merge custom commands from constructor and hook + merged_commands = {**self.custom_commands, **self.get_custom_commands()} + + # /status — enhance with registry info + if cmd_name == "status": + status_text = build_status_text( + session_name=self.session_name, + branch_name=self.bot_id, + uptime=uptime_str, + message_count=self.state.get("message_count"), + chat_id=chat_id, + ) + registry_text = self._build_registry_status() + if registry_text: + status_text += f"\n\n{registry_text}" + self.send_message(chat_id, status_text) + self.logger.info("Handled /status command") + return + + result = handle_standard_command( + command=cmd_name, + session_name=self.session_name, + branch_name=self.bot_id, + bot_name=self.bot_name, + custom_commands=merged_commands or None, + chat_id=chat_id, + message_count=self.state.get("message_count"), + uptime=uptime_str, + ) + + if result is not None: + if isinstance(result, tuple): + action, response_text = result + if action == "new": + self._kill_tmux_session() + self.send_message(chat_id, response_text) + self.logger.info("Handled /new command - session killed") + return + else: + self.send_message(chat_id, result) + self.logger.info("Handled /%s command", cmd_name) + return + + # Not a standard command - fall through to regular message processing + + # Regular message handling + if text: + self.handle_message(chat_id, text, message) + else: + self.logger.info("Ignoring unsupported message type") + + # ============================================= + # MESSAGE HANDLING + # ============================================= + + def handle_message(self, chat_id: int, text: str, message: dict) -> None: + """ + Handle a regular text message. + + Pre-processes via on_message hook, ensures tmux session, writes + pending file, starts heartbeat, and injects into tmux. + + Args: + chat_id: Telegram chat ID + text: Message text + message: Full message dict + """ + message_id = message.get("message_id", 0) + + # Hook: pre-process message text + prompt = self.on_message(text) + + # Track message + self.state["message_count"] = self.state.get("message_count", 0) + 1 + self.state["last_message_time"] = time.time() + + self.logger.info("Processing message (msg_id=%d)", message_id) + + # Ensure tmux session + if not self.ensure_tmux_session(): + self.logger.error("Cannot process message - tmux session unavailable") + self.send_message( + chat_id, "Failed to start Claude session. Check logs." + ) + return + + # Send processing indicator + processing_result = self.send_message(chat_id, PROCESSING_MSG) + processing_msg_id = ( + processing_result.get("message_id") if processing_result else None + ) + + # Write pending file + if not self.write_pending_file(chat_id, message_id, processing_msg_id): + self.logger.error("Failed to write pending file") + self.send_message(chat_id, "Internal error writing pending file.") + return + + # Start heartbeat + if processing_msg_id: + self._start_heartbeat(chat_id, processing_msg_id) + + # Inject into tmux + if not self.inject_message(prompt): + self.logger.error("Failed to inject message into tmux") + self._stop_heartbeat() + self.pending_file.unlink(missing_ok=True) + self.send_message( + chat_id, "Failed to send message to Claude session." + ) + return + + self.logger.info("Message processed successfully (msg_id=%d)", message_id) + + def handle_file(self, chat_id: int, message: dict) -> None: + """ + Handle file uploads (photos and documents). + + Downloads the file via Telegram API, detects type, builds prompt, + then follows the same flow as handle_message. + + Args: + chat_id: Telegram chat ID + message: Full message dict containing photo or document + """ + message_id = message.get("message_id", 0) + caption = message.get("caption", "") + photo_list = message.get("photo") + document = message.get("document") + + file_id = None + filename = None + + if photo_list: + # Use highest quality photo (last in array) + best_photo = photo_list[-1] + file_id = best_photo.get("file_id", "") + self.logger.info( + "Photo from user (file_id=%s, caption=%s)", + file_id[:20] if file_id else "none", + caption[:50] if caption else "none", + ) + elif document: + file_id = document.get("file_id", "") + filename = document.get("file_name", "") + file_size = document.get("file_size", 0) + self.logger.info("Document from user: %s (%d bytes)", filename, file_size) + + if file_size > MAX_FILE_SIZE: + self.send_message( + chat_id, + f"File too large ({file_size // 1024}KB). Max is 10MB.", + ) + return + + if not file_id: + return + + # Download file via Telegram API + file_path = self._download_file(file_id, filename) + if not file_path: + self.send_message(chat_id, "Failed to download file. Try again?") + return + + # Detect type and build prompt + file_type = detect_file_type(file_path) + prompt = build_file_prompt( + file_path, file_type, caption=caption or None, sender_name="Patrick" + ) + + # Hook: pre-process + prompt = self.on_message(prompt) + + # Track message + self.state["message_count"] = self.state.get("message_count", 0) + 1 + self.state["last_message_time"] = time.time() + + # Ensure tmux session + if not self.ensure_tmux_session(): + self.logger.error("Cannot process file - tmux session unavailable") + self.send_message(chat_id, "Failed to start Claude session. Check logs.") + if file_type == "text": + file_path.unlink(missing_ok=True) + return + + # Send processing indicator + processing_result = self.send_message( + chat_id, f"Processing {file_type} file..." + ) + processing_msg_id = ( + processing_result.get("message_id") if processing_result else None + ) + + # Write pending file + if not self.write_pending_file(chat_id, message_id, processing_msg_id): + self.logger.error("Failed to write pending file for file upload") + self.send_message(chat_id, "Internal error writing pending file.") + return + + # Clean up text files immediately (content is inline in prompt) + if file_type == "text": + file_path.unlink(missing_ok=True) + + # Start heartbeat + if processing_msg_id: + self._start_heartbeat(chat_id, processing_msg_id) + + # Inject into tmux + if not self.inject_message(prompt): + self.logger.error("Failed to inject file message into tmux") + self._stop_heartbeat() + self.pending_file.unlink(missing_ok=True) + self.send_message(chat_id, "Failed to send file to Claude session.") + return + + self.logger.info("File processed successfully (msg_id=%d)", message_id) + + def _download_file( + self, file_id: str, filename: Optional[str] = None + ) -> Optional[Path]: + """ + Download a file from Telegram via getFile API + urllib. + + Args: + file_id: Telegram file_id from the message + filename: Optional original filename + + Returns: + Path to the downloaded file, or None on failure + """ + # Step 1: Get file info + url = f"https://api.telegram.org/bot{self.bot_token}/getFile?file_id={file_id}" + try: + with urlopen(Request(url), timeout=15) as resp: + data = json.loads(resp.read().decode("utf-8")) + except Exception as e: + self.logger.error("getFile API failed: %s", e) + return None + + if not data.get("ok"): + self.logger.error( + "getFile error: %s", data.get("description", "unknown") + ) + return None + + file_info = data.get("result", {}) + file_path_remote = file_info.get("file_path", "") + file_size = file_info.get("file_size", 0) + + if not file_path_remote: + self.logger.error("No file_path in getFile response") + return None + + if file_size > MAX_FILE_SIZE: + self.logger.warning( + "File too large: %d bytes (max %d)", file_size, MAX_FILE_SIZE + ) + return None + + # Step 2: Download + download_url = ( + f"https://api.telegram.org/file/bot{self.bot_token}/{file_path_remote}" + ) + + TEMP_DIR.mkdir(parents=True, exist_ok=True) + + if filename: + safe_name = "".join( + c if c.isalnum() or c in ".-_" else "_" for c in Path(filename).name + ) + else: + ext = Path(file_path_remote).suffix or ".jpg" + safe_name = f"{uuid.uuid4()}{ext}" + + dest = TEMP_DIR / safe_name + + try: + with urlopen(Request(download_url), timeout=30) as resp: + dest.write_bytes(resp.read()) + self.logger.info("Downloaded file to %s (%d bytes)", dest, file_size) + return dest + except Exception as e: + self.logger.error("File download failed: %s", e) + return None + + # ============================================= + # /CREATE CHAT COMMAND + # ============================================= + + def _handle_create_command(self, chat_id: int, args: str) -> None: + """ + Handle /create chat @branch — automated or manual bot creation. + + If Telethon is configured, creates the bot via BotFather automatically. + Otherwise, falls back to the manual token-paste flow. + + Args: + chat_id: Telegram chat ID + args: Command arguments (e.g., "chat dev_central" or "chat @dev_central") + """ + # Parse args: /create chat + parts = args.strip().split() + + if len(parts) < 2 or parts[0].lower() != "chat": + self.send_message( + chat_id, + "Usage: /create chat \n\n" + "Example: /create chat dev_central", + ) + return + + branch_name = parts[1].lstrip("@").lower() + + # Validate branch exists in BRANCH_REGISTRY.json + branch_info = validate_branch(branch_name) + if not branch_info: + self.send_message( + chat_id, + f"Branch '@{branch_name}' not found in BRANCH_REGISTRY.json.\n\n" + "Check available branches and try again.", + ) + return + + # Check if branch already has a bot + existing = get_bot_by_branch(branch_name) + if existing: + self.send_message( + chat_id, + f"Branch '@{branch_name}' already has a bot: " + f"@{existing.get('username', '?')} (bot_id={existing.get('bot_id')})", + ) + return + + branch_path = branch_info.get("path", "") + + # Check if Telethon automation is available + telethon_ready, telethon_reason = check_telethon_setup() + + if telethon_ready: + # Automated flow — create bot via BotFather + register in one step + self._handle_create_automated(chat_id, branch_name, branch_path) + else: + # Manual fallback — ask user to paste a BotFather token + self.logger.info( + "Telethon not ready (%s), falling back to manual token flow", + telethon_reason, + ) + self._create_state[chat_id] = { + "branch_name": branch_name, + "branch_path": branch_path, + "started_at": time.time(), + } + self.send_message( + chat_id, + f"Branch @{branch_name} found at {branch_path}.\n\n" + "Now paste the BotFather token for the new bot.\n" + "(Get one from @BotFather -> /newbot)\n\n" + "/cancel to abort.", + ) + self.logger.info( + "/create chat: branch @%s validated, awaiting token from chat %d", + branch_name, chat_id, + ) + + def _handle_create_automated(self, chat_id: int, branch_name: str, branch_path: str) -> None: + """ + Fully automated bot creation via Telethon BotFather client. + + Creates the bot with @BotFather, then registers it via bot_factory. + + Args: + chat_id: Telegram chat ID + branch_name: Branch name (e.g., "dev_central") + branch_path: Branch working directory path + """ + self.send_message( + chat_id, + f"Creating bot for @{branch_name} via BotFather...\n" + "This takes a few seconds.", + ) + + # Step 1: Create bot via BotFather automation + bf_result = create_bot_via_botfather(branch_name) + if not bf_result: + self.send_message( + chat_id, + f"BotFather automation failed for @{branch_name}.\n" + "Check system logs. You can retry or use manual token mode:\n" + "Paste a BotFather token to create manually.", + ) + # Fall back to manual mode + self._create_state[chat_id] = { + "branch_name": branch_name, + "branch_path": branch_path, + "started_at": time.time(), + } + return + + bot_token = bf_result["token"] + bot_username = bf_result["username"] + display_name = bf_result["display_name"] + + self.logger.info( + "BotFather created @%s for branch @%s, registering...", + bot_username, branch_name, + ) + + # Step 2: Register via bot_factory (validate, write config, registry, systemd) + result = create_bot( + bot_id=branch_name, + bot_token=bot_token, + branch_name=branch_name, + work_dir=branch_path, + bot_name=display_name, + allowed_user_ids=self.allowed_user_ids, + ) + + if not result: + self.send_message( + chat_id, + f"Bot @{bot_username} was created in BotFather but registration failed.\n" + f"Token: (check system logs)\n" + "Run /create chat again or register manually.", + ) + return + + auto_started = result.get("auto_started", False) + status_line = "Bot is running!" if auto_started else ( + f"Start it with:\nsystemctl --user start telegram-bot@{branch_name}" + ) + + self.send_message( + chat_id, + f"Bot created for @{branch_name}!\n\n" + f"Username: @{bot_username}\n" + f"Display name: {display_name}\n" + f"Bot ID: {branch_name}\n" + f"Work dir: {branch_path}\n" + f"Service: telegram-bot@{branch_name}\n\n" + f"{status_line}", + ) + + self.logger.info( + "/create: bot @%s created automatically for branch @%s (started=%s)", + bot_username, branch_name, auto_started, + ) + + def _handle_create_token(self, chat_id: int, text: str) -> None: + """ + Handle token paste — step 2: validate token and create bot. + + Args: + chat_id: Telegram chat ID + text: The token text pasted by the user + """ + state = self._create_state.get(chat_id) + if not state: + return + + # Check state TTL + if time.time() - state.get("started_at", 0) > self._create_state_ttl: + del self._create_state[chat_id] + self.send_message(chat_id, "Create session expired. Start again with /create chat .") + return + + branch_name = state["branch_name"] + branch_path = state["branch_path"] + bot_token = text.strip() + + # Basic token format check + if ":" not in bot_token or len(bot_token) < 20: + self.send_message( + chat_id, + "That doesn't look like a valid bot token.\n" + "Format: 123456789:ABCdefGHIjklMNO_pqr\n\n" + "Paste the token from @BotFather, or /cancel to abort.", + ) + return + + # Clean up state before the potentially slow API calls + del self._create_state[chat_id] + + self.send_message(chat_id, f"Validating token and creating @{branch_name} bot...") + + # Validate the token via Telegram getMe + bot_info = validate_token(bot_token) + if not bot_info: + self.send_message( + chat_id, + "Token validation failed. The token may be invalid or expired.\n" + "Get a fresh token from @BotFather and try /create chat again.", + ) + return + + bot_username = bot_info.get("username", "unknown") + + # Create the bot via bot_factory + result = create_bot( + bot_id=branch_name, + bot_token=bot_token, + branch_name=branch_name, + work_dir=branch_path, + allowed_user_ids=self.allowed_user_ids, + ) + + if not result: + self.send_message( + chat_id, + f"Bot creation failed for @{branch_name}. Check system logs.", + ) + return + + self.send_message( + chat_id, + f"Bot created for @{branch_name}!\n\n" + f"Username: @{bot_username}\n" + f"Bot ID: {branch_name}\n" + f"Work dir: {branch_path}\n" + f"Service: telegram-bot@{branch_name}\n\n" + f"Start it with:\n" + f"systemctl --user start telegram-bot@{branch_name}", + ) + + self.logger.info( + "/create: bot @%s created for branch @%s", + bot_username, branch_name, + ) + + def _build_registry_status(self) -> str: + """ + Build registry info string for /status display. + + Returns: + Formatted string showing registered bots, or empty string if none. + """ + try: + bots = registry_list_bots() + except Exception: + return "" + + if not bots: + return "Registered Bots: none" + + lines = [f"Registered Bots: {len(bots)}"] + for bot in bots: + bot_id = bot.get("bot_id", "?") + username = bot.get("username", "?") + status = bot.get("status", "?") + branch = bot.get("branch_name") or "base" + lines.append(f" {bot_id} (@{username}) - {branch} - {status}") + + return "\n".join(lines) + + # ============================================= + # TEXT CHUNKING + # ============================================= + + def chunk_text(self, text: str, limit: int = TELEGRAM_CHAR_LIMIT) -> list[str]: + """ + Split text into chunks for Telegram's message character limit. + + Uses smart breaking: tries sentence boundaries, then paragraphs, + then newlines, then spaces, and finally hard breaks. + + Args: + text: The full text to chunk + limit: Maximum characters per chunk (default 4096) + + Returns: + List of text chunks, each within the limit + """ + if len(text) <= limit: + return [text] + + chunks: list[str] = [] + remaining = text + + while remaining: + if len(remaining) <= limit: + chunks.append(remaining) + break + + chunk = remaining[:limit] + + # Try to break at sentence boundary + best_break = -1 + for i in range(len(chunk) - 1, max(0, len(chunk) - 500), -1): + if chunk[i] in ".!?" and ( + i + 1 >= len(chunk) or chunk[i + 1] in " \n" + ): + best_break = i + 1 + break + + # Try double newline + if best_break == -1: + newline_pos = chunk.rfind("\n\n") + if newline_pos > limit // 2: + best_break = newline_pos + 2 + + # Try single newline + if best_break == -1: + newline_pos = chunk.rfind("\n") + if newline_pos > limit // 2: + best_break = newline_pos + 1 + + # Try space + if best_break == -1: + space_pos = chunk.rfind(" ") + if space_pos > limit // 2: + best_break = space_pos + 1 + + # Hard break + if best_break == -1: + best_break = limit + + chunks.append(remaining[:best_break].rstrip()) + remaining = remaining[best_break:].lstrip() + + return chunks + + # ============================================= + # SECURITY + # ============================================= + + def is_user_allowed(self, user_id: int) -> bool: + """ + Check if a user ID is in the allowlist. + + Args: + user_id: Telegram user ID + + Returns: + True if allowed (or allowlist empty) + """ + if not self.allowed_user_ids: + return True + return user_id in self.allowed_user_ids + + def check_rate_limit(self, user_id: int) -> bool: + """ + Check if user is within rate limits using a sliding window. + + Args: + user_id: Telegram user ID + + Returns: + True if within limits, False if rate limited + """ + current_time = time.time() + + if user_id not in self._rate_limit_tracker: + self._rate_limit_tracker[user_id] = [] + + # Prune old timestamps + self._rate_limit_tracker[user_id] = [ + ts + for ts in self._rate_limit_tracker[user_id] + if current_time - ts < RATE_LIMIT_WINDOW + ] + + if len(self._rate_limit_tracker[user_id]) >= RATE_LIMIT_MESSAGES: + return False + + self._rate_limit_tracker[user_id].append(current_time) + return True + + # ============================================= + # TMUX SESSION MANAGEMENT + # ============================================= + + def ensure_tmux_session(self) -> bool: + """ + Ensure a tmux session is available for message injection. + + In shared-session mode: attaches to an existing tmux session (e.g., + Patrick's running Claude Code on PC). Falls back to own session if + the shared session is not found. + + In normal mode: creates telegram-{bot_id} session with Claude Code. + + Returns: + True if session is ready + """ + # Shared-session mode: attach to existing session if available + if self._shared_session_name: + try: + result = subprocess.run( + ["tmux", "has-session", "-t", self._shared_session_name], + capture_output=True, + ) + if result.returncode == 0: + self.session_name = self._shared_session_name + self._using_shared_session = True + self.logger.info( + "Shared session '%s' found — injecting into existing session", + self._shared_session_name, + ) + return True + else: + self._using_shared_session = False + self.session_name = f"telegram-{self.bot_id}" + self.logger.warning( + "Shared session '%s' not found — falling back to own session", + self._shared_session_name, + ) + except FileNotFoundError: + self._using_shared_session = False + self.session_name = f"telegram-{self.bot_id}" + + if self._tmux_session_exists(): + return True + + # Validate work_dir exists — tmux silently falls back to HOME on bad paths + if not self.work_dir.is_dir(): + self.logger.error( + "work_dir does not exist: %s — refusing to create tmux session", self.work_dir + ) + return False + + self.logger.info( + "Creating tmux session '%s' at %s", self.session_name, self.work_dir + ) + + try: + # env -u CLAUDECODE prevents "cannot run inside another Claude" error + env = os.environ.copy() + env.pop("CLAUDECODE", None) + + subprocess.run( + [ + "tmux", "new-session", "-d", + "-s", self.session_name, + "-c", str(self.work_dir), + ], + check=True, + capture_output=True, + env=env, + ) + + # Set AIPASS_BOT_ID environment variable in the tmux session + subprocess.run( + [ + "tmux", "send-keys", "-t", self.session_name, + f"export AIPASS_BOT_ID={self.bot_id}", "Enter", + ], + capture_output=True, + ) + time.sleep(0.3) + + # Launch Claude + claude_cmd = f"{CLAUDE_BIN} --permission-mode bypassPermissions" + subprocess.run( + [ + "tmux", "send-keys", "-t", self.session_name, + claude_cmd, "Enter", + ], + capture_output=True, + ) + + self.logger.info( + "tmux session created, waiting 5s for Claude to initialize..." + ) + time.sleep(5) + + # Hook: post-creation + self.on_session_create(self.session_name, self.work_dir) + + return True + + except subprocess.CalledProcessError as e: + self.logger.error( + "Failed to create tmux session: %s", + e.stderr.decode() if e.stderr else str(e), + ) + return False + except FileNotFoundError: + self.logger.error("tmux not found - is it installed?") + return False + + def inject_message(self, text: str) -> bool: + """ + Inject a message into the tmux session via send-keys. + + Uses -l flag for literal text (no shell interpretation), + followed by Enter to submit. + + Args: + text: The message text to inject + + Returns: + True if injection succeeded + """ + try: + subprocess.run( + ["tmux", "send-keys", "-t", self.session_name, "-l", text], + check=True, + capture_output=True, + ) + time.sleep(SEND_KEYS_DELAY) + subprocess.run( + ["tmux", "send-keys", "-t", self.session_name, "Enter"], + check=True, + capture_output=True, + ) + self.logger.info("Message injected into tmux session") + return True + except subprocess.CalledProcessError as e: + self.logger.error( + "Failed to inject message: %s", + e.stderr.decode() if e.stderr else str(e), + ) + return False + + def _tmux_session_exists(self) -> bool: + """Check if the tmux session exists.""" + try: + result = subprocess.run( + ["tmux", "has-session", "-t", self.session_name], + capture_output=True, + ) + return result.returncode == 0 + except FileNotFoundError: + return False + + def _kill_tmux_session(self) -> bool: + """Kill the tmux session. Protects shared sessions from being killed.""" + # Shared-session protection: never kill a session we don't own + if self._using_shared_session: + self.logger.info( + "Shared session '%s' — detaching instead of killing", + self.session_name, + ) + self._using_shared_session = False + self.session_name = f"telegram-{self.bot_id}" + return True + + if not self._tmux_session_exists(): + self.logger.info( + "tmux session '%s' not running, nothing to kill", self.session_name + ) + return True + + try: + subprocess.run( + ["tmux", "kill-session", "-t", self.session_name], + check=True, + capture_output=True, + ) + self.logger.info("Killed tmux session '%s'", self.session_name) + return True + except subprocess.CalledProcessError as e: + self.logger.error( + "Failed to kill tmux session '%s': %s", + self.session_name, + e.stderr.decode() if e.stderr else str(e), + ) + return False + + # ============================================= + # PENDING FILE MANAGEMENT + # ============================================= + + def write_pending_file( + self, chat_id: int, message_id: int, processing_message_id: Optional[int] = None + ) -> bool: + """ + Write the pending file for Stop hook coordination. + + Args: + chat_id: Telegram chat ID + message_id: Original message's Telegram message ID + processing_message_id: ID of the "Processing..." message to edit + + Returns: + True if written successfully + """ + PENDING_DIR.mkdir(parents=True, exist_ok=True) + + transcript_line_after = self._get_transcript_line_count() + + pending_data = { + "chat_id": chat_id, + "message_id": message_id, + "bot_token": self.bot_token, + "bot_id": self.bot_id, + "work_dir": str(self.work_dir), + "session_name": self.session_name, + "processing_message_id": processing_message_id, + "timestamp": time.time(), + "transcript_line_after": transcript_line_after, + } + + try: + self.pending_file.write_text( + json.dumps(pending_data, indent=2), + encoding="utf-8", + ) + self.logger.info("Pending file written for message %d", message_id) + return True + except OSError as e: + self.logger.error("Failed to write pending file: %s", e) + return False + + def clean_stale_pending(self) -> None: + """Remove stale pending file if older than PENDING_TTL and tmux session dead.""" + if not self.pending_file.exists(): + return + try: + age = time.time() - self.pending_file.stat().st_mtime + if age > PENDING_TTL and not self._tmux_session_exists(): + self.pending_file.unlink() + self.logger.info("Cleaned stale pending file (%.0fs old)", age) + except OSError as e: + self.logger.warning("Failed to clean stale pending file: %s", e) + + def _get_transcript_line_count(self) -> int: + """ + Count lines in the Claude JSONL transcript for Layer 3 position tracking. + + Returns: + Line count of the JSONL transcript, or 0 if unavailable + """ + slug = str(self.work_dir).replace("/", "-") + # Look for transcript files matching the session pattern + projects_dir = Path.home() / ".claude" / "projects" / slug + if not projects_dir.exists(): + return 0 + + # Find the most recent JSONL transcript + jsonl_files = sorted(projects_dir.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True) + if not jsonl_files: + return 0 + + try: + text = jsonl_files[0].read_text(encoding="utf-8").strip() + return len(text.split("\n")) if text else 0 + except OSError as e: + self.logger.warning("Could not read transcript for line count: %s", e) + return 0 + + # ============================================= + # HEARTBEAT THREAD + # ============================================= + + def _start_heartbeat(self, chat_id: int, processing_msg_id: int) -> None: + """ + Start a background thread that updates the "Processing..." message + with elapsed time. + + Args: + chat_id: Chat ID where the processing message was sent + processing_msg_id: Message ID of the "Processing..." message + """ + self._stop_heartbeat() # Ensure no stale thread + self._heartbeat_stop.clear() + + def _heartbeat_loop(): + start = time.time() + while not self._heartbeat_stop.is_set(): + self._heartbeat_stop.wait(HEARTBEAT_INTERVAL) + if self._heartbeat_stop.is_set(): + break + + # Only update if pending file still exists and tmux alive + if not self.pending_file.exists(): + break + if not self._tmux_session_exists(): + break + + elapsed = time.time() - start + elapsed_str = self._format_elapsed(elapsed) + self.edit_message( + chat_id, processing_msg_id, f"Processing... ({elapsed_str})" + ) + + self._heartbeat_thread = threading.Thread( + target=_heartbeat_loop, daemon=True, name=f"heartbeat-{self.bot_id}" + ) + self._heartbeat_thread.start() + + def _stop_heartbeat(self) -> None: + """Signal the heartbeat thread to stop and wait for it.""" + self._heartbeat_stop.set() + if self._heartbeat_thread is not None and self._heartbeat_thread.is_alive(): + self._heartbeat_thread.join(timeout=5) + self._heartbeat_thread = None + + @staticmethod + def _format_elapsed(seconds: float) -> str: + """ + Format elapsed seconds as human-readable string. + + Args: + seconds: Elapsed time in seconds + + Returns: + Formatted string like "30s", "1m 0s", "2m 30s" + """ + total = int(seconds) + if total < 60: + return f"{total}s" + minutes, secs = divmod(total, 60) + return f"{minutes}m {secs}s" + + # ============================================= + # OVERRIDABLE HOOKS + # ============================================= + + def on_message(self, text: str) -> str: + """ + Hook: pre-process message text before tmux injection. + + Override in subclasses to modify the prompt sent to Claude. + + Args: + text: Raw message text + + Returns: + Processed text to inject into tmux + """ + return text + + def on_response(self, text: str) -> str: + """ + Hook: post-process response text before sending to Telegram. + + Override in subclasses to modify Claude's response. + + Args: + text: Raw response text from Claude + + Returns: + Processed text to send to Telegram + """ + return text + + def on_session_create(self, session_name: str, work_dir: Path) -> None: + """ + Hook: called after a new tmux session is created. + + Override in subclasses to perform post-creation setup + (e.g., injecting "hi" to trigger startup protocol). + + Args: + session_name: The tmux session name that was created + work_dir: The working directory of the session + """ + pass + + def get_custom_commands(self) -> dict: + """ + Hook: return additional bot-specific commands. + + Override in subclasses to add custom commands to /help and /start. + Base implementation includes /create and /cancel for bot management. + + Returns: + Dict of commands in telegram_standards format + """ + return { + "create": { + "description": "Create a new branch bot: /create chat ", + "menu_text": "Create branch bot", + }, + "cancel": { + "description": "Cancel active /create flow", + "menu_text": "Cancel", + }, + } + + # ============================================= + # LOCK FILE MANAGEMENT + # ============================================= + + def _create_lock(self) -> None: + """Write PID to lock file.""" + self._lock_file.parent.mkdir(parents=True, exist_ok=True) + try: + self._lock_file.write_text( + json.dumps({ + "pid": os.getpid(), + "started": datetime.now().isoformat(), + "session": self.session_name, + "bot_id": self.bot_id, + }), + encoding="utf-8", + ) + self.logger.info("Lock file created: %s", self._lock_file) + except OSError as e: + self.logger.error("Failed to create lock file: %s", e) + + def _remove_lock(self) -> None: + """Delete the lock file.""" + try: + if self._lock_file.exists(): + self._lock_file.unlink() + self.logger.info("Lock file removed") + except OSError as e: + self.logger.error("Failed to remove lock file: %s", e) + + def _check_lock(self) -> bool: + """ + Check if another instance of this bot is running. + + Verifies both PID liveness AND that the process is actually this bot. + Handles PID reuse: if the PID is alive but belongs to a different + process, the lock is treated as stale and cleaned. + + Returns: + True if another live instance holds the lock, False otherwise + """ + if not self._lock_file.exists(): + return False + + try: + lock_data = json.loads( + self._lock_file.read_text(encoding="utf-8") + ) + pid = lock_data.get("pid", 0) + + if pid: + try: + os.kill(pid, 0) # Signal 0 = check existence + except OSError: + self.logger.info("Cleaning stale lock (PID %d is dead)", pid) + self._lock_file.unlink(missing_ok=True) + return False + + # PID is alive — verify it's actually this bot (not PID reuse) + try: + cmdline = Path(f"/proc/{pid}/cmdline").read_bytes() + cmd_str = cmdline.decode("utf-8", errors="replace").replace("\x00", " ") + if f"--bot-id {self.bot_id}" not in cmd_str: + self.logger.info( + "Cleaning stale lock (PID %d is alive but not bot-%s)", + pid, self.bot_id, + ) + self._lock_file.unlink(missing_ok=True) + return False + except OSError: + pass # /proc not available, trust PID check + + return True # PID alive and belongs to this bot + + except (json.JSONDecodeError, OSError): + self._lock_file.unlink(missing_ok=True) + + return False + + # ============================================= + # SIGNAL HANDLING + # ============================================= + + def _shutdown_handler(self, signum, _frame) -> None: + """Handle SIGTERM/SIGINT for clean shutdown.""" + sig_name = ( + signal.Signals(signum).name if hasattr(signal, "Signals") else str(signum) + ) + self.logger.info("Received %s, shutting down...", sig_name) + self.state["running"] = False + + def _cleanup(self) -> None: + """Clean up resources on exit.""" + if self._log_streamer is not None: + self._log_streamer.stop() + self._log_streamer = None + self._stop_heartbeat() + self._remove_lock() + self.logger.info("Bot stopped") + + # ============================================= + # OFFSET PERSISTENCE + # ============================================= + + def _load_offset(self) -> int: + """Load the last processed update offset from disk.""" + if not self._offset_file.exists(): + return 0 + try: + with open(self._offset_file, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("offset", 0) + except (json.JSONDecodeError, OSError): + return 0 + + def _save_offset(self, offset: int) -> None: + """Persist the current update offset to disk.""" + self._offset_file.parent.mkdir(parents=True, exist_ok=True) + try: + with open(self._offset_file, "w", encoding="utf-8") as f: + json.dump( + {"offset": offset, "updated": datetime.now().isoformat()}, f + ) + except OSError as e: + self.logger.error("Failed to save offset: %s", e) + + +# ============================================= +# CLI ENTRY POINT +# ============================================= + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="AIPass Telegram Bot") + parser.add_argument("--bot-id", required=True, help="Bot identifier") + parser.add_argument("--config", help="Path to bot config JSON") + args = parser.parse_args() + + # Load config from ~/.aipass/telegram_bots/{bot_id}.json or --config path + config_path = ( + Path(args.config) + if args.config + else Path.home() / ".aipass" / "telegram_bots" / f"{args.bot_id}.json" + ) + + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + + bot = BaseBot( + bot_id=args.bot_id, + bot_token=config["bot_token"], + work_dir=Path(config.get("work_dir", str(Path.home()))), + bot_name=config.get("bot_name", "AIPass Bot"), + allowed_user_ids=config.get("allowed_user_ids", []), + branch_name=config.get("branch_name"), + shared_session=config.get("shared_session"), + ) + sys.exit(bot.run()) diff --git a/src/aipass/api/apps/handlers/telegram/bot_factory.py b/src/aipass/api/apps/handlers/telegram/bot_factory.py new file mode 100644 index 00000000..f5487a2c --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/bot_factory.py @@ -0,0 +1,537 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: bot_factory.py - Bot creation and deletion factory +# Date: 2026-02-24 +# Version: 1.2.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.2.0 (2026-02-25): Fix: create_bot always uses registry path when branch_name provided +# - v1.1.0 (2026-02-24): Auto-start bot process after create_bot via Popen fire-and-forget +# - v1.0.0 (2026-02-24): Initial - bot lifecycle management (create, delete, validate, systemd) +# +# CODE STANDARDS: +# - Pure functions with proper error handling (graceful - never raise) +# - Uses Prax system_logger (FPLAN-0382 migration) +# - Stdlib only (urllib for HTTP, subprocess for systemd) +# ============================================= + +""" +Bot Creation and Deletion Factory + +Manages the full lifecycle of Telegram bots in the multi-bot architecture: +- Validate bot tokens via Telegram getMe API +- Validate branch existence against BRANCH_REGISTRY.json +- Write per-bot config files to ~/.aipass/telegram_bots/{bot_id}.json +- Register/deregister bots in the central bot registry +- Set BotFather commands via setMyCommands API +- Enable/disable/stop systemd user services + +All HTTP calls use urllib (stdlib). No external dependencies. +""" + +# Infrastructure +import sys +from pathlib import Path + +# Standard library +import json +import subprocess +from datetime import datetime, timezone +from typing import Optional +from urllib.request import urlopen, Request +from urllib.error import URLError, HTTPError + +# Logging (Prax system_logger — FPLAN-0382) +from aipass.prax.apps.modules.logger import system_logger as logger + +# Internal imports +from aipass.api.apps.handlers.telegram.bot_registry import ( + ensure_registry, register_bot, deregister_bot, get_bot, get_bot_by_branch +) + +# ============================================= +# CONSTANTS +# ============================================= + +TELEGRAM_API = "https://api.telegram.org/bot{token}" +BOT_CONFIG_DIR = Path.home() / ".aipass" / "telegram_bots" +BRANCH_REGISTRY = Path.home() / "BRANCH_REGISTRY.json" +SYSTEMD_DIR = Path.home() / ".config" / "systemd" / "user" + +# Default commands set on every new bot via BotFather +DEFAULT_BOT_COMMANDS = [ + {"command": "start", "description": "Start the bot"}, + {"command": "help", "description": "Show available commands"}, + {"command": "status", "description": "Show session status"}, + {"command": "new", "description": "Start a fresh session"}, +] + +# ============================================= +# TELEGRAM API HELPERS +# ============================================= + + +def validate_token(bot_token: str) -> Optional[dict]: + """ + Validate a bot token via the Telegram getMe API call. + + Args: + bot_token: Telegram bot token string (e.g., "123456:ABC-DEF..."). + + Returns: + Bot info dict with keys like "id", "username", "first_name" on success. + None if the token is invalid or the API is unreachable. + """ + url = f"{TELEGRAM_API.format(token=bot_token)}/getMe" + + try: + req = Request(url, method="GET") + with urlopen(req, timeout=15) as resp: + result = json.loads(resp.read().decode("utf-8")) + + if result.get("ok") and result.get("result"): + bot_info = result["result"] + logger.info("Token validated: @%s (id=%s)", bot_info.get("username"), bot_info.get("id")) + return bot_info + + logger.warning("Token validation failed: API returned ok=false") + return None + + except HTTPError as e: + logger.warning("Token validation HTTP error %d: %s", e.code, e.reason) + return None + except URLError as e: + logger.warning("Token validation network error: %s", e) + return None + except Exception as e: + logger.warning("Token validation unexpected error: %s", e) + return None + + +def validate_branch(branch_name: str) -> Optional[dict]: + """ + Check that a branch exists in BRANCH_REGISTRY.json. + + Args: + branch_name: Branch name to look up (case-insensitive, matches email field). + + Returns: + Branch info dict from the registry on success, None if not found. + """ + try: + if not BRANCH_REGISTRY.exists(): + logger.warning("Branch registry not found: %s", BRANCH_REGISTRY) + return None + + with open(BRANCH_REGISTRY, "r", encoding="utf-8") as f: + registry = json.load(f) + + branches = registry.get("branches", []) + target = branch_name.lower() + + for branch_entry in branches: + clean_email = branch_entry.get("email", "").replace("@", "").lower() + if clean_email == target: + logger.info("Branch validated: %s -> %s", branch_name, branch_entry.get("path")) + return branch_entry + + logger.warning("Branch '%s' not found in registry", branch_name) + return None + + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to read branch registry: %s", e) + return None + + +def set_bot_commands(bot_token: str, commands: list[dict]) -> bool: + """ + Set BotFather commands via the Telegram setMyCommands API. + + Args: + bot_token: Telegram bot token. + commands: List of command dicts, each with "command" and "description" keys. + + Returns: + True if commands were set successfully, False otherwise. + """ + url = f"{TELEGRAM_API.format(token=bot_token)}/setMyCommands" + + payload = {"commands": commands} + data = json.dumps(payload).encode("utf-8") + req = Request(url, data=data, headers={"Content-Type": "application/json"}) + + try: + with urlopen(req, timeout=15) as resp: + result = json.loads(resp.read().decode("utf-8")) + if result.get("ok"): + logger.info("Bot commands set successfully (%d commands)", len(commands)) + return True + logger.warning("setMyCommands failed: %s", result.get("description")) + return False + + except (HTTPError, URLError) as e: + logger.warning("Failed to set bot commands: %s", e) + return False + except Exception as e: + logger.warning("Unexpected error setting bot commands: %s", e) + return False + + +# ============================================= +# SYSTEMD SERVICE MANAGEMENT +# ============================================= + + +def enable_service(bot_id: str) -> bool: + """ + Enable the systemd user service for a bot (does not start it). + + Runs: systemctl --user enable telegram-bot@{bot_id} + + Args: + bot_id: Bot identifier used in the service template. + + Returns: + True if the service was enabled successfully, False otherwise. + """ + SERVICE_NAME = f"telegram-bot@{bot_id}" + try: + result = subprocess.run( + ["systemctl", "--user", "enable", SERVICE_NAME], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + logger.info("Enabled systemd service: %s", SERVICE_NAME) + return True + + logger.warning("Failed to enable service %s: %s", SERVICE_NAME, result.stderr.strip()) + return False + + except subprocess.TimeoutExpired: + logger.warning("Timeout enabling service: %s", SERVICE_NAME) + return False + except OSError as e: + logger.warning("Error enabling service %s: %s", SERVICE_NAME, e) + return False + + +def disable_service(bot_id: str) -> bool: + """ + Disable the systemd user service for a bot. + + Runs: systemctl --user disable telegram-bot@{bot_id} + + Args: + bot_id: Bot identifier used in the service template. + + Returns: + True if the service was disabled successfully, False otherwise. + """ + SERVICE_NAME = f"telegram-bot@{bot_id}" + try: + result = subprocess.run( + ["systemctl", "--user", "disable", SERVICE_NAME], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + logger.info("Disabled systemd service: %s", SERVICE_NAME) + return True + + logger.warning("Failed to disable service %s: %s", SERVICE_NAME, result.stderr.strip()) + return False + + except subprocess.TimeoutExpired: + logger.warning("Timeout disabling service: %s", SERVICE_NAME) + return False + except OSError as e: + logger.warning("Error disabling service %s: %s", SERVICE_NAME, e) + return False + + +def start_bot_process(bot_id: str) -> bool: + """ + Launch the bot process via subprocess.Popen (fire-and-forget). + + Starts base_bot.py --bot-id {bot_id} as a detached process. + This is called after create_bot() to auto-start the new bot. + + Args: + bot_id: Bot identifier to start. + + Returns: + True if the process was launched successfully, False otherwise. + """ + BASE_BOT_PATH = Path(__file__).resolve().parent / "base_bot.py" + PYTHON = str(Path.home() / ".venv" / "bin" / "python3") + + try: + proc = subprocess.Popen( + [PYTHON, str(BASE_BOT_PATH), "--bot-id", bot_id], + start_new_session=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + logger.info("Started bot process: bot_id=%s, pid=%d", bot_id, proc.pid) + return True + + except OSError as e: + logger.warning("Failed to start bot process for '%s': %s", bot_id, e) + return False + + +def stop_service(bot_id: str) -> bool: + """ + Stop the systemd user service for a bot. + + Runs: systemctl --user stop telegram-bot@{bot_id} + + Args: + bot_id: Bot identifier used in the service template. + + Returns: + True if the service was stopped successfully, False otherwise. + """ + SERVICE_NAME = f"telegram-bot@{bot_id}" + try: + result = subprocess.run( + ["systemctl", "--user", "stop", SERVICE_NAME], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0: + logger.info("Stopped systemd service: %s", SERVICE_NAME) + return True + + logger.warning("Failed to stop service %s: %s", SERVICE_NAME, result.stderr.strip()) + return False + + except subprocess.TimeoutExpired: + logger.warning("Timeout stopping service: %s", SERVICE_NAME) + return False + except OSError as e: + logger.warning("Error stopping service %s: %s", SERVICE_NAME, e) + return False + + +# ============================================= +# BOT LIFECYCLE +# ============================================= + + +def create_bot( + bot_id: str, + bot_token: str, + branch_name: Optional[str] = None, + work_dir: Optional[str] = None, + bot_name: Optional[str] = None, + allowed_user_ids: Optional[list[int]] = None, +) -> Optional[dict]: + """ + Create a new bot: validate, write config, register, setup systemd. + + Steps: + 1. Validate token via getMe + 2. If branch_name provided: validate branch exists in BRANCH_REGISTRY.json + 3. Check bot_id not already registered + 4. Write config file: ~/.aipass/telegram_bots/{bot_id}.json + 5. Register in bot registry + 6. Set BotFather commands via setMyCommands API + 7. Enable systemd service (don't start - leave that to caller) + + Args: + bot_id: Unique identifier for this bot (e.g., "dev_central", "base"). + bot_token: Telegram bot token from BotFather. + branch_name: AIPass branch name to associate, or None for base bot. + work_dir: Working directory for Claude sessions. Auto-resolved from branch if None. + bot_name: Human-readable bot name. Auto-generated if None. + allowed_user_ids: List of Telegram user IDs allowed to use this bot. + + Returns: + Bot info dict on success, None on any failure. + """ + # Step 1: Validate token + bot_info = validate_token(bot_token) + if not bot_info: + logger.warning("create_bot failed: invalid token for bot_id '%s'", bot_id) + return None + + BOT_USERNAME = bot_info.get("username", "unknown") + + # Step 2: Validate branch if provided + RESOLVED_WORK_DIR = str(Path.home()) if work_dir is None else str(work_dir) + if branch_name: + branch_info = validate_branch(branch_name) + if not branch_info: + logger.warning("create_bot failed: branch '%s' not found", branch_name) + return None + # Always use registry path as source of truth when branch_name is provided + REGISTRY_PATH = branch_info.get("path", "") + if REGISTRY_PATH: + if work_dir and str(work_dir) != REGISTRY_PATH: + logger.warning( + "create_bot: explicit work_dir '%s' differs from registry path '%s' — using registry", + work_dir, REGISTRY_PATH, + ) + RESOLVED_WORK_DIR = REGISTRY_PATH + + # Step 3: Check not already registered + existing = get_bot(bot_id) + if existing: + logger.warning("create_bot failed: bot_id '%s' already registered", bot_id) + return None + + # Also check no other bot owns this branch + if branch_name: + branch_bot = get_bot_by_branch(branch_name) + if branch_bot: + logger.warning( + "create_bot failed: branch '%s' already has bot '%s'", + branch_name, branch_bot.get("bot_id"), + ) + return None + + # Step 4: Write config file + ensure_registry() + BOT_CONFIG_DIR.mkdir(parents=True, exist_ok=True) + + RESOLVED_BOT_NAME = bot_name or f"AIPass {bot_id.replace('_', ' ').title()} Bot" + CONFIG_PATH = BOT_CONFIG_DIR / f"{bot_id}.json" + + config_data = { + "bot_id": bot_id, + "bot_token": bot_token, + "bot_name": RESOLVED_BOT_NAME, + "branch_name": branch_name, + "work_dir": RESOLVED_WORK_DIR, + "allowed_user_ids": allowed_user_ids or [], + "created_at": datetime.now(timezone.utc).isoformat(), + } + + try: + CONFIG_PATH.write_text( + json.dumps(config_data, indent=2), + encoding="utf-8", + ) + logger.info("Wrote bot config: %s", CONFIG_PATH) + except OSError as e: + logger.warning("Failed to write bot config: %s", e) + return None + + # Step 5: Register in bot registry + registered = register_bot( + bot_id=bot_id, + username=BOT_USERNAME, + branch_name=branch_name, + work_dir=RESOLVED_WORK_DIR, + config_path=str(CONFIG_PATH), + ) + if not registered: + # Clean up config file on registration failure + CONFIG_PATH.unlink(missing_ok=True) + logger.warning("create_bot failed: registry registration failed for '%s'", bot_id) + return None + + # Step 6: Set BotFather commands + set_bot_commands(bot_token, DEFAULT_BOT_COMMANDS) + + # Step 7: Enable systemd service + enable_service(bot_id) + + # Step 8: Auto-start the bot process + started = start_bot_process(bot_id) + + logger.info( + "Bot created: %s (@%s, branch=%s, work_dir=%s, started=%s)", + bot_id, BOT_USERNAME, branch_name, RESOLVED_WORK_DIR, started, + ) + + return { + "bot_id": bot_id, + "username": BOT_USERNAME, + "bot_name": RESOLVED_BOT_NAME, + "branch_name": branch_name, + "work_dir": RESOLVED_WORK_DIR, + "config_path": str(CONFIG_PATH), + "service_name": f"telegram-bot@{bot_id}", + "auto_started": started, + } + + +def delete_bot(bot_id: str, kill_tmux: bool = True) -> bool: + """ + Delete a bot: stop service, kill tmux, remove config, deregister. + + Steps: + 1. Stop systemd service + 2. Kill tmux session if exists and kill_tmux is True + 3. Remove config file + 4. Clean up pending file (both v1 and v2 naming) + 5. Deregister from registry + + Args: + bot_id: Bot identifier to delete. + kill_tmux: Whether to kill the associated tmux session (default True). + + Returns: + True if the bot was fully cleaned up, False on any failure. + """ + bot = get_bot(bot_id) + if not bot: + logger.warning("delete_bot failed: bot '%s' not found in registry", bot_id) + return False + + # Step 1: Stop systemd service + stop_service(bot_id) + disable_service(bot_id) + + # Step 2: Kill tmux session if requested + if kill_tmux: + TMUX_SESSION_NAME = f"telegram-{bot_id}" + try: + subprocess.run( + ["tmux", "kill-session", "-t", TMUX_SESSION_NAME], + capture_output=True, + text=True, + timeout=5, + ) + logger.info("Killed tmux session: %s", TMUX_SESSION_NAME) + except (subprocess.TimeoutExpired, OSError) as e: + logger.info("tmux kill-session for '%s' skipped (may not exist): %s", TMUX_SESSION_NAME, e) + + # Step 3: Remove config file + CONFIG_PATH = bot.get("config_path", "") + if CONFIG_PATH: + try: + Path(CONFIG_PATH).unlink(missing_ok=True) + logger.info("Removed config file: %s", CONFIG_PATH) + except OSError as e: + logger.warning("Failed to remove config file: %s", e) + + # Step 4: Clean up pending files (both v1 and v2 naming) + PENDING_DIR = Path.home() / ".aipass" / "telegram_pending" + BRANCH_NAME = bot.get("branch_name", bot_id) + + # v2 naming: bot-{bot_id}.json + PENDING_V2 = PENDING_DIR / f"bot-{bot_id}.json" + PENDING_V2.unlink(missing_ok=True) + + # v1 naming: telegram-{branch_name}.json + if BRANCH_NAME: + PENDING_V1 = PENDING_DIR / f"telegram-{BRANCH_NAME}.json" + PENDING_V1.unlink(missing_ok=True) + + # Step 5: Deregister from registry + if not deregister_bot(bot_id): + logger.warning("delete_bot: deregistration failed for '%s'", bot_id) + return False + + logger.info("Bot deleted: %s", bot_id) + return True diff --git a/src/aipass/api/apps/handlers/telegram/bot_operations.py b/src/aipass/api/apps/handlers/telegram/bot_operations.py new file mode 100644 index 00000000..7358efc4 --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/bot_operations.py @@ -0,0 +1,241 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: bot_operations.py - Bot operation handlers for multi-bot module +# Date: 2026-02-24 +# Version: 1.0.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-24): Initial - start, stop, status, list operations for multi-bot system +# +# CODE STANDARDS: +# - Pure functions with proper error handling (graceful - never raise) +# - No Prax imports (handler tier 3) +# - Stdlib only (subprocess for systemd) +# - Returns values for caller to log/display - no handler-level logging +# ============================================= + +""" +Bot Operation Handlers for Multi-Bot Architecture + +Implementation logic for bot lifecycle operations: +- start_bot: load config and run polling loop +- stop_bot: stop systemd service +- get_status: query registry for bot status +- get_all_bots: list all registered bots +- format_bot_details: format a bot entry for display + +Called by the telegram_bot module (thin orchestration layer). +All functions return values - the module layer handles logging and display. +""" + +# Infrastructure +import sys +from pathlib import Path + +# Standard library +import subprocess + +# Internal handler imports +from aipass.api.apps.handlers.telegram.base_bot import BaseBot +from aipass.api.apps.handlers.telegram.branch_plugin import BranchPlugin +from aipass.api.apps.handlers.telegram.bot_registry import list_bots, get_bot +from aipass.api.apps.handlers.telegram.config import load_bot_config + +# ============================================= +# BOT OPERATIONS +# ============================================= + + +def start_bot(bot_id: str) -> int | None: + """ + Load config and start a bot's polling loop. + + Loads config from ~/.aipass/telegram_bots/{bot_id}.json. + If config has "branch_name", creates a BranchPlugin, else a BaseBot. + Calls bot.run() which blocks until terminated. + + Args: + bot_id: Bot identifier to start + + Returns: + Bot exit code, or None if config loading failed. + """ + config = load_bot_config(bot_id) + if not config: + return None + + bot_token = config.get("bot_token") + if not bot_token: + return None + + work_dir = Path(config.get("work_dir", str(Path.home()))) + bot_name = config.get("bot_name", f"AIPass {bot_id} Bot") + allowed_user_ids = config.get("allowed_user_ids", []) + branch_name = config.get("branch_name") + + if branch_name: + bot = BranchPlugin( + branch_name=branch_name, + bot_id=bot_id, + bot_token=bot_token, + work_dir=work_dir, + bot_name=bot_name, + allowed_user_ids=allowed_user_ids, + ) + else: + bot = BaseBot( + bot_id=bot_id, + bot_token=bot_token, + work_dir=work_dir, + bot_name=bot_name, + allowed_user_ids=allowed_user_ids, + ) + + return bot.run() + + +def stop_bot(bot_id: str) -> tuple[bool, str]: + """ + Stop a bot's systemd service. + + Args: + bot_id: Bot identifier to stop + + Returns: + Tuple of (success, message). + """ + service_name = f"telegram-bot@{bot_id}" + + try: + result = subprocess.run( + ["systemctl", "--user", "stop", service_name], + capture_output=True, + text=True, + timeout=10, + ) + + if result.returncode == 0: + return True, f"Stopped {service_name}" + + return False, f"Failed to stop {service_name}: {result.stderr.strip()}" + + except subprocess.TimeoutExpired: + return False, f"Timeout stopping {service_name}" + except OSError as e: + return False, f"Error stopping {service_name}: {e}" + + +def get_status(bot_id: str | None = None) -> list[dict]: + """ + Get bot status entries. If no bot_id, returns all bots. + + Args: + bot_id: Specific bot to check, or None for all bots + + Returns: + List of bot entry dicts. Empty list if not found. + """ + if bot_id: + bot = get_bot(bot_id) + return [bot] if bot else [] + + return list_bots() + + +def get_all_bots() -> list[dict]: + """ + Get all registered bots. + + Returns: + List of bot entry dicts. + """ + return list_bots() + + +def format_bot_details(bot: dict) -> list[str]: + """ + Format a single bot entry into display lines. + + Args: + bot: Bot entry dict from registry. + + Returns: + List of formatted strings for display. + """ + bot_id = bot.get("bot_id", "?") + username = bot.get("username", "?") + branch = bot.get("branch_name") or "none (base bot)" + work_dir = bot.get("work_dir", "?") + status = bot.get("status", "?") + service = bot.get("service_name", f"telegram-bot@{bot_id}") + + return [ + f"Bot ID: {bot_id}", + f"Username: @{username}", + f"Branch: {branch}", + f"Work Dir: {work_dir}", + f"Status: {status}", + f"Service: {service}", + ] + + +def format_bot_table(bots: list[dict]) -> list[str]: + """ + Format a list of bots into table rows. + + Args: + bots: List of bot entry dicts. + + Returns: + List of formatted strings (header + separator + rows + total). + """ + lines = [] + lines.append(f" {'Bot ID':<18} {'Branch':<16} {'Username':<24} {'Status':<10}") + lines.append(f" {'---' * 6:<18} {'---' * 5:<16} {'---' * 8:<24} {'---' * 3:<10}") + + for bot in bots: + bot_id = bot.get("bot_id", "?") + branch = bot.get("branch_name") or "-" + username = f"@{bot.get('username', '?')}" + status = bot.get("status", "?") + lines.append(f" {bot_id:<18} {branch:<16} {username:<24} {status}") + + lines.append(f" Total: {len(bots)} bot(s)") + return lines + + +def parse_create_args(args: list) -> dict | None: + """ + Parse create command arguments. + + Args: + args: Arguments after 'create' (bot_id, token, [--branch name], [--work-dir path]) + + Returns: + Dict with parsed values, or None if args are insufficient. + """ + if len(args) < 2: + return None + + result = { + "bot_id": args[0], + "bot_token": args[1], + "branch_name": None, + "work_dir": None, + } + + i = 2 + while i < len(args): + if args[i] == "--branch" and i + 1 < len(args): + result["branch_name"] = args[i + 1] + i += 2 + elif args[i] == "--work-dir" and i + 1 < len(args): + result["work_dir"] = args[i + 1] + i += 2 + else: + i += 1 + + return result diff --git a/src/aipass/api/apps/handlers/telegram/bot_registry.py b/src/aipass/api/apps/handlers/telegram/bot_registry.py new file mode 100644 index 00000000..f2a73774 --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/bot_registry.py @@ -0,0 +1,364 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: bot_registry.py - Bot registry management for multi-bot architecture +# Date: 2026-02-24 +# Version: 1.0.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-24): Initial - registry CRUD with fcntl locking for multi-bot management +# +# CODE STANDARDS: +# - Pure functions with proper error handling (graceful - never raise) +# - Uses Prax system_logger (FPLAN-0382 migration) +# - Thread-safe via fcntl.flock (shared read, exclusive write) +# ============================================= + +""" +Bot Registry Management for Multi-Bot Architecture + +Manages a central registry of all Telegram bots in the AIPass ecosystem. +Each bot entry tracks its ID, branch association, working directory, +config path, systemd service name, and status. + +Registry location: ~/.aipass/telegram_bots/_registry.json + +Thread-safe via fcntl.flock file locking (same pattern as session_store.py). +All public functions return None/False on errors - never raise exceptions. +""" + +# Infrastructure +import sys +from pathlib import Path + +# Standard library +import fcntl +import json +from datetime import datetime, timezone +from typing import Optional + +# Logging (Prax system_logger — FPLAN-0382) +from aipass.prax.apps.modules.logger import system_logger as logger + +# ============================================= +# CONSTANTS +# ============================================= + +REGISTRY_DIR = Path.home() / ".aipass" / "telegram_bots" +REGISTRY_FILE = REGISTRY_DIR / "_registry.json" + +# ============================================= +# EMPTY REGISTRY TEMPLATE +# ============================================= + + +def _empty_registry() -> dict: + """Return a fresh empty registry structure.""" + return { + "bots": {}, + "metadata": { + "version": "1.0.0", + "last_updated": datetime.now(timezone.utc).isoformat(), + }, + } + + +def _now_iso() -> str: + """Return current UTC timestamp in ISO format.""" + return datetime.now(timezone.utc).isoformat() + + +# ============================================= +# REGISTRY LIFECYCLE +# ============================================= + + +def ensure_registry() -> None: + """ + Create registry directory and file if they don't exist. + + Safe to call multiple times - only creates what is missing. + """ + try: + REGISTRY_DIR.mkdir(parents=True, exist_ok=True) + if not REGISTRY_FILE.exists(): + data = _empty_registry() + with open(REGISTRY_FILE, "w", encoding="utf-8") as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + try: + json.dump(data, f, indent=2) + finally: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + logger.info("Created new bot registry at %s", REGISTRY_FILE) + except OSError as e: + logger.warning("Failed to ensure registry: %s", e) + + +# ============================================= +# READ / WRITE WITH LOCKING +# ============================================= + + +def load_registry() -> dict: + """ + Load registry with fcntl shared lock. + + Returns: + Registry dict. Returns empty structure if file missing or corrupt. + """ + if not REGISTRY_FILE.exists(): + return _empty_registry() + + try: + with open(REGISTRY_FILE, "r", encoding="utf-8") as f: + fcntl.flock(f.fileno(), fcntl.LOCK_SH) + try: + data = json.load(f) + finally: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + + if not isinstance(data, dict) or "bots" not in data: + logger.warning("Registry file has unexpected structure, returning empty") + return _empty_registry() + + return data + + except (json.JSONDecodeError, OSError) as e: + logger.warning("Failed to load registry: %s", e) + return _empty_registry() + + +def save_registry(data: dict) -> bool: + """ + Save registry with fcntl exclusive lock. + + Args: + data: Full registry dict to write. + + Returns: + True if saved successfully, False on error. + """ + try: + REGISTRY_DIR.mkdir(parents=True, exist_ok=True) + + # Update metadata timestamp + if "metadata" not in data: + data["metadata"] = {} + data["metadata"]["last_updated"] = _now_iso() + + with open(REGISTRY_FILE, "w", encoding="utf-8") as f: + fcntl.flock(f.fileno(), fcntl.LOCK_EX) + try: + json.dump(data, f, indent=2) + finally: + fcntl.flock(f.fileno(), fcntl.LOCK_UN) + + return True + + except OSError as e: + logger.warning("Failed to save registry: %s", e) + return False + + +# ============================================= +# CRUD OPERATIONS +# ============================================= + + +def get_bot(bot_id: str) -> Optional[dict]: + """ + Get a single bot entry by bot_id. + + Args: + bot_id: Unique bot identifier. + + Returns: + Bot entry dict or None if not found. + """ + registry = load_registry() + return registry.get("bots", {}).get(bot_id) + + +def list_bots(status: Optional[str] = None) -> list[dict]: + """ + List all bots, optionally filtered by status. + + Args: + status: Filter by status (e.g., "active", "inactive"). None returns all. + + Returns: + List of bot entry dicts. + """ + registry = load_registry() + bots = list(registry.get("bots", {}).values()) + + if status is not None: + bots = [b for b in bots if b.get("status") == status] + + return bots + + +def register_bot( + bot_id: str, + username: str, + branch_name: Optional[str], + work_dir: str, + config_path: str, + bot_token_ref: Optional[str] = None, +) -> bool: + """ + Register a new bot in the registry. + + Args: + bot_id: Unique bot identifier. + username: Telegram bot username (e.g., "aipass_dev_central_bot"). + branch_name: AIPass branch name, or None for the base bot. + work_dir: Working directory for Claude sessions. + config_path: Path to the bot's config JSON file. + bot_token_ref: Optional env var name or reference for the token. + + Returns: + True on success, False if bot_id already exists or on error. + """ + registry = load_registry() + bots = registry.get("bots", {}) + + if bot_id in bots: + logger.warning("Bot '%s' already registered", bot_id) + return False + + now = _now_iso() + entry = { + "bot_id": bot_id, + "username": username, + "branch_name": branch_name, + "work_dir": str(work_dir), + "config_path": str(config_path), + "service_name": f"telegram-bot@{bot_id}", + "status": "active", + "created_at": now, + "updated_at": now, + } + + if bot_token_ref: + entry["bot_token_env"] = bot_token_ref + + bots[bot_id] = entry + registry["bots"] = bots + + if not save_registry(registry): + return False + + logger.info("Registered bot '%s' (branch=%s, work_dir=%s)", bot_id, branch_name, work_dir) + return True + + +def update_bot(bot_id: str, **kwargs) -> bool: + """ + Update specific fields of a bot entry. + + Auto-updates the updated_at timestamp. + + Args: + bot_id: Bot identifier to update. + **kwargs: Fields to update (e.g., status="inactive", username="new_name"). + + Returns: + True on success, False if bot not found or on error. + """ + registry = load_registry() + bots = registry.get("bots", {}) + + if bot_id not in bots: + logger.warning("Cannot update bot '%s': not found", bot_id) + return False + + for key, value in kwargs.items(): + bots[bot_id][key] = value + + bots[bot_id]["updated_at"] = _now_iso() + registry["bots"] = bots + + if not save_registry(registry): + return False + + logger.info("Updated bot '%s': %s", bot_id, list(kwargs.keys())) + return True + + +def deregister_bot(bot_id: str) -> bool: + """ + Remove a bot from the registry. + + Args: + bot_id: Bot identifier to remove. + + Returns: + True on success, False if bot not found or on error. + """ + registry = load_registry() + bots = registry.get("bots", {}) + + if bot_id not in bots: + logger.warning("Cannot deregister bot '%s': not found", bot_id) + return False + + del bots[bot_id] + registry["bots"] = bots + + if not save_registry(registry): + return False + + logger.info("Deregistered bot '%s'", bot_id) + return True + + +# ============================================= +# LOOKUP HELPERS +# ============================================= + + +def get_bot_by_branch(branch_name: str) -> Optional[dict]: + """ + Find a bot by its branch_name. + + Args: + branch_name: AIPass branch name (e.g., "dev_central"). + + Returns: + Bot entry dict or None if not found. + """ + registry = load_registry() + for bot in registry.get("bots", {}).values(): + if bot.get("branch_name") == branch_name: + return bot + return None + + +def get_bot_by_work_dir(work_dir) -> Optional[dict]: + """ + Find a bot whose work_dir matches the given path. + + Used by the response router to match CWD to a bot. + + Args: + work_dir: Path (str or Path) to match against bot work_dir fields. + + Returns: + Bot entry dict or None if not found. + """ + target = str(Path(work_dir).resolve()) + registry = load_registry() + + for bot in registry.get("bots", {}).values(): + bot_dir = bot.get("work_dir", "") + if bot_dir: + try: + if str(Path(bot_dir).resolve()) == target: + return bot + except (ValueError, OSError): + continue + + return None diff --git a/src/aipass/api/apps/handlers/telegram/botfather_client.py b/src/aipass/api/apps/handlers/telegram/botfather_client.py new file mode 100644 index 00000000..166b93a8 --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/botfather_client.py @@ -0,0 +1,532 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: botfather_client.py - Telethon-based BotFather automation client +# Date: 2026-02-24 +# Version: 1.0.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-24): Initial - automated bot creation via BotFather using Telethon +# +# CODE STANDARDS: +# - Telethon for Telegram user-account interaction with @BotFather +# - Graceful error handling (never raise - return None on failure) +# - Sync wrapper for async Telethon calls (callable from stdlib code) +# - Uses Prax system_logger (FPLAN-0382 migration) +# ============================================= + +""" +BotFather Automation Client + +Automates Telegram bot creation by driving a conversation with @BotFather +using Telethon (user-account MTProto client). This replaces the manual +"go to BotFather, create a bot, paste the token" workflow. + +Flow: + 1. Connect to Telegram as Patrick's user account (pre-authenticated session) + 2. Send /newbot to @BotFather + 3. Provide display name and username + 4. Parse the bot token from BotFather's success response + 5. Return token + metadata for bot_factory.py to complete registration + +Requirements: + - Telethon 1.42.0+ installed in .venv + - One-time manual phone auth to create .telethon.session file + - API credentials in ~/.aipass/telegram_bots/.telethon_config.json +""" + +# Infrastructure +import sys +from pathlib import Path + +# Standard library +import asyncio +import json +import re +import time +from typing import Any, Optional + +# Logging (Prax system_logger — FPLAN-0382) +from aipass.prax.apps.modules.logger import system_logger as logger + +# Third party (Telethon) — runtime-imported in methods to avoid Pyright issues +TELETHON_AVAILABLE = False +try: + import telethon as _telethon_check # noqa: F401 + TELETHON_AVAILABLE = True + del _telethon_check +except ImportError: + pass + +# ============================================= +# CONSTANTS +# ============================================= + +BOT_CONFIG_DIR = Path.home() / ".aipass" / "telegram_bots" +TELETHON_CONFIG_PATH = BOT_CONFIG_DIR / ".telethon_config.json" +SESSION_PATH = BOT_CONFIG_DIR / ".telethon" # Telethon appends .session automatically + +BOTFATHER_USERNAME = "BotFather" +BOT_TOKEN_PATTERN = re.compile(r"\d+:[A-Za-z0-9_-]+") + +# Timeouts +MESSAGE_TIMEOUT = 30 # seconds to wait for BotFather response +MAX_USERNAME_ATTEMPTS = 3 + + +# ============================================= +# CONFIG LOADER +# ============================================= + + +def _load_telethon_config() -> Optional[dict]: + """ + Load Telethon API credentials from .telethon_config.json. + + Expected format: + {"api_id": 12345, "api_hash": "abc123..."} + + Returns: + Dict with "api_id" (int) and "api_hash" (str), or None on failure. + """ + try: + if not TELETHON_CONFIG_PATH.exists(): + logger.warning("Telethon config not found: %s", TELETHON_CONFIG_PATH) + return None + + with open(TELETHON_CONFIG_PATH, "r", encoding="utf-8") as f: + config = json.load(f) + + api_id = config.get("api_id") + api_hash = config.get("api_hash") + + if not api_id or not api_hash: + logger.warning("Telethon config missing api_id or api_hash") + return None + + # Ensure api_id is an integer + config["api_id"] = int(api_id) + config["api_hash"] = str(api_hash) + + logger.info("Telethon config loaded successfully") + return config + + except (json.JSONDecodeError, ValueError, OSError) as e: + logger.warning("Failed to load Telethon config: %s", e) + return None + + +# ============================================= +# SETUP CHECK +# ============================================= + + +def check_telethon_setup() -> tuple[bool, str]: + """ + Check whether Telethon is ready for BotFather automation. + + Verifies: + 1. Telethon library is importable + 2. .telethon_config.json exists with valid credentials + 3. .telethon.session exists (phone auth already completed) + + Returns: + (True, "ready") if everything is in place. + (False, "reason") with a human-readable explanation of what is missing. + """ + if not TELETHON_AVAILABLE: + return (False, "Telethon library not installed. Run: pip install telethon") + + if not TELETHON_CONFIG_PATH.exists(): + return (False, f"Telethon config not found at {TELETHON_CONFIG_PATH}") + + config = _load_telethon_config() + if config is None: + return (False, "Telethon config is invalid (missing api_id or api_hash)") + + # Telethon creates session files with .session extension + session_file = Path(str(SESSION_PATH) + ".session") + if not session_file.exists(): + return (False, f"Telethon session not found at {session_file}. Run one-time phone auth first.") + + return (True, "ready") + + +# ============================================= +# HELPER FUNCTIONS +# ============================================= + + +def _format_display_name(branch_name: str) -> str: + """ + Convert a branch_name to a BotFather display name. + + Examples: + "dev_central" -> "AIPass Dev Central" + "flow" -> "AIPass Flow" + "vera" -> "AIPass Vera" + + Args: + branch_name: AIPass branch name (snake_case). + + Returns: + Display name string. + """ + title = branch_name.replace("_", " ").title() + return f"AIPass {title}" + + +def _format_username(branch_name: str, suffix: int = 0) -> str: + """ + Generate a BotFather username from a branch name. + + Examples: + ("dev_central", 0) -> "aipass_dev_central_bot" + ("dev_central", 1) -> "aipass_dev_central_1_bot" + ("dev_central", 2) -> "aipass_dev_central_2_bot" + + Args: + branch_name: AIPass branch name (snake_case). + suffix: Numeric suffix for retries (0 = no suffix). + + Returns: + Username string ending in _bot. + """ + if suffix == 0: + return f"aipass_{branch_name}_bot" + return f"aipass_{branch_name}_{suffix}_bot" + + +# ============================================= +# BOTFATHER CLIENT +# ============================================= + + +class BotFatherClient: + """ + Telethon-based client that automates bot creation via @BotFather. + + Uses Patrick's authenticated user session to send commands to BotFather + and parse the resulting bot token. + + Usage: + client = BotFatherClient(api_id=12345, api_hash="abc...") + result = await client.create_bot("dev_central") + # result = {"token": "123:ABC", "username": "aipass_dev_central_bot", "display_name": "AIPass Dev Central"} + """ + + def __init__(self, api_id: int, api_hash: str) -> None: + self._api_id = api_id + self._api_hash = api_hash + self._client: Any = None + + async def connect(self) -> bool: + """ + Connect to Telegram using the existing session file. + + The session file must already exist from a prior manual phone auth. + This method will NOT prompt for phone/code input. + + Returns: + True if connected and authorized, False otherwise. + """ + try: + from telethon import TelegramClient as _TelegramClient + + self._client = _TelegramClient( + str(SESSION_PATH), + self._api_id, + self._api_hash, + ) + await self._client.connect() + + if not await self._client.is_user_authorized(): + logger.warning("Telethon session exists but is not authorized. Re-run phone auth.") + await self._client.disconnect() + self._client = None + return False + + me = await self._client.get_me() + if me: + logger.info( + "Connected to Telegram as: %s (id=%s)", + getattr(me, "first_name", "?"), + getattr(me, "id", "?"), + ) + else: + logger.info("Connected to Telegram (could not resolve self)") + + return True + + except Exception as e: + logger.warning("Failed to connect to Telegram: %s", e) + self._client = None + return False + + async def disconnect(self) -> None: + """Disconnect from Telegram gracefully.""" + if self._client: + try: + await self._client.disconnect() + logger.info("Disconnected from Telegram") + except Exception as e: + logger.warning("Error during disconnect: %s", e) + finally: + self._client = None + + async def _send_and_wait(self, entity: Any, message: str) -> Optional[str]: + """ + Send a message to BotFather and wait for a response. + + Handles FloodWaitError by sleeping for the required duration and retrying once. + + Args: + entity: The BotFather entity to send to. + message: The text message to send. + + Returns: + BotFather's response text, or None on timeout/error. + """ + if not self._client: + logger.warning("_send_and_wait called without active client") + return None + + from telethon.errors import FloodWaitError as _FloodWaitError + from telethon.errors import RPCError as _RPCError + + try: + await self._client.send_message(entity, message) + logger.info("Sent to BotFather: %s", message) + except _FloodWaitError as e: + wait_seconds = e.seconds + logger.warning("FloodWaitError: waiting %d seconds before retry", wait_seconds) + await asyncio.sleep(wait_seconds) + try: + await self._client.send_message(entity, message) + logger.info("Sent to BotFather (after flood wait): %s", message) + except Exception as retry_err: + logger.warning("Failed to send after flood wait: %s", retry_err) + return None + except _RPCError as e: + logger.warning("RPC error sending to BotFather: %s", e) + return None + except Exception as e: + logger.warning("Unexpected error sending to BotFather: %s", e) + return None + + # Wait for BotFather's response + deadline = time.monotonic() + MESSAGE_TIMEOUT + # Brief pause to let BotFather process + await asyncio.sleep(1.5) + + try: + while time.monotonic() < deadline: + # Get the most recent messages from BotFather + messages = await self._client.get_messages(entity, limit=1) + if not messages: + await asyncio.sleep(1.0) + continue + # get_messages returns a list-like object + msg_list = list(messages) + if msg_list: + latest = msg_list[0] + # Check that this message is FROM BotFather (not our own) + if getattr(latest, "out", True) is False and getattr(latest, "text", None): + response_text: str = latest.text + logger.info("BotFather response received (%d chars)", len(response_text)) + return response_text + + # Poll interval + await asyncio.sleep(1.0) + + logger.warning("Timeout waiting for BotFather response (after %ds)", MESSAGE_TIMEOUT) + return None + + except Exception as e: + logger.warning("Error reading BotFather response: %s", e) + return None + + async def create_bot(self, branch_name: str) -> Optional[dict]: + """ + Create a new Telegram bot via @BotFather conversation. + + Conversation flow: + 1. /newbot + 2. Display name (e.g., "AIPass Dev Central") + 3. Username (e.g., "aipass_dev_central_bot") + 4. Parse token from success response + + If the username is taken, retries with numeric suffixes up to MAX_USERNAME_ATTEMPTS. + + Args: + branch_name: AIPass branch name (e.g., "dev_central", "flow"). + + Returns: + Dict with "token", "username", "display_name" on success. + None on any failure. + """ + if not self._client: + logger.warning("create_bot called without active connection") + return None + + display_name = _format_display_name(branch_name) + + # Resolve BotFather entity + try: + botfather = await self._client.get_entity(BOTFATHER_USERNAME) + logger.info("Resolved BotFather entity: %s", getattr(botfather, "id", "?")) + except Exception as e: + logger.warning("Failed to resolve @BotFather entity: %s", e) + return None + + # Step 1: Send /newbot + response = await self._send_and_wait(botfather, "/newbot") + if not response: + logger.warning("BotFather did not respond to /newbot") + return None + + # BotFather should ask for a name + if "name" not in response.lower(): + logger.warning("Unexpected BotFather response to /newbot: %s", response[:200]) + return None + + logger.info("BotFather asked for bot name") + + # Step 2: Send display name + response = await self._send_and_wait(botfather, display_name) + if not response: + logger.warning("BotFather did not respond to display name") + return None + + # BotFather should ask for a username + if "username" not in response.lower(): + logger.warning("Unexpected BotFather response to display name: %s", response[:200]) + return None + + logger.info("BotFather asked for username") + + # Step 3: Try usernames with incrementing suffix + for attempt in range(MAX_USERNAME_ATTEMPTS): + username = _format_username(branch_name, suffix=attempt) + logger.info("Trying username: %s (attempt %d/%d)", username, attempt + 1, MAX_USERNAME_ATTEMPTS) + + response = await self._send_and_wait(botfather, username) + if not response: + logger.warning("BotFather did not respond to username '%s'", username) + return None + + # Check if the username was accepted (token in response) + token_match = BOT_TOKEN_PATTERN.search(response) + if token_match: + token = token_match.group() + logger.info( + "Bot created successfully: @%s (token: %s...%s)", + username, token[:8], token[-4:], + ) + return { + "token": token, + "username": username, + "display_name": display_name, + } + + # Username taken - BotFather says "Sorry" or mentions "already" + if "sorry" in response.lower() or "already" in response.lower() or "taken" in response.lower(): + logger.info("Username '%s' is taken, trying next", username) + # If not the last attempt, BotFather is still waiting for a username + # so we can send another one directly without restarting /newbot + continue + + # Unexpected response + logger.warning( + "Unexpected BotFather response for username '%s': %s", + username, response[:200], + ) + return None + + logger.warning( + "All %d username attempts exhausted for branch '%s'", + MAX_USERNAME_ATTEMPTS, branch_name, + ) + return None + + +# ============================================= +# SYNCHRONOUS WRAPPER +# ============================================= + + +def create_bot_via_botfather(branch_name: str) -> Optional[dict]: + """ + Synchronous wrapper to create a Telegram bot via BotFather automation. + + This is the main entry point for stdlib-based callers (e.g., base_bot.py). + Loads config, connects via Telethon, drives the BotFather conversation, + and returns the result. + + Args: + branch_name: AIPass branch name (e.g., "dev_central"). + + Returns: + Dict with "token", "username", "display_name" on success. + None on any failure (config missing, connection failed, BotFather error, etc.). + """ + # Pre-flight checks + ready, reason = check_telethon_setup() + if not ready: + logger.warning("Telethon setup check failed: %s", reason) + return None + + # Load config + config = _load_telethon_config() + if config is None: + logger.warning("Cannot create bot: Telethon config not loaded") + return None + + api_id = config["api_id"] + api_hash = config["api_hash"] + + # Run the async flow + client = BotFatherClient(api_id, api_hash) + result = None + + async def _run() -> Optional[dict]: + connected = await client.connect() + if not connected: + return None + try: + return await client.create_bot(branch_name) + finally: + await client.disconnect() + + try: + # Handle the case where an event loop is already running + try: + loop = asyncio.get_running_loop() + except RuntimeError: + loop = None + + if loop and loop.is_running(): + # We're inside an existing event loop (unlikely for our stdlib callers, + # but handle gracefully). Create a new loop in a thread. + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + future = pool.submit(asyncio.run, _run()) + result = future.result(timeout=120) + else: + result = asyncio.run(_run()) + + except Exception as e: + logger.warning("create_bot_via_botfather failed: %s", e) + return None + + if result: + logger.info( + "Bot created via BotFather: @%s for branch '%s'", + result.get("username"), branch_name, + ) + else: + logger.warning("Bot creation via BotFather failed for branch '%s'", branch_name) + + return result diff --git a/src/aipass/api/apps/handlers/telegram/branch_plugin.py b/src/aipass/api/apps/handlers/telegram/branch_plugin.py new file mode 100644 index 00000000..b67fb613 --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/branch_plugin.py @@ -0,0 +1,166 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: branch_plugin.py - BranchPlugin extends BaseBot for per-branch bots +# Date: 2026-02-24 +# Version: 1.0.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-24): Initial - BranchPlugin with message prefixing and startup injection +# +# CODE STANDARDS: +# - Inherits Prax get_direct_logger() from BaseBot (FPLAN-0382 migration) +# - Extends BaseBot via hook overrides +# - No duplicated logic - all core behavior lives in BaseBot +# ============================================= + +""" +BranchPlugin - Per-branch Telegram bot extending BaseBot. + +Each AIPass branch gets its own dedicated Telegram bot. BranchPlugin overrides +BaseBot's hooks to: + - Prefix incoming messages with "Patrick via Telegram: " + - Prefix outgoing responses with "@branch_name" + - Inject "hi" on session creation to trigger the branch startup protocol + +Usage: + bot = BranchPlugin( + branch_name="dev_central", + bot_id="dev_central", + bot_token="123:ABC", + work_dir=Path("/home/aipass/aipass_os/dev_central"), + bot_name="AIPass Dev Central Bot", + allowed_user_ids=[7235222625], + ) + sys.exit(bot.run()) +""" + +# Infrastructure +import sys +from pathlib import Path + +# Standard library +import argparse +import json +import time + +# Sibling import +from aipass.api.apps.handlers.telegram.base_bot import BaseBot + + +# ============================================= +# BranchPlugin CLASS +# ============================================= + +class BranchPlugin(BaseBot): + """ + Per-branch Telegram bot that extends BaseBot with branch-specific behavior. + + Overrides BaseBot hooks to prefix messages, tag responses, and trigger + the AIPass startup protocol when a new tmux session is created. + """ + + def __init__(self, branch_name: str, **kwargs) -> None: + """ + Initialize BranchPlugin. + + Args: + branch_name: AIPass branch name (e.g., "dev_central", "seed") + **kwargs: All BaseBot constructor arguments (bot_id, bot_token, etc.) + """ + self.branch_name = branch_name + super().__init__(**kwargs) + + # ============================================= + # HOOK OVERRIDES + # ============================================= + + def on_message(self, text: str) -> str: + """ + Prefix incoming messages with sender attribution. + + Args: + text: Raw message text from Telegram + + Returns: + Prefixed text for Claude: "Patrick via Telegram: {text}" + """ + return f"Patrick via Telegram: {text}" + + def on_response(self, text: str) -> str: + """ + Prefix outgoing responses with branch tag. + + Args: + text: Raw response text from Claude + + Returns: + Tagged text: "@{branch_name}\n{text}" + """ + return f"@{self.branch_name}\n{text}" + + def on_session_create(self, session_name: str, work_dir: Path) -> None: + """ + Inject "hi" after tmux session creation to trigger startup protocol. + + Waits 2 seconds for Claude to fully initialize, then injects "hi" + which triggers the AIPass startup sequence (reading memories, etc.). + + Args: + session_name: The tmux session name that was created + work_dir: The working directory of the session + """ + self.logger.info( + "Branch session created for @%s, injecting startup greeting", + self.branch_name, + ) + time.sleep(2) + self.inject_message("hi") + + +# ============================================= +# CLI ENTRY POINT +# ============================================= + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="AIPass Telegram Branch Bot") + parser.add_argument("--bot-id", required=True, help="Bot identifier") + parser.add_argument("--config", help="Path to bot config JSON") + args = parser.parse_args() + + # Load config from ~/.aipass/telegram_bots/{bot_id}.json or --config path + config_path = ( + Path(args.config) + if args.config + else Path.home() / ".aipass" / "telegram_bots" / f"{args.bot_id}.json" + ) + + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + + # If config has "branch_name", create BranchPlugin; otherwise BaseBot + shared_session = config.get("shared_session") + + if config.get("branch_name"): + bot = BranchPlugin( + branch_name=config["branch_name"], + bot_id=args.bot_id, + bot_token=config["bot_token"], + work_dir=Path(config["work_dir"]), + bot_name=config.get("bot_name", f"AIPass {config['branch_name']} Bot"), + allowed_user_ids=config.get("allowed_user_ids", []), + shared_session=shared_session, + ) + else: + bot = BaseBot( + bot_id=args.bot_id, + bot_token=config["bot_token"], + work_dir=Path(config.get("work_dir", str(Path.home()))), + bot_name=config.get("bot_name", "AIPass Bot"), + allowed_user_ids=config.get("allowed_user_ids", []), + shared_session=shared_session, + ) + + sys.exit(bot.run()) diff --git a/src/aipass/api/apps/handlers/telegram/config.py b/src/aipass/api/apps/handlers/telegram/config.py new file mode 100644 index 00000000..bfbd0ccf --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/config.py @@ -0,0 +1,259 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: config.py - Telegram Configuration Handler +# Date: 2026-02-03 +# Version: 1.2.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.2.0 (2026-02-24): Add multi-bot config: load_bot_config, list_bot_configs, validate_bot_config +# - v1.1.0 (2026-02-03): Add allowed_user_ids loading for user allowlist +# - v1.0.0 (2026-02-03): Initial config loader for Telegram bridge +# +# CODE STANDARDS: +# - Pure functions with proper error raising +# - No Prax imports (handler tier 3) +# ============================================= + +""" +Telegram Configuration Handler + +Manages Telegram bot configuration: +- Load bot token from config file (legacy single-bot: ~/.aipass/telegram_config.json) +- Load bot username +- Load allowed user IDs for access control +- Load per-bot configs (multi-bot: ~/.aipass/telegram_bots/{bot_id}.json) +- List and validate bot configs +""" + +# Infrastructure +import sys +from pathlib import Path + +# Standard library +import json +from typing import Optional, List + +# ============================================= +# CONSTANTS +# ============================================= + +CONFIG_PATH = Path.home() / ".aipass" / "telegram_config.json" +BOT_CONFIG_DIR = Path.home() / ".aipass" / "telegram_bots" + +REQUIRED_BOT_FIELDS = ("bot_id", "bot_token") + +# ============================================= +# CONFIGURATION LOADING +# ============================================= + +def load_telegram_config() -> Optional[dict]: + """ + Load Telegram configuration from config file. + + Config file location: ~/.aipass/telegram_config.json + Expected structure: + { + "telegram_bot_token": "...", + "telegram_bot_username": "aipass_bridge_bot", + "allowed_user_ids": [] + } + + Returns: + Configuration dict or None if load fails + """ + try: + if not CONFIG_PATH.exists(): + return None + + with open(CONFIG_PATH, 'r', encoding='utf-8') as f: + config = json.load(f) + + return config + + except json.JSONDecodeError: + return None + except Exception: + return None + + +def get_bot_token() -> Optional[str]: + """ + Get Telegram bot token from config. + + Returns: + Bot token string or None if not found + """ + config = load_telegram_config() + if not config: + return None + + token = config.get("telegram_bot_token") + if not token: + return None + + return token + + +def get_bot_username() -> Optional[str]: + """ + Get Telegram bot username from config. + + Returns: + Bot username string or None if not found + """ + config = load_telegram_config() + if not config: + return None + + username = config.get("telegram_bot_username") + if not username: + return None + + return username + + +def get_allowed_user_ids() -> List[int]: + """ + Get list of allowed Telegram user IDs from config. + + Returns: + List of allowed user IDs. Empty list means allow all (for testing). + """ + config = load_telegram_config() + if not config: + return [] + + allowed = config.get("allowed_user_ids", []) + if not isinstance(allowed, list): + return [] + + return [int(uid) for uid in allowed if isinstance(uid, (int, str))] + + +def validate_config() -> bool: + """ + Validate that Telegram configuration is complete. + + Returns: + True if config is valid, False otherwise + """ + config = load_telegram_config() + if not config: + return False + + if not config.get("telegram_bot_token"): + return False + + return True + + +# ============================================= +# MULTI-BOT CONFIGURATION (per-bot configs) +# ============================================= + + +def load_bot_config(bot_id: str) -> dict | None: + """ + Load per-bot config from ~/.aipass/telegram_bots/{bot_id}.json. + + Config format: + { + "bot_id": "dev_central", + "bot_token": "123:ABC...", + "bot_name": "AIPass Dev Central Bot", + "branch_name": "dev_central", // null for base bot + "work_dir": "/home/aipass/aipass_os/dev_central", + "allowed_user_ids": [7235222625] + } + + Args: + bot_id: Bot identifier matching the config filename. + + Returns: + Config dict or None if not found/invalid. + """ + config_path = BOT_CONFIG_DIR / f"{bot_id}.json" + + try: + if not config_path.exists(): + return None + + with open(config_path, 'r', encoding='utf-8') as f: + config = json.load(f) + + if not isinstance(config, dict): + return None + + return config + + except json.JSONDecodeError: + return None + except OSError: + return None + + +def list_bot_configs() -> list[str]: + """ + List all bot config files (returns list of bot_ids). + + Scans ~/.aipass/telegram_bots/ for .json files, excluding + internal files that start with underscore (e.g., _registry.json). + + Returns: + List of bot_id strings derived from config filenames. + """ + if not BOT_CONFIG_DIR.exists(): + return [] + + bot_ids = [] + try: + for path in sorted(BOT_CONFIG_DIR.glob("*.json")): + # Skip internal files (e.g., _registry.json) + if path.stem.startswith("_"): + continue + bot_ids.append(path.stem) + except OSError: + return [] + + return bot_ids + + +def validate_bot_config(config: object) -> tuple[bool, str]: + """ + Validate a bot config dict. + + Checks for required fields and basic type correctness. + + Args: + config: Bot config dict to validate. + + Returns: + Tuple of (valid, error_message). error_message is empty on success. + """ + if not isinstance(config, dict): + return False, "Config must be a dict" + + # Check required fields + for field in REQUIRED_BOT_FIELDS: + if not config.get(field): + return False, f"Missing required field: {field}" + + # Type checks + bot_token = config.get("bot_token", "") + if not isinstance(bot_token, str) or ":" not in bot_token: + return False, "bot_token must be a string in format 'id:hash'" + + if "work_dir" in config and config["work_dir"] is not None: + work_dir = Path(config["work_dir"]) + if not work_dir.is_absolute(): + return False, "work_dir must be an absolute path" + + if "allowed_user_ids" in config: + allowed = config["allowed_user_ids"] + if not isinstance(allowed, list): + return False, "allowed_user_ids must be a list" + + return True, "" diff --git a/src/aipass/api/apps/handlers/telegram/file_handler.py b/src/aipass/api/apps/handlers/telegram/file_handler.py new file mode 100644 index 00000000..6c05423f --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/file_handler.py @@ -0,0 +1,221 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: file_handler.py - Telegram File Upload Handler +# Date: 2026-02-10 +# Version: 1.0.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-10): Initial file handler - photo, document, file upload support (Phase 5 FPLAN-0312) +# +# CODE STANDARDS: +# - Pure functions with proper error raising +# - No Prax imports (handler tier 3) +# ============================================= + +""" +Telegram File Upload Handler + +Handles file uploads from Telegram: +- Downloads files to temp directory +- Detects file type (text, image, pdf, binary) +- Builds Claude prompts with file content inline or path reference +- Cleans up temp files after processing +""" + +# Infrastructure +import sys +from pathlib import Path + +import uuid + + +# Constants +TEMP_DIR = Path('/tmp/telegram_uploads') +MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB +TEXT_CONTENT_LIMIT = 50000 + +# Text file extensions that can be read as UTF-8 +SUPPORTED_TEXT_EXTENSIONS = { + '.py', '.js', '.ts', '.java', '.go', '.rs', '.rb', '.php', '.c', '.cpp', + '.h', '.hpp', '.cs', '.swift', '.kt', + '.sh', '.bash', '.zsh', '.sql', '.html', '.css', '.scss', + '.json', '.yaml', '.yml', '.xml', '.toml', '.ini', '.cfg', + '.md', '.txt', '.rst', '.log', '.csv', '.env', '.gitignore', '.dockerfile', +} + +# Image file extensions +IMAGE_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp', '.svg'} + +# Map file extensions to language names for code blocks +LANGUAGE_MAP = { + '.py': 'python', '.js': 'javascript', '.ts': 'typescript', '.java': 'java', + '.go': 'go', '.rs': 'rust', '.rb': 'ruby', '.php': 'php', '.c': 'c', + '.cpp': 'cpp', '.h': 'c', '.cs': 'csharp', '.swift': 'swift', '.kt': 'kotlin', + '.sh': 'bash', '.bash': 'bash', '.sql': 'sql', '.html': 'html', '.css': 'css', + '.json': 'json', '.yaml': 'yaml', '.yml': 'yaml', '.xml': 'xml', + '.toml': 'toml', '.md': 'markdown', +} + + +def _sanitize_filename(raw_filename: str) -> str: + """ + Sanitize a filename by removing path separators and dangerous characters. + + Args: + raw_filename: The original filename to sanitize + + Returns: + A safe filename string + """ + safe_name = Path(raw_filename).name + safe_name = "".join(c if c.isalnum() or c in '.-_' else '_' for c in safe_name) + return safe_name or str(uuid.uuid4()) + + +async def download_telegram_file(file_obj, filename: str | None = None) -> Path: + """ + Download a Telegram file to the temp directory. + + Args: + file_obj: Telegram File object (from get_file()) + filename: Optional original filename + + Returns: + Path to the downloaded file + + Raises: + ValueError: If file exceeds MAX_FILE_SIZE + """ + if file_obj.file_size and file_obj.file_size > MAX_FILE_SIZE: + raise ValueError( + f"File too large: {file_obj.file_size} bytes " + f"(max {MAX_FILE_SIZE // (1024 * 1024)}MB)" + ) + + TEMP_DIR.mkdir(parents=True, exist_ok=True) + + if filename: + safe_name = _sanitize_filename(filename) + else: + ext = '' + if file_obj.file_path: + ext = Path(file_obj.file_path).suffix + safe_name = f"{uuid.uuid4()}{ext}" + + dest = TEMP_DIR / safe_name + await file_obj.download_to_drive(dest) + print("[INFO]", "Downloaded file to %s (%s bytes)", dest, file_obj.file_size) + return dest + + +def detect_file_type(file_path: Path) -> str: + """ + Detect the type of a file based on extension and content. + + Args: + file_path: Path to the file + + Returns: + One of: 'text', 'image', 'pdf', 'binary' + """ + suffix = file_path.suffix.lower() + + if suffix in SUPPORTED_TEXT_EXTENSIONS: + return 'text' + + if suffix in IMAGE_EXTENSIONS: + return 'image' + + if suffix == '.pdf': + return 'pdf' + + # Unknown extension - try reading as UTF-8 + try: + with open(file_path, 'rb') as f: + sample = f.read(1024) + sample.decode('utf-8') + return 'text' + except (UnicodeDecodeError, OSError): + return 'binary' + + +def build_file_prompt( + file_path: Path, + file_type: str, + caption: str | None = None, + sender_name: str = 'Patrick' +) -> str: + """ + Build a Claude prompt that includes file content. + + Args: + file_path: Path to the downloaded file + file_type: One of 'text', 'image', 'pdf', 'binary' + caption: Optional caption from the Telegram message + sender_name: Name of the sender + + Returns: + Formatted prompt string for Claude + """ + FILE_NAME = file_path.name + + if file_type == 'text': + try: + FILE_CONTENT = file_path.read_text(encoding='utf-8', errors='ignore') + except OSError: + FILE_CONTENT = '[Error reading file]' + + if len(FILE_CONTENT) > TEXT_CONTENT_LIMIT: + FILE_CONTENT = FILE_CONTENT[:TEXT_CONTENT_LIMIT] + '\n[...truncated]' + + FILE_SUFFIX = file_path.suffix.lower() + FILE_LANGUAGE = LANGUAGE_MAP.get(FILE_SUFFIX, '') + + PROMPT_CAPTION = caption or 'Review this file' + return ( + f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n" + f"File: {FILE_NAME}\n\n" + f"```{FILE_LANGUAGE}\n{FILE_CONTENT}\n```" + ) + + elif file_type == 'image': + PROMPT_CAPTION = caption or 'What do you see in this image?' + return ( + f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n" + f"[Image attached at: {file_path}]\n" + f"Please use the Read tool to view the image file at the path above." + ) + + elif file_type == 'pdf': + PROMPT_CAPTION = caption or 'Review this document' + return ( + f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n" + f"[PDF document at: {file_path}]\n" + f"Please use the Read tool to view the PDF file at the path above." + ) + + else: # binary + try: + FILE_SIZE = file_path.stat().st_size + except OSError: + FILE_SIZE = 0 + PROMPT_CAPTION = caption or 'I sent a file' + return ( + f"{sender_name} via Telegram: {PROMPT_CAPTION}\n\n" + f"[File at: {file_path}] (binary, {FILE_SIZE} bytes)\n" + f"Note: This is a binary file that may not be directly readable." + ) + + +def cleanup_file(file_path: Path) -> None: + """ + Remove a temporary file. + + Args: + file_path: Path to the file to clean up + """ + file_path.unlink(missing_ok=True) + print("[INFO]", "Cleaned up temp file: %s", file_path) diff --git a/src/aipass/api/apps/handlers/telegram/log_streamer.py b/src/aipass/api/apps/handlers/telegram/log_streamer.py new file mode 100644 index 00000000..47f88a60 --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/log_streamer.py @@ -0,0 +1,256 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: log_streamer.py - Stream system logs to Telegram +# Date: 2026-02-26 +# Version: 1.0.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-26): Initial - daemon thread log tailing with batched Telegram delivery +# +# CODE STANDARDS: +# - Uses Prax get_direct_logger() — no event pipeline (FPLAN-0382 migration) +# - Silent failure on Telegram errors (log warning, never crash) +# - Handlers implement logic, modules orchestrate +# ============================================= + +""" +LogStreamer - Stream system log lines to a Telegram chat. + +v1.0.0 + +Runs as a background daemon thread, tailing log files for a specific branch +and batching new lines to send via the Telegram Bot API. Tracks file positions +to only deliver new content, handles file rotation, and discovers new log files +each cycle. + +Usage: + streamer = LogStreamer(bot_token="...", chat_id=123456, branch_name="api") + streamer.start() + # ... later ... + streamer.stop() +""" + +# Infrastructure +import sys +from pathlib import Path + +# Standard library +import json +import threading +from typing import Dict, List +from urllib.error import URLError +from urllib.request import Request, urlopen + +# Logging (Prax direct logger — FPLAN-0382, no event pipeline) +from aipass.prax.apps.modules.logger import get_direct_logger + +# ============================================= +# CONSTANTS +# ============================================= + +SYSTEM_LOGS_DIR = Path("/home/aipass/system_logs") +BATCH_INTERVAL = 5.0 +TELEGRAM_MAX_LENGTH = 4000 + + +# ============================================= +# LOG STREAMER +# ============================================= + + +class LogStreamer: + """Stream system log lines for a branch to Telegram via batched sends.""" + + def __init__(self, bot_token: str, chat_id: int, branch_name: str) -> None: + self.bot_token = bot_token + self.chat_id = chat_id + self.branch_name = branch_name + + self._running = False + self._stop_event = threading.Event() + self._thread: threading.Thread | None = None + self.log_positions: Dict[str, int] = {} + + # Direct logger (no event pipeline — avoids recursion with log tailing) + self.logger = get_direct_logger() + + # Initialize positions to end of all existing log files + self._init_positions() + + # ----------------------------------------- + # POSITION TRACKING + # ----------------------------------------- + + def _get_log_files(self) -> List[Path]: + """Find all log files matching this branch's pattern.""" + if not SYSTEM_LOGS_DIR.exists(): + return [] + return sorted(SYSTEM_LOGS_DIR.glob(f"{self.branch_name}_*.log")) + + def _init_positions(self) -> None: + """Set initial positions to end of file so we only tail new lines.""" + for log_file in self._get_log_files(): + file_path = str(log_file) + try: + self.log_positions[file_path] = log_file.stat().st_size + except OSError: + self.log_positions[file_path] = 0 + self.logger.info( + "Initialized positions for %d log files (branch: %s)", + len(self.log_positions), self.branch_name + ) + + def _read_new_lines(self) -> List[str]: + """Read new lines from all tracked log files.""" + all_new_lines: List[str] = [] + + for log_file in self._get_log_files(): + file_path = str(log_file) + + try: + current_size = log_file.stat().st_size + except OSError: + continue + + last_pos = self.log_positions.get(file_path, 0) + + try: + # New file discovered mid-run: start from beginning + if file_path not in self.log_positions: + last_pos = 0 + self.logger.info("New log file discovered: %s", file_path) + + # File rotation: size shrank, reset to beginning + if current_size < last_pos: + self.logger.info("File rotation detected: %s", file_path) + last_pos = 0 + + # Read new content + if current_size > last_pos: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + f.seek(last_pos) + new_content = f.read() + self.log_positions[file_path] = f.tell() + + lines = new_content.splitlines() + if lines: + all_new_lines.extend(lines) + else: + # Update position even when nothing new (handles new file registration) + self.log_positions[file_path] = current_size + except OSError as e: + self.logger.warning("Failed to process %s: %s", file_path, e) + continue + + return all_new_lines + + # ----------------------------------------- + # TELEGRAM DELIVERY + # ----------------------------------------- + + def _send_message(self, message: str) -> bool: + """Send a message to Telegram. Returns True on success.""" + url = f"https://api.telegram.org/bot{self.bot_token}/sendMessage" + payload = json.dumps({ + "chat_id": self.chat_id, + "text": message, + "disable_notification": True + }).encode("utf-8") + req = Request(url, data=payload, headers={"Content-Type": "application/json"}) + + try: + with urlopen(req, timeout=10) as resp: + result = json.loads(resp.read()) + return result.get("ok", False) + except (URLError, Exception) as e: + self.logger.warning("Telegram send failed: %s", e) + return False + + def _send_batched(self, lines: List[str]) -> None: + """Split lines into messages respecting TELEGRAM_MAX_LENGTH, send each.""" + if not lines: + return + + batch: List[str] = [] + batch_len = 0 + + for line in lines: + # +1 for the newline separator between lines + line_len = len(line) + (1 if batch else 0) + + if batch_len + line_len > TELEGRAM_MAX_LENGTH and batch: + # Send current batch + message = "\n".join(batch) + self._send_message(message) + batch = [] + batch_len = 0 + + batch.append(line) + batch_len += line_len + + # Send remaining + if batch: + message = "\n".join(batch) + self._send_message(message) + + # ----------------------------------------- + # DAEMON THREAD + # ----------------------------------------- + + def _run(self) -> None: + """Main loop: read new lines, batch, send, sleep.""" + self.logger.info("Log streamer started for branch: %s", self.branch_name) + self.logger.info("Watching: %s/%s_*.log (chat_id=%s)", SYSTEM_LOGS_DIR, self.branch_name, self.chat_id) + + while self._running: + try: + new_lines = self._read_new_lines() + if new_lines: + self.logger.info("Found %d new log lines, sending to Telegram", len(new_lines)) + self._send_batched(new_lines) + except Exception as e: + self.logger.warning("Streamer cycle error: %s", e) + + # Interruptible sleep + self._stop_event.wait(BATCH_INTERVAL) + + self.logger.info("Log streamer stopped for branch: %s", self.branch_name) + + # ----------------------------------------- + # PUBLIC API + # ----------------------------------------- + + def start(self) -> None: + """Start the log streamer daemon thread.""" + if self._running: + self.logger.warning("Log streamer already running") + return + + self._running = True + self._stop_event.clear() + self._thread = threading.Thread( + target=self._run, + name=f"log-streamer-{self.branch_name}", + daemon=True + ) + self._thread.start() + self.logger.info("Daemon thread started: %s", self._thread.name) + + def stop(self) -> None: + """Stop the log streamer and wait for thread to finish.""" + if not self._running: + return + + self._running = False + self._stop_event.set() + + if self._thread is not None: + self._thread.join(timeout=BATCH_INTERVAL + 2) + if self._thread.is_alive(): + self.logger.warning("Daemon thread did not exit cleanly") + self._thread = None + + self.logger.info("Log streamer stopped") diff --git a/src/aipass/api/apps/handlers/telegram/notifier.py b/src/aipass/api/apps/handlers/telegram/notifier.py new file mode 100644 index 00000000..a4219d9b --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/notifier.py @@ -0,0 +1,115 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: notifier.py - Telegram Push Notifications +# Date: 2026-02-17 +# Version: 1.1.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2026-02-18): DPLAN-005 - Add silent mode, CLI interface for cross-branch use, markdown support +# - v1.0.0 (2026-02-17): Initial implementation - reusable Telegram notification sender +# +# CODE STANDARDS: +# - Handlers implement logic, modules orchestrate +# - No cross-branch imports, no Prax logger +# ============================================= + +""" +Telegram notification sender for the scheduler bot. + +Can be used two ways: +1. Import (within API branch): send_telegram_notification("message") +2. CLI (cross-branch, no import guard): python3 notifier.py "message" + Flags: --silent (silent push), --markdown (Markdown parse mode) + +Reads bot token and chat_id from ~/.aipass/scheduler_config.json. +""" + +# Infrastructure +import sys +from pathlib import Path + +# Standard library +import json +from urllib.request import Request, urlopen +from urllib.error import URLError + +# ============================================= +# CONSTANTS +# ============================================= + +CONFIG_PATH = Path.home() / ".aipass" / "scheduler_config.json" + +# ============================================= +# PUBLIC API +# ============================================= + + +def send_telegram_notification( + message: str, + silent: bool = False, + parse_mode: str | None = None, +) -> bool: + """ + Send a message to Telegram via the scheduler bot. + + Args: + message: Text to send (plain text or Markdown) + silent: If True, send as silent notification (no sound on phone) + parse_mode: Telegram parse mode ("Markdown" or "HTML"). None for plain text. + + Returns: + True if sent successfully, False otherwise + """ + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + config = json.load(f) + bot_token = config["telegram_bot_token"] + chat_id = config["telegram_chat_id"] + except (FileNotFoundError, KeyError, json.JSONDecodeError): + return False + + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + payload_dict: dict[str, object] = {"chat_id": chat_id, "text": message} + if silent: + payload_dict["disable_notification"] = True + if parse_mode: + payload_dict["parse_mode"] = parse_mode + + data = json.dumps(payload_dict).encode("utf-8") + req = Request(url, data=data, headers={"Content-Type": "application/json"}) + + try: + with urlopen(req, timeout=15) as resp: + result = json.loads(resp.read()) + return result.get("ok", False) + except (URLError, Exception): + return False + + +# ============================================= +# CLI INTERFACE (cross-branch use) +# ============================================= + +if __name__ == "__main__": + args = sys.argv[1:] + if not args or args[0] in ("-h", "--help"): + print("Usage: python3 notifier.py [--silent] [--markdown] \"message\"") + print(" --silent Send as silent notification (no sound)") + print(" --markdown Use Telegram Markdown parse mode") + sys.exit(0) + + silent_flag = "--silent" in args + markdown_flag = "--markdown" in args + msg_args = [a for a in args if not a.startswith("--")] + + if not msg_args: + print("Error: no message provided", file=sys.stderr) + sys.exit(1) + + msg = " ".join(msg_args) + mode = "Markdown" if markdown_flag else None + ok = send_telegram_notification(msg, silent=silent_flag, parse_mode=mode) + sys.exit(0 if ok else 1) diff --git a/src/aipass/api/apps/handlers/telegram/output_parser.py(disabled) b/src/aipass/api/apps/handlers/telegram/output_parser.py(disabled) new file mode 100644 index 00000000..271349d9 --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/output_parser.py(disabled) @@ -0,0 +1,158 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: output_parser.py - Claude Stream JSON Output Parser +# Date: 2026-02-10 +# Version: 1.0.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-10): Initial - parse stream-json from Claude CLI +# +# CODE STANDARDS: +# - Pure functions with proper error raising +# - No Prax imports (handler tier 3) +# ============================================= + +""" +Claude Stream JSON Output Parser + +Parses newline-delimited JSON output from Claude CLI when invoked with +--output-format stream-json --verbose. + +Output format (3 line types): + 1. system/init - session_id, model, tools + 2. assistant - message content blocks (text, tool_use) + 3. result - success/error, session_id, cost, duration +""" + +import sys +import json +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + +# Infrastructure +AIPASS_ROOT = Path.home() / "aipass_core" +sys.path.insert(0, str(AIPASS_ROOT)) + +logger = logging.getLogger("telegram_output_parser") + + +@dataclass +class ParsedResult: + """Structured result from parsing Claude stream-json output.""" + success: bool + text: str + session_id: Optional[str] = None + cost_usd: Optional[float] = None + duration_ms: Optional[int] = None + error_message: Optional[str] = None + + +class OutputParser: + """Parse Claude CLI stream-json output into structured results.""" + + @staticmethod + def parse_stream(raw_output: str) -> ParsedResult: + """ + Parse raw stream-json output from Claude CLI. + + Splits output by newlines, parses each line as JSON, and extracts: + - Text from assistant message content blocks (type='text' only) + - session_id from result line + - cost from result line total_cost_usd + - Error detection from result line is_error / subtype='error' + + Falls back to returning raw text if JSON parsing fails entirely. + + Args: + raw_output: Raw stdout from Claude CLI with --output-format stream-json + + Returns: + ParsedResult with extracted fields + """ + if not raw_output or not raw_output.strip(): + return ParsedResult(success=False, text="", error_message="Empty output") + + text_parts: List[str] = [] + session_id: Optional[str] = None + cost_usd: Optional[float] = None + duration_ms: Optional[int] = None + is_error = False + error_message: Optional[str] = None + parsed_any = False + + for line in raw_output.strip().splitlines(): + line = line.strip() + if not line: + continue + + try: + data = json.loads(line) + except json.JSONDecodeError: + logger.info("Skipping non-JSON line: %s", line[:100]) + continue + + parsed_any = True + line_type = data.get("type") + + if line_type == "assistant": + # Extract text from content blocks + message = data.get("message", {}) + content_blocks = message.get("content", []) + for block in content_blocks: + if block.get("type") == "text": + text_parts.append(block.get("text", "")) + + elif line_type == "result": + # Extract session_id, cost, duration, error status + session_id = data.get("session_id", session_id) + cost_usd = data.get("total_cost_usd", cost_usd) + duration_ms = data.get("duration_ms", duration_ms) + + if data.get("is_error", False) or data.get("subtype") == "error": + is_error = True + error_message = data.get("result", "Unknown error") + + elif line_type == "system": + # Init line - capture session_id as fallback + if not session_id: + session_id = data.get("session_id") + + # If we couldn't parse any JSON at all, fall back to raw text + if not parsed_any: + logger.warning("No valid JSON lines found, returning raw output") + return ParsedResult(success=True, text=raw_output.strip()) + + combined_text = "\n".join(text_parts) if text_parts else "" + + if is_error: + return ParsedResult( + success=False, + text=combined_text, + session_id=session_id, + cost_usd=cost_usd, + duration_ms=duration_ms, + error_message=error_message, + ) + + if not combined_text: + return ParsedResult( + success=False, + text="", + session_id=session_id, + cost_usd=cost_usd, + duration_ms=duration_ms, + error_message="No text content in response", + ) + + return ParsedResult( + success=True, + text=combined_text, + session_id=session_id, + cost_usd=cost_usd, + duration_ms=duration_ms, + ) diff --git a/src/aipass/api/apps/handlers/telegram/response_router.py b/src/aipass/api/apps/handlers/telegram/response_router.py new file mode 100644 index 00000000..e626f2e0 --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/response_router.py @@ -0,0 +1,339 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: response_router.py - CWD-safe response routing for multi-bot architecture +# Date: 2026-02-24 +# Version: 1.0.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-24): Initial - CWD-safe pending file matching with directory tree resolution +# +# CODE STANDARDS: +# - Pure functions with proper error handling (graceful - never raise) +# - Uses Prax system_logger (FPLAN-0382 migration) +# - Stdlib only (no external deps) +# ============================================= + +""" +CWD-Safe Response Routing for Multi-Bot Architecture + +Fixes the CWD mismatch bug in the Stop hook. When Claude fires the Stop hook, +the working directory may be a subdirectory of the branch root (e.g., +/home/aipass/aipass_os/dev_central/git_repo/ instead of +/home/aipass/aipass_os/dev_central/). The old logic used Path.cwd().name +which fails in subdirectories. + +New logic uses cwd.relative_to(work_dir) which succeeds if CWD is ANYWHERE +in the bot's directory tree. + +Pending file naming: +- v2 (new): bot-{bot_id}.json +- v1 (legacy): telegram-{branch_name}.json +Both formats are supported during the transition period. +""" + +# Infrastructure +import sys +from pathlib import Path + +# Standard library +import json +import os +import subprocess +import time +from typing import Optional + +# Logging (Prax system_logger — FPLAN-0382) +from aipass.prax.apps.modules.logger import system_logger as logger + +# ============================================= +# CONSTANTS +# ============================================= + +PENDING_DIR = Path.home() / ".aipass" / "telegram_pending" +PENDING_TTL = 3600 # 1 hour + + +# ============================================= +# DIRECTORY TREE MATCHING +# ============================================= + + +def is_cwd_in_tree(cwd: Path, work_dir) -> bool: + """ + Check if cwd is within work_dir's directory tree using relative_to(). + + This is the core fix for the CWD mismatch bug. Instead of comparing + directory names (which fails in subdirectories), we check if cwd is + a child of work_dir at any depth. + + Args: + cwd: Current working directory to check. + work_dir: Bot's configured working directory (str or Path). + + Returns: + True if cwd is within work_dir's tree, False otherwise. + """ + try: + cwd.relative_to(Path(work_dir)) + return True + except ValueError: + return False + + +# ============================================= +# TMUX SESSION CHECKING +# ============================================= + + +def is_tmux_alive(session_name: str) -> bool: + """ + Check if a tmux session exists. + + Args: + session_name: Name of the tmux session to check. + + Returns: + True if the session exists, False otherwise. + """ + try: + result = subprocess.run( + ["tmux", "has-session", "-t", session_name], + capture_output=True, + text=True, + timeout=5, + ) + return result.returncode == 0 + except (subprocess.TimeoutExpired, OSError): + return False + + +# ============================================= +# PENDING FILE EXPIRY +# ============================================= + + +def is_pending_expired(pending_data: dict) -> bool: + """ + Check if a pending file is expired. + + A pending file is considered expired only when BOTH conditions are met: + 1. The timestamp is older than PENDING_TTL seconds + 2. The associated tmux session is no longer alive + + This prevents premature cleanup of pending files for long-running sessions. + + Args: + pending_data: Parsed contents of a pending file. + + Returns: + True if the pending file should be cleaned up, False otherwise. + """ + # Condition 1: Check TTL + timestamp = pending_data.get("timestamp", 0) + if isinstance(timestamp, str): + try: + timestamp = float(timestamp) + except ValueError: + timestamp = 0 + + if time.time() - timestamp <= PENDING_TTL: + return False # Still within TTL, not expired + + # Condition 2: Check tmux session + # Derive session name from bot_id or branch_name + bot_id = pending_data.get("bot_id", "") + branch_name = pending_data.get("branch_name", "") + + # Try the bot_id-based tmux session name first (v2) + if bot_id: + if is_tmux_alive(f"telegram-{bot_id}"): + return False # Session alive, not expired + + # Try the branch-based tmux session name (v1) + if branch_name and branch_name != bot_id: + if is_tmux_alive(f"telegram-{branch_name}"): + return False # Session alive, not expired + + # Past TTL AND no tmux session alive + return True + + +# ============================================= +# PENDING FILE LOADING +# ============================================= + + +def _load_pending_file(pending_path: Path) -> Optional[dict]: + """ + Load and parse a pending file from disk. + + Args: + pending_path: Path to the pending JSON file. + + Returns: + Parsed dict with "pending_path" key added, or None on error. + """ + try: + data = json.loads(pending_path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + return None + data["pending_path"] = str(pending_path) + return data + except (json.JSONDecodeError, OSError): + return None + + +# ============================================= +# MAIN ROUTING LOGIC +# ============================================= + + +def find_pending_bot( + cwd: Optional[Path] = None, + session_id: Optional[str] = None, + env_bot_id: Optional[str] = None, +) -> Optional[dict]: + """ + Find which bot's pending file matches the current context. + + Uses a priority-based matching strategy: + + Priority 1: AIPASS_BOT_ID env var (set in tmux session by BaseBot) + Direct match: look for bot-{env_bot_id}.json + + Priority 2: cwd.relative_to(work_dir) - CWD anywhere in bot's directory tree + Load each pending file, check if cwd is within its work_dir + + Priority 3: session_id match - fallback for legacy compatibility + Check session_id field in each pending file + + Args: + cwd: Current working directory. Defaults to Path.cwd(). + session_id: Claude Code session ID for fallback matching. + env_bot_id: Bot ID from environment. Defaults to AIPASS_BOT_ID env var. + + Returns: + Pending file data dict with "pending_path" key, or None if no match. + """ + if not PENDING_DIR.exists(): + return None + + if cwd is None: + try: + cwd = Path.cwd() + except OSError: + cwd = Path.home() + + if env_bot_id is None: + env_bot_id = os.environ.get("AIPASS_BOT_ID") + + # Priority 1: Direct match via AIPASS_BOT_ID env var + if env_bot_id: + # v2 naming: bot-{bot_id}.json + PENDING_V2 = PENDING_DIR / f"bot-{env_bot_id}.json" + if PENDING_V2.exists(): + data = _load_pending_file(PENDING_V2) + if data and not is_pending_expired(data): + logger.info("Matched pending by AIPASS_BOT_ID: %s", env_bot_id) + return data + + # Also check v1 naming for this bot_id + PENDING_V1 = PENDING_DIR / f"telegram-{env_bot_id}.json" + if PENDING_V1.exists(): + data = _load_pending_file(PENDING_V1) + if data and not is_pending_expired(data): + logger.info("Matched pending by AIPASS_BOT_ID (v1 naming): %s", env_bot_id) + return data + + # Priority 2: CWD directory tree matching + # Check all pending files and see if CWD is within any bot's work_dir + ALL_PENDING = list(PENDING_DIR.glob("bot-*.json")) + list(PENDING_DIR.glob("telegram-*.json")) + + for pending_path in ALL_PENDING: + data = _load_pending_file(pending_path) + if not data: + continue + + if is_pending_expired(data): + continue + + work_dir = data.get("work_dir", "") + if work_dir and is_cwd_in_tree(cwd, work_dir): + logger.info("Matched pending by CWD tree: cwd=%s within work_dir=%s", cwd, work_dir) + return data + + # Legacy v1 files may not have work_dir - try branch_name directory matching + branch_name = data.get("branch_name", "") + if branch_name and not work_dir: + # CWD's directory name or any parent matches branch_name + path_cursor = cwd + while path_cursor != path_cursor.parent: + if path_cursor.name == branch_name: + logger.info("Matched pending by branch name in CWD path: %s", branch_name) + return data + path_cursor = path_cursor.parent + + # Priority 3: Session ID fallback + if session_id: + for pending_path in ALL_PENDING: + data = _load_pending_file(pending_path) + if not data: + continue + + if is_pending_expired(data): + continue + + if data.get("session_id") == session_id: + logger.info("Matched pending by session_id: %s", session_id[:8]) + return data + + return None + + +# ============================================= +# CLEANUP +# ============================================= + + +def clean_expired_pending() -> int: + """ + Remove all expired pending files from the pending directory. + + A file is expired when it is past TTL AND its tmux session is dead. + + Returns: + Number of expired files removed. + """ + if not PENDING_DIR.exists(): + return 0 + + REMOVED_COUNT = 0 + ALL_PENDING = list(PENDING_DIR.glob("bot-*.json")) + list(PENDING_DIR.glob("telegram-*.json")) + + for pending_path in ALL_PENDING: + data = _load_pending_file(pending_path) + if not data: + # Corrupt or unreadable file - remove it + try: + pending_path.unlink(missing_ok=True) + REMOVED_COUNT += 1 + logger.info("Removed corrupt pending file: %s", pending_path.name) + except OSError as e: + logger.warning("Failed to remove corrupt pending file %s: %s", pending_path.name, e) + continue + + if is_pending_expired(data): + try: + pending_path.unlink(missing_ok=True) + REMOVED_COUNT += 1 + logger.info("Removed expired pending file: %s", pending_path.name) + except OSError as e: + logger.warning("Failed to remove expired pending file %s: %s", pending_path.name, e) + + if REMOVED_COUNT > 0: + logger.info("Cleaned %d expired pending file(s)", REMOVED_COUNT) + + return REMOVED_COUNT diff --git a/src/aipass/api/apps/handlers/telegram/spawner.py(disabled) b/src/aipass/api/apps/handlers/telegram/spawner.py(disabled) new file mode 100644 index 00000000..724375b1 --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/spawner.py(disabled) @@ -0,0 +1,574 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: spawner.py - Claude Session Spawner +# Date: 2026-02-03 +# Version: 4.3.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v4.3.0 (2026-02-12): Branch targeting - @branch prefix routes Claude to branch CWD +# - v4.2.0 (2026-02-10): Add raw_prompt flag to run_claude_capture (Phase 5 FPLAN-0312) +# - v4.1.0 (2026-02-10): Session persistence - use stored session_id for resume, deterministic as fallback +# - v4.0.0 (2026-02-10): Stream JSON output parsing - structured results with session_id, cost +# - v3.2.0 (2026-02-10): Security fix - subprocess_exec for capture mode (no shell injection) +# - v3.1.0 (2026-02-09): Fix session ID collision - random UUID for new sessions, deterministic for resume only +# +# CODE STANDARDS: +# - Pure functions with proper error raising +# - No Prax imports (handler tier 3) +# ============================================= + +""" +Claude Session Spawner + +Two modes of operation: +1. Visual mode: Spawn visible sessions (gnome-terminal/tmux) - fire and forget +2. Capture mode: Run Claude directly and capture stdout for response + +Supports branch targeting: messages starting with @branch_name spawn Claude +in that branch's directory. Default: /home/aipass/aipass_os/dev_central. +""" + +import sys +import os +import re +import json +import subprocess +import shutil +import asyncio +import uuid +from pathlib import Path +from typing import Tuple, Optional, List + +# Infrastructure +AIPASS_ROOT = Path.home() / "aipass_core" +sys.path.insert(0, str(AIPASS_ROOT)) + +import logging +logger = logging.getLogger("telegram_spawner") + +from api.apps.handlers.telegram.output_parser import OutputParser, ParsedResult +from api.apps.handlers.telegram.session_store import get_session, save_session + +# Constants +DEFAULT_SESSION_PATH = Path.home() / "aipass_os" / "dev_central" +BRANCH_REGISTRY_PATH = Path.home() / "BRANCH_REGISTRY.json" +CLAUDE_BIN = str(Path.home() / ".local" / "bin" / "claude") +TMUX_SESSION_NAME = "telegram-claude" +TELEGRAM_CHAR_LIMIT = 4096 +DEFAULT_TIMEOUT = 120 # seconds + +# Backwards compatibility +SESSION_PATH = DEFAULT_SESSION_PATH + + +def resolve_branch_target(message: str) -> Tuple[str, Path]: + """ + Extract @branch target from message and resolve to a directory path. + + If message starts with @branch_name, look up the branch in BRANCH_REGISTRY.json + and return the cleaned message (without the @branch prefix) and the branch path. + If no @branch prefix or branch not found, returns original message and DEFAULT_SESSION_PATH. + + Returns: + Tuple of (cleaned_message, target_path) + """ + match = re.match(r'^@(\w+)\s*(.*)', message, re.DOTALL) + if not match: + return message, DEFAULT_SESSION_PATH + + branch_name = match.group(1).lower() + rest_of_message = match.group(2).strip() + + try: + with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f: + registry = json.load(f) + + branches = registry.get("branches", []) + for branch_entry in branches: + # Match against email (@branch_name) or name field + clean_email = branch_entry.get("email", "").replace("@", "").lower() + if clean_email == branch_name: + branch_path = Path(branch_entry.get("path", "")) + if branch_path.is_dir(): + logger.info("Branch target resolved: @%s -> %s", branch_name, branch_path) + return rest_of_message or "hi", branch_path + else: + logger.warning("Branch path not found on disk: %s", branch_path) + return message, DEFAULT_SESSION_PATH + + except (FileNotFoundError, json.JSONDecodeError, KeyError) as e: + logger.warning("Failed to resolve branch @%s: %s", branch_name, e) + + return message, DEFAULT_SESSION_PATH + + +def has_display() -> bool: + """Check if DISPLAY environment variable is set (GUI available).""" + return bool(os.environ.get("DISPLAY")) + + +def has_gnome_terminal() -> bool: + """Check if gnome-terminal is available.""" + return shutil.which("gnome-terminal") is not None + + +def has_tmux() -> bool: + """Check if tmux is available.""" + return shutil.which("tmux") is not None + + +def build_prompt(message: str) -> str: + """ + Build the Claude prompt from Telegram message (shell-escaped). + + Used by visual mode (gnome-terminal, tmux) which still requires shell escaping. + + Args: + message: The message text + + Returns: + Formatted prompt string with shell-safe escaping + """ + # Escape single quotes for shell safety + safe_message = message.replace("'", "'\"'\"'") + return f"Patrick via Telegram: {safe_message}" + + +def build_prompt_clean(message: str) -> str: + """ + Build the Claude prompt from Telegram message (no shell escaping). + + Used by capture mode where subprocess_exec passes args directly, + bypassing the shell entirely. + + Args: + message: The message text + + Returns: + Formatted prompt string (raw, no escaping needed) + """ + return f"Patrick via Telegram: {message}" + + +def spawn_gnome_terminal(prompt: str) -> Tuple[bool, str, Optional[int]]: + """ + Spawn Claude in a visible gnome-terminal window. + + Args: + prompt: The prompt to pass to Claude + + Returns: + Tuple of (success, message, pid or None) + """ + try: + # Escape prompt for shell + safe_prompt = prompt.replace("'", "'\"'\"'") + + # Build command: gnome-terminal spawns, runs claude, stays open + cmd = [ + "gnome-terminal", + "--", + "bash", "-c", + f"cd {SESSION_PATH} && claude -p '{safe_prompt}' --session-id {uuid.uuid4()} --permission-mode bypassPermissions; exec bash" + ] + + logger.info("Spawning gnome-terminal at %s", SESSION_PATH) + + process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + + return True, f"gnome-terminal spawned (PID: {process.pid})", process.pid + + except Exception as e: + logger.error("gnome-terminal spawn failed: %s", e) + return False, str(e), None + + +def spawn_tmux(prompt: str) -> Tuple[bool, str, Optional[int]]: + """ + Spawn Claude in a tmux session (headless fallback). + + Creates or attaches to session named 'telegram-claude'. + + Args: + prompt: The prompt to pass to Claude + + Returns: + Tuple of (success, message, pid or None) + """ + try: + # Escape prompt for shell + safe_prompt = prompt.replace("'", "'\"'\"'") + + # Check if session already exists + check_cmd = ["tmux", "has-session", "-t", TMUX_SESSION_NAME] + session_exists = subprocess.run( + check_cmd, + capture_output=True + ).returncode == 0 + + if session_exists: + # Kill existing session to start fresh + subprocess.run( + ["tmux", "kill-session", "-t", TMUX_SESSION_NAME], + capture_output=True + ) + logger.info("Killed existing tmux session: %s", TMUX_SESSION_NAME) + + # Create new session with Claude + cmd = [ + "tmux", "new-session", + "-d", # Detached + "-s", TMUX_SESSION_NAME, + "-c", str(SESSION_PATH), # Working directory + f"claude -p '{safe_prompt}' --session-id {uuid.uuid4()} --permission-mode bypassPermissions" + ] + + logger.info("Spawning tmux session '%s' at %s", TMUX_SESSION_NAME, SESSION_PATH) + + process = subprocess.Popen( + cmd, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL + ) + process.wait() + + if process.returncode == 0: + msg = f"tmux session '{TMUX_SESSION_NAME}' created. Attach with: tmux attach -t {TMUX_SESSION_NAME}" + return True, msg, None # tmux doesn't give us the claude PID directly + else: + return False, f"tmux exited with code {process.returncode}", None + + except Exception as e: + logger.error("tmux spawn failed: %s", e) + return False, str(e), None + + +def spawn_claude_session(sender_name: str, message: str) -> Tuple[bool, str]: + """ + Spawn a visible Claude session for a Telegram message. + + Strategy: + 1. If DISPLAY exists and gnome-terminal available → use gnome-terminal + 2. Otherwise if tmux available → use tmux + 3. If neither available → return error + + Args: + sender_name: Telegram sender name/username + message: The message text from Telegram + + Returns: + Tuple of (success, status_message) + """ + _ = sender_name # Reserved for future use (e.g., multi-user filtering) + prompt = build_prompt(message) + + # Strategy 1: gnome-terminal (GUI) + if has_display() and has_gnome_terminal(): + logger.info("Using gnome-terminal (DISPLAY available)") + success, msg, _ = spawn_gnome_terminal(prompt) + return success, msg + + # Strategy 2: tmux (headless) + if has_tmux(): + logger.info("Using tmux (headless fallback)") + success, msg, _ = spawn_tmux(prompt) + return success, msg + + # No spawner available + error_msg = "No spawner available. Install gnome-terminal (GUI) or tmux (headless)." + logger.error(error_msg) + return False, error_msg + + +# ============================================= +# RESPONSE CHUNKING +# ============================================= + +def chunk_response(text: str, limit: int = TELEGRAM_CHAR_LIMIT) -> List[str]: + """ + Split text into chunks for Telegram's message limit. + + Attempts to split at sentence boundaries when possible. + + Args: + text: The full response text + limit: Maximum characters per chunk (default 4096) + + Returns: + List of text chunks, each within the limit + """ + if len(text) <= limit: + return [text] + + chunks: List[str] = [] + remaining = text + + while remaining: + if len(remaining) <= limit: + chunks.append(remaining) + break + + # Try to find a sentence boundary within limit + chunk = remaining[:limit] + + # Look for sentence endings (. ! ?) followed by space or newline + best_break = -1 + for i in range(len(chunk) - 1, max(0, len(chunk) - 500), -1): + if chunk[i] in '.!?' and (i + 1 >= len(chunk) or chunk[i + 1] in ' \n'): + best_break = i + 1 + break + + # If no sentence boundary, try paragraph break + if best_break == -1: + newline_pos = chunk.rfind('\n\n') + if newline_pos > limit // 2: + best_break = newline_pos + 2 + + # If still no good break, try single newline + if best_break == -1: + newline_pos = chunk.rfind('\n') + if newline_pos > limit // 2: + best_break = newline_pos + 1 + + # Last resort: break at space + if best_break == -1: + space_pos = chunk.rfind(' ') + if space_pos > limit // 2: + best_break = space_pos + 1 + + # Ultimate fallback: hard break at limit + if best_break == -1: + best_break = limit + + chunks.append(remaining[:best_break].rstrip()) + remaining = remaining[best_break:].lstrip() + + return chunks + + +# ============================================= +# CAPTURE MODE - Run Claude and capture output +# ============================================= + +def _chat_id_to_uuid(chat_id: int) -> str: + """Generate a deterministic UUID from a Telegram chat ID.""" + namespace = uuid.UUID("a1a55000-0000-4000-8000-000000000000") + return str(uuid.uuid5(namespace, str(chat_id))) + + +async def _run_claude_cmd( + args: List[str], + timeout: int = DEFAULT_TIMEOUT, + target_cwd: Optional[Path] = None +) -> Tuple[bool, str, Optional[str]]: + """ + Execute a Claude CLI command and capture output. + + Uses subprocess_exec (no shell) to prevent shell injection attacks. + Parses stream-json output via OutputParser. + + Args: + args: Command arguments as a list (e.g. [CLAUDE_BIN, '-p', prompt, ...]) + timeout: Maximum seconds to wait for response + target_cwd: Working directory for the Claude process (defaults to DEFAULT_SESSION_PATH) + + Returns: + Tuple of (success, response_text or error_message, session_id or None) + """ + session_cwd = target_cwd or DEFAULT_SESSION_PATH + try: + process = await asyncio.create_subprocess_exec( + *args, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + cwd=session_cwd + ) + + try: + raw_stdout, raw_stderr = await asyncio.wait_for( + process.communicate(), + timeout=timeout + ) + except asyncio.TimeoutError: + process.kill() + await process.wait() + return False, f"Claude timed out after {timeout} seconds", None + + if process.returncode != 0: + error_text = raw_stderr.decode('utf-8', errors='replace').strip() + return False, f"Claude error: {error_text or 'Unknown error'}", None + + raw_output = raw_stdout.decode('utf-8', errors='replace').strip() + + if not raw_output: + return False, "Claude returned empty response", None + + # Parse stream-json output + result = OutputParser.parse_stream(raw_output) + + if result.success: + logger.info( + "Parsed response: %d chars, session=%s, cost=$%.4f", + len(result.text), + result.session_id or "unknown", + result.cost_usd or 0.0, + ) + return True, result.text, result.session_id + else: + error = result.error_message or "Parse failed" + logger.warning("Parse error: %s", error) + return False, error, result.session_id + + except Exception as e: + return False, f"Capture failed: {str(e)}", None + + +async def run_claude_capture( + message: str, + chat_id: int = 0, + timeout: int = DEFAULT_TIMEOUT, + raw_prompt: bool = False, + target_cwd: Optional[Path] = None +) -> Tuple[bool, str, Optional[str]]: + """ + Run Claude CLI and capture its response with session persistence. + + Strategy (when chat_id provided): + 1. Look up stored session_id → try --resume with it + 2. If no stored or resume fails → create new with random UUID + 3. If random create fails → fall back to deterministic UUID + + Args: + message: The message/prompt to send to Claude + chat_id: Telegram chat ID for session persistence + timeout: Maximum seconds to wait for response + raw_prompt: If True, use message as-is (skip build_prompt_clean) + target_cwd: Working directory for the Claude process (branch targeting) + + Returns: + Tuple of (success, response_text or error_message, session_id or None) + """ + if raw_prompt: + prompt = message + else: + prompt = build_prompt_clean(message) + + if chat_id: + # Strategy 1: Try stored session_id from session_store + stored = get_session(chat_id) + if stored and stored.get("session_id"): + stored_id = stored["session_id"] + resume_args = [ + CLAUDE_BIN, '-p', prompt, + '--resume', stored_id, + '--output-format', 'stream-json', '--verbose', + '--permission-mode', 'bypassPermissions' + ] + logger.info("Attempting resume with stored session %s for chat %s", stored_id[:8], chat_id) + + success, response, session_id = await _run_claude_cmd(resume_args, timeout, target_cwd=target_cwd) + if success: + logger.info("Stored session resumed - response captured (%d chars)", len(response)) + return True, response, session_id + + logger.info("Stored session resume failed for chat %s, creating new session", chat_id) + + # Strategy 2: Create new session with random UUID + random_id = str(uuid.uuid4()) + logger.info("Creating new session for chat %s with random ID %s", chat_id, random_id[:8]) + create_args = [ + CLAUDE_BIN, '-p', prompt, + '--session-id', random_id, + '--output-format', 'stream-json', '--verbose', + '--permission-mode', 'bypassPermissions' + ] + success, response, session_id = await _run_claude_cmd(create_args, timeout, target_cwd=target_cwd) + if success: + return True, response, session_id + + # Strategy 3: Deterministic UUID as last resort + deterministic_id = _chat_id_to_uuid(chat_id) + logger.info("Random create failed, falling back to deterministic ID %s", deterministic_id[:8]) + fallback_args = [ + CLAUDE_BIN, '-p', prompt, + '--resume', deterministic_id, + '--output-format', 'stream-json', '--verbose', + '--permission-mode', 'bypassPermissions' + ] + return await _run_claude_cmd(fallback_args, timeout, target_cwd=target_cwd) + else: + # No chat_id - one-shot with no session ID + one_shot_args = [ + CLAUDE_BIN, '-p', prompt, + '--output-format', 'stream-json', '--verbose', + '--permission-mode', 'bypassPermissions' + ] + logger.info("Running Claude in one-shot mode (timeout: %ds)", timeout) + return await _run_claude_cmd(one_shot_args, timeout, target_cwd=target_cwd) + + +async def spawn_and_capture( + sender_name: str, + message: str, + chat_id: int = 0, + timeout: int = DEFAULT_TIMEOUT, + target_cwd: Optional[Path] = None +) -> Tuple[bool, List[str], Optional[str]]: + """ + Run Claude and return chunked response for Telegram. + + This is the main entry point for capture mode. + + Args: + sender_name: Telegram sender name/username (for logging) + message: The message text from Telegram + chat_id: Telegram chat ID (for logging) + timeout: Maximum seconds to wait for Claude + target_cwd: Working directory for the Claude process (branch targeting) + + Returns: + Tuple of (success, list_of_response_chunks, session_id or None) + """ + logger.info("spawn_and_capture called by %s (chat_id: %s)", sender_name, chat_id) + + success, response, session_id = await run_claude_capture(message, chat_id, timeout, target_cwd=target_cwd) + + if not success: + return False, [response], session_id + + chunks = chunk_response(response) + logger.info("Response split into %d chunk(s)", len(chunks)) + + return True, chunks, session_id + + +if __name__ == "__main__": + # Test the spawner + print("=" * 60) + print("TELEGRAM CLAUDE SPAWNER") + print("=" * 60) + print() + print("Environment check:") + print(f" DISPLAY: {os.environ.get('DISPLAY', 'Not set')}") + print(f" gnome-terminal: {'Available' if has_gnome_terminal() else 'Not found'}") + print(f" tmux: {'Available' if has_tmux() else 'Not found'}") + print() + print(f"Session path: {SESSION_PATH}") + print(f"tmux session name: {TMUX_SESSION_NAME}") + print() + print("Would spawn with:") + if has_display() and has_gnome_terminal(): + print(" → gnome-terminal (GUI mode)") + elif has_tmux(): + print(" → tmux (headless mode)") + else: + print(" → ERROR: No spawner available") + print() diff --git a/src/aipass/api/apps/handlers/telegram/telegram_standards.py b/src/aipass/api/apps/handlers/telegram/telegram_standards.py new file mode 100644 index 00000000..ebcb96ee --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/telegram_standards.py @@ -0,0 +1,371 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: telegram_standards.py - Shared Telegram Bot Standards +# Date: 2026-02-15 +# Version: 1.0.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-15): Initial - shared commands, templates, and utilities for all bots +# +# CODE STANDARDS: +# - stdlib ONLY (importable by all bots) +# - Provides text generation, not message delivery +# - Single source of truth for standard bot commands +# ============================================= + +""" +Shared Telegram Bot Standards for AIPass + +Central definitions for standard commands, response templates, and text +builders used by ALL AIPass Telegram bots (bridge, assistant, test, scheduler). + +Two bot types exist in AIPass: + - Async bots (python-telegram-bot): Use the text builders directly and + deliver via their own async handlers. + - Sync bots (stdlib urllib): Use parse_command() and handle_standard_command() + to process commands, then deliver via their own send functions. + +This module has ZERO external dependencies (stdlib only). Every bot can import it. + +Usage (async bot): + from aipass.api.apps.handlers.telegram.telegram_standards import ( + build_welcome_text, build_status_text, STANDARD_COMMANDS, + ) + +Usage (sync bot): + from aipass.api.apps.handlers.telegram.telegram_standards import ( + parse_command, handle_standard_command, STANDARD_COMMANDS, + ) +""" + +import subprocess +from typing import Optional + + +# ============================================= +# STANDARD COMMAND REGISTRY +# ============================================= + +STANDARD_COMMANDS: dict[str, dict[str, str]] = { + "start": { + "description": "Welcome message and command list", + "menu_text": "Start / welcome message", + }, + "help": { + "description": "Show available commands", + "menu_text": "Show help", + }, + "new": { + "description": "Kill current session and start fresh (clean Claude context)", + "menu_text": "Fresh session", + }, + "status": { + "description": "Show session info (branch, uptime, session state)", + "menu_text": "Session status", + }, +} + + +# ============================================= +# RESPONSE TEMPLATES +# ============================================= + +PROCESSING_MSG = "Processing..." + +ERROR_TEMPLATE = "Something went wrong: {error}" + +HELP_FOOTER = "\nSend any message to chat with Claude." + +# Internal templates (used by builder functions) +_WELCOME_HEADER = "Hello! I'm {bot_name}." +_WELCOME_BRANCH = "Branch: @{branch_name}" + +_STATUS_HEADER = "Session Status" + + +# ============================================= +# TEXT BUILDERS +# ============================================= + +def _format_command_list( + standard_commands: dict[str, dict[str, str]], + custom_commands: Optional[dict[str, dict[str, str]]] = None, +) -> str: + """ + Format a combined command list as readable text. + + Each command appears as: /command - description + + Args: + standard_commands: The STANDARD_COMMANDS dict (or a subset). + custom_commands: Optional additional commands in the same format. + + Returns: + Multi-line string of formatted commands. + """ + lines: list[str] = [] + for cmd, info in standard_commands.items(): + lines.append(f"/{cmd} - {info['description']}") + if custom_commands: + for cmd, info in custom_commands.items(): + lines.append(f"/{cmd} - {info['description']}") + return "\n".join(lines) + + +def build_help_text( + standard_commands: Optional[dict[str, dict[str, str]]] = None, + custom_commands: Optional[dict[str, dict[str, str]]] = None, +) -> str: + """ + Build a /help message combining standard and custom commands. + + Args: + standard_commands: Command registry dict. Defaults to STANDARD_COMMANDS. + custom_commands: Optional bot-specific commands in the same format. + + Returns: + Formatted help text string. + """ + if standard_commands is None: + standard_commands = STANDARD_COMMANDS + + parts: list[str] = [ + "Commands:", + _format_command_list(standard_commands, custom_commands), + HELP_FOOTER, + ] + return "\n".join(parts) + + +def build_welcome_text( + bot_name: str, + branch_name: str, + standard_commands: Optional[dict[str, dict[str, str]]] = None, + custom_commands: Optional[dict[str, dict[str, str]]] = None, +) -> str: + """ + Build the /start welcome message. + + Args: + bot_name: Display name of the bot (e.g., "AIPass Bridge Bot"). + branch_name: The branch this bot operates on (e.g., "dev_central"). + standard_commands: Command registry dict. Defaults to STANDARD_COMMANDS. + custom_commands: Optional bot-specific commands in the same format. + + Returns: + Formatted welcome text string. + """ + if standard_commands is None: + standard_commands = STANDARD_COMMANDS + + parts: list[str] = [ + _WELCOME_HEADER.format(bot_name=bot_name), + _WELCOME_BRANCH.format(branch_name=branch_name), + "", + "Commands:", + _format_command_list(standard_commands, custom_commands), + HELP_FOOTER, + ] + return "\n".join(parts) + + +def build_status_text( + session_name: str, + branch_name: str, + uptime: Optional[str] = None, + message_count: Optional[int] = None, + chat_id: Optional[str | int] = None, +) -> str: + """ + Build the /status response. + + Checks tmux session state via subprocess. Reports branch, session, + activity status, and optional metrics. + + Args: + session_name: tmux session name (e.g., "telegram-assistant"). + branch_name: Branch name (e.g., "assistant"). + uptime: Optional human-readable uptime string. + message_count: Optional count of messages processed. + chat_id: Optional Telegram chat ID to display. + + Returns: + Formatted status text string. + """ + active = _tmux_session_exists(session_name) + + lines: list[str] = [_STATUS_HEADER] + if chat_id is not None: + lines.append(f"Chat ID: {chat_id}") + lines.append(f"Branch: @{branch_name}") + lines.append(f"Session: {session_name}") + lines.append(f"State: {'Active' if active else 'Inactive'}") + if uptime: + lines.append(f"Uptime: {uptime}") + if message_count is not None: + lines.append(f"Messages: {message_count}") + + return "\n".join(lines) + + +def build_botfather_commands( + standard_commands: Optional[dict[str, dict[str, str]]] = None, + custom_commands: Optional[dict[str, dict[str, str]]] = None, +) -> list[dict[str, str]]: + """ + Build command list for BotFather setMyCommands API. + + Returns the format expected by Telegram's setMyCommands endpoint: + [{"command": "start", "description": "Start / welcome message"}, ...] + + Args: + standard_commands: Command registry dict. Defaults to STANDARD_COMMANDS. + custom_commands: Optional bot-specific commands in the same format. + + Returns: + List of dicts with "command" and "description" keys. + """ + if standard_commands is None: + standard_commands = STANDARD_COMMANDS + + result: list[dict[str, str]] = [] + for cmd, info in standard_commands.items(): + result.append({"command": cmd, "description": info["menu_text"]}) + if custom_commands: + for cmd, info in custom_commands.items(): + result.append({"command": cmd, "description": info["menu_text"]}) + return result + + +# ============================================= +# SYNC BOT UTILITIES (stdlib bots) +# ============================================= + +def parse_command(text: str) -> Optional[tuple[str, str]]: + """ + Extract command name and arguments from message text. + + Handles both '/command' and '/command@bot_username' formats. + Returns None if the text is not a command. + + Args: + text: Raw message text from Telegram. + + Returns: + Tuple of (command_name, args_string) or None if not a command. + command_name is lowercase without the leading slash. + args_string is everything after the command, stripped. + + Examples: + parse_command("/status") -> ("status", "") + parse_command("/new please") -> ("new", "please") + parse_command("/help@mybot") -> ("help", "") + parse_command("hello world") -> None + """ + if not text or not text.startswith("/"): + return None + + # Split on whitespace: first part is /command[@botname], rest is args + parts = text.split(None, 1) + raw_command = parts[0][1:] # Remove leading / + args = parts[1] if len(parts) > 1 else "" + + # Strip @bot_username suffix if present + if "@" in raw_command: + raw_command = raw_command.split("@", 1)[0] + + command = raw_command.lower().strip() + if not command: + return None + + return (command, args.strip()) + + +def handle_standard_command( + command: str, + session_name: str, + branch_name: str, + bot_name: str, + custom_commands: Optional[dict[str, dict[str, str]]] = None, + chat_id: Optional[str | int] = None, + message_count: Optional[int] = None, + uptime: Optional[str] = None, +) -> Optional[str | tuple[str, str]]: + """ + Handle a standard command and return the response text. + + For most commands, returns a string with the response text. + For /new, returns a tuple ("new", instructions_text) to signal + the caller that they need to kill and restart their tmux session. + The caller is responsible for tmux operations and for sending + the response text. + + Returns None if the command is not a standard command. + + Args: + command: The command name (lowercase, no slash). + session_name: tmux session name (e.g., "telegram-assistant"). + branch_name: Branch name (e.g., "assistant"). + bot_name: Display name of the bot. + custom_commands: Optional bot-specific commands for help text. + chat_id: Optional Telegram chat ID (for /status display). + message_count: Optional message count (for /status display). + uptime: Optional uptime string (for /status display). + + Returns: + - str: Response text for /start, /help, /status + - tuple[str, str]: ("new", response_text) for /new command + - None: Command is not a standard command + """ + if command == "start": + return build_welcome_text( + bot_name=bot_name, + branch_name=branch_name, + custom_commands=custom_commands, + ) + + if command == "help": + return build_help_text(custom_commands=custom_commands) + + if command == "new": + response_text = f"Session cleared for @{branch_name}. Next message starts fresh." + return ("new", response_text) + + if command == "status": + return build_status_text( + session_name=session_name, + branch_name=branch_name, + uptime=uptime, + message_count=message_count, + chat_id=chat_id, + ) + + return None + + +# ============================================= +# INTERNAL HELPERS +# ============================================= + +def _tmux_session_exists(session_name: str) -> bool: + """ + Check if a tmux session exists by name. + + Args: + session_name: The tmux session name to check. + + Returns: + True if the session is running, False otherwise. + """ + try: + result = subprocess.run( + ["tmux", "has-session", "-t", session_name], + capture_output=True, + ) + return result.returncode == 0 + except FileNotFoundError: + # tmux not installed + return False diff --git a/src/aipass/api/apps/handlers/telegram/tmux_manager.py b/src/aipass/api/apps/handlers/telegram/tmux_manager.py new file mode 100644 index 00000000..47bc8002 --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram/tmux_manager.py @@ -0,0 +1,315 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: tmux_manager.py - tmux Session Manager for Telegram Bridge +# Date: 2026-02-12 +# Version: 1.2.0 +# Category: api/handlers/telegram +# +# CHANGELOG (Max 5 entries): +# - v1.2.0 (2026-03-01): Add AIPASS_SESSION_TYPE env var + auto /rename for Claude session identification +# - v1.1.0 (2026-02-24): Add bot_id awareness — AIPASS_BOT_ID env var in tmux sessions +# - v1.0.0 (2026-02-12): Initial - tmux session create/kill/send/list for persistent Claude sessions +# +# CODE STANDARDS: +# - Pure functions with proper error raising +# - No Prax imports (handler tier 3) +# ============================================= + +""" +tmux Session Manager for Telegram Bridge + +Manages persistent Claude Code sessions in tmux: +- Create named sessions (telegram-{branch_name}) running Claude Code +- Inject messages via tmux send-keys -l (literal mode) +- Kill/list sessions +- Capture pane content for status display + +Each tmux session runs `claude --permission-mode bypassPermissions` continuously. +Messages are injected via send-keys, responses captured via Stop hook. +""" + +# Infrastructure +import sys +from pathlib import Path + +import asyncio +import shutil +import subprocess +import time +from typing import List, Optional + + +# Constants +SESSION_PREFIX = "telegram-" +DEFAULT_BRANCH = "dev_central" +CLAUDE_BIN = str(Path.home() / ".local" / "bin" / "claude") +SEND_KEYS_DELAY = 0.5 # seconds between text injection and Enter + + +RENAME_DELAY = 3 # seconds to wait for Claude to initialize before /rename + + +def _session_name(branch_name: str) -> str: + """Build tmux session name from branch name.""" + return f"{SESSION_PREFIX}{branch_name}" + + +def _send_rename(session_name: str, branch_name: str) -> None: + """Send /rename to a tmux session after Claude initializes.""" + time.sleep(RENAME_DELAY) + rename_cmd = f"/rename {branch_name.upper()}-telegram" + subprocess.run( + ["tmux", "send-keys", "-t", session_name, rename_cmd, "Enter"], + capture_output=True, + ) + + +def has_tmux() -> bool: + """Check if tmux is available on the system.""" + return shutil.which("tmux") is not None + + +def session_exists(branch_name: str) -> bool: + """ + Check if a tmux session exists for the given branch. + + Args: + branch_name: Branch name (e.g. 'dev_central') + + Returns: + True if session is alive + """ + name = _session_name(branch_name) + result = subprocess.run( + ["tmux", "has-session", "-t", name], + capture_output=True, + ) + return result.returncode == 0 + + +def create_session(branch_name: str, branch_path: Path, *, bot_id: Optional[str] = None) -> bool: + """ + Create a tmux session and launch Claude Code inside it. + + Session is named telegram-{branch_name} and starts in branch_path. + Claude is launched with AIPASS_SESSION_TYPE=telegram and + --permission-mode bypassPermissions. After initialization, + sends /rename BRANCH-telegram for the /resume picker. + + Args: + branch_name: Branch name for session naming + branch_path: Working directory for Claude Code + bot_id: Optional bot ID — sets AIPASS_BOT_ID env var in tmux session + + Returns: + True if session was created successfully + """ + name = _session_name(branch_name) + + if session_exists(branch_name): + print("[INFO]", "Session %s already exists", name) + return True + + if not branch_path.is_dir(): + print("[ERROR]", "Branch path does not exist: %s", branch_path) + return False + + try: + # Create detached tmux session + result = subprocess.run( + [ + "tmux", "new-session", + "-d", # Detached + "-s", name, # Session name + "-c", str(branch_path), # Working directory + ], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print("[ERROR]", "Failed to create tmux session %s: %s", name, result.stderr) + return False + + # Set bot_id environment variable if provided + if bot_id: + subprocess.run( + ["tmux", "set-environment", "-t", name, "AIPASS_BOT_ID", bot_id], + capture_output=True, + text=True, + ) + + # Launch Claude Code inside the session + # Explicit cd guarantees CWD even if shell profile drifts it + # AIPASS_SESSION_TYPE=telegram lets drone status label this session + claude_cmd = ( + f"cd '{branch_path}' && " + f"AIPASS_SESSION_TYPE=telegram {CLAUDE_BIN} --permission-mode bypassPermissions" + ) + subprocess.run( + ["tmux", "send-keys", "-t", name, claude_cmd, "Enter"], + capture_output=True, + ) + + # Rename the Claude conversation for the /resume picker + # Claude needs a few seconds to initialize before /rename works + _send_rename(name, branch_name) + + print("[INFO]", "Created tmux session %s at %s", name, branch_path) + return True + + except Exception as e: + print("[ERROR]", "Error creating tmux session %s: %s", name, e) + return False + + +async def send_message(branch_name: str, message: str) -> bool: + """ + Inject a message into a tmux session via send-keys. + + Uses -l flag for literal mode (no shell interpretation). + Sends text first, waits briefly, then sends Enter. + + Args: + branch_name: Branch name identifying the session + message: The message text to inject + + Returns: + True if message was sent successfully + """ + name = _session_name(branch_name) + + if not session_exists(branch_name): + print("[ERROR]", "Session %s does not exist", name) + return False + + try: + # Send text literally (no shell interpretation) + result = subprocess.run( + ["tmux", "send-keys", "-t", name, "-l", message], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print("[ERROR]", "Failed to send text to %s: %s", name, result.stderr) + return False + + # Wait before sending Enter (prevents rapid keystroke issues) + await asyncio.sleep(SEND_KEYS_DELAY) + + # Send Enter to submit the message + result = subprocess.run( + ["tmux", "send-keys", "-t", name, "Enter"], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + print("[ERROR]", "Failed to send Enter to %s: %s", name, result.stderr) + return False + + print("[INFO]", "Injected message into %s (%d chars)", name, len(message)) + return True + + except Exception as e: + print("[ERROR]", "Error sending to tmux session %s: %s", name, e) + return False + + +def kill_session(branch_name: str) -> bool: + """ + Kill a tmux session for the given branch. + + Args: + branch_name: Branch name identifying the session + + Returns: + True if session was killed (or didn't exist) + """ + name = _session_name(branch_name) + + if not session_exists(branch_name): + print("[INFO]", "Session %s does not exist, nothing to kill", name) + return True + + try: + result = subprocess.run( + ["tmux", "kill-session", "-t", name], + capture_output=True, + text=True, + ) + + if result.returncode == 0: + print("[INFO]", "Killed tmux session %s", name) + return True + else: + print("[ERROR]", "Failed to kill session %s: %s", name, result.stderr) + return False + + except Exception as e: + print("[ERROR]", "Error killing tmux session %s: %s", name, e) + return False + + +def list_sessions() -> List[str]: + """ + List all active telegram-* tmux sessions. + + Returns: + List of branch names with active sessions + """ + try: + result = subprocess.run( + ["tmux", "list-sessions", "-F", "#{session_name}"], + capture_output=True, + text=True, + ) + + if result.returncode != 0: + return [] + + sessions = [] + for line in result.stdout.strip().split("\n"): + line = line.strip() + if line.startswith(SESSION_PREFIX): + branch = line[len(SESSION_PREFIX):] + if branch: + sessions.append(branch) + + return sessions + + except Exception: + return [] + + +def get_session_pane(branch_name: str) -> Optional[str]: + """ + Capture current visible pane content from a tmux session. + + Args: + branch_name: Branch name identifying the session + + Returns: + Pane content as string, or None if session doesn't exist + """ + name = _session_name(branch_name) + + if not session_exists(branch_name): + return None + + try: + result = subprocess.run( + ["tmux", "capture-pane", "-t", name, "-p"], + capture_output=True, + text=True, + ) + + if result.returncode == 0: + return result.stdout + return None + + except Exception: + return None diff --git a/src/aipass/api/apps/handlers/telegram_service/__init__.py b/src/aipass/api/apps/handlers/telegram_service/__init__.py new file mode 100644 index 00000000..fe53ea9c --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram_service/__init__.py @@ -0,0 +1,29 @@ +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py - Telegram Service Handler Package +# Date: 2026-02-03 +# Version: 1.0.0 +# Category: api/handlers +# CODE STANDARDS: Seed v1.0.0 +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-03): Initial package +# ============================================= + +"""Telegram Service Handler - systemd control operations""" + +from aipass.api.apps.handlers.telegram_service.service import ( + start_service, + stop_service, + get_status, + get_logs, + SERVICE_NAME, +) + +__all__ = [ + "start_service", + "stop_service", + "get_status", + "get_logs", + "SERVICE_NAME", +] diff --git a/src/aipass/api/apps/handlers/telegram_service/service.py b/src/aipass/api/apps/handlers/telegram_service/service.py new file mode 100644 index 00000000..46b2653f --- /dev/null +++ b/src/aipass/api/apps/handlers/telegram_service/service.py @@ -0,0 +1,113 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: service.py - Telegram Service Handler +# Date: 2026-02-03 +# Version: 1.0.0 +# Category: api/handlers +# CODE STANDARDS: Seed v1.0.0 +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-03): Initial handler - systemd service operations +# ============================================= + +""" +Telegram Service Handler + +Low-level systemd operations for telegram-bridge service. +""" + +import subprocess +from pathlib import Path +from typing import Tuple + +SERVICE_NAME = "telegram-bridge" +LOG_FILE = Path.home() / "system_logs" / "telegram_bridge.log" + + +def start_service() -> Tuple[bool, str]: + """ + Start the telegram-bridge service + + Returns: + Tuple of (success, message) + """ + result = subprocess.run( + ["systemctl", "--user", "start", SERVICE_NAME], + capture_output=True, + text=True + ) + + if result.returncode == 0: + return True, "Service started" + return False, result.stderr.strip() if result.stderr else "Unknown error" + + +def stop_service() -> Tuple[bool, str]: + """ + Stop the telegram-bridge service + + Returns: + Tuple of (success, message) + """ + result = subprocess.run( + ["systemctl", "--user", "stop", SERVICE_NAME], + capture_output=True, + text=True + ) + + if result.returncode == 0: + return True, "Service stopped" + return False, result.stderr.strip() if result.stderr else "Unknown error" + + +def get_status() -> Tuple[str, str]: + """ + Get telegram-bridge service status + + Returns: + Tuple of (status_code, details) + status_code: 'running', 'stopped', 'not_found', 'unknown' + """ + result = subprocess.run( + ["systemctl", "--user", "status", SERVICE_NAME], + capture_output=True, + text=True + ) + + output = result.stdout if result.stdout else result.stderr + + if "Active: active (running)" in output: + return "running", output + elif "Active: inactive (dead)" in output: + return "stopped", output + elif "could not be found" in output.lower(): + return "not_found", output + return "unknown", output + + +def get_logs(lines: int = 30) -> Tuple[bool, str]: + """ + Get recent service logs + + Args: + lines: Number of recent lines to return + + Returns: + Tuple of (success, log_content or error_message) + """ + if not LOG_FILE.exists(): + return False, f"No log file found at {LOG_FILE}" + + try: + with open(LOG_FILE, encoding="utf-8") as f: + all_lines = f.readlines() + recent = all_lines[-lines:] if len(all_lines) > lines else all_lines + + if not recent: + return False, "Log file is empty" + + return True, "".join(recent) + except OSError as e: + return False, f"Failed to read logs: {e}" diff --git a/src/aipass/api/apps/handlers/usage/__init__.py b/src/aipass/api/apps/handlers/usage/__init__.py new file mode 100644 index 00000000..dfb934ec --- /dev/null +++ b/src/aipass/api/apps/handlers/usage/__init__.py @@ -0,0 +1,7 @@ +""" +Usage Tracking Domain + +Handlers for API usage monitoring and cost tracking. +Query generation metrics, aggregate statistics, and data cleanup. +""" +__version__ = "1.0.0" diff --git a/src/aipass/api/apps/handlers/usage/aggregation.py b/src/aipass/api/apps/handlers/usage/aggregation.py new file mode 100644 index 00000000..1d2b04a3 --- /dev/null +++ b/src/aipass/api/apps/handlers/usage/aggregation.py @@ -0,0 +1,291 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: aggregation.py - Usage Aggregation Handler +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: api/handlers/usage +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial handler - usage stats aggregation +# - v1.1.0 (2025-11-16): Extracted aggregation logic from archive +# ============================================= + +""" +Usage Aggregation Handler + +Business logic for aggregating usage statistics: +- Calculate per-caller stats from usage data +- Daily/monthly rollups and summaries +- Cost, token, and latency aggregation +- Model usage tracking and breakdown + +Extracted from: /home/aipass/aipass_core/api/apps/archive.temp/api_usage.py +Functions: get_caller_usage(), get_session_summary(), get_daily_usage() +""" + +import sys +from pathlib import Path + +# Standard library imports +from datetime import datetime +from typing import Dict, Any, List, Optional + +# Standard library for JSON operations +import json + + +# ============================================= +# MODULE CONSTANTS +# ============================================= + +MODULE_NAME = "aggregation" +DATA_FILE = "usage_tracker_data.json" # Standard 3-file pattern +# Navigate: aggregation.py -> usage/ -> handlers/ -> apps/ -> api/ +API_JSON_DIR = Path(__file__).resolve().parent.parent.parent.parent / "api_json" + + +# ============================================= +# AGGREGATION FUNCTIONS +# ============================================= + +def get_caller_usage(caller: str) -> Dict[str, Any]: + """ + Calculate usage statistics for specific caller + + Args: + caller: Module name that made API calls + + Returns: + Dict with requests, total_cost, total_tokens, models_used, last_request + Returns empty dict {} if no data found + """ + try: + # Load usage data from JSON + data_path = API_JSON_DIR / DATA_FILE + if not data_path.exists(): + # logger.info(f"[{MODULE_NAME}] No usage data file found") + return {} + + with open(data_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if not data or "data" not in data: + # logger.info(f"[{MODULE_NAME}] No usage data available") + return {} + + # Extract caller-specific data + usage_by_caller = data["data"].get("usage_by_caller", {}) + caller_data = usage_by_caller.get(caller, {}) + + if not caller_data: + # logger.info(f"[{MODULE_NAME}] No usage data found for caller: {caller}") + return {} + + # logger.info(f"[{MODULE_NAME}] Retrieved usage stats for {caller}: {caller_data.get('requests', 0)} requests") + return caller_data + + except Exception as e: + # logger.error(f"[{MODULE_NAME}] Failed to get caller usage for {caller}: {e}") + return {} + + +def get_session_summary(session_id: Optional[str] = None) -> Dict[str, Any]: + """ + Aggregate current session usage totals + + Args: + session_id: Optional session identifier (unused, for future support) + + Returns: + Dict with start_time, total_requests, total_cost, total_tokens + Returns empty dict {} if no session data found + """ + try: + # Load usage data from JSON + data_path = API_JSON_DIR / DATA_FILE + if not data_path.exists(): + # logger.info(f"[{MODULE_NAME}] No session data file found") + return {} + + with open(data_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if not data or "data" not in data: + # logger.info(f"[{MODULE_NAME}] No session data available") + return {} + + # Extract session summary + session_data = data["data"].get("current_session", {}) + + if not session_data: + # logger.info(f"[{MODULE_NAME}] No session summary found") + return {} + + # logger.info(f"[{MODULE_NAME}] Retrieved session summary: {session_data.get('total_requests', 0)} requests") + return session_data + + except Exception as e: + # logger.error(f"[{MODULE_NAME}] Failed to get session summary: {e}") + return {} + + +def get_daily_usage(date: Optional[str] = None) -> Dict[str, Any]: + """ + Calculate daily usage rollup + + Args: + date: Date string YYYY-MM-DD format (None = today) + + Returns: + Dict with requests, cost, tokens for the date + Returns empty dict {} if no data found + """ + try: + # Default to today if no date provided + if not date: + date = datetime.now().date().isoformat() + + # Load usage data from JSON + data_path = API_JSON_DIR / DATA_FILE + if not data_path.exists(): + # logger.info(f"[{MODULE_NAME}] No daily usage data file found") + return {} + + with open(data_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if not data or "data" not in data: + # logger.info(f"[{MODULE_NAME}] No daily usage data available") + return {} + + # Extract daily totals + daily_totals = data["data"].get("daily_totals", {}) + daily_data = daily_totals.get(date, {}) + + if not daily_data: + # logger.info(f"[{MODULE_NAME}] No usage data found for date: {date}") + return {} + + # logger.info(f"[{MODULE_NAME}] Retrieved daily usage for {date}: {daily_data.get('requests', 0)} requests") + return daily_data + + except Exception as e: + # logger.error(f"[{MODULE_NAME}] Failed to get daily usage for {date}: {e}") + return {} + + +def calculate_totals(usage_data: List[Dict]) -> Dict[str, float]: + """ + Aggregate cost, tokens, latency from usage records + + Args: + usage_data: List of dicts with total_cost, tokens_prompt, tokens_completion, latency + + Returns: + Dict with total_cost, total_tokens, total_requests, avg_latency, total_latency + """ + try: + if not usage_data: + # logger.info(f"[{MODULE_NAME}] No usage data provided for totals calculation") + return { + "total_cost": 0.0, + "total_tokens": 0, + "total_requests": 0, + "avg_latency": 0.0, + "total_latency": 0 + } + + total_cost = 0.0 + total_tokens = 0 + total_latency = 0 + latency_count = 0 + + for record in usage_data: + # Aggregate cost + total_cost += float(record.get("total_cost", 0)) + + # Aggregate tokens (prompt + completion) + tokens_prompt = int(record.get("tokens_prompt", 0)) + tokens_completion = int(record.get("tokens_completion", 0)) + total_tokens += tokens_prompt + tokens_completion + + # Aggregate latency (optional field) + if "latency" in record: + total_latency += int(record.get("latency", 0)) + latency_count += 1 + + # Calculate average latency + avg_latency = total_latency / latency_count if latency_count > 0 else 0.0 + + result = { + "total_cost": total_cost, + "total_tokens": total_tokens, + "total_requests": len(usage_data), + "avg_latency": avg_latency, + "total_latency": total_latency + } + + # logger.info(f"[{MODULE_NAME}] Calculated totals: {result['total_requests']} requests, ${result['total_cost']:.6f}") + return result + + except Exception as e: + # logger.error(f"[{MODULE_NAME}] Failed to calculate totals: {e}") + return { + "total_cost": 0.0, + "total_tokens": 0, + "total_requests": 0, + "avg_latency": 0.0, + "total_latency": 0 + } + + +def get_model_breakdown(caller: Optional[str] = None) -> Dict[str, Dict[str, int]]: + """ + Calculate model usage breakdown by caller or globally + + Args: + caller: Optional caller name to filter by (None = all callers) + + Returns: + Dict of {model_name: {"requests": count}} + Returns empty dict {} if no data found + """ + try: + data_path = API_JSON_DIR / DATA_FILE + if not data_path.exists(): + # logger.info(f"[{MODULE_NAME}] No model breakdown data file found") + return {} + + with open(data_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if not data or "data" not in data: + # logger.info(f"[{MODULE_NAME}] No model breakdown data available") + return {} + + model_stats = {} + usage_by_caller = data["data"].get("usage_by_caller", {}) + + if caller: + # Single caller breakdown + caller_data = usage_by_caller.get(caller, {}) + models_used = caller_data.get("models_used", {}) + for model, count in models_used.items(): + model_stats[model] = {"requests": count} + else: + # Global breakdown across all callers + for caller_name, caller_data in usage_by_caller.items(): + models_used = caller_data.get("models_used", {}) + for model, count in models_used.items(): + if model not in model_stats: + model_stats[model] = {"requests": 0} + model_stats[model]["requests"] += count + + # logger.info(f"[{MODULE_NAME}] Retrieved model breakdown: {len(model_stats)} models") + return model_stats + + except Exception as e: + # logger.error(f"[{MODULE_NAME}] Failed to get model breakdown: {e}") + return {} diff --git a/src/aipass/api/apps/handlers/usage/cleanup.py b/src/aipass/api/apps/handlers/usage/cleanup.py new file mode 100644 index 00000000..8cd72bc2 --- /dev/null +++ b/src/aipass/api/apps/handlers/usage/cleanup.py @@ -0,0 +1,246 @@ +#!/home/aipass/.venv/bin/python3 +# -*- coding: utf-8 -*- + +# ===================AIPASS==================== +# META DATA HEADER +# Name: cleanup.py - Usage data retention and cleanup +# Date: 2025-11-16 +# Version: 0.1.0 +# Category: api/handlers +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2025-11-16): Extracted from api_usage.py +# - v1.0.0 (2025-11-15): Initial handler - old data cleanup +# +# CODE STANDARDS: +# - Handler layer (standalone functions) +# - Uses CLI service for output +# - Under 300 lines +# ============================================== + +""" +Usage Data Cleanup Handler + +Manages data retention policies and cleanup operations. +Removes old generation tracking data based on retention rules. +""" + +# Infrastructure +from pathlib import Path +import sys + +# Standard library +import json +from datetime import datetime, timedelta +from typing import Optional, Dict, List + +# CLI services +from aipass.cli.apps.modules import console, success, warning + + +def _read_json(file_path: Path) -> Optional[Dict]: + """Read JSON file with error handling.""" + try: + if not file_path.exists(): + return None + with open(file_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + # logger.error(f"Failed to read JSON from {file_path}: {e}") + return None + + +def _write_json(file_path: Path, data: Dict) -> bool: + """Write JSON file with error handling.""" + try: + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + # logger.error(f"Failed to write JSON to {file_path}: {e}") + return False + + +def cleanup_old_data(data_file_path: Path, retention_days: int = 30) -> int: + """ + Remove usage data older than retention period. + + Args: + data_file_path: Path to the usage data JSON file + retention_days: Number of days to retain data (default: 30) + + Returns: + int: Number of generation entries cleaned up + """ + try: + cutoff_date = datetime.now() - timedelta(days=retention_days) + data = _read_json(data_file_path) + if not data: + return 0 + + # Extract the actual data content (handle wrapper structure) + data_content = data.get("data", data) + + # Identify and remove old generation tracking entries + old_generations = _identify_old_generations( + data_content.get("generation_tracking", {}), + cutoff_date + ) + + if not old_generations: + return 0 + + for gen_id in old_generations: + del data_content["generation_tracking"][gen_id] + + # Update wrapper if needed + if "data" in data: + data["data"] = data_content + data["timestamp"] = datetime.now().isoformat() + + _write_json(data_file_path, data) + # logger.info(f"Cleaned up {len(old_generations)} generation entries") + success(f"Cleaned up {len(old_generations)} generation entries older than {retention_days} days") + + return len(old_generations) + + except Exception as e: + # logger.error(f"Cleanup failed: {e}") + console.print(f"[red]Cleanup failed: {e}[/red]") + raise + + +def _identify_old_generations(generation_tracking: Dict, cutoff_date: datetime) -> List[str]: + """Identify generation IDs older than cutoff date.""" + old_generations = [] + + for gen_id, gen_data in generation_tracking.items(): + try: + timestamp_str = gen_data.get("timestamp") + if not timestamp_str: + old_generations.append(gen_id) + continue + + gen_date = datetime.fromisoformat(timestamp_str) + if gen_date < cutoff_date: + old_generations.append(gen_id) + + except (ValueError, TypeError): + old_generations.append(gen_id) + + return old_generations + + +def cleanup_daily_totals(data_file_path: Path, retention_days: int = 90) -> int: + """Remove daily total entries older than retention period.""" + try: + cutoff_date = (datetime.now() - timedelta(days=retention_days)).date() + data = _read_json(data_file_path) + if not data: + return 0 + + data_content = data.get("data", data) + old_dates = [] + daily_totals = data_content.get("daily_totals", {}) + + for date_str in daily_totals.keys(): + try: + date_obj = datetime.fromisoformat(date_str).date() + if date_obj < cutoff_date: + old_dates.append(date_str) + except (ValueError, TypeError): + old_dates.append(date_str) + + if not old_dates: + return 0 + + for date_str in old_dates: + del data_content["daily_totals"][date_str] + + if "data" in data: + data["data"] = data_content + data["timestamp"] = datetime.now().isoformat() + + _write_json(data_file_path, data) + # logger.info(f"Cleaned up {len(old_dates)} daily total entries") + success(f"Cleaned up {len(old_dates)} daily total entries older than {retention_days} days") + + return len(old_dates) + + except Exception as e: + # logger.error(f"Daily totals cleanup failed: {e}") + console.print(f"[red]Daily totals cleanup failed: {e}[/red]") + raise + + +def auto_cleanup(data_file_path: Path, config: Optional[Dict] = None) -> Dict[str, int]: + """Perform automatic cleanup based on configuration.""" + try: + gen_retention = config.get("cleanup_old_data_days", 30) if config else 30 + daily_retention = config.get("cleanup_daily_totals_days", 90) if config else 90 + + generations_removed = cleanup_old_data(data_file_path, gen_retention) + daily_totals_removed = cleanup_daily_totals(data_file_path, daily_retention) + + # logger.info(f"Auto cleanup: {generations_removed} generations, {daily_totals_removed} daily totals removed") + + return { + "generations_removed": generations_removed, + "daily_totals_removed": daily_totals_removed + } + + except Exception as e: + # logger.error(f"Auto cleanup failed: {e}") + console.print(f"[red]Auto cleanup failed: {e}[/red]") + raise + + +def get_cleanup_stats(data_file_path: Path) -> Dict[str, int]: + """Get statistics about data that could be cleaned up.""" + empty_stats = { + "total_generations": 0, + "total_daily_totals": 0, + "cleanable_generations": 0, + "cleanable_daily_totals": 0 + } + + try: + data = _read_json(data_file_path) + if not data: + return empty_stats + + data_content = data.get("data", data) + generation_tracking = data_content.get("generation_tracking", {}) + daily_totals = data_content.get("daily_totals", {}) + + # Count cleanable generations (older than 30 days) + cutoff_30 = datetime.now() - timedelta(days=30) + cleanable_gens = len(_identify_old_generations(generation_tracking, cutoff_30)) + + # Count cleanable daily totals (older than 90 days) + cutoff_90 = (datetime.now() - timedelta(days=90)).date() + cleanable_daily = sum( + 1 for date_str in daily_totals.keys() + if _is_old_date(date_str, cutoff_90) + ) + + return { + "total_generations": len(generation_tracking), + "total_daily_totals": len(daily_totals), + "cleanable_generations": cleanable_gens, + "cleanable_daily_totals": cleanable_daily + } + + except Exception as e: + # logger.error(f"Failed to get cleanup stats: {e}") + return empty_stats + + +def _is_old_date(date_str: str, cutoff_date) -> bool: + """Check if date string is older than cutoff.""" + try: + date_obj = datetime.fromisoformat(date_str).date() + return date_obj < cutoff_date + except (ValueError, TypeError): + return True diff --git a/src/aipass/api/apps/handlers/usage/tracking.py b/src/aipass/api/apps/handlers/usage/tracking.py new file mode 100644 index 00000000..5fa69adb --- /dev/null +++ b/src/aipass/api/apps/handlers/usage/tracking.py @@ -0,0 +1,335 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: tracking.py - Usage Tracking Handler +# Date: 2025-11-16 +# Version: 1.0.0 +# Category: api/handlers/usage +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-16): Extracted tracking logic from archive +# ============================================= + +""" +Usage Tracking Handler + +Business logic for tracking API usage from OpenRouter: +- Query OpenRouter /generation endpoint for real metrics +- Retrieve cost, tokens (prompt + completion), latency data +- Store generation tracking data with newest-first ordering +- Handle HTTP requests with proper error handling + +Extracted from: /home/aipass/aipass_core/api/apps/archive.temp/api_usage.py +Functions: track_usage(), _get_generation_metrics(), _store_usage_data() +""" + +import sys +from pathlib import Path + +# Standard library imports +import json +import requests +import time +from datetime import datetime +from typing import Dict, Any, Optional + +# ============================================= +# MODULE CONSTANTS +# ============================================= + +MODULE_NAME = "tracking" +DATA_FILE = "usage_tracker_data.json" # Standard 3-file pattern +# Navigate: tracking.py -> usage/ -> handlers/ -> apps/ -> api/ +API_JSON_DIR = Path(__file__).resolve().parent.parent.parent.parent / "api_json" + +# OpenRouter API configuration +OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1" +GENERATION_ENDPOINT = f"{OPENROUTER_BASE_URL}/generation" + +# Default configuration values +DEFAULT_GENERATION_CHECK_DELAY = 2 # seconds to wait before querying metrics + + +# ============================================= +# CORE TRACKING FUNCTIONS +# ============================================= + +def track_usage(generation_id: str, caller: str, model: str = "unknown", api_key: Optional[str] = None) -> Dict[str, Any]: + """ + Track API usage for generation ID by querying OpenRouter metrics + + This is the main entry point for usage tracking. It: + 1. Waits for OpenRouter to process the generation + 2. Queries the /generation endpoint for real metrics + 3. Stores the usage data with newest-first ordering + + Args: + generation_id: OpenRouter generation ID from API response + caller: Module name that made the API call (e.g., "flow_mbank") + model: Model name used for the request (default: "unknown") + api_key: OpenRouter API key (optional, loads from config if not provided) + + Returns: + Dict with success status and metrics or error message + Example: {"success": True, "metrics": {...}} or {"success": False, "error": "..."} + """ + try: + # Tracking usage for caller - generation + + # Get API key if not provided + if not api_key: + # Import here to avoid circular dependencies + try: + from aipass.api.apps.handlers.auth.keys import get_api_key + api_key = get_api_key("openrouter") + except Exception as e: + # Failed to load API key + return {"success": False, "error": "No API key available"} + + if not api_key: + # No API key available for tracking + return {"success": False, "error": "No API key available"} + + # Wait for OpenRouter to process the generation + time.sleep(DEFAULT_GENERATION_CHECK_DELAY) + + # Query OpenRouter for real metrics + metrics = get_generation_metrics(generation_id, api_key) + + if not metrics: + # Failed to retrieve generation metrics + return {"success": False, "error": "Failed to retrieve generation metrics"} + + # Store the usage data + if store_usage_data(caller, model, generation_id, metrics): + # Successfully tracked usage + return {"success": True, "metrics": metrics} + else: + # Failed to store usage data + return {"success": False, "error": "Failed to store usage data"} + + except Exception as e: + # Usage tracking failed + return {"success": False, "error": str(e)} + + +def get_generation_metrics(generation_id: str, api_key: str) -> Optional[Dict[str, Any]]: + """ + Query OpenRouter /generation endpoint for real usage metrics + + Makes HTTP GET request to: + https://openrouter.ai/api/v1/generation?id={generation_id} + + Args: + generation_id: OpenRouter generation ID + api_key: OpenRouter API key for authentication + + Returns: + Dict with metrics: + - total_cost: Total cost in USD + - tokens_prompt: Number of prompt tokens + - tokens_completion: Number of completion tokens + - generation_time: Generation time in milliseconds + - latency: Total latency in milliseconds + - provider_name: Provider that served the request + + Returns None if request fails or data is invalid + """ + try: + # Set up request headers + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json" + } + + # Query the generation endpoint + response = requests.get( + f"{GENERATION_ENDPOINT}?id={generation_id}", + headers=headers, + timeout=30 + ) + + # Check response status + if response.status_code == 200: + data = response.json() + + # Validate response structure + if not data or "data" not in data: + # Invalid response structure from OpenRouter + return None + + # Extract metrics from response + metrics = data["data"] + result = { + "total_cost": float(metrics.get("total_cost", 0)), + "tokens_prompt": int(metrics.get("tokens_prompt", 0)), + "tokens_completion": int(metrics.get("tokens_completion", 0)), + "generation_time": int(metrics.get("generation_time", 0)), + "latency": int(metrics.get("latency", 0)), + "provider_name": metrics.get("provider_name", "unknown") + } + + # Retrieved metrics for generation_id + return result + + else: + # OpenRouter API returned non-200 status + return None + + except requests.exceptions.Timeout: + # Request timeout querying generation + return None + + except requests.exceptions.RequestException as e: + # Request error querying generation + return None + + except (ValueError, KeyError) as e: + # Error parsing metrics + return None + + except Exception as e: + # Unexpected error getting generation metrics + return None + + +def store_usage_data(caller: str, model: str, generation_id: str, metrics: Dict[str, Any]) -> bool: + """ + Store usage data with aggregation and newest-first ordering + + Updates: + - current_session: Total requests, cost, tokens + - usage_by_caller: Per-caller statistics and model tracking + - daily_totals: Daily aggregated statistics + - generation_tracking: Individual generation details (newest first) + + Args: + caller: Module name that made the call + model: Model name used + generation_id: OpenRouter generation ID + metrics: Usage metrics from get_generation_metrics() + + Returns: + True if successfully stored, False on error + """ + try: + # Ensure API JSON directory exists + API_JSON_DIR.mkdir(parents=True, exist_ok=True) + data_path = API_JSON_DIR / DATA_FILE + + # Load current data or create initial structure + if data_path.exists(): + with open(data_path, 'r', encoding='utf-8') as f: + data_wrapper = json.load(f) + current_data = data_wrapper.get("data", {}) + else: + current_data = { + "current_session": { + "start_time": datetime.now().isoformat(), + "total_requests": 0, + "total_cost": 0.0, + "total_tokens": 0 + }, + "usage_by_caller": {}, + "daily_totals": {}, + "monthly_totals": {}, + "generation_tracking": {} + } + + # Calculate total tokens + total_tokens = metrics["tokens_prompt"] + metrics["tokens_completion"] + + # Update session totals + current_data["current_session"]["total_requests"] += 1 + current_data["current_session"]["total_cost"] += metrics["total_cost"] + current_data["current_session"]["total_tokens"] += total_tokens + + # Update per-caller tracking + if caller not in current_data["usage_by_caller"]: + current_data["usage_by_caller"][caller] = { + "requests": 0, + "total_cost": 0.0, + "total_tokens": 0, + "models_used": {}, + "last_request": None + } + + caller_data = current_data["usage_by_caller"][caller] + caller_data["requests"] += 1 + caller_data["total_cost"] += metrics["total_cost"] + caller_data["total_tokens"] += total_tokens + caller_data["last_request"] = datetime.now().isoformat() + + # Track models used by caller + if model not in caller_data["models_used"]: + caller_data["models_used"][model] = 0 + caller_data["models_used"][model] += 1 + + # Update daily totals + today = datetime.now().date().isoformat() + if today not in current_data["daily_totals"]: + current_data["daily_totals"][today] = { + "requests": 0, + "cost": 0.0, + "tokens": 0 + } + + current_data["daily_totals"][today]["requests"] += 1 + current_data["daily_totals"][today]["cost"] += metrics["total_cost"] + current_data["daily_totals"][today]["tokens"] += total_tokens + + # Store generation details with newest-first ordering + new_entry = { + "timestamp": datetime.now().isoformat(), + "caller": caller, + "model": model, + "usage_data": metrics + } + + # Create new dict with new entry first, then existing entries + current_tracking = current_data["generation_tracking"] + current_data["generation_tracking"] = {generation_id: new_entry, **current_tracking} + + # Save updated data with proper wrapper structure + data_wrapper = { + "module_name": "api_usage", + "timestamp": datetime.now().isoformat(), + "data": current_data + } + + with open(data_path, 'w', encoding='utf-8') as f: + json.dump(data_wrapper, f, indent=2, ensure_ascii=False) + + # Stored usage data for caller + return True + + except Exception as e: + # Failed to store usage data + return False + + +# ============================================= +# HELPER FUNCTIONS +# ============================================= + +def load_usage_data() -> Dict[str, Any]: + """ + Load current usage data from JSON file + + Returns: + Dict with usage data or empty dict if file doesn't exist + """ + try: + data_path = API_JSON_DIR / DATA_FILE + if not data_path.exists(): + return {} + + with open(data_path, 'r', encoding='utf-8') as f: + data_wrapper = json.load(f) + + return data_wrapper.get("data", {}) + + except Exception as e: + # Failed to load usage data + return {} diff --git a/src/aipass/api/apps/json_templates/__init__.py b/src/aipass/api/apps/json_templates/__init__.py new file mode 100644 index 00000000..5d00b535 --- /dev/null +++ b/src/aipass/api/apps/json_templates/__init__.py @@ -0,0 +1 @@ +# JSON Templates package - Default JSON file templates diff --git a/src/aipass/api/apps/json_templates/default/config.json b/src/aipass/api/apps/json_templates/default/config.json new file mode 100644 index 00000000..d29d029f --- /dev/null +++ b/src/aipass/api/apps/json_templates/default/config.json @@ -0,0 +1,9 @@ +{ + "module_name": "{{MODULE_NAME}}", + "version": "1.0.0", + "timestamp": "2025-11-13", + "config": { + "auto_save": true, + "enabled": true + } +} diff --git a/src/aipass/api/apps/json_templates/default/data.json b/src/aipass/api/apps/json_templates/default/data.json new file mode 100644 index 00000000..82912a72 --- /dev/null +++ b/src/aipass/api/apps/json_templates/default/data.json @@ -0,0 +1,8 @@ +{ + "module_name": "{{MODULE_NAME}}", + "created": "2025-11-13", + "last_updated": "2025-11-13", + "operations_total": 0, + "operations_successful": 0, + "operations_failed": 0 +} diff --git a/src/aipass/api/apps/json_templates/default/log.json b/src/aipass/api/apps/json_templates/default/log.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/aipass/api/apps/json_templates/default/log.json @@ -0,0 +1 @@ +[] diff --git a/src/aipass/api/apps/modules/api_key.py b/src/aipass/api/apps/modules/api_key.py new file mode 100644 index 00000000..27c28ff3 --- /dev/null +++ b/src/aipass/api/apps/modules/api_key.py @@ -0,0 +1,215 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: api_key.py - API Key Management Module +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: api/modules +# CODE STANDARDS: Seed v1.0.0 +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial module - orchestrates key management +# ============================================= + +""" +API Key Management Module + +Orchestrates API key and credential operations: +- Get/validate keys +- List providers +- Initialize .env template +""" + +import sys +from pathlib import Path + +from typing import List +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console, header, success, error, warning, section +from aipass.api.apps.handlers.json import json_handler +from aipass.api.apps.handlers.auth import keys, env +from aipass.api.apps.handlers.config import provider + + +def print_introspection(): + """Show module introspection - connected handlers and capabilities""" + console.print() + header("API Key Module Introspection") + console.print() + + console.print("[cyan]Purpose:[/cyan] API key management and validation") + console.print() + + console.print("[cyan]Connected Handlers:[/cyan]") + console.print(" • api.apps.handlers.auth.keys") + console.print(" • api.apps.handlers.auth.env") + console.print(" • api.apps.handlers.config.provider") + console.print(" • api.apps.handlers.json.json_handler") + console.print() + + console.print("[cyan]Available Workflows:[/cyan]") + console.print(" • get_key() - Retrieve API key") + console.print(" • validate_key() - Validate credentials") + console.print(" • list_providers() - Show providers") + console.print(" • init_env() - Initialize configuration") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle API key management commands + + Args: + command: Command name + args: Command arguments + + Returns: + True if command was handled, False otherwise + """ + try: + if command not in ["get-key", "validate", "list-providers", "init"]: + return False + + # Log operation + json_handler.log_operation(f"api_key_{command}", {"command": command}) + + if command == "get-key": + get_key(args) + elif command == "validate": + validate_key(args) + elif command == "list-providers": + list_providers() + elif command == "init": + init_env() + + return True + except Exception as e: + logger.error(f"Error in api_key.handle_command: {e}") + raise + + +def get_key(args: List[str]): + """Orchestrate key retrieval workflow""" + provider_name = args[0] if args else "openrouter" + + header(f"Get API Key - {provider_name}") + console.print() + + # Call handler to get key + api_key = keys.get_api_key(provider_name) + + if api_key: + success(f"API key retrieved for {provider_name}") + console.print(f" Key (first 20 chars): {api_key[:20]}...") + else: + error(f"Failed to retrieve API key for {provider_name}") + + +def validate_key(args: List[str]): + """Orchestrate key validation workflow""" + provider_name = args[0] if args else "openrouter" + + header(f"Validate API Key - {provider_name}") + console.print() + + # Get key from handler + api_key = keys.get_api_key(provider_name) + + if not api_key: + error(f"No API key found for {provider_name}") + return + + # Validate via handler + is_valid = keys.validate_key(api_key, provider_name) + + if is_valid: + success(f"API key for {provider_name} is valid") + else: + error(f"API key for {provider_name} is invalid") + + +def list_providers(): + """Orchestrate list providers workflow""" + header("Available Providers") + console.print() + + # TODO: Get from handler when implemented + console.print(" - openrouter") + console.print() + + +def init_env(): + """Orchestrate initialization workflow""" + header("Initialize API Configuration") + console.print() + + # Create .env template via handler + if env.create_env_template(): + success("Environment template created") + else: + error("Failed to create environment template") + + +def print_help(): + """Print help output for API key management""" + import argparse + + parser = argparse.ArgumentParser( + description='API Key Management Module - Manage API keys and credentials', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +COMMANDS: + get-key - Retrieve API key for a provider + validate - Validate API key + list-providers - List available providers + init - Initialize .env template + +USAGE: + python3 api_key.py [args] + python3 api_key.py --help + +EXAMPLES: + # Get key for provider + python3 api_key.py get-key openrouter + + # Validate key + python3 api_key.py validate openrouter + + # List providers + python3 api_key.py list-providers + + # Initialize environment + python3 api_key.py init + """ + ) + console.print(parser.format_help()) + + +if __name__ == "__main__": + """Standalone execution mode""" + args = sys.argv[1:] + + # Show introspection when run without arguments + if len(args) == 0: + print_introspection() + sys.exit(0) + + # Show help for explicit help flags + if args[0] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + # Execute command + command = args[0] + remaining_args = args[1:] if len(args) > 1 else [] + + if handle_command(command, remaining_args): + sys.exit(0) + else: + console.print() + console.print(f"[red]Unknown command: {command}[/red]") + console.print() + console.print("Run [dim]python3 api_key.py --help[/dim] for available commands") + console.print() + sys.exit(1) diff --git a/src/aipass/api/apps/modules/openrouter_client.py b/src/aipass/api/apps/modules/openrouter_client.py new file mode 100644 index 00000000..fa8c079e --- /dev/null +++ b/src/aipass/api/apps/modules/openrouter_client.py @@ -0,0 +1,272 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: openrouter_client.py - OpenRouter Client Module +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: api/modules +# CODE STANDARDS: Seed v1.0.0 +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial module - orchestrates OpenRouter operations +# ============================================= + +""" +OpenRouter Client Module + +Orchestrates LLM API client operations: +- Test connections +- Make API calls +- List models +- Check status +""" + +import sys +from pathlib import Path + +from typing import List +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console, header, success, error, warning, section +from aipass.api.apps.handlers.json import json_handler +from aipass.api.apps.handlers.auth import keys +from aipass.api.apps.handlers.openrouter import client, models + + +def print_introspection(): + """Show module introspection - connected handlers and capabilities""" + console.print() + header("OpenRouter Client Module Introspection") + console.print() + + console.print("[cyan]Purpose:[/cyan] OpenRouter LLM API client operations") + console.print() + + console.print("[cyan]Connected Handlers:[/cyan]") + console.print(" • api.apps.handlers.auth.keys") + console.print(" • api.apps.handlers.openrouter.client") + console.print(" • api.apps.handlers.openrouter.models") + console.print(" • api.apps.handlers.json.json_handler") + console.print() + + console.print("[cyan]Available Workflows:[/cyan]") + console.print(" • test_connection() - Test connection") + console.print(" • make_call() - Make API call") + console.print(" • list_models() - List models") + console.print(" • check_status() - Check status") + console.print() + + +def print_help(): + """Print module help with argparse""" + import argparse + + parser = argparse.ArgumentParser( + prog="python3 api.py", + description="OpenRouter Client - Manage LLM API connections", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +COMMANDS: + test - Test OpenRouter connection + call - Make API call to model + models - List available models + status - Check connection status + +USAGE: + python3 api.py test + python3 api.py call [--model MODEL] + python3 api.py models + python3 api.py status + +ARGUMENTS: + prompt - Prompt to send to the model + --model - Model to use (optional) + +EXAMPLES: + # Test OpenRouter connection + python3 api.py test + + # Make an API call + python3 api.py call "What is AI?" --model gpt-4 + + # List available models + python3 api.py models + + # Check connection status + python3 api.py status + """ + ) + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # test command + subparsers.add_parser("test", help="Test OpenRouter connection") + + # call command + call_parser = subparsers.add_parser("call", help="Make API call to model") + call_parser.add_argument("prompt", help="Prompt to send") + call_parser.add_argument("--model", help="Model to use") + + # models command + subparsers.add_parser("models", help="List available models") + + # status command + subparsers.add_parser("status", help="Check connection status") + + console.print(parser.format_help()) + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle OpenRouter client commands + + Args: + command: Command name + args: Command arguments + + Returns: + True if command was handled, False otherwise + """ + try: + if command not in ["test", "call", "models", "status"]: + return False + + # Log operation + json_handler.log_operation(f"openrouter_{command}", {"command": command}) + + if command == "test": + test_connection() + elif command == "call": + make_call(args) + elif command == "models": + list_models() + elif command == "status": + check_status() + + return True + except Exception as e: + logger.error(f"Error in openrouter_client.handle_command: {e}") + raise + + +def test_connection(): + """Orchestrate connection test workflow""" + header("Test OpenRouter Connection") + console.print() + + # Get API key via handler + api_key = keys.get_api_key("openrouter") + + if not api_key: + error("No API key configured") + return + + warning("Testing connection...") + + # TODO: Call handler to test connection when implemented + success("Connection test successful") + + +def make_call(args: List[str]): + """Orchestrate API call workflow""" + header("OpenRouter API Call") + console.print() + + # TODO: Parse args for model, messages + # TODO: Call handler to make request + warning("API call workflow - TODO") + + +def list_models(): + """Orchestrate list models workflow""" + header("Available Models") + console.print() + + # Get API key via handler + api_key = keys.get_api_key("openrouter") + + if not api_key: + error("No API key configured") + return + + warning("Fetching available models...") + + # Call handler to fetch models + model_list = models.fetch_models_from_api(api_key) + + if model_list: + success(f"Found {len(model_list)} models") + for model in model_list[:10]: + console.print(f" - {model}") + if len(model_list) > 10: + console.print(f" ... and {len(model_list) - 10} more") + else: + error("Failed to fetch models") + + +def check_status(): + """Orchestrate status check workflow""" + header("OpenRouter Client Status") + console.print() + + # TODO: Call handlers for status when implemented + warning("Client status - TODO") + + +# ============================================= +# PUBLIC API - Re-export handler functions +# ============================================= + +def get_response(prompt: str, caller: str | None = None, model: str | None = None, **kwargs): + """ + Public API: Get response from OpenRouter + + This is a re-export of the handler function for cross-branch access. + Flow and other branches should use this module-level function instead + of importing directly from handlers. + + Args: + prompt: User prompt text + caller: Module making the request (auto-detected if not provided) + model: Model to use (required - caller must provide from branch config) + **kwargs: Additional OpenAI API parameters + + Returns: + Dict with 'content', 'id', 'model' or None on failure + + Example: + >>> from aipass.api.apps.modules.openrouter_client import get_response + >>> response = get_response("Hello", caller="flow", model="anthropic/claude-3.5-sonnet") + >>> if response: + ... print(response['content']) + """ + return client.get_response(prompt, caller, model, **kwargs) + + +if __name__ == "__main__": + """Standalone execution mode""" + args = sys.argv[1:] + + # Show introspection when run without arguments + if len(args) == 0: + print_introspection() + sys.exit(0) + + # Show help for explicit help flags + if args[0] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + # Execute command + command = args[0] + remaining_args = args[1:] if len(args) > 1 else [] + + if handle_command(command, remaining_args): + sys.exit(0) + else: + console.print() + console.print(f"[red]Unknown command: {command}[/red]") + console.print() + console.print("Run [dim]python3 openrouter_client.py --help[/dim] for available commands") + console.print() + sys.exit(1) diff --git a/src/aipass/api/apps/modules/telegram_bot.py b/src/aipass/api/apps/modules/telegram_bot.py new file mode 100644 index 00000000..080cf562 --- /dev/null +++ b/src/aipass/api/apps/modules/telegram_bot.py @@ -0,0 +1,416 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: telegram_bot.py - Telegram multi-bot module (replaces telegram_bridge.py + telegram_chat.py) +# Date: 2026-02-24 +# Version: 1.0.0 +# Category: api/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-24): Initial - unified multi-bot public API module +# +# CODE STANDARDS: +# - Module layer: thin orchestration over handler functions +# - This is the PUBLIC API that other branches import from +# - Replaces telegram_bridge.py and telegram_chat.py +# ============================================= + +""" +Telegram Multi-Bot Module - Public API + +Unified entry point for the AIPass multi-bot Telegram architecture. +Re-exports all handler internals so other branches can import them +without triggering the cross-branch handler guard. + +Replaces: + - telegram_bridge.py (bridge operations) + - telegram_chat.py (direct chat + standards re-exports) + +Usage from other branches: + from aipass.api.apps.modules.telegram_bot import BaseBot, BranchPlugin + from aipass.api.apps.modules.telegram_bot import create_bot, delete_bot + from aipass.api.apps.modules.telegram_bot import list_bots, get_bot + from aipass.api.apps.modules.telegram_bot import load_bot_config + from aipass.api.apps.modules.telegram_bot import ( + STANDARD_COMMANDS, build_help_text, build_welcome_text, + build_status_text, parse_command, handle_standard_command, + ) +""" + +# Infrastructure +import sys +from pathlib import Path + +# Prax logger and CLI utilities +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console, header, success, error, warning + +# Handler imports (operations) +from aipass.api.apps.handlers.telegram import bot_operations + +# ============================================= +# RE-EXPORTS FROM HANDLERS (Public API) +# ============================================= + +from aipass.api.apps.handlers.telegram.base_bot import BaseBot +from aipass.api.apps.handlers.telegram.branch_plugin import BranchPlugin +from aipass.api.apps.handlers.telegram.bot_registry import ( + list_bots, + get_bot, + register_bot, + get_bot_by_branch, +) +from aipass.api.apps.handlers.telegram.bot_factory import create_bot, delete_bot +from aipass.api.apps.handlers.telegram.config import load_bot_config +from aipass.api.apps.handlers.telegram.telegram_standards import ( + STANDARD_COMMANDS, + PROCESSING_MSG, + build_help_text, + build_welcome_text, + build_status_text, + build_botfather_commands, + parse_command, + handle_standard_command, +) + +# ============================================= +# MODULE INTROSPECTION +# ============================================= + + +def print_introspection() -> None: + """Show module introspection - connected handlers and capabilities""" + console.print() + header("Telegram Multi-Bot Module Introspection") + console.print() + + console.print("[cyan]Purpose:[/cyan] Multi-bot Telegram architecture for AIPass") + console.print() + + console.print("[cyan]Connected Handlers:[/cyan]") + console.print(" - api.apps.handlers.telegram.base_bot") + console.print(" - api.apps.handlers.telegram.branch_plugin") + console.print(" - api.apps.handlers.telegram.bot_registry") + console.print(" - api.apps.handlers.telegram.bot_factory") + console.print(" - api.apps.handlers.telegram.bot_operations") + console.print(" - api.apps.handlers.telegram.config") + console.print(" - api.apps.handlers.telegram.telegram_standards") + console.print() + + console.print("[cyan]Available Commands:[/cyan]") + console.print(" - start - Start a specific bot (polling loop)") + console.print(" - stop - Stop a bot's systemd service") + console.print(" - status [bot_id] - Show bot status (single or all)") + console.print(" - list - List all registered bots") + console.print(" - create [options] - Create new bot") + console.print(" - delete - Delete a bot") + console.print() + + +def print_help() -> None: + """Print module help with argparse""" + import argparse + + parser = argparse.ArgumentParser( + prog="python3 telegram_bot.py", + description="Telegram Multi-Bot Module - AIPass multi-bot management", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +COMMANDS: + start - Start a bot (long-polling, blocks) + stop - Stop a bot's systemd service + status [bot_id] - Show bot status (single or all) + list - List all registered bots + create [opts] - Create a new bot + delete - Delete a bot + +CREATE OPTIONS: + --branch - Associate with an AIPass branch + --work-dir - Working directory for Claude sessions + +EXAMPLES: + # Start a bot (blocks until Ctrl+C) + python3 telegram_bot.py start dev_central + + # Check all bot statuses + python3 telegram_bot.py status + + # Create a new branch bot + python3 telegram_bot.py create dev_central 123:ABC --branch dev_central + + # List all bots + python3 telegram_bot.py list + """ + ) + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + start_p = subparsers.add_parser("start", help="Start a bot") + start_p.add_argument("bot_id", help="Bot identifier") + + stop_p = subparsers.add_parser("stop", help="Stop a bot's systemd service") + stop_p.add_argument("bot_id", help="Bot identifier") + + status_p = subparsers.add_parser("status", help="Show bot status") + status_p.add_argument("bot_id", nargs="?", help="Bot identifier (optional)") + + subparsers.add_parser("list", help="List all registered bots") + + create_p = subparsers.add_parser("create", help="Create a new bot") + create_p.add_argument("bot_id", help="Bot identifier") + create_p.add_argument("token", help="Telegram bot token") + create_p.add_argument("--branch", help="AIPass branch name") + create_p.add_argument("--work-dir", help="Working directory") + + delete_p = subparsers.add_parser("delete", help="Delete a bot") + delete_p.add_argument("bot_id", help="Bot identifier") + + console.print(parser.format_help()) + + +# ============================================= +# COMMAND HANDLING +# ============================================= + + +def handle_command(command: str, args: list) -> bool: + """ + Handle module commands routed via drone. + + Args: + command: Command name (e.g., "telegram_bot") + args: Command arguments + + Returns: + True if command was handled, False to pass through + """ + if not args: + return False + + subcommand = args[0] + + if subcommand == "start" and len(args) >= 2: + _cmd_start(args[1]) + return True + + elif subcommand == "stop" and len(args) >= 2: + _cmd_stop(args[1]) + return True + + elif subcommand == "status": + bot_id = args[1] if len(args) >= 2 else None + _cmd_status(bot_id) + return True + + elif subcommand == "list": + _cmd_list() + return True + + elif subcommand == "create" and len(args) >= 3: + _cmd_create(args[1:]) + return True + + elif subcommand == "delete" and len(args) >= 2: + _cmd_delete(args[1]) + return True + + elif subcommand == "help": + print_help() + return True + + return False + + +# ============================================= +# THIN ORCHESTRATION (delegates to bot_operations handler) +# ============================================= + + +def _cmd_start(bot_id: str) -> None: + """Orchestrate bot start: header, delegate, exit.""" + header(f"Starting Bot: {bot_id}") + console.print() + + config = load_bot_config(bot_id) + if not config: + error(f"No config found for bot '{bot_id}'") + console.print("[dim]Expected: ~/.aipass/telegram_bots/{bot_id}.json[/dim]") + return + + if not config.get("bot_token"): + error(f"No bot_token in config for '{bot_id}'") + return + + bot_name = config.get("bot_name", f"AIPass {bot_id} Bot") + logger.info("Starting bot '%s' (%s)", bot_id, bot_name) + success(f"Launching {bot_name}...") + console.print() + + exit_code = bot_operations.start_bot(bot_id) + if exit_code is None: + error(f"Bot '{bot_id}' failed to start") + else: + sys.exit(exit_code) + + +def _cmd_stop(bot_id: str) -> None: + """Orchestrate bot stop: header, delegate, display result.""" + header(f"Stopping Bot: {bot_id}") + console.print() + + ok, msg = bot_operations.stop_bot(bot_id) + if ok: + logger.info("Stopped bot '%s'", bot_id) + success(msg) + else: + logger.warning("Failed to stop bot '%s': %s", bot_id, msg) + error(msg) + + console.print() + + +def _cmd_status(bot_id: str | None = None) -> None: + """Orchestrate status display: header, delegate, format.""" + if bot_id: + header(f"Bot Status: {bot_id}") + else: + header("All Bots Status") + console.print() + + bots = bot_operations.get_status(bot_id) + if not bots: + if bot_id: + error(f"Bot '{bot_id}' not found in registry") + else: + warning("No bots registered") + console.print("[dim]Use 'create' to add a new bot[/dim]") + console.print() + return + + for bot in bots: + for line in bot_operations.format_bot_details(bot): + console.print(f" [cyan]{line}[/cyan]") + console.print() + + +def _cmd_list() -> None: + """Orchestrate bot listing: header, delegate, format.""" + header("Registered Bots") + console.print() + + bots = bot_operations.get_all_bots() + if not bots: + warning("No bots registered") + console.print("[dim]Use 'create' to add a new bot[/dim]") + console.print() + return + + for line in bot_operations.format_bot_table(bots): + console.print(line) + console.print() + + +def _cmd_create(args: list) -> None: + """Orchestrate bot creation: parse args, delegate, display result.""" + parsed = bot_operations.parse_create_args(args) + if not parsed: + error("Usage: create [--branch ] [--work-dir ]") + return + + bot_id = parsed["bot_id"] + header(f"Creating Bot: {bot_id}") + console.print() + + result = create_bot( + bot_id=bot_id, + bot_token=parsed["bot_token"], + branch_name=parsed["branch_name"], + work_dir=parsed["work_dir"], + ) + + if result: + logger.info("Bot created via module: %s", bot_id) + success(f"Bot '{bot_id}' created successfully") + console.print() + details = bot_operations.format_bot_details({ + "bot_id": result["bot_id"], + "username": result["username"], + "branch_name": result.get("branch_name"), + "work_dir": result["work_dir"], + "status": "active", + "service_name": result["service_name"], + }) + for line in details: + console.print(f" [cyan]{line}[/cyan]") + else: + logger.warning("Bot creation failed for '%s'", bot_id) + error(f"Failed to create bot '{bot_id}'. Check logs for details.") + + console.print() + + +def _cmd_delete(bot_id: str) -> None: + """Orchestrate bot deletion: verify, delegate, display result.""" + header(f"Deleting Bot: {bot_id}") + console.print() + + bot = get_bot(bot_id) + if not bot: + error(f"Bot '{bot_id}' not found in registry") + console.print() + return + + result = delete_bot(bot_id) + + if result: + logger.info("Bot deleted via module: %s", bot_id) + success(f"Bot '{bot_id}' deleted successfully") + else: + logger.warning("Bot deletion failed for '%s'", bot_id) + error(f"Failed to delete bot '{bot_id}'. Check logs for details.") + + console.print() + + +# ============================================= +# STANDALONE EXECUTION +# ============================================= + +if __name__ == "__main__": + """Standalone execution mode""" + args = sys.argv[1:] + + # Show introspection when run without arguments + if len(args) == 0: + print_introspection() + sys.exit(0) + + # Show help for explicit help flags + if args[0] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + # Execute command directly (standalone mode, not drone-routed) + command = args[0] + + if command == "start" and len(args) >= 2: + _cmd_start(args[1]) + elif command == "stop" and len(args) >= 2: + _cmd_stop(args[1]) + elif command == "status": + bot_id = args[1] if len(args) >= 2 else None + _cmd_status(bot_id) + elif command == "list": + _cmd_list() + elif command == "create" and len(args) >= 3: + _cmd_create(args[1:]) + elif command == "delete" and len(args) >= 2: + _cmd_delete(args[1]) + else: + console.print() + console.print(f"[red]Unknown command: {command}[/red]") + console.print() + console.print("Run [dim]python3 telegram_bot.py --help[/dim] for available commands") + console.print() + sys.exit(1) diff --git a/src/aipass/api/apps/modules/telegram_service.py b/src/aipass/api/apps/modules/telegram_service.py new file mode 100644 index 00000000..fb84ea30 --- /dev/null +++ b/src/aipass/api/apps/modules/telegram_service.py @@ -0,0 +1,231 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: telegram_service.py - Telegram Service Control Module +# Date: 2026-02-03 +# Version: 1.0.0 +# Category: api/modules +# CODE STANDARDS: Seed v1.0.0 +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-03): Initial module - systemd service control for telegram bridge +# ============================================= + +""" +Telegram Service Control Module + +Manages the telegram-bridge systemd user service: +- drone @api telegram start → Start service +- drone @api telegram stop → Stop service +- drone @api telegram status → Check service status +""" + +import sys +from pathlib import Path + +from typing import List +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console, header, success, error, warning +from aipass.api.apps.handlers.telegram_service import service + + +def print_introspection() -> None: + """Show module introspection""" + console.print() + header("Telegram Service Control Introspection") + console.print() + + console.print("[cyan]Purpose:[/cyan] Manage telegram-bridge systemd service") + console.print() + + console.print("[cyan]Connected Handlers:[/cyan]") + console.print(" • api.apps.handlers.telegram_service.service") + console.print() + + console.print("[cyan]Available Commands:[/cyan]") + console.print(" • telegram start - Start the service") + console.print(" • telegram stop - Stop the service") + console.print(" • telegram status - Check service status") + console.print(" • telegram logs - View recent logs") + console.print() + + console.print("[cyan]Service:[/cyan]") + console.print(f" • Name: {service.SERVICE_NAME}.service") + console.print(" • Type: systemd user service") + console.print() + + +def print_help() -> None: + """Print module help""" + import argparse + + parser = argparse.ArgumentParser( + prog="drone @api telegram", + description="Telegram Service Control - Manage the telegram-bridge service", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +SUBCOMMANDS: + start - Start the telegram-bridge service + stop - Stop the telegram-bridge service + status - Check service status + logs - View recent service logs + +USAGE: + drone @api telegram start + drone @api telegram stop + drone @api telegram status + drone @api telegram logs + +EXAMPLES: + # Start the bot + drone @api telegram start + + # Check if running + drone @api telegram status + + # Stop the bot + drone @api telegram stop + + # View logs + drone @api telegram logs + """ + ) + console.print(parser.format_help()) + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle telegram service commands + + Args: + command: Command name (should be 'telegram') + args: Subcommand and arguments + + Returns: + True if command was handled, False otherwise + """ + if command != "telegram": + return False + + try: + if not args: + print_help() + return True + + subcommand = args[0] + + if subcommand == "start": + _handle_start() + elif subcommand == "stop": + _handle_stop() + elif subcommand == "status": + _handle_status() + elif subcommand == "logs": + _handle_logs() + elif subcommand in ["--help", "-h", "help"]: + print_help() + else: + error(f"Unknown subcommand: {subcommand}") + console.print() + console.print("[dim]Use 'drone @api telegram --help' for usage[/dim]") + + return True + except Exception as e: + logger.error("Error in telegram_service.handle_command: %s", str(e)) + raise + + +def _handle_start() -> None: + """Orchestrate service start""" + header("Telegram Bridge Service") + console.print() + + ok, msg = service.start_service() + + if ok: + success(msg) + console.print() + console.print("[dim]Check status: drone @api telegram status[/dim]") + console.print("[dim]View logs: drone @api telegram logs[/dim]") + else: + error(f"Failed to start service: {msg}") + + +def _handle_stop() -> None: + """Orchestrate service stop""" + header("Telegram Bridge Service") + console.print() + + ok, msg = service.stop_service() + + if ok: + success(msg) + else: + error(f"Failed to stop service: {msg}") + + +def _handle_status() -> None: + """Orchestrate status check""" + header("Telegram Bridge Service Status") + console.print() + + status_code, output = service.get_status() + + if status_code == "running": + success("Service is running") + elif status_code == "stopped": + warning("Service is stopped") + elif status_code == "not_found": + error("Service not found - run 'systemctl --user daemon-reload'") + else: + warning("Unknown status") + + console.print() + + for line in output.split("\n"): + line = line.strip() + if any(k in line for k in ["Active:", "Main PID:", "Memory:", "CPU:"]): + console.print(f" {line}") + + console.print() + console.print("[dim]Logs: drone @api telegram logs[/dim]") + + +def _handle_logs() -> None: + """Orchestrate log retrieval""" + header("Telegram Bridge Service Logs") + console.print() + + ok, content = service.get_logs(30) + + if not ok: + warning(content) + return + + console.print(f"[dim]Showing last 30 lines[/dim]") + console.print() + + for line in content.split("\n"): + console.print(line.rstrip()) + + +if __name__ == "__main__": + """Standalone execution mode""" + args = sys.argv[1:] + + if len(args) == 0: + print_introspection() + sys.exit(0) + + if args[0] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + if handle_command("telegram", args): + sys.exit(0) + else: + console.print() + console.print(f"[red]Unknown command: {args[0]}[/red]") + console.print() + sys.exit(1) diff --git a/src/aipass/api/apps/modules/usage_tracker.py b/src/aipass/api/apps/modules/usage_tracker.py new file mode 100644 index 00000000..48d7a1ae --- /dev/null +++ b/src/aipass/api/apps/modules/usage_tracker.py @@ -0,0 +1,276 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: usage_tracker.py - Usage Tracking Module +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: api/modules +# CODE STANDARDS: Seed v1.0.0 +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial module - orchestrates usage tracking +# ============================================= + +""" +Usage Tracking Module + +Orchestrates API usage monitoring operations: +- Track generation usage +- Display statistics +- Session summaries +- Cleanup old data +""" + +import sys +from pathlib import Path + +from typing import List +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console, header, success, error, warning, section +from aipass.api.apps.handlers.json import json_handler +from aipass.api.apps.handlers.usage import tracking, aggregation, cleanup + + +def print_introspection(): + """Show module introspection - connected handlers and capabilities""" + console.print() + header("Usage Tracker Module Introspection") + console.print() + + console.print("[cyan]Purpose:[/cyan] API usage monitoring and cost tracking") + console.print() + + console.print("[cyan]Connected Handlers:[/cyan]") + console.print(" • api.apps.handlers.usage.tracking") + console.print(" • api.apps.handlers.usage.aggregation") + console.print(" • api.apps.handlers.usage.cleanup") + console.print(" • api.apps.handlers.json.json_handler") + console.print() + + console.print("[cyan]Available Workflows:[/cyan]") + console.print(" • track_usage() - Track usage") + console.print(" • show_stats() - Show statistics") + console.print(" • show_session() - Show session") + console.print(" • show_caller_usage() - Caller stats") + console.print(" • cleanup_data() - Clean old data") + console.print() + + +def print_help(): + """Print module help with argparse""" + import argparse + + parser = argparse.ArgumentParser( + prog="python3 api.py", + description="Usage Tracker - Monitor API usage and costs", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +COMMANDS: + track - Track API usage + stats - Show usage statistics + session - Show session data + caller-usage - Show usage by caller + cleanup - Clean up old usage data + +USAGE: + python3 api.py track + python3 api.py stats + python3 api.py session + python3 api.py caller-usage + python3 api.py cleanup [days] + +ARGUMENTS: + caller - Caller identifier + days - Number of days to retain (default: 30) + +EXAMPLES: + # Track usage for a caller + python3 api.py track my_application + + # Show usage statistics + python3 api.py stats + + # Show session data + python3 api.py session + + # Show usage for specific caller + python3 api.py caller-usage my_application + + # Cleanup data older than 60 days + python3 api.py cleanup 60 + """ + ) + + subparsers = parser.add_subparsers(dest="command", help="Available commands") + + # track command + track_parser = subparsers.add_parser("track", help="Track API usage") + track_parser.add_argument("caller", help="Caller identifier") + + # stats command + subparsers.add_parser("stats", help="Show usage statistics") + + # session command + subparsers.add_parser("session", help="Show session data") + + # caller-usage command + caller_parser = subparsers.add_parser("caller-usage", help="Show usage by caller") + caller_parser.add_argument("caller", help="Caller identifier") + + # cleanup command + cleanup_parser = subparsers.add_parser("cleanup", help="Clean up old usage data") + cleanup_parser.add_argument("days", nargs="?", default="30", help="Days to retain (default: 30)") + + console.print(parser.format_help()) + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle usage tracking commands + + Args: + command: Command name + args: Command arguments + + Returns: + True if command was handled, False otherwise + """ + try: + if command not in ["track", "stats", "session", "caller-usage", "cleanup"]: + return False + + # Log operation + json_handler.log_operation(f"usage_{command}", {"command": command}) + + if command == "track": + track_usage(args) + elif command == "stats": + show_stats() + elif command == "session": + show_session() + elif command == "caller-usage": + show_caller_usage(args) + elif command == "cleanup": + cleanup_data(args) + + return True + except Exception as e: + logger.error(f"Error in usage_tracker.handle_command: {e}") + raise + + +def track_usage(args: List[str]): + """Orchestrate usage tracking workflow""" + header("Track API Usage") + console.print() + + # TODO: Parse args for generation_id, caller, model + warning("Usage tracking workflow - TODO") + + +def show_stats(): + """Orchestrate statistics display workflow""" + header("Usage Statistics") + console.print() + + # Call handler for session summary + stats = aggregation.get_session_summary() + + if stats: + console.print(f" Total Requests: {stats.get('total_requests', 0)}") + console.print(f" Total Cost: ${stats.get('total_cost', 0.0):.6f}") + console.print(f" Total Tokens: {stats.get('total_tokens', 0)}") + else: + warning("No usage data available") + + +def show_session(): + """Orchestrate session summary workflow""" + header("Session Summary") + console.print() + + # Call handler for session data + summary = aggregation.get_session_summary() + + if summary: + console.print(f" Session Requests: {summary.get('total_requests', 0)}") + console.print(f" Session Cost: ${summary.get('total_cost', 0.0):.6f}") + console.print(f" Session Tokens: {summary.get('total_tokens', 0)}") + else: + warning("No session data available") + + +def show_caller_usage(args: List[str]): + """Orchestrate caller usage display workflow""" + if not args: + error("Caller name required") + return + + caller = args[0] + + header(f"Usage for Caller: {caller}") + console.print() + + # Call handler for caller stats + usage = aggregation.get_caller_usage(caller) + + if usage: + console.print(f" Requests: {usage.get('requests', 0)}") + console.print(f" Total Cost: ${usage.get('total_cost', 0.0):.6f}") + console.print(f" Total Tokens: {usage.get('total_tokens', 0)}") + else: + warning(f"No usage data found for caller: {caller}") + + +def cleanup_data(args: List[str]): + """Orchestrate cleanup workflow""" + days = int(args[0]) if args else 30 + + header(f"Cleanup Old Data (retain {days} days)") + console.print() + + # Call handler for cleanup + # Navigate: usage_tracker.py -> modules/ -> apps/ -> api/ + API_JSON_DIR = Path(__file__).resolve().parent.parent.parent / "api_json" + data_path = API_JSON_DIR / "usage_tracker_data.json" + if cleanup.cleanup_old_data(data_path, days): + success(f"Cleaned up data older than {days} days") + + # Fire trigger event + try: + from trigger.apps.modules.core import trigger + trigger.fire('usage_data_cleaned', days=days, data_path=str(data_path)) + except ImportError: + pass # Silent fallback + else: + error("Cleanup failed") + + +if __name__ == "__main__": + """Standalone execution mode""" + args = sys.argv[1:] + + # Show introspection when run without arguments + if len(args) == 0: + print_introspection() + sys.exit(0) + + # Show help for explicit help flags + if args[0] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + # Execute command + command = args[0] + remaining_args = args[1:] if len(args) > 1 else [] + + if handle_command(command, remaining_args): + sys.exit(0) + else: + console.print() + console.print(f"[red]Unknown command: {command}[/red]") + console.print() + console.print("Run [dim]python3 usage_tracker.py --help[/dim] for available commands") + console.print() + sys.exit(1) diff --git a/src/aipass/api/apps/plugins/__init__.py b/src/aipass/api/apps/plugins/__init__.py index e69de29b..69b056dd 100644 --- a/src/aipass/api/apps/plugins/__init__.py +++ b/src/aipass/api/apps/plugins/__init__.py @@ -0,0 +1 @@ +# Plugins package - Pluggable components for branch capabilities diff --git a/src/aipass/cli/apps/__init__.py b/src/aipass/cli/apps/__init__.py index ebc51438..5f6b8dc0 100644 --- a/src/aipass/cli/apps/__init__.py +++ b/src/aipass/cli/apps/__init__.py @@ -1 +1,21 @@ -# CLI apps package +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: apps/__init__.py +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: cli/apps +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial implementation - Public API +# ============================================= + +""" +Apps Package - Branch application modules and handlers + +Provides core application modules, handlers, and extension system for CLI branch. + +Usage: + from aipass.cli.apps import handlers +""" diff --git a/src/aipass/cli/apps/branch.py b/src/aipass/cli/apps/branch.py deleted file mode 100644 index 7085d0c4..00000000 --- a/src/aipass/cli/apps/branch.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -CLI Branch - Main Orchestrator - -Auto-discovery architecture: -- Scans modules/ directory for .py files with handle_command() -- Routes commands to discovered modules automatically -- No manual imports or routing needed -""" - -import sys -import importlib -from pathlib import Path -from typing import List, Any - -from aipass.prax import logger - -# ============================================================================= -# MODULE DISCOVERY -# ============================================================================= - -MODULES_DIR = Path(__file__).parent / "modules" - - -def discover_modules() -> List[Any]: - """Auto-discover modules in modules/ directory.""" - modules = [] - - if not MODULES_DIR.exists(): - return modules - - for file_path in MODULES_DIR.glob("*.py"): - if file_path.name.startswith("_"): - continue - - module_name = f"apps.modules.{file_path.stem}" - - try: - module = importlib.import_module(module_name) - if hasattr(module, "handle_command"): - modules.append(module) - except Exception as e: - logger.error(f"[CLI] Failed to load module {module_name}: {e}") - - return modules - - -def route_command(command: str, args: List[str], modules: List[Any]) -> bool: - """Route command to appropriate module.""" - for module in modules: - try: - if module.handle_command(command, args): - return True - except Exception as e: - logger.error(f"[CLI] Module {module.__name__} error: {e}") - return False - - -# ============================================================================= -# MAIN ENTRY POINT -# ============================================================================= - -def main(): - """Main entry point - routes commands or shows help.""" - modules = discover_modules() - args = sys.argv[1:] - - if len(args) == 0 or args[0] in ["--help", "-h", "help"]: - print(f"CLI - {len(modules)} modules discovered") - for module in modules: - name = module.__name__.split(".")[-1] - desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" - print(f" {name:20} {desc}") - return 0 - - command = args[0] - remaining = args[1:] if len(args) > 1 else [] - - if route_command(command, remaining, modules): - return 0 - - print(f"Unknown command: {command}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/aipass/cli/apps/cli.py b/src/aipass/cli/apps/cli.py new file mode 100755 index 00000000..2718b0ad --- /dev/null +++ b/src/aipass/cli/apps/cli.py @@ -0,0 +1,373 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: cli.py - CLI Branch Entry Point [SHOWROOM] +# Date: 2025-11-12 +# Version: 0.2.0 +# Category: cli +# +# CHANGELOG (Max 5 entries): +# - v0.2.0 (2025-11-15): Added print_introspection() - the CLI showroom +# - v0.1.0 (2025-11-12): Initial structure - service provider for display/output +# +# CODE STANDARDS: +# - SHOWROOM - demonstrates CLI capabilities and architecture +# ============================================= + +""" +CLI Branch - Universal Display/Output Service Provider + +PURPOSE: Provides consistent CLI display across all AIPass branches. +Similar to Prax (logging service), CLI is a service provider for output. + +SERVICES PROVIDED: +- Display: headers, success/error/warning messages, sections +- Templates: Reusable output patterns (operation_start, operation_complete) +- Formatting: Tables, lists, progress indicators + +ARCHITECTURE: +- apps/modules/ = PUBLIC API (what other branches import) +- apps/handlers/ = PRIVATE implementation (internal use only) +""" + +import sys +import importlib +from typing import List + +# Prax logger +from aipass.prax.apps.modules.logger import system_logger as logger + +# Rich library components +from rich.table import Table +from rich.columns import Columns +from rich.panel import Panel +from rich.text import Text +from rich.rule import Rule +from rich import box + +# CLI modules (showcasing our own services!) +from aipass.cli.apps.modules.display import console as CONSOLE, header, success, error, warning, section +from aipass.cli.apps.modules.templates import operation_start, operation_complete + + +# ============================================================================= +# INTROSPECTION DISPLAY +# ============================================================================= + +def get_handler_exports(handler_package_name: str) -> List[str]: + """Get exported functions from handler package""" + try: + handler_module = importlib.import_module(f"aipass.cli.apps.handlers.{handler_package_name}") + return getattr(handler_module, '__all__', []) + except Exception: + return [] + + +def print_introspection(): + """ + Display discovered modules and handlers - Quick reference + + Called when cli.py runs with no arguments. + Shows what's connected to CLI entry point (modules and handler domains). + """ + CONSOLE.print() + CONSOLE.print("[bold cyan]CLI - Command Line Interface Branch[/bold cyan]") + CONSOLE.print() + CONSOLE.print("[dim]Universal Display & Output Service Provider[/dim]") + CONSOLE.print() + + # Discover modules + CONSOLE.print("[yellow]Discovered Modules:[/yellow] 3") + CONSOLE.print() + CONSOLE.print(" [cyan]•[/cyan] display") + CONSOLE.print(" [cyan]•[/cyan] templates") + CONSOLE.print(" [cyan]•[/cyan] console (Rich library wrapper)") + CONSOLE.print() + CONSOLE.print("[dim]Run 'python3 cli.py --help' for usage information[/dim]") + CONSOLE.print() + + +def print_help(): + """Display Rich-formatted help - CLI services showcase! + + Demonstrates: + - How to import and use CLI modules + - Rich formatting patterns (headers, tables, panels, columns) + - Drone compliance with Commands line + - Beautiful terminal presentation + """ + + CONSOLE.print() + + # ============================================================================= + # SECTION 1: MAIN HEADER + # Demonstrates: header() function from cli.apps.modules.display + # ============================================================================= + header("CLI - Display & Templates Service Provider") + + CONSOLE.print("[dim]Universal display and output formatting for all AIPass branches[/dim]") + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # ============================================================================= + # SECTION 2: WHAT IS CLI? + # Demonstrates: Rich text styling with [bold], [cyan], [green], etc. + # ============================================================================= + CONSOLE.print("[bold cyan]WHAT IS CLI?[/bold cyan]") + CONSOLE.print() + CONSOLE.print("CLI is the [bold]Display & Templates Service[/bold] - like Prax for logging:") + CONSOLE.print(" [green]✓[/green] Centralized display formatting (headers, tables, panels)") + CONSOLE.print(" [green]✓[/green] Reusable templates for common operations") + CONSOLE.print(" [green]✓[/green] Rich library integration for beautiful output") + CONSOLE.print(" [green]✓[/green] Consistent styling across all AIPass branches") + CONSOLE.print() + CONSOLE.print("Update CLI once → All branches instantly benefit from improvements") + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # ============================================================================= + # SECTION 3: PUBLIC SERVICES + # Demonstrates: Table() from rich.table with columns, styling + # ============================================================================= + CONSOLE.print("[bold cyan]PUBLIC SERVICES (apps/modules/):[/bold cyan]") + CONSOLE.print() + + services_table = Table(show_header=True, header_style="bold cyan", border_style="dim") + services_table.add_column("Module", style="green") + services_table.add_column("Key Functions", style="white") + services_table.add_column("Purpose", style="dim") + + services_table.add_row( + "display", + "header(), success(), error(), warning(), section()", + "Terminal output formatting" + ) + services_table.add_row( + "templates", + "operation_start(), operation_complete()", + "Standard operation patterns" + ) + + CONSOLE.print(services_table) + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # ============================================================================= + # SECTION 4: IMPORT EXAMPLES + # Demonstrates: Code examples with [dim] styling and [yellow] for labels + # ============================================================================= + CONSOLE.print("[bold cyan]HOW TO IMPORT CLI SERVICES:[/bold cyan]") + CONSOLE.print() + + CONSOLE.print("[yellow]Display functions:[/yellow]") + CONSOLE.print("[dim] from aipass.cli.apps.modules.display import header, success, error, warning[/dim]") + CONSOLE.print() + + CONSOLE.print("[yellow]Templates:[/yellow]") + CONSOLE.print("[dim] from aipass.cli.apps.modules.templates import operation_start, operation_complete[/dim]") + CONSOLE.print() + + CONSOLE.print("[yellow]Rich console:[/yellow]") + CONSOLE.print("[dim] from rich.console import Console[/dim]") + CONSOLE.print("[dim] CONSOLE = Console() # Access Rich library directly[/dim]") + CONSOLE.print() + + CONSOLE.print("─" * 70) + CONSOLE.print() + + # ============================================================================= + # SECTION 5: USAGE EXAMPLES + # Demonstrates: Columns() for side-by-side layout + # ============================================================================= + CONSOLE.print("[bold cyan]USAGE EXAMPLES:[/bold cyan]") + CONSOLE.print() + + example_cols = [ + "[yellow]Display header:[/yellow]\n[dim]header('Create Branch',\n {'Name': 'feature',\n 'Type': 'module'})[/dim]", + "[yellow]Show success:[/yellow]\n[dim]success('Files created',\n items=12,\n time='2.3s')[/dim]", + "[yellow]Show error:[/yellow]\n[dim]error('Path not found',\n suggestion='Check spelling')[/dim]" + ] + + CONSOLE.print(Columns(example_cols, equal=True, expand=True)) + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # ============================================================================= + # SECTION 6: ARCHITECTURE + # Demonstrates: Panel() for highlighted content blocks + # ============================================================================= + CONSOLE.print("[bold cyan]ARCHITECTURE:[/bold cyan]") + CONSOLE.print() + + arch_text = """[bold]CLI Branch Structure:[/bold] + +[green]✓[/green] apps/modules/ = PUBLIC API (what branches import) + - display.py Display functions (header, success, error, etc.) + - templates.py Standard operation patterns + +[green]✓[/green] apps/handlers/ = PRIVATE (internal implementation) + - display/ Header, message formatters + - templates/ Operation patterns + +[green]✓[/green] Rich library = Underlying formatting engine + - Console, Table, Panel, Columns, Text styling""" + + CONSOLE.print(Panel(arch_text, border_style="green", padding=(1, 2), box=box.ROUNDED)) + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # ============================================================================= + # SECTION 8: FOR BRANCHES USING CLI + # Demonstrates: Service provider pattern explanation + # ============================================================================= + CONSOLE.print("[bold cyan]FOR BRANCHES USING CLI:[/bold cyan]") + CONSOLE.print() + + workflow_text = """[bold]Replace custom display code with CLI imports:[/bold] + + [green]✓[/green] Displaying headers? → [dim]from aipass.cli.apps.modules.display import header[/dim] + [green]✓[/green] Showing success/errors? → [dim]from aipass.cli.apps.modules.display import success, error[/dim] + [green]✓[/green] Operation templates? → [dim]from aipass.cli.apps.modules.templates import operation_*[/dim] + [green]✓[/green] Rich formatting? → [dim]from rich.console import Console[/dim] + +[bold]Benefits:[/bold] + • No code duplication across branches + • Consistent formatting system-wide + • Update CLI once → affects all branches instantly + • Rich library integration done once, used everywhere""" + + CONSOLE.print(Panel(workflow_text, border_style="green", padding=(1, 2), box=box.ROUNDED)) + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # ============================================================================= + # SECTION 9: QUICK REFERENCE + # Demonstrates: Using both CONSOLE.print() and module functions + # ============================================================================= + CONSOLE.print("[bold cyan]QUICK REFERENCE:[/bold cyan]") + CONSOLE.print() + + quick_ref = Table(show_header=True, header_style="bold cyan", border_style="dim") + quick_ref.add_column("Task", style="green") + quick_ref.add_column("CLI Function", style="yellow") + quick_ref.add_column("Example", style="dim") + + quick_ref.add_row( + "Display title", + "header()", + "header('Task Complete')" + ) + quick_ref.add_row( + "Success message", + "success()", + "success('Created', items=5)" + ) + quick_ref.add_row( + "Error message", + "error()", + "error('Failed', suggestion='Retry')" + ) + quick_ref.add_row( + "Warning message", + "warning()", + "warning('Be careful')" + ) + quick_ref.add_row( + "Section break", + "section()", + "section('Results')" + ) + quick_ref.add_row( + "Operation start", + "operation_start()", + "operation_start('Process', count=10)" + ) + quick_ref.add_row( + "Operation end", + "operation_complete()", + "operation_complete(created=5, failed=0)" + ) + + CONSOLE.print(quick_ref) + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # ============================================================================= + # SECTION 10: FULL DOCUMENTATION + # ============================================================================= + CONSOLE.print("[bold cyan]FULL DOCUMENTATION:[/bold cyan]") + CONSOLE.print() + CONSOLE.print(" [yellow]Source code:[/yellow] [dim]/home/aipass/aipass_core/cli/apps/[/dim]") + CONSOLE.print(" [yellow]Public API:[/yellow] [dim]/home/aipass/aipass_core/cli/apps/modules/[/dim]") + CONSOLE.print(" [yellow]Implementation:[/yellow] [dim]/home/aipass/aipass_core/cli/apps/handlers/[/dim]") + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # ============================================================================= + # SECTION 11: RICH FORMATTING TIPS + # This section itself demonstrates Rich formatting! + # ============================================================================= + CONSOLE.print("[bold cyan]RICH FORMATTING TIPS:[/bold cyan]") + CONSOLE.print() + CONSOLE.print(" [yellow]Text styles:[/yellow] [bold]bold[/bold], [dim]dim[/dim], [italic]italic[/italic], [underline]underline[/underline]") + CONSOLE.print(" [yellow]Colors:[/yellow] [red]red[/red], [green]green[/green], [yellow]yellow[/yellow], [blue]blue[/blue], [cyan]cyan[/cyan]") + CONSOLE.print(" [yellow]Icons:[/yellow] ✓ ✅ ❌ ⚠️ ⚙️ → • — ─") + CONSOLE.print(" [yellow]Structures:[/yellow] Table, Panel, Columns, Text, Progress") + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # ============================================================================= + # SECTION 12: DRONE COMPLIANCE + # CRITICAL: Commands line must be present for drone discovery + # ============================================================================= + CONSOLE.print("[dim]Commands: help, --help, -h[/dim]") + CONSOLE.print() + + +def show_version(): + """Print version from META DATA HEADER.""" + CONSOLE.print("CLI v0.2.0") + + +def main(): + """CLI branch entry point - shows available services""" + + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_introspection() + return + + # Handle version flag + if len(sys.argv) > 1 and sys.argv[1] in ['--version', '-V']: + show_version() + return + + # Handle help flags + if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + print_help() + return + + # Default behavior for other commands + print_help() + + +if __name__ == "__main__": + try: + main() + except KeyboardInterrupt: + CONSOLE.print("\n[yellow]Operation cancelled[/yellow]") + sys.exit(0) + except Exception as e: + logger.error(f"CLI error: {e}", exc_info=True) + CONSOLE.print(f"\n[red]❌ Error: {e}[/red]") + sys.exit(1) diff --git a/src/aipass/cli/apps/extensions/__init__.py b/src/aipass/cli/apps/extensions/__init__.py new file mode 100644 index 00000000..6839ee7d --- /dev/null +++ b/src/aipass/cli/apps/extensions/__init__.py @@ -0,0 +1,24 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: extensions/__init__.py +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: cli/extensions +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial implementation - Public API +# ============================================= + +""" +Extensions Package - Drop-in extensions for branch functionality + +Provides extension modules for extending branch capabilities with minimal integration. + +Usage: + from aipass.cli.apps.extensions import load_extensions +""" + +__version__ = '1.0.0' + diff --git a/src/aipass/cli/apps/handlers/__init__.py b/src/aipass/cli/apps/handlers/__init__.py old mode 100644 new mode 100755 index e69de29b..99270689 --- a/src/aipass/cli/apps/handlers/__init__.py +++ b/src/aipass/cli/apps/handlers/__init__.py @@ -0,0 +1,154 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: handlers/__init__.py +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: cli/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial implementation - Public API +# ============================================= + +""" +Handlers Package - Core handler modules for CLI operations + +Provides error handling, request handlers, and operational utilities. + +Usage: + from aipass.cli.apps.handlers import error_handler +""" + +import inspect +from pathlib import Path + +__version__ = '1.0.0' + +MY_BRANCH = "cli" + + +def _find_real_caller(): + """ + Walk the stack to find the actual file that triggered this import. + + Skips: + - This file (handlers/__init__.py) + - Python's importlib internals + - Frozen modules + + Returns tuple: (file_path, import_line) or (None, None) + """ + stack = inspect.stack() + this_file = str(Path(__file__).resolve()) + + for frame_info in stack: + filename = frame_info.filename + + # Skip this file + if this_file in str(Path(filename).resolve()): + continue + + # Skip Python internals + if filename.startswith("<") or "importlib" in filename: + continue + + # Found a real file - try to get the import line + import_line = None + if frame_info.code_context: + import_line = frame_info.code_context[0].strip() + + return str(Path(filename).resolve()), import_line + + return None, None + + +def _extract_branch_name(filepath: str) -> str: + """Extract branch name from a file path.""" + parts = filepath.split("/") + for i, part in enumerate(parts): + if part in ("aipass_core", "MEMORY_BANK", "Nexus"): + if i + 1 < len(parts): + return parts[i + 1] + return "unknown" + + +def _guard_branch_access(): + """ + Block cross-branch handler imports. + + Only code from within the 'cli' branch can import these handlers. + External branches must use cli.apps.modules instead. + """ + caller_file, import_line = _find_real_caller() + + # DEBUG: Print what we found + import os + if os.environ.get("AIPASS_DEBUG_GUARD"): + import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) + print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) + + if caller_file is None: + # Can't determine caller from real files + # Check if we're being run from command line (external) + # by looking at the raw stack for or + stack = inspect.stack() + for frame in stack: + if frame.filename in ("", ""): + # Try to get the import line from the frame + target_line = "unknown" + if frame.code_context: + target_line = frame.code_context[0].strip() + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller: interactive/script\n" + f" Blocked: {target_line}\n" + f"\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"\n" + f" Example:\n" + f" from {MY_BRANCH}.apps.modules.logger import logger\n" + f"\n" + f" For full standards guide:\n" + f" drone @seed handlers\n" + f"{'='*60}" + ) + return # Allow if truly can't determine + + # Check if caller is from our branch + if f"/{MY_BRANCH}/" in caller_file: + return # Same branch, allowed + + # External caller - block access + caller_branch = _extract_branch_name(caller_file) + caller_filename = Path(caller_file).name + blocked_import = import_line if import_line else "unknown" + + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller branch: {caller_branch}\n" + f" Caller file: {caller_filename}\n" + f" Blocked: {blocked_import}\n" + f"\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"\n" + f" Example:\n" + f" from {MY_BRANCH}.apps.modules.logger import logger\n" + f"\n" + f" For full standards guide:\n" + f" drone @seed handlers\n" + f"{'='*60}" + ) + + +# Run guard at import time +_guard_branch_access() diff --git a/src/aipass/cli/apps/handlers/json/__init__.py b/src/aipass/cli/apps/handlers/json/__init__.py new file mode 100644 index 00000000..72fa95d0 --- /dev/null +++ b/src/aipass/cli/apps/handlers/json/__init__.py @@ -0,0 +1,8 @@ +""" +JSON Handler - CLI Branch + +This handler manages JSON operations for the CLI branch. +It provides utilities for reading, writing, and managing JSON files. +""" + +__all__ = [] \ No newline at end of file diff --git a/src/aipass/cli/apps/handlers/json/json_handler.py b/src/aipass/cli/apps/handlers/json/json_handler.py new file mode 100755 index 00000000..1480e30c --- /dev/null +++ b/src/aipass/cli/apps/handlers/json/json_handler.py @@ -0,0 +1,263 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: json_handler.py - JSON Auto-Creating Handler +# Date: 2025-11-21 +# Version: 1.1.0 +# Category: cli/handlers/json +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2025-11-21): Refactored to comply with error handling +# - v1.0.0 (2025-11-13): Initial JSON auto-creation system +# +# CODE STANDARDS: +# - Pure functions with proper error raising +# - No Prax imports (handler tier 3) +# ============================================= + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, List, Any, Optional +import sys +import inspect + +# Constants +CLI_ROOT = Path.home() / "aipass_core" / "cli" +CLI_JSON_DIR = CLI_ROOT / "cli_json" +JSON_TEMPLATES_DIR = CLI_ROOT / "apps" / "json_templates" + + +def _get_caller_module_name() -> str: + """ + Auto-detect calling module name from call stack + + Returns: + Module name (e.g., "imports_standard" from imports_standard.py) + """ + stack = inspect.stack() + # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller + if len(stack) > 2: + caller_frame = stack[2] + caller_path = Path(caller_frame.filename) + module_name = caller_path.stem + + # Validate module name + if module_name and not module_name.startswith('_'): + return module_name + + # Fallback + return "unknown" + + +def load_template(json_type: str, module_name: str) -> Any: + """Load JSON template from template file""" + template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json" + + if not template_path.exists(): + raise FileNotFoundError(f"Template not found: {template_path}") + + with open(template_path, 'r', encoding='utf-8') as f: + template = json.load(f) + + # Replace placeholders + template_str = json.dumps(template) + template_str = template_str.replace("{{MODULE_NAME}}", module_name) + template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat()) + + return json.loads(template_str) + + +def validate_json_structure(data: Any, json_type: str) -> bool: + """Validate JSON structure matches expected type""" + if json_type == "config": + if not isinstance(data, dict): + return False + required = ["module_name", "version", "config"] + return all(key in data for key in required) + + elif json_type == "data": + if not isinstance(data, dict): + return False + required = ["created", "last_updated"] + return all(key in data for key in required) + + elif json_type == "log": + return isinstance(data, list) + + return False + + +def get_json_path(module_name: str, json_type: str) -> Path: + """Get path for module JSON file""" + filename = f"{module_name}_{json_type}.json" + return CLI_JSON_DIR / filename + + +def ensure_json_exists(module_name: str, json_type: str) -> bool: + """Ensure JSON file exists, create from template if missing""" + CLI_JSON_DIR.mkdir(parents=True, exist_ok=True) + + json_path = get_json_path(module_name, json_type) + + if json_path.exists(): + try: + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if validate_json_structure(data, json_type): + return True + # If corrupted, fall through to regenerate + except Exception: + # If unreadable, fall through to regenerate + pass + + template = load_template(json_type, module_name) + + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(template, f, indent=2, ensure_ascii=False) + return True + + +def load_json(module_name: str, json_type: str) -> Optional[Any]: + """Load JSON file, auto-create if missing""" + if not ensure_json_exists(module_name, json_type): + return None + + json_path = get_json_path(module_name, json_type) + + with open(json_path, 'r', encoding='utf-8') as f: + return json.load(f) + + +def save_json(module_name: str, json_type: str, data: Any) -> bool: + """Save JSON file""" + json_path = get_json_path(module_name, json_type) + + if not validate_json_structure(data, json_type): + raise ValueError(f"Invalid structure for {json_type} JSON") + + if json_type == "data" and isinstance(data, dict): + data["last_updated"] = datetime.now().date().isoformat() + + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + + +def ensure_module_jsons(module_name: str) -> bool: + """Ensure all 3 JSON files exist for a module""" + ensure_json_exists(module_name, "config") + ensure_json_exists(module_name, "data") + ensure_json_exists(module_name, "log") + return True + + +def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool: + """ + Add entry to module log with automatic rotation + + Auto-detects calling module if module_name not provided. + Implements config-controlled log limits to prevent unbounded growth. + When max_log_entries is reached, removes oldest entries (FIFO). + + Args: + operation: Operation name to log + data: Optional data dict + module_name: Optional module name (auto-detected if not provided) + + Returns: + True if successful, False otherwise + """ + # Auto-detect module name if not provided + if module_name is None: + module_name = _get_caller_module_name() + + ensure_module_jsons(module_name) + + # Load config to get max_log_entries + config = load_json(module_name, "config") + max_entries = 100 # Default + if config and "config" in config: + max_entries = config["config"].get("max_log_entries", 100) + + # Load existing log + log = load_json(module_name, "log") + if log is None: + log = [] + + # Create new entry + entry = { + "timestamp": datetime.now().isoformat(), + "operation": operation + } + + if data: + entry["data"] = data # type: ignore[assignment] + + # Add new entry + log.append(entry) + + # Rotate if exceeds max (keep most recent entries) + if len(log) > max_entries: + log = log[-max_entries:] + + return save_json(module_name, "log", log) + + +def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool: + """Increment a counter in data JSON""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + if counter_name not in data: + data[counter_name] = 0 + + data[counter_name] += amount + + return save_json(module_name, "data", data) + + +def update_data_metrics(module_name: str, **metrics) -> bool: + """Update data metrics""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + for key, value in metrics.items(): + data[key] = value + + return save_json(module_name, "data", data) + + +if __name__ == "__main__": + from rich.console import Console + from rich.panel import Panel + + console = Console() + + console.print() + console.print(Panel.fit( + "[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", + border_style="bright_blue" + )) + console.print() + console.print("[yellow]TESTING:[/yellow] Creating CLI JSONs...") + + # Test auto-creation + log_operation("test_operation", {"test": "data"}, "cli") + increment_counter("cli", "test_counter", 1) + update_data_metrics("cli", test_metric="working") + + console.print() + console.print("[green]Check /home/aipass/aipass_core/cli/cli_json/ for created files:[/green]") + console.print(" [dim]•[/dim] cli_config.json") + console.print(" [dim]•[/dim] cli_data.json") + console.print(" [dim]•[/dim] cli_log.json") + console.print() diff --git a/src/aipass/cli/apps/handlers/templates/__init__.py b/src/aipass/cli/apps/handlers/templates/__init__.py new file mode 100644 index 00000000..8ffc023e --- /dev/null +++ b/src/aipass/cli/apps/handlers/templates/__init__.py @@ -0,0 +1,36 @@ +""" +Templates Handler Layer +====================== + +This directory contains handler implementations for the templates module. + +Purpose: +-------- +- Template-specific formatters +- Operation template utilities +- Message template handlers +- Custom template processing + +Pattern: +-------- +Each handler file in this directory: +1. Provides specific template functionality +2. Can be imported by templates.py module +3. Extends template capabilities + +Current State: +-------------- +Directory created for architectural completeness. +Handlers will be added as template functionality expands. + +Examples of Future Handlers: +--------------------------- +- template_formatter.py - Custom formatting for templates +- operation_templates.py - Operation-specific templates +- message_templates.py - Message formatting templates +- progress_templates.py - Progress bar and status templates +""" + +# META: HANDLER_TEMPLATES +# STATUS: Ready for handlers +# PATTERN: Service provider handler layer \ No newline at end of file diff --git a/src/aipass/cli/apps/json_templates/__init__.py b/src/aipass/cli/apps/json_templates/__init__.py new file mode 100644 index 00000000..ae62b033 --- /dev/null +++ b/src/aipass/cli/apps/json_templates/__init__.py @@ -0,0 +1,24 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: json_templates/__init__.py +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: cli/templates +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial implementation - Public API +# ============================================= + +""" +JSON Templates Package - Default JSON file templates + +Provides standard JSON template definitions for branch configuration and data files. + +Usage: + from aipass.cli.apps.json_templates import get_template +""" + +__version__ = '1.0.0' + diff --git a/src/aipass/cli/apps/json_templates/default/config.json b/src/aipass/cli/apps/json_templates/default/config.json new file mode 100644 index 00000000..d29d029f --- /dev/null +++ b/src/aipass/cli/apps/json_templates/default/config.json @@ -0,0 +1,9 @@ +{ + "module_name": "{{MODULE_NAME}}", + "version": "1.0.0", + "timestamp": "2025-11-13", + "config": { + "auto_save": true, + "enabled": true + } +} diff --git a/src/aipass/cli/apps/json_templates/default/data.json b/src/aipass/cli/apps/json_templates/default/data.json new file mode 100644 index 00000000..82912a72 --- /dev/null +++ b/src/aipass/cli/apps/json_templates/default/data.json @@ -0,0 +1,8 @@ +{ + "module_name": "{{MODULE_NAME}}", + "created": "2025-11-13", + "last_updated": "2025-11-13", + "operations_total": 0, + "operations_successful": 0, + "operations_failed": 0 +} diff --git a/src/aipass/cli/apps/json_templates/default/log.json b/src/aipass/cli/apps/json_templates/default/log.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/aipass/cli/apps/json_templates/default/log.json @@ -0,0 +1 @@ +[] diff --git a/src/aipass/cli/apps/modules/__init__.py b/src/aipass/cli/apps/modules/__init__.py index 74039db1..f9ffbaf9 100644 --- a/src/aipass/cli/apps/modules/__init__.py +++ b/src/aipass/cli/apps/modules/__init__.py @@ -1,2 +1,55 @@ -"""CLI modules - display services.""" -from aipass.cli.apps.modules.display import console, header, success, error, warning, section +""" +CLI Public API - Services Exported to Other Branches + +Import these in your branch modules: + # Rich console (lowercase service instance pattern) + from aipass.cli.apps.modules import console + console.print("message") # Rich formatted output + + # Display functions + from aipass.cli.apps.modules import header, success, error, warning + + # Operation templates + from aipass.cli.apps.modules import operation_start, operation_complete + +PATTERN (from Prax): +- This directory contains PUBLIC API +- apps/handlers/ contains PRIVATE implementation +- Modules are thin wrappers exposing clean interfaces +- CLI provides display services to all branches +- Lowercase 'console' follows service instance pattern (like 'logger') +""" + +# Rich console (primary service - like Prax logger) +from aipass.cli.apps.modules.display import console + +# Display functions +from aipass.cli.apps.modules.display import ( + header, + success, + error, + warning, + section +) + +# Operation templates +from aipass.cli.apps.modules.templates import ( + operation_start, + operation_complete +) + +__all__ = [ + # Rich console (primary service) + 'console', + + # Display + 'header', + 'success', + 'error', + 'warning', + 'section', + + # Templates + 'operation_start', + 'operation_complete', +] diff --git a/src/aipass/cli/apps/modules/display.py b/src/aipass/cli/apps/modules/display.py old mode 100644 new mode 100755 index 510db22f..f5f20cbf --- a/src/aipass/cli/apps/modules/display.py +++ b/src/aipass/cli/apps/modules/display.py @@ -1,38 +1,415 @@ -"""CLI Display - Minimal stub for AIPass public repo.""" +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: display.py - CLI Display Module +# Date: 2025-11-15 +# Version: 0.4.0 +# Category: cli/modules +# +# CHANGELOG (Max 5 entries): +# - v0.4.0 (2025-11-15): Replaced argparse help with Rich formatted help (SEED pattern) +# - v0.3.0 (2025-11-15): Restructured to follow SEED module pattern +# - v0.2.0 (2025-11-12): Implemented Rich library formatting +# - v0.1.0 (2025-11-12): Public API for display functions +# +# CODE STANDARDS: +# - PUBLIC API - thin wrapper over handler implementation +# - Follows SEED module pattern (introspection/help/command handling) +# ============================================= + +""" +CLI Display Module - PUBLIC API + +Provides display functions for all branches: +- header() - Bordered section headers +- success() - Green checkmark + message +- error() - Red X + error message +- warning() - Yellow warning + message +- section() - Visual section breaks + +Uses Rich library for beautiful terminal output. +""" + +import sys +from pathlib import Path +from typing import Dict, Any, Optional, List + +# Rich library from rich.console import Console +from rich.panel import Panel +from rich.table import Table +from rich.columns import Columns -console = Console() +# Initialize Rich console (lowercase follows service instance pattern) +CONSOLE = Console() # Internal constant +console = CONSOLE # Primary export (lowercase service instance pattern) + +# Trigger loaded lazily to avoid circular import +_trigger = None +_trigger_loaded = False -def header(title: str, details: dict | None = None) -> None: - """Display a bordered header.""" - console.print(f"\n[bold cyan]{'─' * 40}[/bold cyan]") - console.print(f"[bold white]{title}[/bold white]") +# ============================================================================ +# MODULE PATTERN FUNCTIONS (SEED compliant) +# ============================================================================ + +def print_introspection(): + """Display module info and connected handlers""" + CONSOLE.print() + CONSOLE.print("[bold cyan]CLI Display Module[/bold cyan]") + CONSOLE.print() + + CONSOLE.print("[yellow]Connected Handlers:[/yellow]") + CONSOLE.print() + + # Auto-discover handler files from handlers/display/ + handlers_dir = Path(__file__).parent.parent / "handlers" / "display" + + if handlers_dir.exists(): + handler_files = sorted([f for f in handlers_dir.iterdir() if f.is_file() and f.suffix == '.py' and f.name != '__init__.py']) + + if handler_files: + CONSOLE.print(" [cyan]handlers/display/[/cyan]") + for handler_file in handler_files: + CONSOLE.print(f" [dim]- {handler_file.name}[/dim]") + CONSOLE.print() + else: + CONSOLE.print(" [dim]handlers/display/ (empty - no handlers yet)[/dim]") + CONSOLE.print() + else: + CONSOLE.print(" [dim]handlers/display/ (not found)[/dim]") + CONSOLE.print() + + CONSOLE.print("[dim]Run 'python3 display.py --help' for usage[/dim]") + CONSOLE.print() + + +def print_help(): + """Display Rich-formatted help - CLI Display Module showpiece!""" + + CONSOLE.print() + + # ========================================================================= + # RICH FORMATTING TIP: Use header() function for main titles + # header() creates a bordered box automatically + # ========================================================================= + header("CLI Display Module") + + CONSOLE.print() + + # RICH FORMATTING TIP: [dim] makes text appear dimmed/grayed out + CONSOLE.print("[dim]Rich terminal output formatting for all AIPass branches[/dim]") + CONSOLE.print() + CONSOLE.print("─" * 70) # Separator line (matches handler style) + CONSOLE.print() + + # ========================================================================= + # RICH FORMATTING TIP: Match handler style - simple [bold cyan]LABEL:[/bold cyan] format + # NO decorative borders (═══), just clean headers like handlers use + # ========================================================================= + CONSOLE.print("[bold cyan]WHAT IS DISPLAY?[/bold cyan]") + CONSOLE.print() + CONSOLE.print("Display is the [bold]CLI's universal output service[/bold] that provides:") + # RICH FORMATTING TIP: Use [green]✓[/green] for checkmarks in lists + CONSOLE.print(" [green]✓[/green] Consistent Rich-formatted output across all branches") + CONSOLE.print(" [green]✓[/green] Five core display functions ([green]header, success, error, warning, section[/green])") + CONSOLE.print(" [green]✓[/green] Beautiful terminal output with colors, panels, and formatting") + CONSOLE.print(" [green]✓[/green] Integration with CLI error handler for advanced error display") + CONSOLE.print() + + # ========================================================================= + # RICH FORMATTING TIP: Tables are powerful for structured data + # Create with Table(), add columns, add rows, then print + # ========================================================================= + CONSOLE.print("[bold cyan]PUBLIC API FUNCTIONS (5 total):[/bold cyan]") + CONSOLE.print() + + # RICH FORMATTING TIP: Table styling - show_header, header_style, border_style + table = Table(show_header=True, header_style="bold cyan", border_style="dim") + table.add_column("Function", style="green") # Column styling + table.add_column("Signature", style="yellow") + table.add_column("Purpose", style="dim") + + table.add_row("header()", "title, details=None", "Bordered section headers with optional key-value details") + table.add_row("success()", "message, **kwargs", "Success messages with green checkmark + optional details") + table.add_row("error()", "message, suggestion=None", "Error messages with red X + optional suggestion") + table.add_row("warning()", "message, details=None", "Warning messages with yellow symbol + optional details") + table.add_row("section()", "title", "Visual section separators with title and line") + + # RICH FORMATTING TIP: Print the table after adding all rows + CONSOLE.print(table) + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # ========================================================================= + # RICH FORMATTING TIP: Columns layout for side-by-side content + # Columns([item1, item2, item3], equal=True, expand=True) + # ========================================================================= + CONSOLE.print("[bold cyan]USAGE:[/bold cyan]") + CONSOLE.print() + + usage_examples = [ + "[yellow]Module Info:[/yellow]\n [dim]python3 display.py[/dim]\n [dim]drone cli display[/dim]", + "[yellow]Run Demo:[/yellow]\n [dim]python3 display.py demo[/dim]\n [dim]drone cli demo[/dim]", + "[yellow]Show Help:[/yellow]\n [dim]python3 display.py --help[/dim]\n [dim]drone cli display --help[/dim]" + ] + + # RICH FORMATTING TIP: Columns creates side-by-side layout + CONSOLE.print(Columns(usage_examples, equal=True, expand=True)) + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # ========================================================================= + # RICH FORMATTING TIP: Panels are good for highlighted content blocks + # Panel(content, border_style="color", padding=(top/bottom, left/right)) + # ========================================================================= + CONSOLE.print("[bold cyan]CODE EXAMPLES:[/bold cyan]") + CONSOLE.print() + + code_examples = """[bold]Import and use in your Python code:[/bold] + + [yellow]from aipass.cli.apps.modules.display import header, success, error, warning, section[/yellow] + + [dim]# Display section header[/dim] + header('Create Branch', {'Name': 'new_branch', 'Type': 'module'}) + + [dim]# Show success with details[/dim] + success('Branch created successfully', items=5, time='2.3s') + + [dim]# Display error with suggestion[/dim] + error('Branch not found', suggestion='Check branch name spelling') + + [dim]# Show warning[/dim] + warning('Template version mismatch', details='Expected v2.1, found v2.0') + + [dim]# Create section break[/dim] + section('Validation Results')""" + + # RICH FORMATTING TIP: Panel wraps content in a bordered box + CONSOLE.print(Panel(code_examples, border_style="green", padding=(1, 2))) + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # Simple header style (matching handlers) + CONSOLE.print("[bold cyan]INTEGRATION:[/bold cyan]") + CONSOLE.print() + CONSOLE.print(" [green]✓[/green] [bold]Rich Formatting:[/bold] Beautiful terminal output with colors and styles") + CONSOLE.print(" [green]✓[/green] [bold]All Branches:[/bold] Import and use display functions for consistent output") + CONSOLE.print(" [green]✓[/green] [bold]Rich Library:[/bold] Built on Rich for beautiful terminal formatting") + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # Simple header style (matching handlers) + CONSOLE.print("[bold cyan]REFERENCE:[/bold cyan]") + CONSOLE.print() + # RICH FORMATTING TIP: Use [yellow] for labels, [dim] for paths + CONSOLE.print(" [yellow]Module:[/yellow] [dim]/home/aipass/aipass_core/cli/apps/modules/display.py[/dim]") + CONSOLE.print(" [yellow]Handlers:[/yellow] [dim]/home/aipass/aipass_core/cli/apps/handlers/display/[/dim]") + CONSOLE.print(" [yellow]Standards:[/yellow] [dim]/home/aipass/standards/CODE_STANDARDS/cli.md[/dim]") + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # RICH FORMATTING TIP: Use [bold] for emphasis without color + CONSOLE.print("[bold]TIP:[/bold] Run [green]python3 display.py demo[/green] to see all functions in action!") + CONSOLE.print() + CONSOLE.print("─" * 70) + CONSOLE.print() + + # RICH FORMATTING TIP: Commands line required for drone discovery + # This is how drone finds available commands - keep [dim] style + CONSOLE.print("[dim]Commands: display, show, demo, --help[/dim]") + CONSOLE.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """Handle module commands""" + if command == "demo": + run_demo() + return True + elif command in ["display", "show"]: + print_introspection() + return True + else: + return False + + +def run_demo(): + """Run display function demonstrations""" + CONSOLE.print() + header("CLI Display Module - Demo") + + CONSOLE.print("[bold]Display functions with Rich formatting:[/bold]") + CONSOLE.print() + + # Demo success + success("Operation completed successfully", items=5, time="2.3s") + CONSOLE.print() + + # Demo warning + warning("Template version mismatch", details="Expected v2.1, found v2.0") + CONSOLE.print() + + # Demo error + error("Cannot create virtual environment", suggestion="sudo apt install python3-venv") + CONSOLE.print() + + # Demo section + section("Summary") + CONSOLE.print(" ✅ 3 operations succeeded") + CONSOLE.print(" ⚠️ 1 warning") + CONSOLE.print(" ❌ 1 error") + CONSOLE.print() + + CONSOLE.print("[bold green]✨ Rich library integration complete![/bold green]") + CONSOLE.print("[dim]All display functions now use Rich for beautiful terminal output[/dim]") + CONSOLE.print() + + +# ============================================================================ +# PUBLIC API FUNCTIONS (Keep existing - don't break compatibility) +# ============================================================================ + +def header(title: str, details: Optional[Dict[str, Any]] = None) -> None: + """ + Display bordered section header using Rich Panel + + Args: + title: Header title + details: Optional key-value pairs to display + + Example: + header('Create Branch', {'Name': 'new_branch', 'Type': 'module'}) + """ + CONSOLE.print(Panel(f"[bold cyan]{title}[/bold cyan]", expand=False)) if details: - for k, v in details.items(): - console.print(f" {k}: {v}") - console.print(f"[bold cyan]{'─' * 40}[/bold cyan]\n") + CONSOLE.print() + for key, value in details.items(): + CONSOLE.print(f" [dim]{key}:[/dim] {value}") + # Fire trigger event for header display (lazy load to avoid circular import) + global _trigger, _trigger_loaded + if not _trigger_loaded: + _trigger_loaded = True + try: + from aipass.trigger.apps.modules.core import trigger as t + _trigger = t + except ImportError: + pass + if _trigger: + _trigger.fire('cli_header_displayed', title=title) + CONSOLE.print() def success(message: str, **kwargs) -> None: - """Display success message.""" - console.print(f"[green]✓[/green] {message}") + """ + Display success message with Rich styling + + Args: + message: Success message + **kwargs: Optional details to display + + Example: + success('Branch created', items=5, time='2.3s') + """ + CONSOLE.print(f"✅ [green]{message}[/green]") + for key, value in kwargs.items(): + CONSOLE.print(f" [dim]{key}: {value}[/dim]") def error(message: str, suggestion: str | None = None) -> None: - """Display error message.""" - console.print(f"[red]✗[/red] {message}") + """ + Display error message with Rich styling + + Args: + message: Error message + suggestion: Optional suggestion for fixing + + Example: + error('Branch not found', suggestion='Check branch name spelling') + """ + CONSOLE.print(f"❌ [red bold]{message}[/red bold]") if suggestion: - console.print(f" [dim]{suggestion}[/dim]") + CONSOLE.print(f" [yellow]→ Try: {suggestion}[/yellow]") def warning(message: str, details: str | None = None) -> None: - """Display warning message.""" - console.print(f"[yellow]⚠[/yellow] {message}") + """ + Display warning message with Rich styling + + Args: + message: Warning message + details: Optional additional details + + Example: + warning('Branch already exists, skipping') + """ + CONSOLE.print(f"⚠️ [yellow]{message}[/yellow]") if details: - console.print(f" [dim]{details}[/dim]") + CONSOLE.print(f" [dim]{details}[/dim]") def section(title: str) -> None: - """Display section title.""" - console.print(f"\n[bold]{title}[/bold]") + """ + Display section separator with Rich styling + + Args: + title: Section title + + Example: + section('Validation Results') + """ + CONSOLE.print() + CONSOLE.print(f"[bold]{title}[/bold]") + CONSOLE.print("─" * 50) + + +# ============================================================================ +# MODULE EXPORTS +# ============================================================================ + +# Note: __all__ uses lowercase by convention (Python standard library pattern) +__all__ = [ + 'console', # Primary export (service instance pattern) + 'CONSOLE', # Internal constant (kept for backward compatibility) + 'header', + 'success', + 'error', + 'warning', + 'section', +] + +# ============================================================================ +# ENTRY POINT (SEED pattern) +# ============================================================================ + +if __name__ == "__main__": + try: + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + # Handle help flag (drone compliance) + if sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + # Route commands + command = sys.argv[1] + args = sys.argv[2:] if len(sys.argv) > 2 else [] + + if handle_command(command, args): + sys.exit(0) + else: + CONSOLE.print(f"[red]Unknown command: {command}[/red]") + CONSOLE.print("[dim]Run 'python3 display.py --help' for usage[/dim]") + sys.exit(1) + except Exception as e: + # Note: Can't use Prax logger here due to circular import (display <- prax <- display) + CONSOLE.print(f"[red]Error: {e}[/red]") + sys.exit(1) diff --git a/src/aipass/cli/apps/modules/templates.py b/src/aipass/cli/apps/modules/templates.py new file mode 100755 index 00000000..b77a90f7 --- /dev/null +++ b/src/aipass/cli/apps/modules/templates.py @@ -0,0 +1,246 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: templates.py - CLI Templates Module +# Date: 2025-11-15 +# Version: 0.3.0 +# Category: cli/modules +# +# CHANGELOG (Max 5 entries): +# - v0.3.0 (2025-11-15): Restructured to follow SEED module pattern +# - v0.2.0 (2025-11-12): Implemented Rich library formatting +# - v0.1.0 (2025-11-12): Public API for output templates +# +# CODE STANDARDS: +# - PUBLIC API - reusable output patterns +# - Follows SEED module pattern (introspection/help/command handling) +# ============================================= + +""" +CLI Templates Module - PUBLIC API + +Pre-built output templates for common operations: +- operation_start() - Standard operation header +- operation_complete() - Standard completion summary + +Uses Rich library for beautiful terminal output. +""" + +import sys +from pathlib import Path +from typing import Dict, Any, Optional, List + +# Import console from CLI display module (using our own service!) +from aipass.cli.apps.modules.display import console as CONSOLE + + +# ============================================================================ +# MODULE PATTERN FUNCTIONS (SEED compliant) +# ============================================================================ + +def print_introspection(): + """Display module info and connected handlers""" + CONSOLE.print() + CONSOLE.print("[bold cyan]CLI Templates Module[/bold cyan]") + CONSOLE.print() + + CONSOLE.print("[yellow]Connected Handlers:[/yellow]") + CONSOLE.print() + + # Auto-discover handler files from handlers/templates/ + handlers_dir = Path(__file__).parent.parent / "handlers" / "templates" + + if handlers_dir.exists(): + handler_files = sorted([f for f in handlers_dir.iterdir() if f.is_file() and f.suffix == '.py' and f.name != '__init__.py']) + + if handler_files: + CONSOLE.print(" [cyan]handlers/templates/[/cyan]") + for handler_file in handler_files: + CONSOLE.print(f" [dim]- {handler_file.name}[/dim]") + CONSOLE.print() + else: + CONSOLE.print(" [dim]handlers/templates/ (empty - no handlers yet)[/dim]") + CONSOLE.print() + else: + CONSOLE.print(" [dim]handlers/templates/ (not found)[/dim]") + CONSOLE.print() + + CONSOLE.print("[dim]Run 'python3 templates.py --help' for usage[/dim]") + CONSOLE.print() + + +def print_help(): + """Print Rich-formatted help output""" + # Import header from CLI services + from aipass.cli.apps.modules.display import header + + CONSOLE.print() + header("CLI Templates Module - Reusable Operation Output Patterns") + + CONSOLE.print("[bold cyan]What This Module Provides:[/bold cyan]") + CONSOLE.print() + CONSOLE.print(" [yellow]operation_start(operation, **details)[/yellow]") + CONSOLE.print(" Standard operation header with Rich styling") + CONSOLE.print(" Example: operation_start('Creating branch', target='/path', type='module')") + CONSOLE.print() + CONSOLE.print(" [yellow]operation_complete(success=None, results=None, **summary)[/yellow]") + CONSOLE.print(" Standard completion summary with Rich styling") + CONSOLE.print(" Example: operation_complete(created=5, skipped=2, time='3.2s')") + CONSOLE.print() + + CONSOLE.print("[bold cyan]Usage Examples:[/bold cyan]") + CONSOLE.print() + CONSOLE.print(" [green]# Show module info[/green]") + CONSOLE.print(" python3 templates.py") + CONSOLE.print() + CONSOLE.print(" [green]# Run demo[/green]") + CONSOLE.print(" python3 templates.py demo") + CONSOLE.print() + CONSOLE.print(" [green]# Via drone[/green]") + CONSOLE.print(" drone cli templates") + CONSOLE.print(" drone cli ops demo") + CONSOLE.print() + + CONSOLE.print("[bold cyan]Integration Example:[/bold cyan]") + CONSOLE.print() + CONSOLE.print(" [dim]from aipass.cli.apps.modules.templates import operation_start, operation_complete[/dim]") + CONSOLE.print() + CONSOLE.print(" [dim]# Start operation[/dim]") + CONSOLE.print(" [dim]operation_start('Creating files', target='/home/aipass/my_branch')[/dim]") + CONSOLE.print() + CONSOLE.print(" [dim]# ... do work ...[/dim]") + CONSOLE.print() + CONSOLE.print(" [dim]# Complete operation[/dim]") + CONSOLE.print(" [dim]operation_complete(created=12, skipped=0, time='1.2s')[/dim]") + CONSOLE.print() + + CONSOLE.print("[bold cyan]Reference:[/bold cyan]") + CONSOLE.print(" [dim]/home/aipass/standards/CODE_STANDARDS/cli.md[/dim]") + CONSOLE.print() + + CONSOLE.print("[bold]Commands: templates, demo, --help[/bold]") + CONSOLE.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """Handle 'templates' and 'demo' commands""" + # Handle demo as direct command + if command == "demo": + run_demo() + return True + + # Handle templates command + if command == "templates": + # Check for subcommand in args + if args and args[0] == "demo": + run_demo() + else: + # templates just shows introspection + print_introspection() + return True + + return False + + +def run_demo(): + """Run template function demonstrations""" + from aipass.cli.apps.modules.display import header + + CONSOLE.print() + header("CLI Templates Module - Demo") + + CONSOLE.print("[bold]Operation templates with Rich formatting:[/bold]") + CONSOLE.print() + + # Demo operation start + operation_start("Creating new branch", target="/home/aipass/my_branch", type="module") + + # Simulate some work + CONSOLE.print("✅ [green]Directory created[/green]") + CONSOLE.print(" [dim]/home/aipass/my_branch[/dim]") + CONSOLE.print() + + CONSOLE.print("✅ [green]Files copied[/green]") + CONSOLE.print(" [dim]12 files from template[/dim]") + CONSOLE.print() + + # Demo operation complete + operation_complete(created=12, skipped=0, time="1.2s") + + CONSOLE.print("[bold green]✨ Rich library integration complete![/bold green]") + CONSOLE.print("[dim]Templates provide consistent operation patterns across all branches[/dim]") + CONSOLE.print() + + +# ============================================================================ +# PUBLIC API FUNCTIONS (Keep existing - don't break compatibility) +# ============================================================================ + +def operation_start(operation: str, **details) -> None: + """ + Display standard operation start template with Rich styling + + Args: + operation: Operation name + **details: Operation details to display + + Example: + operation_start('Create Branch', target='/path', name='mybranch') + """ + CONSOLE.print() + CONSOLE.print(f"⚙️ [blue]{operation}...[/blue]") + if details: + for key, value in details.items(): + CONSOLE.print(f" [dim]{key}: {value}[/dim]") + CONSOLE.print() + + +def operation_complete(success: bool | None = None, **summary) -> None: + """ + Display standard operation completion template with Rich styling + + Args: + success: True if successful, False if errors + **summary: Summary statistics + + Example: + operation_complete(True, created=5, skipped=2, time='3.2s') + """ + CONSOLE.print() + CONSOLE.print("─" * 50) + CONSOLE.print("[bold]Summary:[/bold]") + + for key, value in summary.items(): + CONSOLE.print(f" {key}: {value}") + + if summary.get('time'): + CONSOLE.print(f" [dim]Completed in {summary['time']}[/dim]") + CONSOLE.print() + + +# ============================================================================ +# ENTRY POINT (SEED pattern) +# ============================================================================ + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + # Handle help flag (drone compliance) + if sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + # Route commands + command = sys.argv[1] + args = sys.argv[2:] if len(sys.argv) > 2 else [] + + if handle_command(command, args): + sys.exit(0) + else: + CONSOLE.print(f"[red]Unknown command: {command}[/red]") + CONSOLE.print("[dim]Run 'python3 templates.py --help' for usage[/dim]") + sys.exit(1) diff --git a/src/aipass/cli/apps/plugins/__init__.py b/src/aipass/cli/apps/plugins/__init__.py index e69de29b..b9f11e6e 100644 --- a/src/aipass/cli/apps/plugins/__init__.py +++ b/src/aipass/cli/apps/plugins/__init__.py @@ -0,0 +1,24 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: plugins/__init__.py +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: cli/plugins +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial implementation - Public API +# ============================================= + +""" +Plugins Package - Pluggable components for branch capabilities + +Provides plugin system for extending branch functionality with custom components. + +Usage: + from aipass.cli.apps.plugins import PluginManager +""" + +__version__ = '1.0.0' + diff --git a/src/aipass/drone/__init__.py b/src/aipass/drone/__init__.py index 93ee41a0..556efbed 100644 --- a/src/aipass/drone/__init__.py +++ b/src/aipass/drone/__init__.py @@ -1 +1,75 @@ -"""Drone - Command routing for AIPass.""" +""" +AIPass Drone — Command routing & discovery module. + +Provides symbolic addressing for multi-agent systems. Resolves @branch names +to absolute paths at runtime via AIPASS_REGISTRY.json. + +Core API: + resolve_branch(name) -> str # @branch -> absolute path + list_branches(type, status) # List all branches + branch_exists(name) -> bool # Check if branch exists + get_branch_info(name) -> dict # Get branch metadata + +Command Routing: + route_command(target, command, args, timeout) -> CommandResult + discover_modules(target) -> list # Available commands for a branch + get_help(target, command) -> HelpResult + +@all Operations: + route_all(command, args, timeout) -> dict[str, CommandResult] + get_system_help() -> dict[str, HelpResult] + +Registry Management: + set_registry_path(path) # Set custom registry location + get_registry_path() -> Path # Get current registry path +""" + +from aipass.drone.apps.modules.config import get_registry_path, reset_registry_path, set_registry_path +from aipass.drone.apps.modules.discovery import HelpResult, discover_modules, get_help, get_system_help +from aipass.drone.apps.handlers.exceptions import ( + BranchAlreadyExistsError, + BranchNotFoundError, + CommandExecutionError, + InvalidPathError, + RegistryCorruptError, + RegistryError, + RegistryNotFoundError, + RegistryPermissionError, + RoutingError, +) +from aipass.drone.apps.handlers.executor import CommandResult +from aipass.drone.apps.modules.resolver import branch_exists, get_branch_info, list_branches, resolve_branch +from aipass.drone.apps.modules.router import route_all, route_command + +__version__ = "1.0.0" + +__all__ = [ + # Core API + "resolve_branch", + "list_branches", + "branch_exists", + "get_branch_info", + # Command routing + "route_command", + "discover_modules", + "get_help", + "CommandResult", + # Help & discovery + "HelpResult", + "get_system_help", + "route_all", + # Registry management + "set_registry_path", + "get_registry_path", + "reset_registry_path", + # Exceptions + "RoutingError", + "BranchNotFoundError", + "BranchAlreadyExistsError", + "InvalidPathError", + "RegistryError", + "RegistryNotFoundError", + "RegistryCorruptError", + "RegistryPermissionError", + "CommandExecutionError", +] diff --git a/src/aipass/drone/apps/branch.py b/src/aipass/drone/apps/branch.py deleted file mode 100644 index b6689951..00000000 --- a/src/aipass/drone/apps/branch.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -DRONE Branch - Main Orchestrator - -Auto-discovery architecture: -- Scans modules/ directory for .py files with handle_command() -- Routes commands to discovered modules automatically -- No manual imports or routing needed -""" - -import sys -import importlib -from pathlib import Path -from typing import List, Any - -from aipass.prax import logger - -# ============================================================================= -# MODULE DISCOVERY -# ============================================================================= - -MODULES_DIR = Path(__file__).parent / "modules" - - -def discover_modules() -> List[Any]: - """Auto-discover modules in modules/ directory.""" - modules = [] - - if not MODULES_DIR.exists(): - return modules - - for file_path in MODULES_DIR.glob("*.py"): - if file_path.name.startswith("_"): - continue - - module_name = f"apps.modules.{file_path.stem}" - - try: - module = importlib.import_module(module_name) - if hasattr(module, "handle_command"): - modules.append(module) - except Exception as e: - logger.error(f"[DRONE] Failed to load module {module_name}: {e}") - - return modules - - -def route_command(command: str, args: List[str], modules: List[Any]) -> bool: - """Route command to appropriate module.""" - for module in modules: - try: - if module.handle_command(command, args): - return True - except Exception as e: - logger.error(f"[DRONE] Module {module.__name__} error: {e}") - return False - - -# ============================================================================= -# MAIN ENTRY POINT -# ============================================================================= - -def main(): - """Main entry point - routes commands or shows help.""" - modules = discover_modules() - args = sys.argv[1:] - - if len(args) == 0 or args[0] in ["--help", "-h", "help"]: - print(f"DRONE - {len(modules)} modules discovered") - for module in modules: - name = module.__name__.split(".")[-1] - desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" - print(f" {name:20} {desc}") - return 0 - - command = args[0] - remaining = args[1:] if len(args) > 1 else [] - - if route_command(command, remaining, modules): - return 0 - - print(f"Unknown command: {command}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/aipass/drone/apps/drone.py b/src/aipass/drone/apps/drone.py new file mode 100644 index 00000000..f5a16559 --- /dev/null +++ b/src/aipass/drone/apps/drone.py @@ -0,0 +1,249 @@ +""" +Drone - Command Router & Discovery for AIPass + +Routes commands to registered branches and internal modules. +Standard branch entry point (apps/drone.py pattern). +""" + +# =================== META ==================== +# Name: drone.py +# Description: Drone - Command Router & Discovery +# Version: 1.0.0 +# Created: 2026-03-05 +# Modified: 2026-03-05 +# ============================================= + +import sys +from typing import List + +from aipass.drone.apps.handlers.exceptions import ( + BranchNotFoundError, + CommandExecutionError, +) +from aipass.drone.apps.modules.discovery import get_help +from aipass.drone.apps.modules.resolver import list_branches +from aipass.drone.apps.modules.router import route_command +from aipass.drone.apps.modules.module_registry import ( + is_module, + list_modules, + get_module_info, + get_module_introspective, + route_module_command, + get_module_help, +) + +VERSION = "1.0.0" + + +# ============================================================================= +# HELP & INTROSPECTION +# ============================================================================= + +def show_help() -> None: + """Display drone help.""" + print() + print("Drone - Command Router & Discovery") + print() + print("Routes commands to AIPass branches and internal modules.") + print() + print("Usage:") + print(" drone @target command [args] Route command to branch or module") + print(" drone @target --help Show help for branch or module") + print(" drone systems List registered branches and modules") + print(" drone --help Show this help") + print(" drone --version Show version") + print() + print("Examples:") + print(" drone @seedgo audit aipass") + print(" drone @seedgo list") + print(" drone @flow status") + print(" drone systems") + print() + + +def show_introspection() -> None: + """Show discovery view (no args).""" + print() + print("Drone - Command Router & Discovery") + print() + + modules = list_modules() + branches = list_branches() + + if modules: + print(f"Internal Modules ({len(modules)}):") + for name in modules: + info = get_module_info(name) + if info: + print(f" @{name:<18} {info.description}") + else: + print(f" @{name:<18} (not available)") + if branches: + print() + + if branches: + print(f"Registered Branches ({len(branches)}):") + for name in sorted(branches): + print(f" @{name}") + + if not modules and not branches: + print("No branches or modules registered.") + + print() + print("Run 'drone --help' for usage information") + print() + + +# ============================================================================= +# COMMAND HANDLERS +# ============================================================================= + +def _handle_systems() -> int: + """Handle `drone systems` — list registered branches and modules.""" + branches = list_branches() + modules = list_modules() + + if not branches and not modules: + print("No branches or modules registered.") + return 0 + + if modules: + print(f"Modules ({len(modules)}):") + for name in modules: + info = get_module_info(name) + if info: + print(f" @{name:<18} {info.description}") + else: + print(f" @{name:<18} (not available)") + if branches: + print() + + if branches: + print(f"Branches ({len(branches)}):") + for name in sorted(branches): + print(f" {name}") + + return 0 + + +def _handle_module(name: str, args: List[str]) -> int: + """Handle routing to an internal module.""" + if not args: + intro_text = get_module_introspective(name) + if intro_text: + print(intro_text, end="") + else: + print(f"No information available for @{name}.") + return 0 + + if args == ["--help"]: + help_text = get_module_help(name) + if help_text: + print(help_text, end="") + else: + print(f"No help available for @{name}.") + return 0 + + command = args[0] + cmd_args = args[1:] if len(args) > 1 else None + + try: + result = route_module_command(name, command, cmd_args) + except (ImportError, AttributeError) as exc: + print(f"drone: module @{name} is registered but not available: {exc}", file=sys.stderr) + return 1 + + if result.get("stdout"): + print(result["stdout"], end="") + if result.get("stderr"): + print(result["stderr"], end="", file=sys.stderr) + return result.get("exit_code", 0) + + +def _handle_target(args: List[str]) -> int: + """Handle `drone @target command [args]` or `drone @target --help`.""" + target = args[0] + rest = args[1:] + module_name = target.lstrip("@").lower() + + # Check if this is a registered internal module + if is_module(module_name): + return _handle_module(module_name, rest) + + # Fall through to branch routing + if not rest or rest == ["--help"]: + try: + result = get_help(target) + if result.text: + print(result.text, end="") + else: + print(f"No help available for {target}.") + except BranchNotFoundError as exc: + print(f"drone: {exc}", file=sys.stderr) + return 1 + except CommandExecutionError as exc: + print(f"drone: {exc}", file=sys.stderr) + return 1 + return 0 + + # drone @branch command [args...] + command = rest[0] + cmd_args = rest[1:] + + try: + result = route_command(target, command, args=cmd_args if cmd_args else None) + except BranchNotFoundError as exc: + print(f"drone: {exc}", file=sys.stderr) + return 1 + except CommandExecutionError as exc: + print(f"drone: {exc}", file=sys.stderr) + return 1 + + if result.stdout: + print(result.stdout, end="") + if result.stderr: + print(result.stderr, end="", file=sys.stderr) + return result.exit_code + + +# ============================================================================= +# MAIN ENTRY POINT +# ============================================================================= + +def main() -> int: + """Main entry point - routes commands or shows help.""" + args = sys.argv[1:] + + # No args -> introspection + if not args: + show_introspection() + return 0 + + # --version + if args[0] in ["--version", "-V"]: + print(f"drone v{VERSION}") + return 0 + + # --help + if args[0] in ["--help", "-h", "help"]: + show_help() + return 0 + + command = args[0] + + # systems — list branches and modules + if command == "systems": + return _handle_systems() + + # @target — route to branch or module + if command.startswith("@"): + return _handle_target(args) + + # Unknown command + print(f"drone: unknown command '{command}'", file=sys.stderr) + print("Run 'drone --help' for usage.", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/aipass/drone/apps/handlers/exceptions.py b/src/aipass/drone/apps/handlers/exceptions.py new file mode 100644 index 00000000..bf57ce34 --- /dev/null +++ b/src/aipass/drone/apps/handlers/exceptions.py @@ -0,0 +1,50 @@ +""" +Drone module custom exceptions. + +Defines the exception hierarchy for routing and branch resolution errors. +""" + + +class RoutingError(Exception): + """Base exception for all routing-related errors.""" + pass + + +class BranchNotFoundError(RoutingError): + """Raised when a branch cannot be found in the registry.""" + pass + + +class BranchAlreadyExistsError(RoutingError): + """Raised when attempting to register a branch that already exists.""" + pass + + +class InvalidPathError(RoutingError): + """Raised when a path is invalid or doesn't exist.""" + pass + + +class RegistryError(RoutingError): + """Base exception for registry-related errors.""" + pass + + +class RegistryNotFoundError(RegistryError): + """Raised when the registry file doesn't exist.""" + pass + + +class RegistryCorruptError(RegistryError): + """Raised when the registry file is corrupted or invalid JSON.""" + pass + + +class RegistryPermissionError(RegistryError): + """Raised when there are permission issues accessing the registry.""" + pass + + +class CommandExecutionError(RoutingError): + """Raised when command execution fails.""" + pass diff --git a/src/aipass/drone/apps/handlers/executor.py b/src/aipass/drone/apps/handlers/executor.py new file mode 100644 index 00000000..be21b20d --- /dev/null +++ b/src/aipass/drone/apps/handlers/executor.py @@ -0,0 +1,68 @@ +""" +Safe subprocess execution for branch command routing. + +Wraps subprocess.run with safety guards: timeout enforcement, no shell injection, +captured output, and consistent error wrapping via CommandExecutionError. +""" + +import subprocess +from dataclasses import dataclass +from typing import List + +from .exceptions import CommandExecutionError + + +@dataclass +class CommandResult: + """Result of a routed command execution.""" + + stdout: str + stderr: str + exit_code: int + branch: str + command: str + + +def execute_command( + executable: str, + args: List[str], + cwd: str, + timeout: int = 30, +) -> CommandResult: + """Execute a command via subprocess with safety guards. + + Never uses shell=True to prevent shell injection attacks. + """ + full_cmd = [executable] + list(args) + + try: + result = subprocess.run( + full_cmd, + cwd=cwd, + capture_output=True, + timeout=timeout, + shell=False, + ) + except subprocess.TimeoutExpired as e: + raise CommandExecutionError( + f"Command timed out after {timeout}s: {' '.join(full_cmd)}" + ) from e + except FileNotFoundError as e: + raise CommandExecutionError( + f"Executable not found: {executable!r}" + ) from e + except OSError as e: + raise CommandExecutionError( + f"OS error executing command: {e}" + ) from e + + stdout = result.stdout.decode("utf-8", errors="replace") + stderr = result.stderr.decode("utf-8", errors="replace") + + return CommandResult( + stdout=stdout, + stderr=stderr, + exit_code=result.returncode, + branch="", + command="", + ) diff --git a/src/aipass/drone/apps/modules/__init__.py b/src/aipass/drone/apps/modules/__init__.py index f4315cef..a99baecb 100644 --- a/src/aipass/drone/apps/modules/__init__.py +++ b/src/aipass/drone/apps/modules/__init__.py @@ -1,5 +1,5 @@ -"""Drone modules - command routing services.""" +"""Drone modules — business logic for command routing.""" -def normalize_branch_arg(arg: str) -> str: - """Normalize branch argument (strip @, uppercase).""" - return arg.lstrip("@").upper() +from aipass.drone.apps.modules.resolver import normalize_branch_arg + +__all__ = ["normalize_branch_arg"] diff --git a/src/aipass/drone/apps/modules/config.py b/src/aipass/drone/apps/modules/config.py new file mode 100644 index 00000000..958b780f --- /dev/null +++ b/src/aipass/drone/apps/modules/config.py @@ -0,0 +1,73 @@ +""" +Registry configuration management. + +Locates AIPASS_REGISTRY.json using walk-up finder pattern. +Works in both pip-installed and development environments. +""" + +import os +from pathlib import Path +from typing import Optional + + +_registry_path: Optional[Path] = None + + +def _find_registry() -> Path: + """Find AIPASS_REGISTRY.json by walking up from this file's location. + + Search order: + 1. Explicitly set path via set_registry_path() + 2. AIPASS_REGISTRY environment variable + 3. Walk up from drone package location + 4. Walk up from cwd + 5. Default: ~/.aipass/AIPASS_REGISTRY.json + """ + # Walk up from this file (works for pip editable installs) + current = Path(__file__).resolve().parent + for parent in [current] + list(current.parents): + candidate = parent / "AIPASS_REGISTRY.json" + if candidate.exists(): + return candidate + + # Walk up from cwd (works for regular installs) + cwd = Path.cwd() + for parent in [cwd] + list(cwd.parents): + candidate = parent / "AIPASS_REGISTRY.json" + if candidate.exists(): + return candidate + + # Fallback + return Path.home() / ".aipass" / "AIPASS_REGISTRY.json" + + +def get_registry_path() -> Path: + """Get the current registry path. + + Priority: + 1. Explicitly set path via set_registry_path() + 2. AIPASS_REGISTRY environment variable + 3. Walk-up finder from package location + """ + global _registry_path + + if _registry_path is not None: + return _registry_path + + env_path = os.environ.get("AIPASS_REGISTRY") + if env_path: + return Path(env_path) + + return _find_registry() + + +def set_registry_path(path: str | Path) -> None: + """Set a custom registry path.""" + global _registry_path + _registry_path = Path(path) + + +def reset_registry_path() -> None: + """Reset registry path to default (useful for testing).""" + global _registry_path + _registry_path = None diff --git a/src/aipass/drone/apps/modules/discovery.py b/src/aipass/drone/apps/modules/discovery.py new file mode 100644 index 00000000..5cf7ba3d --- /dev/null +++ b/src/aipass/drone/apps/modules/discovery.py @@ -0,0 +1,169 @@ +""" +Module and command discovery for AIPass branch introspection. + +Introspects branch capabilities by querying entry points for help text +and scanning module directories as a fallback. +""" + +import logging +import subprocess +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Optional + +from aipass.drone.apps.handlers.exceptions import CommandExecutionError +from .resolver import list_branches, resolve_branch + +logger = logging.getLogger(__name__) + + +@dataclass +class HelpResult: + """Structured result from a help query.""" + + branch: str + command: Optional[str] + text: str + commands_found: List[str] = field(default_factory=list) + + +def _get_entry_point(branch_path: str, branch_name: str) -> Optional[Path]: + """Return the apps/{branch_name}.py entry point path if it exists.""" + entry_point = Path(branch_path) / "apps" / f"{branch_name}.py" + return entry_point if entry_point.exists() else None + + +def _scan_modules_directory(branch_path: str) -> List[str]: + """Scan apps/modules/ for .py files and return their stems as command names.""" + modules_dir = Path(branch_path) / "apps" / "modules" + if not modules_dir.is_dir(): + return [] + + excluded = {"__init__", "__main__"} + return sorted( + f.stem + for f in modules_dir.glob("*.py") + if f.stem not in excluded + ) + + +def _parse_help_for_commands(help_text: str) -> List[str]: + """Parse --help output to extract a list of available commands.""" + commands: List[str] = [] + in_commands_section = False + section_markers = {"commands", "subcommands", "available commands"} + + for line in help_text.splitlines(): + stripped = line.strip() + + if any(marker in stripped.lower() for marker in section_markers): + in_commands_section = True + continue + + if in_commands_section and not stripped: + in_commands_section = False + continue + + if in_commands_section: + if line.startswith((" ", "\t")) and stripped: + token = stripped.split()[0] + if not token.startswith("-"): + commands.append(token) + + return commands + + +def discover_modules(target: str) -> List[str]: + """Discover available commands for a branch.""" + branch_path = resolve_branch(target) + branch_name = target.lstrip("@").lower() + + entry_point = _get_entry_point(branch_path, branch_name) + if entry_point is not None: + try: + result = subprocess.run( + ["python3", str(entry_point.relative_to(branch_path)), "--help"], + cwd=branch_path, + capture_output=True, + timeout=10, + shell=False, + ) + help_text = result.stdout.decode("utf-8", errors="replace") + if not help_text: + help_text = result.stderr.decode("utf-8", errors="replace") + + commands = _parse_help_for_commands(help_text) + if commands: + return commands + except (subprocess.TimeoutExpired, OSError): + pass + + return _scan_modules_directory(branch_path) + + +def get_help(target: str, command: Optional[str] = None) -> HelpResult: + """Get structured help for a branch or a specific command.""" + branch_path = resolve_branch(target) + branch_name = target.lstrip("@").lower() + + entry_point = _get_entry_point(branch_path, branch_name) + if entry_point is None: + raise CommandExecutionError( + f"Entry point not found for branch '{branch_name}': " + f"{Path(branch_path) / 'apps' / (branch_name + '.py')}" + ) + + relative_entry = str(entry_point.relative_to(branch_path)) + if command is None: + cmd_args = [relative_entry, "--help"] + else: + cmd_args = [relative_entry, command, "--help"] + + try: + result = subprocess.run( + ["python3"] + cmd_args, + cwd=branch_path, + capture_output=True, + timeout=10, + shell=False, + ) + except subprocess.TimeoutExpired as e: + raise CommandExecutionError( + f"Help command timed out for branch '{branch_name}'" + ) from e + except OSError as e: + raise CommandExecutionError( + f"OS error getting help for branch '{branch_name}': {e}" + ) from e + + stdout = result.stdout.decode("utf-8", errors="replace") + stderr = result.stderr.decode("utf-8", errors="replace") + + text = stdout if stdout.strip() else stderr + commands_found = _parse_help_for_commands(text) + + return HelpResult( + branch=branch_name, + command=command, + text=text, + commands_found=commands_found, + ) + + +def get_system_help() -> Dict[str, HelpResult]: + """Aggregate help across all active branches in the registry.""" + results: Dict[str, HelpResult] = {} + active_branches = list_branches(status="active") + + for symbolic_name in active_branches: + branch_name = symbolic_name.lstrip("@") + try: + help_result = get_help(symbolic_name) + results[branch_name] = help_result + except Exception as exc: + logger.debug( + "get_system_help: skipping branch '%s': %s", + branch_name, exc, + ) + + return results diff --git a/src/aipass/drone/apps/modules/module_registry.py b/src/aipass/drone/apps/modules/module_registry.py new file mode 100644 index 00000000..8649ac3d --- /dev/null +++ b/src/aipass/drone/apps/modules/module_registry.py @@ -0,0 +1,112 @@ +"""Internal module registry for drone. + +Routes @module commands to Python packages installed alongside drone, +as opposed to external branches in AIPASS_REGISTRY.json. + +Modules register by providing a drone_adapter module with: +- DRONE_MODULE dict (name, version, description) +- handle_command(command, args) -> dict with stdout/stderr/exit_code +- get_help(command=None) -> str +""" + +from __future__ import annotations + +import importlib +from dataclasses import dataclass + +# Maps module name -> import path for its drone_adapter +_MODULE_REGISTRY: dict[str, str] = { + "drone": "aipass.drone.drone_adapter", + "seedgo": "aipass.seedgo.drone_adapter", +} + + +@dataclass +class ModuleInfo: + """Metadata about a registered module.""" + + name: str + version: str + description: str + adapter_path: str + + +def list_modules() -> list[str]: + """Return sorted list of registered module names.""" + return sorted(_MODULE_REGISTRY.keys()) + + +def get_module_info(name: str) -> ModuleInfo | None: + """Get module metadata without executing anything.""" + adapter_path = _MODULE_REGISTRY.get(name) + if adapter_path is None: + return None + try: + mod = importlib.import_module(adapter_path) + meta = getattr(mod, "DRONE_MODULE", {}) + return ModuleInfo( + name=meta.get("name", name), + version=meta.get("version", "unknown"), + description=meta.get("description", ""), + adapter_path=adapter_path, + ) + except ImportError: + return None + + +def is_module(name: str) -> bool: + """Check if name is a registered module.""" + return name in _MODULE_REGISTRY + + +def route_module_command(name: str, command: str, args: list[str] | None = None) -> dict: + """Route a command to a module's drone adapter. + + Returns dict with keys: stdout, stderr, exit_code. + """ + adapter_path = _MODULE_REGISTRY[name] + mod = importlib.import_module(adapter_path) + handler = getattr(mod, "handle_command") + return handler(command, args) + + +def get_module_help(name: str, command: str | None = None) -> str: + """Get help text from a module's drone adapter.""" + adapter_path = _MODULE_REGISTRY.get(name) + if adapter_path is None: + return "" + try: + mod = importlib.import_module(adapter_path) + help_fn = getattr(mod, "get_help", None) + if help_fn is None: + return "" + return help_fn(command) + except (ImportError, AttributeError): + return "" + + +def get_module_introspective(name: str) -> str: + """Get introspective view from a module's drone adapter. + + Introspective = discovery mode (no args): shows what's connected. + Falls back to help text if not implemented. + """ + adapter_path = _MODULE_REGISTRY.get(name) + if adapter_path is None: + return "" + try: + mod = importlib.import_module(adapter_path) + intro_fn = getattr(mod, "get_introspective", None) + if intro_fn is not None: + return intro_fn() + help_fn = getattr(mod, "get_help", None) + if help_fn is not None: + return help_fn(None) + return "" + except (ImportError, AttributeError): + return "" + + +def register_module(name: str, adapter_path: str) -> None: + """Register a new module dynamically.""" + _MODULE_REGISTRY[name] = adapter_path diff --git a/src/aipass/drone/apps/modules/registry.py b/src/aipass/drone/apps/modules/registry.py new file mode 100644 index 00000000..f4c1ed5e --- /dev/null +++ b/src/aipass/drone/apps/modules/registry.py @@ -0,0 +1,110 @@ +""" +Registry operations for branch management. + +Handles loading and querying AIPASS_REGISTRY.json. +Supports both list-format (pip) and dict-format (legacy) registries. +""" + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional + +from .config import get_registry_path +from aipass.drone.apps.handlers.exceptions import ( + RegistryCorruptError, + RegistryNotFoundError, + RegistryPermissionError, +) + + +def load_registry() -> Dict[str, Any]: + """Load the branch registry from disk. + + Returns: + Registry dictionary with branches (normalized to dict format) + + Raises: + RegistryNotFoundError: If registry file doesn't exist + RegistryCorruptError: If registry file is invalid JSON + RegistryPermissionError: If registry file cannot be read + """ + registry_path = get_registry_path() + + if not registry_path.exists(): + raise RegistryNotFoundError( + f"Registry not found at {registry_path}. " + "Create an AIPASS_REGISTRY.json in your project root." + ) + + try: + with open(registry_path, "r", encoding="utf-8") as f: + data = json.load(f) + except PermissionError as e: + raise RegistryPermissionError(f"Permission denied reading registry: {e}") + except json.JSONDecodeError as e: + raise RegistryCorruptError(f"Registry file is corrupted: {e}") + except Exception as e: + raise RegistryCorruptError(f"Failed to read registry: {e}") + + if not isinstance(data, dict): + raise RegistryCorruptError("Registry must be a JSON object") + + if "branches" not in data: + raise RegistryCorruptError("Registry missing 'branches' field") + + # Normalize: AIPASS_REGISTRY uses list format, convert to dict keyed by name + branches_raw = data["branches"] + if isinstance(branches_raw, list): + branches_dict = {} + registry_dir = registry_path.parent + for branch in branches_raw: + name = branch.get("name", "").lower() + if not name: + continue + # Resolve relative paths against registry location + raw_path = branch.get("path", "") + branch_path = Path(raw_path) + if not branch_path.is_absolute(): + branch_path = (registry_dir / branch_path).resolve() + entry = dict(branch) + entry["name"] = name + entry["path"] = str(branch_path) + branches_dict[name] = entry + data["branches"] = branches_dict + elif not isinstance(branches_raw, dict): + raise RegistryCorruptError("Registry 'branches' must be a list or dict") + + return data + + +def get_all_branches( + branch_type: Optional[str] = None, + status: str = "active", +) -> List[Dict[str, Any]]: + """Get all branches from the registry, optionally filtered.""" + try: + registry = load_registry() + except RegistryNotFoundError: + return [] + + branches = registry.get("branches", {}).values() + + filtered = [] + for branch in branches: + if status and branch.get("status") != status: + continue + if branch_type and branch.get("type") != branch_type: + continue + filtered.append(branch) + + return filtered + + +def get_branch_by_name(name: str) -> Optional[Dict[str, Any]]: + """Get a single branch by name (case-insensitive).""" + try: + registry = load_registry() + except RegistryNotFoundError: + return None + + return registry.get("branches", {}).get(name.lower()) diff --git a/src/aipass/drone/apps/modules/resolver.py b/src/aipass/drone/apps/modules/resolver.py new file mode 100644 index 00000000..7d86cb00 --- /dev/null +++ b/src/aipass/drone/apps/modules/resolver.py @@ -0,0 +1,87 @@ +""" +Branch resolution logic. + +Resolves symbolic @branch names to absolute paths and metadata. +""" + +from typing import Any, Dict, List, Optional + +from aipass.drone.apps.handlers.exceptions import BranchNotFoundError +from .registry import get_all_branches, get_branch_by_name, load_registry + + +def normalize_branch_name(symbolic_name: str) -> str: + """Normalize a symbolic branch name. Strips @ prefix if present.""" + if symbolic_name.startswith("@"): + return symbolic_name[1:] + return symbolic_name + + +def normalize_branch_arg(target: str) -> str: + """Normalize @branch argument: strip @ prefix, lowercase.""" + return target.lstrip("@").lower() + + +def resolve_branch(symbolic_name: str) -> str: + """Resolve a symbolic branch name to its absolute path. + + Args: + symbolic_name: Branch name with or without @ prefix + + Returns: + Absolute path to branch directory as string + + Raises: + BranchNotFoundError: If branch not in registry + RegistryNotFoundError: If registry file missing + """ + registry = load_registry() + + name = normalize_branch_name(symbolic_name).lower() + branch = registry.get("branches", {}).get(name) + + if branch is None: + raise BranchNotFoundError( + f"Branch '{symbolic_name}' not found in registry" + ) + + return branch["path"] + + +def branch_exists(symbolic_name: str) -> bool: + """Check if a branch exists in the registry.""" + name = normalize_branch_name(symbolic_name).lower() + branch = get_branch_by_name(name) + return branch is not None + + +def get_branch_info(symbolic_name: str) -> Dict[str, Any]: + """Get full metadata for a branch. + + Raises: + BranchNotFoundError: If branch not in registry + """ + registry = load_registry() + + name = normalize_branch_name(symbolic_name).lower() + branch = registry.get("branches", {}).get(name) + + if branch is None: + raise BranchNotFoundError( + f"Branch '{symbolic_name}' not found in registry" + ) + + return branch + + +def list_branches( + branch_type: Optional[str] = None, + status: str = "active", +) -> List[str]: + """List all registered branches, optionally filtered by type and status. + + Returns: + List of branch names with @ prefix + """ + branches = get_all_branches(branch_type=branch_type, status=status) + return [f"@{branch['name']}" for branch in branches] diff --git a/src/aipass/drone/apps/modules/router.py b/src/aipass/drone/apps/modules/router.py new file mode 100644 index 00000000..8eba3dae --- /dev/null +++ b/src/aipass/drone/apps/modules/router.py @@ -0,0 +1,98 @@ +""" +Command routing logic for the AIPass drone module. + +Routes commands to branch entry points by resolving symbolic @branch names, +locating the branch's apps/{name}.py entry point, and executing via subprocess. +""" + +import logging +from pathlib import Path +from typing import Dict, List, Optional + +from aipass.drone.apps.handlers.exceptions import CommandExecutionError +from aipass.drone.apps.handlers.executor import CommandResult, execute_command +from .resolver import list_branches, resolve_branch + +logger = logging.getLogger(__name__) + + +def _find_entry_point(branch_path: str, branch_name: str) -> Path: + """Locate the apps/{branch_name}.py entry point for a branch.""" + entry_point = Path(branch_path) / "apps" / f"{branch_name}.py" + if not entry_point.exists(): + raise CommandExecutionError( + f"Entry point not found for branch '{branch_name}': {entry_point}" + ) + return entry_point + + +def route_command( + target: str, + command: str, + args: Optional[List[str]] = None, + timeout: int = 30, +) -> CommandResult: + """Route a command to a branch's entry point. + + Resolves @target to an absolute path, locates the branch entry point at + {path}/apps/{branch_name}.py, then executes: + python3 apps/{name}.py {command} [args...] + """ + if args is None: + args = [] + + branch_path = resolve_branch(target) + branch_name = target.lstrip("@").lower() + + entry_point = _find_entry_point(branch_path, branch_name) + + relative_entry = str(entry_point.relative_to(branch_path)) + cmd_args = [relative_entry, command] + list(args) + + result = execute_command( + executable="python3", + args=cmd_args, + cwd=branch_path, + timeout=timeout, + ) + + return CommandResult( + stdout=result.stdout, + stderr=result.stderr, + exit_code=result.exit_code, + branch=branch_name, + command=command, + ) + + +def route_all( + command: str, + args: Optional[List[str]] = None, + timeout: int = 30, +) -> Dict[str, CommandResult]: + """Route the same command to ALL active branches in the registry.""" + if args is None: + args = [] + + results: Dict[str, CommandResult] = {} + active_branches = list_branches(status="active") + + for symbolic_name in active_branches: + branch_name = symbolic_name.lstrip("@") + try: + result = route_command(symbolic_name, command, args=list(args), timeout=timeout) + results[branch_name] = result + except Exception as exc: + logger.warning( + "route_all: branch '%s' failed for command '%s': %s", + branch_name, command, exc, + ) + results[branch_name] = CommandResult( + stdout="", + stderr=str(exc), + exit_code=-1, + branch=branch_name, + command=command, + ) + + return results diff --git a/src/aipass/drone/cli.py b/src/aipass/drone/cli.py new file mode 100644 index 00000000..f2b56e32 --- /dev/null +++ b/src/aipass/drone/cli.py @@ -0,0 +1,28 @@ +#!/home/aipass/.venv/bin/python3 +""" +Drone CLI — command-line interface for aipass.drone. + +Entry point: `drone` (wired via pyproject.toml console_scripts). +Thin wrapper that delegates to apps/drone.py main(). + +Usage: + drone Show available commands + drone --help Show help + drone --version Show version + drone systems List registered branches and modules + drone @branch command [args] Route command to branch + drone @module command [args] Route command to internal module +""" + +import sys + +from aipass.drone.apps.drone import main as _drone_main + + +def main() -> None: + """Entry point for the `drone` CLI command.""" + sys.exit(_drone_main()) + + +if __name__ == "__main__": + main() diff --git a/src/aipass/drone/drone_adapter.py b/src/aipass/drone/drone_adapter.py new file mode 100644 index 00000000..764ed1fc --- /dev/null +++ b/src/aipass/drone/drone_adapter.py @@ -0,0 +1,92 @@ +""" +Drone self-routing adapter — bridges drone routing to itself. + +Drone discovers this module via aipass.drone.apps.modules.module_registry +and routes `drone @drone [args]` here. +""" + +import sys +from io import StringIO + +DRONE_MODULE = { + "name": "drone", + "version": "1.0.0", + "description": "Command routing and module discovery", +} + + +def handle_command(command: str, args: list[str] | None = None) -> dict: + """Route a drone command to drone's own entry point. + + Captures stdout/stderr and returns as dict for drone CLI to print. + """ + if args is None: + args = [] + + original_argv = sys.argv + old_stdout = sys.stdout + old_stderr = sys.stderr + captured_out = StringIO() + captured_err = StringIO() + + try: + sys.argv = ["drone", command] + args + sys.stdout = captured_out + sys.stderr = captured_err + + from aipass.drone.apps.drone import main + exit_code = main() + except SystemExit as e: + exit_code = e.code if e.code is not None else 0 + except Exception as e: + captured_err.write(str(e)) + exit_code = 1 + finally: + sys.argv = original_argv + sys.stdout = old_stdout + sys.stderr = old_stderr + + return { + "stdout": captured_out.getvalue(), + "stderr": captured_err.getvalue(), + "exit_code": exit_code if isinstance(exit_code, int) else 1, + } + + +def get_help(command: str | None = None) -> str: + """Return help text for drone.""" + if command: + result = handle_command(command, ["--help"]) + return result.get("stdout", "") or result.get("stderr", "") + + return ( + "drone — Command routing and module discovery\n" + "\n" + "Commands:\n" + " systems List registered branches and modules\n" + " @target command [args] Route command to branch or module\n" + " @target --help Show help for branch or module\n" + "\n" + "Usage via drone:\n" + " drone systems\n" + " drone @seedgo audit aipass\n" + " drone @seedgo list\n" + ) + + +def get_introspective() -> str: + """Discovery mode: show what drone has connected.""" + try: + from aipass.drone.apps.modules.module_registry import list_modules + from aipass.drone.apps.modules.resolver import list_branches + + modules = list_modules() + branches = list_branches() + return ( + f"@drone — Command routing and module discovery\n" + f" Internal modules: {len(modules)} ({', '.join(modules)})\n" + f" Registered branches: {len(branches)}\n" + f" Run 'drone @drone --help' for usage\n" + ) + except Exception: + return "@drone — Command routing and module discovery (run 'drone --help' for usage)\n" diff --git a/src/aipass/drone/tests/conftest.py b/src/aipass/drone/tests/conftest.py index a87088fd..0edced40 100644 --- a/src/aipass/drone/tests/conftest.py +++ b/src/aipass/drone/tests/conftest.py @@ -1,30 +1,18 @@ #!/home/aipass/.venv/bin/python3 +"""Shared pytest fixtures for drone tests.""" -# ===================AIPASS==================== -# META DATA HEADER -# Name: tests/conftest.py -# Date: 2025-11-08 -# Version: 1.0.0 -# Category: cortex/tests -# -# CHANGELOG (Max 5 entries): -# - v1.0.0 (2025-11-08): Initial implementation - Shared pytest fixtures -# -# CODE STANDARDS: -# - Error handling: Use error handler system (apps/handlers/error/) -# ============================================= - -"""Shared pytest fixtures for cortex tests""" -import pytest +import json import shutil import tempfile from pathlib import Path from typing import Generator +import pytest + @pytest.fixture def temp_test_dir() -> Generator[Path, None, None]: - """Creates temporary directory for testing, cleans up after""" + """Creates temporary directory for testing, cleans up after.""" test_dir = Path(tempfile.mkdtemp()) yield test_dir if test_dir.exists(): @@ -32,12 +20,21 @@ def temp_test_dir() -> Generator[Path, None, None]: @pytest.fixture -def sample_test_data() -> dict: - """Provides sample test data - - Customize this fixture for your module's needs - """ - return { - "test_key": "test_value", - "sample_data": "example" +def sample_registry(temp_test_dir: Path) -> Path: + """Create a sample AIPASS_REGISTRY.json for testing.""" + registry = { + "metadata": {"version": "1.0.0"}, + "branches": [ + { + "name": "TEST_BRANCH", + "path": str(temp_test_dir / "test_branch"), + "profile": "library", + "description": "Test branch", + "email": "@test_branch", + "status": "active", + } + ], } + registry_path = temp_test_dir / "AIPASS_REGISTRY.json" + registry_path.write_text(json.dumps(registry, indent=2)) + return registry_path diff --git a/src/aipass/flow/apps/__init__.py b/src/aipass/flow/apps/__init__.py index f5f27db4..73ab12a7 100644 --- a/src/aipass/flow/apps/__init__.py +++ b/src/aipass/flow/apps/__init__.py @@ -1 +1 @@ -# FLOW apps package +# Apps package - Branch application modules and handlers diff --git a/src/aipass/flow/apps/branch.py b/src/aipass/flow/apps/branch.py deleted file mode 100644 index a982edb8..00000000 --- a/src/aipass/flow/apps/branch.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -FLOW Branch - Main Orchestrator - -Auto-discovery architecture: -- Scans modules/ directory for .py files with handle_command() -- Routes commands to discovered modules automatically -- No manual imports or routing needed -""" - -import sys -import importlib -from pathlib import Path -from typing import List, Any - -from aipass.prax import logger - -# ============================================================================= -# MODULE DISCOVERY -# ============================================================================= - -MODULES_DIR = Path(__file__).parent / "modules" - - -def discover_modules() -> List[Any]: - """Auto-discover modules in modules/ directory.""" - modules = [] - - if not MODULES_DIR.exists(): - return modules - - for file_path in MODULES_DIR.glob("*.py"): - if file_path.name.startswith("_"): - continue - - module_name = f"apps.modules.{file_path.stem}" - - try: - module = importlib.import_module(module_name) - if hasattr(module, "handle_command"): - modules.append(module) - except Exception as e: - logger.error(f"[FLOW] Failed to load module {module_name}: {e}") - - return modules - - -def route_command(command: str, args: List[str], modules: List[Any]) -> bool: - """Route command to appropriate module.""" - for module in modules: - try: - if module.handle_command(command, args): - return True - except Exception as e: - logger.error(f"[FLOW] Module {module.__name__} error: {e}") - return False - - -# ============================================================================= -# MAIN ENTRY POINT -# ============================================================================= - -def main(): - """Main entry point - routes commands or shows help.""" - modules = discover_modules() - args = sys.argv[1:] - - if len(args) == 0 or args[0] in ["--help", "-h", "help"]: - print(f"FLOW - {len(modules)} modules discovered") - for module in modules: - name = module.__name__.split(".")[-1] - desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" - print(f" {name:20} {desc}") - return 0 - - command = args[0] - remaining = args[1:] if len(args) > 1 else [] - - if route_command(command, remaining, modules): - return 0 - - print(f"Unknown command: {command}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/aipass/flow/apps/extensions/__init__.py b/src/aipass/flow/apps/extensions/__init__.py new file mode 100644 index 00000000..95322c94 --- /dev/null +++ b/src/aipass/flow/apps/extensions/__init__.py @@ -0,0 +1 @@ +# Extensions package - Drop-in extensions for branch functionality diff --git a/src/aipass/flow/apps/flow.py b/src/aipass/flow/apps/flow.py new file mode 100755 index 00000000..0cca342a --- /dev/null +++ b/src/aipass/flow/apps/flow.py @@ -0,0 +1,316 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: flow.py - FLOW Branch Orchestrator +# Date: 2025-11-30 +# Version: 2.2.1 +# Category: core/planning +# +# CHANGELOG (Max 5 entries): +# - v2.2.1 (2025-11-30): Fixed help UX - removed @ syntax from examples (@ resolution is drone's job) +# - v2.2.0 (2025-11-22): Fixed META header - added category and CODE STANDARDS +# - v2.1.0 (2025-11-15): Added CLI services integration (console, header, success, error) +# - v2.0.0 (2025-11-15): Refactored to auto-discovery pattern, removed manual routing +# +# CODE STANDARDS: +# - Handlers implement logic, modules orchestrate +# ============================================= + +""" +Flow Branch - Main Orchestrator + +Auto-discovery architecture: +- Scans modules/ directory for .py files with handle_command() +- Routes commands to discovered modules automatically +- No manual imports or routing needed +""" + +# INFRASTRUCTURE IMPORT PATTERN +import sys +from pathlib import Path +_PKG_ROOT = Path(__file__).resolve().parents[2] # flow.py → apps/ → flow/ → aipass/ + +# Standard library imports +import importlib +import signal +from typing import List, Any + +# Handle broken pipe gracefully (e.g. output piped to head) +signal.signal(signal.SIGPIPE, signal.SIG_DFL) + +# Prax logger +from aipass.prax.apps.modules.logger import system_logger as logger + +# CLI services for formatted output +from aipass.cli.apps.modules import console, header, success, error + +# ============================================================================= +# MODULE DISCOVERY +# ============================================================================= + +MODULES_DIR = Path(__file__).parent / "modules" + +def discover_modules() -> List[Any]: + """ + Auto-discover modules in modules/ directory + + Modules must implement handle_command(command: str, args: List[str]) -> bool + + Returns: + List of module objects with handle_command function + """ + modules = [] + + if not MODULES_DIR.exists(): + logger.warning(f"[FLOW] Modules directory not found: {MODULES_DIR}") + return modules + + # Discover all .py files (except __init__.py and those starting with _) + for file_path in MODULES_DIR.glob("*.py"): + if file_path.name.startswith("_"): + continue + + module_name = f"aipass.flow.apps.modules.{file_path.stem}" + + try: + module = importlib.import_module(module_name) + + # Check if module has handle_command function + if hasattr(module, 'handle_command'): + modules.append(module) + logger.info(f"[FLOW] Loaded module: {file_path.stem}") + else: + logger.info(f"[FLOW] Skipped {file_path.stem} - no handle_command()") + + except Exception as e: + logger.error(f"[FLOW] Failed to load module {module_name}: {e}") + + return modules + + +def route_command(command: str, args: List[str], modules: List[Any]) -> bool: + """ + Route command to appropriate module + + Args: + command: Command name (e.g., 'create', 'delete', 'list') + args: Additional arguments + modules: List of discovered modules + + Returns: + True if command was handled, False otherwise + """ + for module in modules: + try: + if module.handle_command(command, args): + return True + except BrokenPipeError: + logger.info(f"[FLOW] Broken pipe in {module.__name__} (stdout closed early)") + return True + except Exception as e: + logger.error(f"[FLOW] Module {module.__name__} error: {e}") + + return False + +# ============================================================================= +# MAIN +# ============================================================================= + +def main(): + """Main entry point - routes commands or shows help""" + + # Discover available modules + modules = discover_modules() + + if not modules: + logger.warning("[FLOW] No modules discovered") + error("No modules available") + return 1 + + # Parse arguments + args = sys.argv[1:] + + # Show introspection when run with no arguments + if len(args) == 0: + print_introspection(modules) + return 0 + + # Show version + if args[0] in ['--version', '-V']: + console.print("FLOW v2.2.1") + return 0 + + # Show help for explicit help flags + if args[0] in ['--help', '-h', 'help']: + print_help(modules) + return 0 + + # Extract command and remaining args + # Pattern: flow + # Example: flow create . "Subject" + command = args[0] + remaining_args = args[1:] if len(args) > 1 else [] + + # Check if user wants module-specific help + if remaining_args and remaining_args[0] in ['--help', '-h']: + print_module_help(command, modules) + return 0 + + # Route to modules + if route_command(command, remaining_args, modules): + return 0 + else: + console.print() + console.print(f"[red]Unknown command: {command}[/red]") + console.print() + console.print("Run [dim]python3 flow.py --help[/dim] for available commands") + console.print() + return 1 + + +def print_introspection(modules: List[Any]): + """Display discovered modules with Rich formatting (run with no args)""" + console.print() + console.print("[bold cyan]Flow - PLAN Management System[/bold cyan]") + console.print() + console.print("[dim]Task orchestration and workflow management[/dim]") + console.print() + + console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}") + console.print() + + if modules: + for module in modules: + module_name = module.__name__.split('.')[-1] + # Get first line of docstring + description = "No description" + if module.__doc__: + description = module.__doc__.strip().split('\n')[0] + console.print(f" [cyan]•[/cyan] {module_name:20} [dim]{description}[/dim]") + else: + console.print(" [dim]No modules discovered[/dim]") + + console.print() + console.print("[dim]Run 'python3 flow.py --help' for usage information[/dim]") + console.print() + + +def print_help(modules: List[Any]): + """Display Rich-formatted help (run with --help)""" + console.print() + header("Flow - PLAN Management System") + console.print() + + console.print("[dim]Task orchestration and workflow management for AIPass[/dim]") + console.print() + console.print("─" * 70) + console.print() + + console.print("[bold cyan]USAGE:[/bold cyan]") + console.print() + console.print(" [dim]python3 flow.py [args...][/dim]") + console.print(" [dim]python3 flow.py --help[/dim]") + console.print() + console.print("─" * 70) + console.print() + + console.print("[bold cyan]AVAILABLE COMMANDS:[/bold cyan]") + console.print() + console.print("[dim]Commands can be called by short name (e.g., 'create') or full name (e.g., 'create_plan')[/dim]") + console.print() + + if modules: + for module in modules: + module_name = module.__name__.split('.')[-1] + # Extract short form (before underscore if present) + short_name = module_name.split('_')[0] if '_' in module_name else module_name + + # Get first line of docstring + description = "No description" + if module.__doc__: + description = module.__doc__.strip().split('\n')[0] + + # Display both forms + if short_name != module_name: + console.print(f" [green]{short_name}, {module_name:18}[/green] [dim]{description}[/dim]") + else: + console.print(f" [green]{module_name:20}[/green] [dim]{description}[/dim]") + else: + console.print(" [dim]No modules discovered[/dim]") + + console.print() + console.print("─" * 70) + console.print() + + console.print("[bold cyan]EXAMPLES:[/bold cyan]") + console.print() + console.print(" [yellow]Create new PLAN:[/yellow]") + console.print(" [dim]python3 flow.py create . \"Implementation task\"[/dim]") + console.print(" [dim]python3 flow.py create /path/to/location \"Implementation task\"[/dim]") + console.print() + console.print(" [yellow]Close PLAN:[/yellow]") + console.print(" [dim]python3 flow.py close 42 --yes[/dim]") + console.print() + console.print(" [yellow]Close all open plans:[/yellow]") + console.print(" [dim]python3 flow.py close --all[/dim]") + console.print() + console.print(" [yellow]List plans:[/yellow]") + console.print(" [dim]python3 flow.py list[/dim]") + console.print() + console.print() + console.print("[bold]NOTE:[/bold] @ syntax (e.g., @flow, @seed) only works through drone:") + console.print(" [dim]drone flow create @flow \"Implementation task\"[/dim]") + console.print() + console.print("─" * 70) + console.print() + + console.print("[bold]TIP:[/bold] For module-specific help:") + console.print(" [dim]python3 flow.py --help[/dim]") + console.print() + + +def print_module_help(command: str, modules: List[Any]): + """Display module-specific help with Rich formatting""" + # Try to find the module that handles this command + target_module = None + for module in modules: + module_name = module.__name__.split('.')[-1] + # Check if module name matches command (e.g., create_plan matches "create" or "create_plan") + if command == module_name or module_name.startswith(command): + target_module = module + break + + if not target_module: + console.print() + console.print(f"[red]Unknown command: {command}[/red]") + console.print() + console.print("Run [dim]python3 flow.py --help[/dim] for available commands") + console.print() + return + + console.print() + module_name = target_module.__name__.split('.')[-1] + header(f"Flow - {module_name} Command") + console.print() + + # Display full docstring if available + if target_module.__doc__: + docstring = target_module.__doc__.strip() + console.print(f"[dim]{docstring}[/dim]") + else: + console.print("[dim]No documentation available[/dim]") + + console.print() + + +if __name__ == "__main__": + try: + sys.exit(main()) + except BrokenPipeError: + import os + try: + sys.stdout.close() + except Exception as e: + print(f"Error: {e}") + os._exit(0) diff --git a/src/aipass/flow/apps/handlers/__init__.py b/src/aipass/flow/apps/handlers/__init__.py index e69de29b..55b99d27 100644 --- a/src/aipass/flow/apps/handlers/__init__.py +++ b/src/aipass/flow/apps/handlers/__init__.py @@ -0,0 +1,132 @@ +"""Flow handlers package - Security protected.""" + +import inspect +from pathlib import Path + +MY_BRANCH = "flow" + + +def _find_real_caller(): + """ + Walk the stack to find the actual file that triggered this import. + + Skips: + - This file (handlers/__init__.py) + - Python's importlib internals + - Frozen modules + + Returns tuple: (file_path, import_line) or (None, None) + """ + stack = inspect.stack() + this_file = str(Path(__file__).resolve()) + + for frame_info in stack: + filename = frame_info.filename + + # Skip this file + if this_file in str(Path(filename).resolve()): + continue + + # Skip Python internals + if filename.startswith("<") or "importlib" in filename: + continue + + # Found a real file - try to get the import line + import_line = None + if frame_info.code_context: + import_line = frame_info.code_context[0].strip() + + return str(Path(filename).resolve()), import_line + + return None, None + + +def _extract_branch_name(filepath: str) -> str: + """Extract branch name from a file path.""" + parts = filepath.split("/") + for i, part in enumerate(parts): + if part in ("aipass_core", "MEMORY_BANK", "Nexus"): + if i + 1 < len(parts): + return parts[i + 1] + return "unknown" + + +def _guard_branch_access(): + """ + Block cross-branch handler imports. + + Only code from within the 'flow' branch can import these handlers. + External branches must use flow.apps.modules instead. + """ + caller_file, import_line = _find_real_caller() + + # DEBUG: Print what we found + import os + if os.environ.get("AIPASS_DEBUG_GUARD"): + import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) + print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) + + if caller_file is None: + # Can't determine caller from real files + # Check if we're being run from command line (external) + # by looking at the raw stack for or + stack = inspect.stack() + for frame in stack: + if frame.filename in ("", ""): + # Try to get the import line from the frame + target_line = "unknown" + if frame.code_context: + target_line = frame.code_context[0].strip() + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller: interactive/script\n" + f" Blocked: {target_line}\n" + f"\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"\n" + f" Example:\n" + f" from {MY_BRANCH}.apps.modules.create_plan import handle_command\n" + f"\n" + f" For full standards guide:\n" + f" drone @seed handlers\n" + f"{'='*60}" + ) + return # Allow if truly can't determine + + # Check if caller is from our branch + if f"/{MY_BRANCH}/" in caller_file: + return # Same branch, allowed + + # External caller - block access + caller_branch = _extract_branch_name(caller_file) + caller_filename = Path(caller_file).name + blocked_import = import_line if import_line else "unknown" + + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller branch: {caller_branch}\n" + f" Caller file: {caller_filename}\n" + f" Blocked: {blocked_import}\n" + f"\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"\n" + f" Example:\n" + f" from {MY_BRANCH}.apps.modules.create_plan import handle_command\n" + f"\n" + f" For full standards guide:\n" + f" drone @seed handlers\n" + f"{'='*60}" + ) + + +# Run guard at import time +_guard_branch_access() diff --git a/src/aipass/flow/apps/handlers/config/__init__.py b/src/aipass/flow/apps/handlers/config/__init__.py new file mode 100644 index 00000000..33b5ab4d --- /dev/null +++ b/src/aipass/flow/apps/handlers/config/__init__.py @@ -0,0 +1 @@ +"""Flow config handlers package""" diff --git a/src/aipass/flow/apps/handlers/config/load_config.py b/src/aipass/flow/apps/handlers/config/load_config.py new file mode 100644 index 00000000..37d61bf1 --- /dev/null +++ b/src/aipass/flow/apps/handlers/config/load_config.py @@ -0,0 +1,113 @@ +#!/home/aipass/.venv/bin/python3 +# -*- coding: utf-8 -*- + +# ===================AIPASS==================== +# META DATA HEADER +# Name: load_config.py +# Date: 2025-11-07 +# Version: 1.1.0 +# Category: flow/handlers/config +# +# CHANGELOG: +# - v1.1.0 (2025-11-21): Removed Prax logging per 3-tier standard +# - v1.0.0 (2025-11-07): Extracted from flow_registry_monitor.py +# ============================================= + +""" +Load Config Handler + +Loads module configuration from JSON config file with auto-creation. + +Features: +- Loads config from flow_json/ directory +- Auto-creates default config if missing +- Graceful error handling with fallback +- Reusable across Flow modules + +Usage: + from aipass.flow.apps.handlers.config.load_config import load_config + + config = load_config("registry_monitor") + enabled = config.get("config", {}).get("enabled", True) +""" + +import json +from pathlib import Path +from datetime import datetime, timezone +from typing import Dict, Any + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] +FLOW_ROOT = _PKG_ROOT / "flow" + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "load_config" +FLOW_JSON_DIR = FLOW_ROOT / "flow_json" + +# ============================================= +# HANDLER FUNCTIONS +# ============================================= + +def create_default_config(config_file: Path, module_name: str, default_settings: Dict[str, Any] | None = None) -> Dict[str, Any]: + """ + Create default config file if it doesn't exist + + Args: + config_file: Path to config file + module_name: Name of the module (for metadata) + default_settings: Optional dict of default config values + + Returns: + Default config structure + """ + if config_file.exists(): + return {} + + default_config = { + "module_name": module_name, + "timestamp": datetime.now(timezone.utc).isoformat(), + "config": default_settings or {"enabled": True} + } + + try: + FLOW_JSON_DIR.mkdir(parents=True, exist_ok=True) + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(default_config, f, indent=2, ensure_ascii=False) + return default_config + except Exception: + return default_config + + +def load_config(module_name: str, default_settings: Dict[str, Any] | None = None) -> Dict[str, Any]: + """ + Load module configuration with auto-creation of defaults + + Args: + module_name: Name of the module (e.g., "registry_monitor") + default_settings: Optional dict of default config values + + Returns: + Config dictionary with structure: + { + "module_name": str, + "timestamp": str, + "config": {...} + } + + Example: + >>> config = load_config("registry_monitor", {"enabled": True, "scan_on_startup": True}) + >>> enabled = config.get("config", {}).get("enabled", True) + """ + config_file = FLOW_JSON_DIR / f"{module_name}_config.json" + + # Create config if it doesn't exist + create_default_config(config_file, module_name, default_settings) + + try: + with open(config_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return {"config": default_settings or {"enabled": True}} diff --git a/src/aipass/flow/apps/handlers/dashboard/__init__.py b/src/aipass/flow/apps/handlers/dashboard/__init__.py new file mode 100644 index 00000000..c5147738 --- /dev/null +++ b/src/aipass/flow/apps/handlers/dashboard/__init__.py @@ -0,0 +1,18 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: __init__.py +# Date: 2025-11-21 +# Version: 1.0.0 +# Category: flow/handlers/dashboard +# +# CHANGELOG: +# - v1.0.0 (2025-11-21): Initial creation for dashboard handlers +# ============================================= + +""" +Dashboard Handlers Package + +Handlers for updating Flow's local dashboard and pushing to central aggregation. +""" diff --git a/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py b/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py new file mode 100644 index 00000000..f1f68664 --- /dev/null +++ b/src/aipass/flow/apps/handlers/dashboard/push_branch_dashboard.py @@ -0,0 +1,357 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: push_branch_dashboard.py - Push flow section to branch dashboards +# Date: 2026-03-01 +# Version: 1.1.0 +# Category: flow/handlers/dashboard +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2026-03-01): Fix cross-branch import guard — inline dashboard write +# instead of importing write_section from devpulse (blocked by handler guard) +# - v1.0.0 (2026-02-25): FPLAN-0373 Phase 2 - write-through flow section to branch dashboards +# +# CODE STANDARDS: +# - Pure handler - no CLI imports, no Prax imports +# - Silent failures (logged via return value, never raised) +# - Direct DASHBOARD.local.json write (no cross-branch imports) +# ============================================= + +""" +Push Flow Section to Branch Dashboard + +Pushes the "flow" section of a branch's DASHBOARD.local.json via the +DevPulse write_section() write-through API. + +Unlike update_local.py (which updates Flow's OWN dashboard), this handler +targets the branch where a plan LIVES. Each branch sees its own active plans +on its own dashboard. + +Data Flow: + 1. Read flow_registry.json (source of truth) + 2. Filter active plans for target branch (by location path) + 3. Get recently closed plans (last 5, within last 7 days) + 4. Get total plan count for this branch + 5. Call write_section(branch_path, "flow", section_data) + +Section Structure: +{ + "managed_by": "flow", + "active_plans": [ + {"id": "FPLAN-0373", "subject": "...", "created": "...", "location": "/home/aipass/..."} + ], + "active_count": 2, + "recently_closed": [ + {"id": "FPLAN-0372", "subject": "...", "closed": "..."} + ], + "total_plans": 15 +} + +Usage: + from aipass.flow.apps.handlers.dashboard.push_branch_dashboard import push_flow_to_branch_dashboard + success = push_flow_to_branch_dashboard(Path("/home/aipass/aipass_os/dev_central")) +""" + +import json +from datetime import datetime, timezone, timedelta +from pathlib import Path +from typing import Dict, Any, List, Tuple + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] +FLOW_ROOT = _PKG_ROOT / "flow" + +# Registry location +REGISTRY_FILE = FLOW_ROOT / "flow_json" / "flow_registry.json" + +# Dashboard template path +DASHBOARD_TEMPLATE_FILE = Path.home() / "aipass_os" / "dev_central" / "devpulse" / "templates" / "DASHBOARD.template.json" + + +# ============================================= +# DASHBOARD WRITE (local — no cross-branch imports) +# ============================================= + +def _write_dashboard_section(branch_path: Path, section_name: str, section_data: Dict[str, Any]) -> bool: + """ + Write a single section to a branch's DASHBOARD.local.json. + + Self-contained dashboard write — equivalent to DevPulse write_section() + but without cross-branch imports. Loads existing dashboard, updates + the named section, recalculates quick_status, and saves. + + Args: + branch_path: Path to branch root directory + section_name: Section key (e.g. "flow") + section_data: Dict of data for this section + + Returns: + True if saved successfully, False on any error + """ + try: + dashboard_path = branch_path / "DASHBOARD.local.json" + + # Load existing or create fresh + if dashboard_path.exists(): + content = dashboard_path.read_text().strip() + if content: + try: + dashboard = json.loads(content) + except json.JSONDecodeError: + dashboard = _create_fresh_dashboard(branch_path) + else: + dashboard = _create_fresh_dashboard(branch_path) + else: + dashboard = _create_fresh_dashboard(branch_path) + + if "sections" not in dashboard: + dashboard["sections"] = {} + + section_data["last_updated"] = datetime.now().isoformat() + dashboard["sections"][section_name] = section_data + dashboard["quick_status"] = _calculate_quick_status(dashboard["sections"]) + dashboard["last_updated"] = datetime.now().isoformat() + + dashboard_path.write_text(json.dumps(dashboard, indent=2)) + return True + + except Exception: + return False + + +def _create_fresh_dashboard(branch_path: Path) -> Dict[str, Any]: + """ + Create fresh dashboard structure from template or fallback. + + Args: + branch_path: Path to branch root + + Returns: + Fresh dashboard dict + """ + if DASHBOARD_TEMPLATE_FILE.exists(): + try: + template = json.loads(DASHBOARD_TEMPLATE_FILE.read_text()) + dashboard = json.loads( + json.dumps(template).replace("{{BRANCHNAME}}", branch_path.name.upper()) + ) + dashboard["last_updated"] = datetime.now().isoformat() + return dashboard + except (json.JSONDecodeError, OSError): + pass + + now = datetime.now().isoformat() + return { + "_warning": "AUTO-GENERATED FILE - DO NOT MANUALLY EDIT.", + "branch": branch_path.name.upper(), + "last_updated": now, + "quick_status": {"action_required": False}, + "sections": { + "ai_mail": {"managed_by": "ai_mail", "new": 0, "opened": 0, "total": 0, "last_updated": ""}, + "flow": {"managed_by": "flow", "active_plans": 0, "recently_closed": [], "last_updated": ""}, + "memory_bank": {"managed_by": "memory_bank", "vectors_stored": 0, "notes": {}, "last_updated": ""}, + "devpulse": {"managed_by": "devpulse", "summary": {}, "last_updated": ""}, + "commons_activity": {"managed_by": "the_commons", "mentions": 0, "last_updated": ""} + } + } + + +def _calculate_quick_status(sections: Dict[str, Any]) -> Dict[str, Any]: + """ + Calculate quick_status from live section data. + + Args: + sections: All dashboard sections dict + + Returns: + Quick status dict + """ + ai_mail = sections.get("ai_mail", {}) + flow = sections.get("flow", {}) + commons = sections.get("commons_activity", {}) + + new_mail = ai_mail.get("new", ai_mail.get("unread", 0)) + opened_mail = ai_mail.get("opened", 0) + active_plans = flow.get("active_plans", 0) + mentions = commons.get("mentions", 0) + + action_required = new_mail > 0 or active_plans > 0 or mentions > 0 + + parts = [] + if new_mail > 0: + parts.append(f"{new_mail} new emails") + if opened_mail > 0: + parts.append(f"{opened_mail} opened") + if active_plans > 0: + parts.append(f"{active_plans} active plans") + if mentions > 0: + parts.append(f"{mentions} mentions") + + return { + "new_mail": new_mail, + "opened_mail": opened_mail, + "active_plans": active_plans, + "commons_mentions": mentions, + "action_required": action_required, + "summary": ", ".join(parts) if parts else "All clear" + } + + +# ============================================= +# HELPER FUNCTIONS +# ============================================= + +def _load_registry() -> Dict[str, Any]: + """ + Load flow_registry.json. + + Returns: + Registry dict or empty structure if unavailable + """ + try: + if not REGISTRY_FILE.exists(): + return {"plans": {}, "next_number": 1} + with open(REGISTRY_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return {"plans": {}, "next_number": 1} + + +def _filter_branch_plans( + registry: Dict[str, Any], + branch_path: Path +) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], int]: + """ + Filter plans for a specific branch from the registry. + + Args: + registry: Full flow_registry.json data + branch_path: Absolute path to the branch directory + + Returns: + Tuple of (active_plans, recently_closed_plans, total_branch_plans) + """ + plans = registry.get("plans", {}) + branch_path_str = str(branch_path) + now = datetime.now(timezone.utc) + cutoff = now - timedelta(days=7) + + active_plans = [] + closed_plans = [] + branch_total = 0 + + for plan_num, plan_data in plans.items(): + location = plan_data.get("location", "") + + # Match plans whose location is this branch + if location != branch_path_str: + continue + + branch_total += 1 + plan_id = f"FPLAN-{plan_num.zfill(4)}" + + if plan_data.get("status") == "open": + active_plans.append({ + "id": plan_id, + "subject": plan_data.get("subject", ""), + "created": plan_data.get("created", ""), + "location": location + }) + elif plan_data.get("status") == "closed": + closed_ts = plan_data.get("closed", "") + # Only include recently closed (within 7 days) + if closed_ts: + try: + closed_dt = datetime.fromisoformat(closed_ts) + if closed_dt >= cutoff: + closed_plans.append({ + "id": plan_id, + "subject": plan_data.get("subject", ""), + "closed": closed_ts + }) + except (ValueError, TypeError): + # If we can't parse the timestamp, include it anyway + closed_plans.append({ + "id": plan_id, + "subject": plan_data.get("subject", ""), + "closed": closed_ts + }) + + # Sort active by created date (newest first) + active_plans.sort(key=lambda x: x.get("created", ""), reverse=True) + + # Sort closed by closed date (newest first), limit to 5 + closed_plans.sort(key=lambda x: x.get("closed", ""), reverse=True) + recent_closed = closed_plans[:5] + + return active_plans, recent_closed, branch_total + + +def _build_section_data( + active_plans: List[Dict[str, Any]], + recently_closed: List[Dict[str, Any]], + total_plans: int +) -> Dict[str, Any]: + """ + Build the flow section data for write_section(). + + Args: + active_plans: List of active plan dicts + recently_closed: List of recently closed plan dicts + total_plans: Total plan count for this branch + + Returns: + Section data dict ready for write_section() + """ + return { + "managed_by": "flow", + "active_plans": active_plans, + "active_count": len(active_plans), + "recently_closed": recently_closed, + "total_plans": total_plans + } + + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def push_flow_to_branch_dashboard(branch_path: Path) -> bool: + """ + Push flow section to a branch's DASHBOARD.local.json via write_section(). + + Reads the flow registry, filters plans for the target branch, + and writes the flow section to the branch's dashboard. + + Dashboard write failures are silent (returns False, never raises). + + Args: + branch_path: Absolute path to the branch directory + (e.g. Path("/home/aipass/aipass_os/dev_central")) + + Returns: + True if successfully written, False on any error + """ + try: + branch_path = Path(branch_path) + + # Guard: only push to paths that already have a dashboard (real branches) + dashboard_file = branch_path / "DASHBOARD.local.json" + if not dashboard_file.exists(): + return False + + # 1. Load the registry + registry = _load_registry() + + # 2. Filter plans for this branch + active_plans, recently_closed, total_plans = _filter_branch_plans(registry, branch_path) + + # 3. Build section data + section_data = _build_section_data(active_plans, recently_closed, total_plans) + + # 4. Write flow section to branch dashboard + return _write_dashboard_section(branch_path, "flow", section_data) + + except Exception: + return False diff --git a/src/aipass/flow/apps/handlers/dashboard/push_central.py b/src/aipass/flow/apps/handlers/dashboard/push_central.py new file mode 100644 index 00000000..a932c609 --- /dev/null +++ b/src/aipass/flow/apps/handlers/dashboard/push_central.py @@ -0,0 +1,253 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: push_central.py +# Date: 2025-11-21 +# Version: 1.1.0 +# Category: flow/handlers/dashboard +# +# CHANGELOG: +# - v1.1.0 (2025-11-21): Integrated aggregate_central for self-healing +# - v1.0.0 (2025-11-21): Initial creation - push Flow's plans to central aggregation +# ============================================= + +""" +Push to Plans Central Handler + +Pushes Flow's plan data to the central PLANS.central.json file at AI_CENTRAL. +This handler follows the 3-tier logging standard (no Prax imports, no logging). + +Features: +- Reads flow_registry.json to get Flow's plans +- Extracts only plans where location='flow' (Flow's own plans) +- Updates branches.flow section in PLANS.central.json +- Preserves all other branch sections +- Calls aggregate_central to rebuild top-level active_plans +- Calculates global statistics across all branches +- Pure handler - returns boolean for success/failure + +Usage: + from aipass.flow.apps.handlers.dashboard.push_central import push_to_plans_central + success = push_to_plans_central() +""" + +import json +from pathlib import Path +from datetime import datetime, timezone +from typing import Dict, Any, List + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] +FLOW_ROOT = _PKG_ROOT / "flow" + +# Module imports +from aipass.flow.apps.modules.aggregate_central import aggregate_central + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "push_central" +FLOW_JSON_DIR = FLOW_ROOT / "flow_json" +REGISTRY_FILE = FLOW_JSON_DIR / "flow_registry.json" +AI_CENTRAL_DIR = Path.home() / "aipass_os" / "AI_CENTRAL" +CENTRAL_FILE = AI_CENTRAL_DIR / "PLANS.central.json" + +# ============================================= +# HELPER FUNCTIONS +# ============================================= + +def _load_registry() -> Dict[str, Any]: + """Load flow_registry.json + + Returns: + Registry dict or empty structure if file doesn't exist + """ + if not REGISTRY_FILE.exists(): + return {"plans": {}, "next_number": 1} + + try: + with open(REGISTRY_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return {"plans": {}, "next_number": 1} + + +def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict], List[Dict]]: + """Extract Flow's own plans from registry + + Args: + registry: The flow_registry.json data + + Returns: + Tuple of (active_plans, recently_closed_plans) + """ + plans = registry.get("plans", {}) + active = [] + closed = [] + + for plan_num, plan_data in plans.items(): + # Only include plans where location is 'flow' (Flow's own plans) + location = plan_data.get("location", "") + if location != "/home/aipass/aipass_core/flow": + continue + + # Build plan entry + plan_entry = { + "plan_id": f"FPLAN-{plan_num.zfill(4)}", + "subject": plan_data.get("subject", ""), + "status": plan_data.get("status", "open"), + "created": plan_data.get("created", ""), + "file_path": plan_data.get("file_path", ""), + "relative_path": plan_data.get("relative_path", "") + } + + if plan_data.get("status") == "open": + active.append(plan_entry) + else: + # Add closed metadata + plan_entry["closed"] = plan_data.get("closed", "") + plan_entry["closed_reason"] = plan_data.get("closed_reason", "") + closed.append(plan_entry) + + # Sort active by created date (newest first) + active.sort(key=lambda x: x.get("created", ""), reverse=True) + + # Sort closed by closed date (newest first) and limit to last 5 + closed.sort(key=lambda x: x.get("closed", ""), reverse=True) + recently_closed = closed[:5] + + return active, recently_closed + + +def _load_central() -> Dict[str, Any]: + """Load existing PLANS.central.json + + Returns: + Central file data or empty structure if file doesn't exist + """ + if not CENTRAL_FILE.exists(): + return { + "generated_at": "", + "branches": {}, + "global_statistics": { + "total_active": 0, + "total_closed": 0, + "branches_reporting": 0 + } + } + + try: + with open(CENTRAL_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return { + "generated_at": "", + "branches": {}, + "global_statistics": { + "total_active": 0, + "total_closed": 0, + "branches_reporting": 0 + } + } + + +def _calculate_global_statistics(central_data: Dict[str, Any]) -> Dict[str, int]: + """Calculate global statistics by summing all branches + + Args: + central_data: The full central file data + + Returns: + Dict with total_active, total_closed, branches_reporting + """ + branches = central_data.get("branches", {}) + total_active = 0 + total_closed = 0 + + for branch_data in branches.values(): + stats = branch_data.get("statistics", {}) + total_active += stats.get("active_count", 0) + total_closed += stats.get("total_closed", 0) + + return { + "total_active": total_active, + "total_closed": total_closed, + "branches_reporting": len(branches) + } + + +# ============================================= +# MAIN HANDLER FUNCTION +# ============================================= + +def push_to_plans_central() -> bool: + """Push Flow's plan data to AI_CENTRAL/PLANS.central.json + + Algorithm: + 1. Read flow_registry.json + 2. Extract only plans where location='flow' (Flow's own plans) + 3. Format for central structure with branch metadata + 4. Read existing PLANS.central.json if exists + 5. Update ONLY branches.flow section + 6. Update global_statistics (total counts across all branches) + 7. Preserve ALL other branch sections + 8. Write back to PLANS.central.json + 9. Call aggregate_central to rebuild top-level active_plans with validation + + Returns: + True on success, False on failure + """ + try: + # Ensure AI_CENTRAL directory exists + AI_CENTRAL_DIR.mkdir(parents=True, exist_ok=True) + + # Load registry + registry = _load_registry() + + # Extract Flow's plans + active_plans, recently_closed = _extract_flow_plans(registry) + + # Build Flow's branch section + now = datetime.now(timezone.utc).isoformat() + flow_section = { + "branch_name": "FLOW", + "branch_path": str(FLOW_ROOT), + "last_updated": now, + "active_plans": active_plans, + "recently_closed": recently_closed, + "statistics": { + "active_count": len(active_plans), + "total_closed": len([p for p in registry.get("plans", {}).values() + if p.get("location") == "/home/aipass/aipass_core/flow" + and p.get("status") == "closed"]) + } + } + + # Load existing central file + central_data = _load_central() + + # Update Flow's section + if "branches" not in central_data: + central_data["branches"] = {} + central_data["branches"]["flow"] = flow_section + + # Update global statistics + central_data["global_statistics"] = _calculate_global_statistics(central_data) + + # Update generated_at timestamp + central_data["generated_at"] = now + + # Write back to central file + with open(CENTRAL_FILE, 'w', encoding='utf-8') as f: + json.dump(central_data, f, indent=2, ensure_ascii=False) + + # Call aggregate_central to rebuild top-level arrays with validation + # This ensures active_plans is built from all branches and validates files exist + aggregate_central(heal=True) + + return True + + except Exception: + return False diff --git a/src/aipass/flow/apps/handlers/dashboard/update_local.py b/src/aipass/flow/apps/handlers/dashboard/update_local.py new file mode 100644 index 00000000..0ff0f53e --- /dev/null +++ b/src/aipass/flow/apps/handlers/dashboard/update_local.py @@ -0,0 +1,294 @@ +#!/home/aipass/.venv/bin/python3 +# -*- coding: utf-8 -*- + +# ===================AIPASS==================== +# META DATA HEADER +# Name: update_local.py +# Date: 2025-11-21 +# Version: 1.0.0 +# Category: flow/handlers/dashboard +# +# CHANGELOG: +# - v1.0.0 (2025-11-21): Initial creation - updates DASHBOARD.local.json +# ============================================= + +""" +Update Dashboard Local Handler + +Updates Flow's DASHBOARD.local.json file with plan summaries from flow_registry.json. + +This handler follows the 3-tier logging standard: +- NO Prax imports +- NO logging calls +- Pure handler - just read, process, write +- Returns boolean for success/failure + +Flow's Dual Role: +- Flow is both a working branch (has its own plans) AND a service provider +- This handler manages Flow's OWN plans in DASHBOARD.local.json +- Flow's section is 'flow_plans', other branches manage their own sections +- Key principle: Each branch touches ONLY its own section, respects others + +Data Flow: +1. Read flow_registry.json (source of truth) +2. Extract Flow's plans only (location='flow') +3. Partition into active (status='open') and recently_closed (status='closed', last 5) +4. Calculate statistics (active_count, total_closed, next_number) +5. Read existing DASHBOARD.local.json if exists +6. Update ONLY the 'flow_plans' section +7. Preserve ALL other sections (other branches manage their own) +8. Write back to DASHBOARD.local.json + +Structure: +{ + "branch": "FLOW", + "last_updated": "ISO timestamp", + "flow_plans": { + "active": [ + { + "plan_id": "FPLAN-0021", + "subject": "...", + "status": "open", + "created": "ISO timestamp", + "file_path": "...", + "location": "flow" + } + ], + "recently_closed": [ /* last 5 closed plans */ ], + "statistics": { + "active_count": 3, + "total_closed": 6, + "next_number": 24 + } + } + // Other sections preserved here +} + +Usage: + from aipass.flow.apps.handlers.dashboard.update_local import update_dashboard_local + + success = update_dashboard_local() + if success: + print("Dashboard updated successfully") + else: + print("Dashboard update failed") +""" + +import json +from pathlib import Path +from datetime import datetime, timezone +from typing import Dict, Any, List, Optional + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] +FLOW_ROOT = _PKG_ROOT / "flow" + +# ============================================= +# CONFIGURATION +# ============================================= + +REGISTRY_FILE = FLOW_ROOT / "flow_json" / "flow_registry.json" +DASHBOARD_FILE = FLOW_ROOT / "DASHBOARD.local.json" + +# ============================================= +# HELPER FUNCTIONS +# ============================================= + +def _read_registry() -> Optional[Dict[str, Any]]: + """ + Read flow_registry.json. + + Returns: + Registry dict or None if error + """ + try: + if not REGISTRY_FILE.exists(): + return None + with open(REGISTRY_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return None + + +def _extract_flow_plans(registry: Dict[str, Any]) -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """ + Extract Flow's plans from registry and partition into active and closed. + + Args: + registry: Registry data + + Returns: + Tuple of (active_plans, closed_plans) + """ + plans = registry.get("plans", {}) + active = [] + closed = [] + + for plan_num, plan_data in plans.items(): + # Only include Flow's own plans (location contains 'flow') + location = plan_data.get("location", "") + if "flow" not in location.lower(): + continue + + # Build plan entry + plan_id = f"FPLAN-{plan_num}" + entry = { + "plan_id": plan_id, + "subject": plan_data.get("subject", ""), + "status": plan_data.get("status", "unknown"), + "file_path": plan_data.get("file_path", ""), + "location": location + } + + # Add timestamps + if "created" in plan_data: + entry["created"] = plan_data["created"] + if "closed" in plan_data: + entry["closed"] = plan_data["closed"] + entry["closed_reason"] = plan_data.get("closed_reason", "") + + # Partition by status + if plan_data.get("status") == "closed": + closed.append(entry) + else: + active.append(entry) + + # Sort by plan_id for consistency + active.sort(key=lambda x: x["plan_id"]) + closed.sort(key=lambda x: x["plan_id"]) + + return active, closed + + +def _calculate_statistics(active: List[Dict[str, Any]], closed: List[Dict[str, Any]], + registry: Dict[str, Any]) -> Dict[str, int]: + """ + Calculate statistics for Flow's plans. + + Args: + active: List of active plans + closed: List of closed plans + registry: Registry data (for next_number) + + Returns: + Statistics dict + """ + return { + "active_count": len(active), + "total_closed": len(closed), + "next_number": registry.get("next_number", 1) + } + + +def _read_existing_dashboard() -> Dict[str, Any]: + """ + Read existing DASHBOARD.local.json if it exists. + + Returns: + Existing dashboard data or empty dict + """ + try: + if not DASHBOARD_FILE.exists(): + return {} + with open(DASHBOARD_FILE, 'r', encoding='utf-8') as f: + content = f.read().strip() + # Handle old markdown format gracefully + if not content or content.startswith("⚠️"): + return {} + # Parse the JSON content we just read + return json.loads(content) + except Exception: + return {} + + +def _build_dashboard_data(active: List[Dict[str, Any]], closed: List[Dict[str, Any]], + statistics: Dict[str, int], + existing: Dict[str, Any]) -> Dict[str, Any]: + """ + Build updated dashboard data with Flow's section. + + Args: + active: Active plans + closed: Closed plans + statistics: Statistics dict + existing: Existing dashboard data + + Returns: + Updated dashboard data + """ + # Start with existing data to preserve other sections + dashboard = existing.copy() + + # Update Flow's section + dashboard["branch"] = "FLOW" + dashboard["last_updated"] = datetime.now(timezone.utc).isoformat() + dashboard["flow_plans"] = { + "active": active, + "recently_closed": closed[-5:] if len(closed) > 0 else [], # Last 5 + "statistics": statistics + } + + return dashboard + + +def _write_dashboard(dashboard: Dict[str, Any]) -> bool: + """ + Write dashboard data to DASHBOARD.local.json. + + Args: + dashboard: Dashboard data + + Returns: + True if successful, False otherwise + """ + try: + DASHBOARD_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(DASHBOARD_FILE, 'w', encoding='utf-8') as f: + json.dump(dashboard, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def update_dashboard_local() -> bool: + """ + Update DASHBOARD.local.json with Flow's plan summaries from registry. + + This is the main handler function that: + 1. Reads flow_registry.json + 2. Extracts Flow's plans (location='flow') + 3. Partitions into active and closed + 4. Updates ONLY the 'flow_plans' section of DASHBOARD.local.json + 5. Preserves all other sections (other branches manage their own) + + Returns: + True if successful, False on any error + + Example: + >>> from aipass.flow.apps.handlers.dashboard.update_local import update_dashboard_local + >>> success = update_dashboard_local() + >>> print(f"Dashboard update: {success}") + """ + # Read registry + registry = _read_registry() + if registry is None: + return False + + # Extract Flow's plans + active, closed = _extract_flow_plans(registry) + + # Calculate statistics + statistics = _calculate_statistics(active, closed, registry) + + # Read existing dashboard (to preserve other sections) + existing = _read_existing_dashboard() + + # Build updated dashboard + dashboard = _build_dashboard_data(active, closed, statistics, existing) + + # Write dashboard + return _write_dashboard(dashboard) diff --git a/src/aipass/flow/apps/handlers/events/__init__.py b/src/aipass/flow/apps/handlers/events/__init__.py new file mode 100644 index 00000000..953c5515 --- /dev/null +++ b/src/aipass/flow/apps/handlers/events/__init__.py @@ -0,0 +1,5 @@ +""" +Flow Handlers - Events + +Thread-safe event queue system for PLAN operations. +""" diff --git a/src/aipass/flow/apps/handlers/json/__init__.py b/src/aipass/flow/apps/handlers/json/__init__.py new file mode 100644 index 00000000..d5eba8d7 --- /dev/null +++ b/src/aipass/flow/apps/handlers/json/__init__.py @@ -0,0 +1 @@ +# JSON handlers - JSON file operations and migrations diff --git a/src/aipass/flow/apps/handlers/json/json_handler.py b/src/aipass/flow/apps/handlers/json/json_handler.py new file mode 100644 index 00000000..07924703 --- /dev/null +++ b/src/aipass/flow/apps/handlers/json/json_handler.py @@ -0,0 +1,286 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: json_handler.py - Auto-Creating JSON Handler +# Date: 2025-11-21 +# Version: 2.1.0 +# Category: flow/handlers/json +# Code Standards: Seed-compliant (3-tier architecture, no logger in handlers) +# +# CHANGELOG (Max 5 entries): +# - v2.1.0 (2025-11-29): Removed Prax logger - handlers must not log (tier 3 standard) +# - v2.0.0 (2025-11-21): Updated to Flow paths, auto-detect module +# - v1.0.0 (2025-11-04): Self-healing JSON system with default templates +# ============================================= + +""" +JSON Handler - Auto-Creating & Self-Healing JSON System + +Handles default JSON files (config, data, log) for flow modules. +Never manually create JSONs - they build themselves. +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, Optional +import inspect + +# Infrastructure +_PKG_ROOT = Path(__file__).resolve().parents[4] + +# Constants +FLOW_ROOT = _PKG_ROOT / "flow" +FLOW_JSON_DIR = FLOW_ROOT / "flow_json" +JSON_TEMPLATES_DIR = FLOW_ROOT / "apps" / "json_templates" + + +def _get_caller_module_name() -> str: + """ + Auto-detect calling module name from call stack + + Returns: + Module name (e.g., "create_plan" from create_plan.py) + """ + try: + stack = inspect.stack() + # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller + if len(stack) > 2: + caller_frame = stack[2] + caller_path = Path(caller_frame.filename) + module_name = caller_path.stem + + # Validate module name + if module_name and not module_name.startswith('_'): + return module_name + + # Fallback + return "unknown" + except Exception: + return "unknown" + + +def load_template(json_type: str, module_name: str) -> Any: + """Load JSON template from template file""" + template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json" + + if not template_path.exists(): + return None + + try: + with open(template_path, 'r', encoding='utf-8') as f: + template = json.load(f) + + # Replace placeholders + template_str = json.dumps(template) + template_str = template_str.replace("{{MODULE_NAME}}", module_name) + template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat()) + + return json.loads(template_str) + except Exception: + return None + + +def validate_json_structure(data: Any, json_type: str) -> bool: + """Validate JSON structure matches expected type""" + if json_type == "config": + if not isinstance(data, dict): + return False + required = ["module_name", "version", "config"] + return all(key in data for key in required) + + elif json_type == "data": + if not isinstance(data, dict): + return False + required = ["created", "last_updated"] + return all(key in data for key in required) + + elif json_type == "log": + return isinstance(data, list) + + return False + + +def get_json_path(module_name: str, json_type: str) -> Path: + """Get path for module JSON file""" + filename = f"{module_name}_{json_type}.json" + return FLOW_JSON_DIR / filename + + +def ensure_json_exists(module_name: str, json_type: str) -> bool: + """Ensure JSON file exists, create from template if missing""" + FLOW_JSON_DIR.mkdir(parents=True, exist_ok=True) + + json_path = get_json_path(module_name, json_type) + + if json_path.exists(): + try: + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if validate_json_structure(data, json_type): + return True + except Exception: + # File exists but is corrupted - will regenerate below + pass + + template = load_template(json_type, module_name) + if template is None: + return False + + try: + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(template, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def load_json(module_name: str, json_type: str) -> Optional[Any]: + """Load JSON file, auto-create if missing""" + if not ensure_json_exists(module_name, json_type): + return None + + json_path = get_json_path(module_name, json_type) + + try: + with open(json_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return None + + +def save_json(module_name: str, json_type: str, data: Any) -> bool: + """Save JSON file""" + json_path = get_json_path(module_name, json_type) + + if not validate_json_structure(data, json_type): + return False + + if json_type == "data" and isinstance(data, dict): + data["last_updated"] = datetime.now().date().isoformat() + + try: + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def ensure_module_jsons(module_name: str) -> bool: + """Ensure all 3 JSON files exist for a module""" + ensure_json_exists(module_name, "config") + ensure_json_exists(module_name, "data") + ensure_json_exists(module_name, "log") + return True + + +def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool: + """ + Add entry to module log with automatic rotation + + Auto-detects calling module if module_name not provided. + Implements config-controlled log limits to prevent unbounded growth. + When max_log_entries is reached, removes oldest entries (FIFO). + + Args: + operation: Operation name to log + data: Optional data dict + module_name: Optional module name (auto-detected if not provided) + + Returns: + True if successful, False otherwise + """ + # Auto-detect module name if not provided + if module_name is None: + module_name = _get_caller_module_name() + + ensure_module_jsons(module_name) + + # Load config to get max_log_entries + config = load_json(module_name, "config") + max_entries = 100 # Default + if config and "config" in config: + max_entries = config["config"].get("max_log_entries", 100) + + # Load existing log + log = load_json(module_name, "log") + if log is None: + log = [] + + # Create new entry + entry: Dict[str, Any] = { + "timestamp": datetime.now().isoformat(), + "operation": operation + } + + if data: + entry["data"] = data + + # Add new entry + log.append(entry) + + # Rotate if exceeds max (keep most recent entries) + if len(log) > max_entries: + log = log[-max_entries:] + + return save_json(module_name, "log", log) + + +def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool: + """Increment a counter in data JSON""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + if counter_name not in data: + data[counter_name] = 0 + + data[counter_name] += amount + + return save_json(module_name, "data", data) + + +def update_data_metrics(module_name: str, **metrics) -> bool: + """Update data metrics""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + for key, value in metrics.items(): + data[key] = value + + return save_json(module_name, "data", data) + + +if __name__ == "__main__": + from rich.console import Console + from rich.panel import Panel + + console = Console() + + console.print() + console.print(Panel.fit( + "[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", + border_style="bright_blue" + )) + console.print() + console.print("[yellow]TESTING:[/yellow] Creating FLOW JSONs...") + + # Test auto-creation + log_operation("test_operation", {"test": "data"}, "flow") + increment_counter("flow", "test_counter", 1) + update_data_metrics("flow", test_metric="working") + + console.print() + console.print("[green]Check /home/aipass/aipass_core/flow/flow_json/ for created files:[/green]") + console.print(" [dim]•[/dim] flow_config.json") + console.print(" [dim]•[/dim] flow_data.json") + console.print(" [dim]•[/dim] flow_log.json") + console.print() diff --git a/src/aipass/flow/apps/handlers/json_templates/__init__.py b/src/aipass/flow/apps/handlers/json_templates/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/aipass/flow/apps/handlers/json_templates/custom/api_config.json b/src/aipass/flow/apps/handlers/json_templates/custom/api_config.json new file mode 100644 index 00000000..5a8117ce --- /dev/null +++ b/src/aipass/flow/apps/handlers/json_templates/custom/api_config.json @@ -0,0 +1,10 @@ +{ + "module_name": "flow_api_config", + "version": "1.0.0", + "description": "Shared API configuration for Flow handlers", + "api_settings": { + "model": "google/gemma-3-12b-it:free", + "timeout_seconds": 120, + "max_tokens": 500 + } +} diff --git a/src/aipass/flow/apps/handlers/json_templates/default/config.json b/src/aipass/flow/apps/handlers/json_templates/default/config.json new file mode 100644 index 00000000..9f7e5454 --- /dev/null +++ b/src/aipass/flow/apps/handlers/json_templates/default/config.json @@ -0,0 +1,9 @@ +{ + "module_name": "{{MODULE_NAME}}", + "version": "1.0.0", + "timestamp": "{{TIMESTAMP}}", + "config": { + "auto_save": true, + "enabled": true + } +} diff --git a/src/aipass/flow/apps/handlers/json_templates/default/data.json b/src/aipass/flow/apps/handlers/json_templates/default/data.json new file mode 100644 index 00000000..c88b23de --- /dev/null +++ b/src/aipass/flow/apps/handlers/json_templates/default/data.json @@ -0,0 +1,8 @@ +{ + "module_name": "{{MODULE_NAME}}", + "created": "{{TIMESTAMP}}", + "last_updated": "{{TIMESTAMP}}", + "operations_total": 0, + "operations_successful": 0, + "operations_failed": 0 +} diff --git a/src/aipass/flow/apps/handlers/json_templates/default/log.json b/src/aipass/flow/apps/handlers/json_templates/default/log.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/aipass/flow/apps/handlers/json_templates/default/log.json @@ -0,0 +1 @@ +[] diff --git a/src/aipass/flow/apps/handlers/mbank/__init__.py b/src/aipass/flow/apps/handlers/mbank/__init__.py new file mode 100644 index 00000000..704bac9c --- /dev/null +++ b/src/aipass/flow/apps/handlers/mbank/__init__.py @@ -0,0 +1,6 @@ +""" +Flow Handlers - Memory Bank + +Memory bank processing for closed plans. +Converts closed PLAN files to TRL-compliant memory bank entries. +""" diff --git a/src/aipass/flow/apps/handlers/mbank/process.py b/src/aipass/flow/apps/handlers/mbank/process.py new file mode 100644 index 00000000..8805d678 --- /dev/null +++ b/src/aipass/flow/apps/handlers/mbank/process.py @@ -0,0 +1,821 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: process.py - Memory Bank Processing Handler +# Date: 2025-11-25 +# Version: 1.5.0 +# Category: flow/handlers/mbank +# +# CHANGELOG: +# - v1.5.0 (2026-02-15): Fixed template detection - markers matched old template (pre-Dec 2025), all 3 template types covered +# - v1.4.0 (2026-02-14): Sequential processing with backoff - stop on 429, delay between API calls +# - v1.3.0 (2025-11-25): Restructured config - separated TRL mapping to flow_mbank_registry.json +# Updated get_ai_model() to use custom api_config.json +# - v1.2.0 (2025-11-25): Added auto-cleanup for -TEMP files in MEMORY_BANK after processing +# - v1.1.0 (2025-11-22): Improved JSON parsing - more robust to AI responses with extra content +# - v1.0.0 (2025-11-21): Extracted from archive_temp/flow_mbank.py +# +# CODE STANDARDS: +# - 3-tier compliant: NO Prax imports, NO logging +# - Raises exceptions for errors (module logs them) +# ============================================= + +""" +Memory Bank Processing Handler + +Handles archival of closed PLAN files to backup_system/processed_plans/. +AI summarization removed — plans vectorized directly from backup_system/processed_plans/. + +Key Functions: +- process_closed_plans() - Main entry point: archive plan → update registry +- archive_plan() - Move to backup_system/processed_plans/ +- is_template_content() - Template detection +- verify_and_heal_orphaned_plans() - Orphan healing logic +""" + +from pathlib import Path + +_PKG_ROOT = Path(__file__).resolve().parents[4] + +# Standard imports +import json +from datetime import datetime, timezone +from typing import Dict, List, Optional, Any + +# AI summarization removed — OpenRouter API no longer needed here +# from aipass.api.apps.modules.openrouter_client import get_response + +# ============================================= +# CONSTANTS +# ============================================= + +FLOW_ROOT = _PKG_ROOT / "flow" +FLOW_JSON_DIR = FLOW_ROOT / "flow_json" +MEMORY_BANK_PATH = Path.home() / "MEMORY_BANK" / "plans" +PROCESSED_PLANS_DIR = _PKG_ROOT / "backup_system" / "processed_plans" +PRIVATE_BRANCH_REGISTRY = Path.home() / "PRIVATE_BRANCH_REGISTRY.json" +REGISTRY_FILE = FLOW_JSON_DIR / "flow_registry.json" +CONFIG_FILE = FLOW_JSON_DIR / "flow_mbank_config.json" +TRL_REGISTRY_FILE = FLOW_JSON_DIR / "flow_mbank_registry.json" +API_CONFIG_FILE = FLOW_ROOT / "apps" / "handlers" / "json_templates" / "custom" / "api_config.json" + +# ============================================= +# PRIVATE BRANCH HELPERS +# ============================================= + +def _is_branch_private(branch_name: str) -> bool: + """Check if branch is in the private registry.""" + if not PRIVATE_BRANCH_REGISTRY.exists(): + return False + try: + with open(PRIVATE_BRANCH_REGISTRY, 'r', encoding='utf-8') as f: + registry = json.load(f) + for branch in registry.get("branches", []): + if branch.get("name", "").upper() == branch_name.upper(): + return True + except (json.JSONDecodeError, IOError): + pass + return False + + +def _get_private_branch_path(branch_name: str) -> Optional[str]: + """Get the path of a private branch.""" + if not PRIVATE_BRANCH_REGISTRY.exists(): + return None + try: + with open(PRIVATE_BRANCH_REGISTRY, 'r', encoding='utf-8') as f: + registry = json.load(f) + for branch in registry.get("branches", []): + if branch.get("name", "").upper() == branch_name.upper(): + return branch.get("path") + except (json.JSONDecodeError, IOError): + pass + return None + + +def _get_private_branch_for_path(plan_path: Path) -> Optional[Dict[str, str]]: + """Check if a plan path falls under a private branch. + + Args: + plan_path: Absolute path to plan file + + Returns: + Dict with 'name' and 'path' if private, None otherwise + """ + if not PRIVATE_BRANCH_REGISTRY.exists(): + return None + try: + with open(PRIVATE_BRANCH_REGISTRY, 'r', encoding='utf-8') as f: + registry = json.load(f) + plan_str = str(plan_path.resolve()) + for branch in registry.get("branches", []): + branch_path = branch.get("path", "") + if branch_path and plan_str.startswith(branch_path): + return {"name": branch.get("name", ""), "path": branch_path} + except (json.JSONDecodeError, IOError): + pass + return None + + +# ============================================= +# CONFIGURATION +# ============================================= + +def load_config() -> Dict[str, Any]: + """Load flow_mbank configuration""" + default_config = { + "module_name": "flow_mbank", + "version": "1.0.0", + "config": { + "enabled": True, + "archive_processed": True + } + } + + if not CONFIG_FILE.exists(): + CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(default_config, f, indent=2, ensure_ascii=False) + return default_config + + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + raise Exception(f"Failed to load config: {e}") + +def load_trl_registry() -> Dict[str, Any]: + """Load TRL mapping registry""" + default_registry = { + "module_name": "flow_mbank", + "description": "TRL (Type-Category-Action) classification registry for memory bank processing", + "version": "1.0.0", + "trl_mapping": { + "types": { + "SEED": "Seed AI System", + "NEXUS": "Nexus AI System", + "SKILL": "Skills Modules", + "PRAX": "Prax Infrastructure", + "FLOW": "Flow Workflow System", + "BACKUP": "Backup System", + "DRONE": "Drone Commands", + "HELP": "Help System", + "MCP": "MCP Servers", + "TOOLS": "Tools & Scripts" + }, + "categories": { + "API": "API & External Services", + "MEM": "Memory & Storage", + "DB": "Database & Data", + "UI": "User Interface", + "CFG": "Configuration", + "DOC": "Documentation", + "TEST": "Testing & QA", + "SEC": "Security", + "NET": "Networking", + "FILE": "File Operations", + "LOG": "Logging & Monitoring", + "DEV": "Development" + }, + "actions": { + "IMP": "Implementation", + "FIX": "Bug Fixes", + "UPD": "Updates & Improvements", + "NEW": "New Features", + "REF": "Refactoring", + "DOC": "Documentation", + "TEST": "Testing", + "CFG": "Configuration", + "MIGR": "Migration", + "OPT": "Optimization" + } + }, + "excluded_paths": [ + "admin", "archive", "backups", "tests", "trash", "__pycache__", + ".git", ".venv", "venv", "node_modules", "mcp_servers" + ] + } + + if not TRL_REGISTRY_FILE.exists(): + TRL_REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(TRL_REGISTRY_FILE, 'w', encoding='utf-8') as f: + json.dump(default_registry, f, indent=2, ensure_ascii=False) + return default_registry + + try: + with open(TRL_REGISTRY_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + raise Exception(f"Failed to load TRL registry: {e}") + +def get_ai_model() -> Optional[str]: + """Get AI model from custom API config""" + try: + if API_CONFIG_FILE.exists(): + with open(API_CONFIG_FILE, 'r', encoding='utf-8') as f: + api_config = json.load(f) + return api_config.get("api_settings", {}).get("model") + + return None + + except Exception: + return None + +# ============================================= +# REGISTRY OPERATIONS +# ============================================= + +def load_flow_registry() -> Dict[str, Any]: + """Load the flow registry""" + if not REGISTRY_FILE.exists(): + raise Exception(f"Flow registry not found at {REGISTRY_FILE}") + + try: + with open(REGISTRY_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + raise Exception(f"Failed to load flow registry: {e}") + +def save_flow_registry(registry: Dict[str, Any]): + """Save the flow registry""" + try: + registry["last_updated"] = datetime.now(timezone.utc).isoformat() + with open(REGISTRY_FILE, 'w', encoding='utf-8') as f: + json.dump(registry, f, indent=2, ensure_ascii=False) + except Exception as e: + raise Exception(f"Failed to save flow registry: {e}") + +def get_closed_plans() -> List[Dict[str, Any]]: + """Get closed PLANs from flow registry + + AUTO-HEAL: Before getting closed plans, verify and heal any orphaned plans + + Returns: + List of dicts with keys: number, path, info + """ + # AUTO-HEAL LAYER: Fix orphaned plans before processing new ones + heal_result = verify_and_heal_orphaned_plans() + + registry = load_flow_registry() + closed_plans = [] + + for plan_num, plan_info in registry.get("plans", {}).items(): + if plan_info.get("status") == "closed" and plan_info.get("processed") != True: + file_path = Path(plan_info.get("file_path", "")) + if file_path.exists(): + closed_plans.append({ + "number": plan_num, + "path": file_path, + "info": plan_info + }) + + return closed_plans + +# ============================================= +# TEMPLATE DETECTION +# ============================================= + +def is_template_content(content: str) -> bool: + """Check if plan content is still unedited template (v3.0) + + Checks against markers from all template types (default, master, proposal). + Template detected if 3+ markers from ANY single template type are found. + + Args: + content: Plan file content + + Returns: + True if plan is essentially an untouched template + """ + # Default template markers (updated Dec 2025 rewrite) + default_markers = [ + "[What do you want to achieve? Specific end state.]", + "[How will agents tackle this? What instructions will they need?]", + "[List any planning docs, specs, or examples to reference]", + "[Working notes, issues encountered, decisions made]", + "[What specifically defines complete for this plan?]", + "## What Are Flow Plans?", + "## Critical: Branch Manager Role", + ] + + # Master template markers + master_markers = [ + "[What this phase accomplishes]", + "[What the agent will build]", + "[Files/outputs expected]", + "[What specifically defines the project complete?]", + "[Patterns discovered that span multiple phases]", + "## Master Plan Overview", + ] + + # Proposal template markers + proposal_markers = [ + "[Clear description of the idea, feature, improvement, or fix]", + "[Why is this valuable? What problem does it solve? What does it enable?]", + "[How would I tackle this? High-level steps.]", + "[Any other branches, services, or approvals needed?]", + ] + + # Check each template type - 3+ markers from any type = template + for markers in [default_markers, master_markers, proposal_markers]: + found = sum(1 for m in markers if m in content) + if found >= 3: + return True + + return False + +# ============================================= +# CONTENT ANALYSIS (DISABLED) +# AI summarization removed — plans vectorized directly from backup_system/processed_plans/ +# ============================================= + +# def analyze_plan_content(plan_path: Path) -> Dict[str, str]: +# """Use OpenRouter to analyze plan content and determine TRL tags +# +# Args: +# plan_path: Path to PLAN file +# +# Returns: +# Dict with keys: type, category, action, summary +# +# Raises: +# Exception: If API call fails or response is invalid +# """ +# trl_registry = load_trl_registry() +# +# # Read plan content +# with open(plan_path, 'r', encoding='utf-8') as f: +# content = f.read() +# +# # Prepare analysis prompt +# try: +# relative_path = str(plan_path.relative_to(_PKG_ROOT)) +# folder_context = str(plan_path.parent.relative_to(_PKG_ROOT)) +# except ValueError: +# relative_path = str(plan_path) +# folder_context = str(plan_path.parent) +# +# trl_mapping = trl_registry["trl_mapping"] +# +# types_desc = "\n".join([f"{k}: {v}" for k, v in trl_mapping["types"].items()]) +# categories_desc = "\n".join([f"{k}: {v}" for k, v in trl_mapping["categories"].items()]) +# actions_desc = "\n".join([f"{k}: {v}" for k, v in trl_mapping["actions"].items()]) +# +# type_codes = "|".join(trl_mapping["types"].keys()) +# category_codes = "|".join(trl_mapping["categories"].keys()) +# action_codes = "|".join(trl_mapping["actions"].keys()) +# +# prompt = f"""Analyze this completed plan file: +# +# Content: {content} +# Folder: {folder_context} +# File: {plan_path.name} +# +# Determine the primary classification using these options: +# +# TYPE options: +# {types_desc} +# +# CATEGORY options: +# {categories_desc} +# +# ACTION options: +# {actions_desc} +# +# Return ONLY a JSON object: +# {{ +# "type": "{type_codes}", +# "category": "{category_codes}", +# "action": "{action_codes}", +# "summary": "brief description" +# }}""" +# +# ai_model = get_ai_model() +# response = get_response(prompt, model=ai_model, caller="flow_mbank") +# +# if response: +# try: +# response_str = response.get('content', '').strip() +# +# if response_str.startswith("```json"): +# start = response_str.find("```json") + 7 +# end = response_str.rfind("```") +# if end > start: +# response_str = response_str[start:end].strip() +# elif response_str.startswith("```"): +# start = response_str.find("```") + 3 +# end = response_str.rfind("```") +# if end > start: +# response_str = response_str[start:end].strip() +# +# try: +# analysis = json.loads(response_str) +# except json.JSONDecodeError as e: +# first_brace = response_str.find('{') +# last_brace = response_str.rfind('}') +# +# if first_brace != -1 and last_brace != -1 and last_brace > first_brace: +# json_only = response_str[first_brace:last_brace + 1] +# try: +# analysis = json.loads(json_only) +# except json.JSONDecodeError: +# raise Exception(f"API returned invalid JSON: {e}") +# else: +# raise Exception(f"API returned invalid JSON: {e}") +# +# required_fields = ['type', 'category', 'action', 'summary'] +# for field in required_fields: +# if field not in analysis: +# raise Exception(f"API response missing required field: {field}") +# +# return analysis +# except json.JSONDecodeError as e: +# raise Exception(f"API returned invalid JSON: {e}") +# else: +# raise Exception("No response from OpenRouter API - check API key and connection") + +# ============================================= +# MEMORY BANK CREATION (DISABLED) +# AI summarization removed — plans vectorized directly from backup_system/processed_plans/ +# ============================================= + +# def create_memory_entry(plan_path: Path, analysis: Dict[str, str]) -> Optional[Path]: +# """Create memory bank entry from analyzed plan +# +# Args: +# plan_path: Path to source PLAN file +# analysis: Dict with type, category, action, summary +# +# Returns: +# Path to created memory file, or None on failure +# """ +# try: +# with open(plan_path, 'r', encoding='utf-8') as f: +# content = f.read() +# +# is_template = is_template_content(content) +# +# try: +# relative_path = plan_path.relative_to(_PKG_ROOT) +# folder_context = str(relative_path.parent).replace("\\", "-").replace("/", "-") +# except ValueError: +# relative_path = plan_path +# folder_context = str(plan_path.parent).replace("\\", "-").replace("/", "-") +# +# if folder_context == "." or folder_context == "": +# folder_context = "root" +# +# today = datetime.now().strftime("%Y%m%d") +# plan_num = plan_path.stem.replace("FPLAN-", "") +# template_suffix = "-TEMP" if is_template else "" +# filename = f"{folder_context}-{analysis['type']}-{analysis['category']}-{analysis['action']}-FPLAN-{plan_num}{template_suffix}-{today}.md" +# +# filename = re.sub(r'[<>:"|?*]', '-', filename) +# filename = re.sub(r'-+', '-', filename) +# +# private_branch = _get_private_branch_for_path(plan_path) +# if private_branch: +# archive_dir = Path(private_branch["path"]) / ".archive" / "plans" +# _private_archive_log = f"[mbank] Archiving plan locally for private branch: {private_branch['name']}" +# else: +# archive_dir = MEMORY_BANK_PATH +# _private_archive_log = None +# +# memory_file = archive_dir / filename +# memory_file.parent.mkdir(parents=True, exist_ok=True) +# +# if memory_file.exists(): +# return None +# +# memory_content = f"""# {analysis['summary']} +# +# **Source**: {relative_path} +# **TRL Tags**: {analysis['type']}-{analysis['category']}-{analysis['action']} +# **Created**: {datetime.now().strftime('%Y-%m-%d')} +# **Location**: {relative_path.parent} +# +# ## Summary +# {analysis['summary']} +# +# ## Original Content +# {content} +# """ +# +# with open(memory_file, 'w', encoding='utf-8') as f: +# f.write(memory_content) +# +# return memory_file +# +# except Exception as e: +# raise Exception(f"Failed to create memory entry: {e}") + +# ============================================= +# PLAN ARCHIVAL +# ============================================= + +def archive_plan(plan_path: Path) -> bool: + """Move processed plan file to backup_system/processed_plans/ + + VERIFICATION: Returns True ONLY if file successfully moved AND verified + + Args: + plan_path: Path to PLAN file to archive + + Returns: + True if successfully archived and verified, False otherwise + """ + try: + # Create processed_plans directory if it doesn't exist + PROCESSED_PLANS_DIR.mkdir(parents=True, exist_ok=True) + + # Move plan to processed_plans directory + destination = PROCESSED_PLANS_DIR / plan_path.name + + # Handle duplicate names by adding timestamp + if destination.exists(): + timestamp = datetime.now().strftime("%H%M%S") + stem = destination.stem + suffix = destination.suffix + destination = PROCESSED_PLANS_DIR / f"{stem}_{timestamp}{suffix}" + + # Store source path for verification + source_path = Path(plan_path) + + # Attempt move + plan_path.rename(destination) + + # VERIFICATION LAYER: Confirm move actually happened + if not destination.exists(): + return False + + if source_path.exists(): + return False + + return True + + except Exception: + return False + +# ============================================= +# TEMP FILE CLEANUP +# ============================================= + +def cleanup_temp_files() -> Dict[str, Any]: + """Remove old -TEMP files from MEMORY_BANK (empty template plans) + + These files are created when empty template plans are closed and processed. + They have no value and should be auto-cleaned. + + Returns: + Dict with keys: + - files_found: int + - files_deleted: int + - failed_deletes: int + - details: list of file operations + """ + files_found = 0 + files_deleted = 0 + failed_deletes = 0 + details = [] + + try: + # Scan MEMORY_BANK/plans/ for files with -TEMP in name + if MEMORY_BANK_PATH.exists(): + for temp_file in MEMORY_BANK_PATH.glob("*-TEMP-*.md"): + files_found += 1 + + try: + # Delete the TEMP file + temp_file.unlink() + + # Verify deletion + if not temp_file.exists(): + files_deleted += 1 + details.append({ + "file": temp_file.name, + "status": "deleted" + }) + else: + failed_deletes += 1 + details.append({ + "file": temp_file.name, + "status": "delete_failed", + "error": "File still exists after deletion" + }) + + except Exception as e: + failed_deletes += 1 + details.append({ + "file": temp_file.name, + "status": "delete_failed", + "error": str(e) + }) + + except Exception as e: + # Failed to scan directory + return { + "files_found": 0, + "files_deleted": 0, + "failed_deletes": 0, + "details": [], + "scan_error": str(e) + } + + return { + "files_found": files_found, + "files_deleted": files_deleted, + "failed_deletes": failed_deletes, + "details": details + } + +# ============================================= +# ORPHAN HEALING +# ============================================= + +def verify_and_heal_orphaned_plans() -> Dict[str, Any]: + """Cross-check registry vs filesystem and auto-heal orphaned plans + + Detects plans where registry says processed=true but file still at original location. + Attempts to move orphaned files to processed_plans/ directory. + + Returns: + Dict with keys: + - orphans_found: int + - successfully_healed: int + - failed_to_heal: int + - orphans: list of plan details + """ + registry = load_flow_registry() + + orphans_found = 0 + successfully_healed = 0 + failed_to_heal = 0 + orphan_details = [] + + for plan_num, plan_info in registry.get("plans", {}).items(): + # Only check plans marked as processed + if plan_info.get("processed") == True and plan_info.get("cleanup_completed") == True: + original_path = Path(plan_info.get("file_path", "")) + + # VERIFICATION: Does file still exist at original location? + if original_path.exists(): + # ORPHAN DETECTED + orphans_found += 1 + + # Attempt auto-heal by moving file now + try: + PROCESSED_PLANS_DIR.mkdir(parents=True, exist_ok=True) + destination = PROCESSED_PLANS_DIR / original_path.name + + # Handle duplicates + if destination.exists(): + timestamp = datetime.now().strftime("%H%M%S") + stem = destination.stem + suffix = destination.suffix + destination = PROCESSED_PLANS_DIR / f"{stem}_{timestamp}{suffix}" + + # Attempt move + original_path.rename(destination) + + # Verify move + if destination.exists() and not original_path.exists(): + successfully_healed += 1 + orphan_details.append({ + "plan": f"FPLAN-{plan_num}", + "status": "healed", + "original_path": str(original_path), + "destination": str(destination) + }) + else: + failed_to_heal += 1 + orphan_details.append({ + "plan": f"FPLAN-{plan_num}", + "status": "heal_failed", + "error": "Verification failed after rename", + "path": str(original_path) + }) + + except Exception as e: + failed_to_heal += 1 + orphan_details.append({ + "plan": f"FPLAN-{plan_num}", + "status": "heal_failed", + "error": str(e), + "path": str(original_path) + }) + + return { + "orphans_found": orphans_found, + "successfully_healed": successfully_healed, + "failed_to_heal": failed_to_heal, + "orphans": orphan_details + } + +# ============================================= +# MAIN PROCESSING +# ============================================= + +def process_closed_plans() -> Dict[str, Any]: + """Main function to process all closed plans + + # AI summarization removed — plans vectorized directly from backup_system/processed_plans/ + # Processing is now: archive_plan() → update registry flags → done + + AUTO-HEAL: Cleans up old -TEMP files from MEMORY_BANK after processing + + Returns: + Dict with keys: + - success: bool + - processed: int (successfully processed) + - errors: int (failed) + - results: list of per-plan results + - error: str (if success=False) + - cleanup: dict (TEMP file cleanup results) + """ + try: + # Get closed plans from registry (includes auto-heal) + closed_plans = get_closed_plans() + + if not closed_plans: + # No closed plans to process, but still run cleanup + cleanup_result = cleanup_temp_files() + return { + "success": True, + "processed": 0, + "errors": 0, + "results": [], + "cleanup": cleanup_result + } + + processed_count = 0 + error_count = 0 + results = [] + + for plan in closed_plans: + try: + plan_path = plan["path"] + plan_num = plan["number"] + + # Generate correlation ID for tracking + correlation_id = f"FPLAN-{plan_num}-{datetime.now().strftime('%H%M%S')}" + + # Archive plan to backup_system/processed_plans/ + archive_success = archive_plan(plan_path) + + # Update registry flags + registry = load_flow_registry() + if plan_num in registry.get("plans", {}): + registry["plans"][plan_num]["cleanup_completed"] = archive_success + registry["plans"][plan_num]["cleanup_date"] = datetime.now(timezone.utc).isoformat() + + if archive_success: + registry["plans"][plan_num]["processed"] = True + registry["plans"][plan_num]["processed_date"] = datetime.now(timezone.utc).isoformat() + + save_flow_registry(registry) + + if archive_success: + processed_count += 1 + results.append({ + "plan": f"FPLAN-{plan_num}", + "status": "archived", + "correlation_id": correlation_id + }) + else: + error_count += 1 + results.append({ + "plan": f"FPLAN-{plan_num}", + "status": "archive_failed", + "error": "Failed to move plan to backup_system/processed_plans/", + "correlation_id": correlation_id + }) + + except Exception as e: + error_count += 1 + plan_num = plan.get('number', 'unknown') + results.append({ + "plan": f"FPLAN-{plan_num}", + "status": "error", + "error": str(e) + }) + + # AUTO-HEAL: Clean up old -TEMP files from MEMORY_BANK + cleanup_result = cleanup_temp_files() + + return { + "success": True, + "processed": processed_count, + "errors": error_count, + "results": results, + "cleanup": cleanup_result + } + + except Exception as e: + return { + "success": False, + "processed": 0, + "errors": 0, + "results": [], + "error": str(e) + } diff --git a/src/aipass/flow/apps/handlers/plan/__init__.py b/src/aipass/flow/apps/handlers/plan/__init__.py new file mode 100644 index 00000000..aacb3924 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/__init__.py @@ -0,0 +1 @@ +"""Flow plan handlers package""" diff --git a/src/aipass/flow/apps/handlers/plan/append_closed_plan.py b/src/aipass/flow/apps/handlers/plan/append_closed_plan.py new file mode 100644 index 00000000..c4f85789 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/append_closed_plan.py @@ -0,0 +1,94 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: append_closed_plan.py - Closed Plans Local Registry Handler +# Date: 2026-03-03 +# Version: 0.1.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2026-03-03): Initial handler - append closed plan to CLOSED_PLANS.local.json +# +# CODE STANDARDS: +# - Reads, appends, writes CLOSED_PLANS.local.json +# - Duplicate-safe (skips if plan_id already exists) +# - UTF-8 encoding +# ============================================= + +""" +Closed Plans Append Handler + +Appends a closed plan entry to the branch's CLOSED_PLANS.local.json file. +Creates the file if it doesn't exist. +""" + +import json +from pathlib import Path + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] + +# External: Prax logger +from aipass.prax.apps.modules.logger import system_logger as logger + +MODULE_NAME = "append_closed_plan" +CLOSED_PLANS_FILE = "CLOSED_PLANS.local.json" + + +def append_to_closed_plans(plan_key: str, plan_info: dict, plan_location: Path) -> bool: + """ + Append a closed plan entry to the branch's CLOSED_PLANS.local.json + + Args: + plan_key: Plan number string (e.g., "0405") + plan_info: Plan info dict from registry (must contain 'closed', may contain 'subject', 'relative_path') + plan_location: Path to the directory where the plan resides (branch directory) + + Returns: + True on success, False on failure + """ + try: + plan_id = f"FPLAN-{plan_key}" + + # Extract date (YYYY-MM-DD) from the closed ISO timestamp + closed_raw = plan_info.get("closed", "") + date_closed = closed_raw[:10] if closed_raw else "" + + # Build the entry + entry = { + "plan_id": plan_id, + "type": "FPLAN", + "subject": plan_info.get("subject", ""), + "date_closed": date_closed, + "location": plan_info.get("relative_path", "") + } + + # Read existing file or create new structure + closed_plans_path = plan_location / CLOSED_PLANS_FILE + + if closed_plans_path.exists(): + with open(closed_plans_path, 'r', encoding='utf-8') as f: + data = json.load(f) + else: + data = {"closed_plans": []} + + # Check for duplicate plan_id before appending + existing_ids = {p.get("plan_id") for p in data.get("closed_plans", [])} + if plan_id in existing_ids: + logger.info(f"[{MODULE_NAME}] {plan_id} already in {CLOSED_PLANS_FILE} at {plan_location}, skipping") + return True + + # Append and write + data["closed_plans"].append(entry) + + with open(closed_plans_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.write('\n') + + logger.info(f"[{MODULE_NAME}] Appended {plan_id} to {closed_plans_path}") + return True + + except Exception as e: + logger.warning(f"[{MODULE_NAME}] Failed to append closed plan: {e}") + return False diff --git a/src/aipass/flow/apps/handlers/plan/auto_cleanup.py b/src/aipass/flow/apps/handlers/plan/auto_cleanup.py new file mode 100644 index 00000000..4f8c2824 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/auto_cleanup.py @@ -0,0 +1,71 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: auto_cleanup.py - Plan Auto-Cleanup Handler +# Date: 2025-11-15 +# Version: 0.2.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v0.2.0 (2025-11-21): Removed Prax logging per 3-tier standard +# - v0.1.0 (2025-11-15): Initial handler - auto-close orphaned plans +# +# CODE STANDARDS: +# - Handler implements business logic +# - Module orchestrates, handler executes +# - No module imports (handler independence) +# ============================================= + +""" +Plan Auto-Cleanup Handler + +Auto-closes open plans whose files no longer exist on disk. +Scans registry for orphaned plans and updates their status. +""" + +from pathlib import Path +from datetime import datetime, timezone +from typing import Dict, Any, Tuple + +# Infrastructure +_PKG_ROOT = Path(__file__).resolve().parents[4] + + +def auto_close_orphaned_plans(registry: Dict[str, Any]) -> Tuple[Dict[str, Any], int]: + """ + Auto-close open plans whose files no longer exist + + Scans all plans with status="open", checks if file_path exists on disk, + and auto-closes any plans with missing files. Updates registry entries + with closed status, timestamp, and reason. + + Args: + registry: Flow registry dictionary with 'plans' section + + Returns: + Tuple of (modified_registry, count_of_closed_plans) + + Side effects: + Modifies registry["plans"][num] entries in-place + + Example: + >>> registry = load_registry() + >>> registry, count = auto_close_orphaned_plans(registry) + >>> if count > 0: + ... save_registry(registry) + """ + auto_closed_count = 0 + + for num, info in registry.get("plans", {}).items(): + if info.get("status") == "open": + plan_file = Path(info.get("file_path", "")) + + if plan_file and not plan_file.exists(): + # Auto-close missing plan + info["status"] = "closed" + info["closed"] = datetime.now(timezone.utc).isoformat() + info["closed_reason"] = "auto_closed_missing_file" + auto_closed_count += 1 + + return registry, auto_closed_count diff --git a/src/aipass/flow/apps/handlers/plan/build_registry_entry.py b/src/aipass/flow/apps/handlers/plan/build_registry_entry.py new file mode 100644 index 00000000..b905c155 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/build_registry_entry.py @@ -0,0 +1,83 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: build_registry_entry.py - Registry Entry Builder +# Date: 2025-11-15 +# Version: 0.1.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2025-11-15): Initial handler - build plan registry entries +# +# CODE STANDARDS: +# - Pure data structure construction +# - UTC timestamps +# ============================================= + +""" +Registry Entry Builder + +Constructs registry entry dictionaries for new plans. +""" + +from pathlib import Path +from datetime import datetime, timezone +from typing import Dict, Any + + +def build_plan_registry_entry( + plan_num: int, + target_dir: Path, + relative_location: str, + subject: str, + plan_file: Path, + template_type: str +) -> Dict[str, Any]: + """ + Build registry entry for new plan + + Constructs a complete registry entry dictionary with all + required metadata for a newly created plan. + + Args: + plan_num: Plan number (e.g., 1, 42, 101) + target_dir: Absolute path to plan directory + relative_location: Relative path string from calculate_relative_location() + subject: Plan subject/title + plan_file: Full path to plan file + template_type: Template type (e.g., "default", "master") + + Returns: + Dictionary with registry entry structure: + { + "location": str (absolute path), + "relative_path": str, + "created": str (ISO timestamp in UTC), + "subject": str, + "status": "open", + "file_path": str (absolute path), + "template_type": str + } + + Example: + >>> entry = build_plan_registry_entry( + ... 1, + ... Path("/home/aipass/aipass_core/flow"), + ... "flow", + ... "My task", + ... Path("/home/aipass/aipass_core/flow/PLAN0001.md"), + ... "default" + ... ) + >>> entry["status"] + "open" + """ + return { + "location": str(target_dir), + "relative_path": relative_location, + "created": datetime.now(timezone.utc).isoformat(), + "subject": subject, + "status": "open", + "file_path": str(plan_file), + "template_type": template_type + } diff --git a/src/aipass/flow/apps/handlers/plan/calculate_relative_path.py b/src/aipass/flow/apps/handlers/plan/calculate_relative_path.py new file mode 100644 index 00000000..cdc27182 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/calculate_relative_path.py @@ -0,0 +1,77 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: calculate_relative_path.py - Relative Path Calculator +# Date: 2025-11-15 +# Version: 0.1.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2025-11-15): Initial handler - relative path calculation +# +# CODE STANDARDS: +# - Pure calculation logic +# - No I/O or logging +# ============================================= + +""" +Relative Path Calculator + +Calculates relative paths from ecosystem root for plan location display. +""" + +from pathlib import Path + + +def calculate_relative_location( + target_dir: Path, + ecosystem_root: Path +) -> str: + """ + Calculate relative location from ecosystem root + + Returns a string representation of target_dir relative to ecosystem_root. + Special cases: + - If target_dir == ecosystem_root, returns "root" + - If target_dir outside ecosystem_root, returns absolute path string + + Args: + target_dir: Absolute path to target directory + ecosystem_root: Root directory for relative calculation + + Returns: + Relative path string, "root" if same as ecosystem_root, + or absolute path string if outside ecosystem_root + + Examples: + >>> calculate_relative_location( + ... Path("/home/aipass/aipass_core/flow"), + ... Path("/home/aipass/aipass_core") + ... ) + "flow" + + >>> calculate_relative_location( + ... Path("/home/aipass/aipass_core"), + ... Path("/home/aipass/aipass_core") + ... ) + "root" + + >>> calculate_relative_location( + ... Path("/tmp/somewhere"), + ... Path("/home/aipass/aipass_core") + ... ) + "/tmp/somewhere" + """ + try: + relative_location = str(target_dir.relative_to(ecosystem_root)) + + # Special case: "." becomes "root" for clarity + if relative_location == ".": + relative_location = "root" + + return relative_location + + except ValueError: + # target_dir is outside ecosystem_root + return str(target_dir) diff --git a/src/aipass/flow/apps/handlers/plan/command_parser.py b/src/aipass/flow/apps/handlers/plan/command_parser.py new file mode 100644 index 00000000..80798090 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/command_parser.py @@ -0,0 +1,178 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: command_parser.py - Command Argument Parser +# Date: 2025-11-15 +# Version: 0.2.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v0.2.0 (2026-02-14): Auto-confirm by default, add --confirm/--interactive flags +# - v0.1.0 (2025-11-15): Initial handler - command argument parsing +# +# CODE STANDARDS: +# - Pure argument parsing logic +# - Returns tuples with parsed values +# ============================================= + +""" +Command Argument Parser + +Parses command-line arguments for plan operations. +""" + +from typing import List, Tuple + + +def parse_create_plan_args(args: List[str]) -> Tuple[str | None, str, str]: + """ + Parse arguments for plan creation + + Args: + args: List of command arguments + + Returns: + Tuple of (location, subject, template_type) + - location: First arg or None + - subject: Second arg or empty string + - template_type: Third arg or "default" + + Examples: + >>> parse_create_plan_args(["@flow", "My task", "master"]) + ("@flow", "My task", "master") + + >>> parse_create_plan_args([]) + (None, "", "default") + + >>> parse_create_plan_args(["@flow"]) + ("@flow", "", "default") + """ + location = args[0] if len(args) > 0 else None + subject = args[1] if len(args) > 1 else "" + template_type = args[2] if len(args) > 2 else "default" + + return location, subject, template_type + + +def parse_delete_command_args(args: List[str]) -> Tuple[str | None, bool, str | None]: + """ + Parse arguments for delete command (DEPRECATED - use parse_close_command_args) + + Args: + args: Command arguments + + Returns: + Tuple of (plan_num, confirm, error_message) + - plan_num: Plan number from first arg, or None if missing + - confirm: False if --yes or -y flag present, True otherwise + - error_message: None if valid, error string if plan_num missing + + Examples: + >>> parse_delete_command_args(["42"]) + ("42", True, None) + + >>> parse_delete_command_args(["42", "--yes"]) + ("42", False, None) + + >>> parse_delete_command_args([]) + (None, True, "Plan number required") + """ + if len(args) < 1: + return None, True, "Plan number required" + + plan_num = args[0] + confirm = '--yes' not in args and '-y' not in args + + return plan_num, confirm, None + + +def parse_close_command_args(args: List[str]) -> Tuple[str | None, bool, bool, str | None]: + """ + Parse arguments for close command + + Auto-confirms by default (running 'close' IS the intent). + Use --confirm or --interactive to explicitly request a confirmation prompt. + --yes/-y kept for backwards compatibility (now redundant, already auto-confirms). + + Args: + args: Command arguments + + Returns: + Tuple of (plan_num, confirm, all_plans, error_message) + - plan_num: Plan number from first arg, or None if --all or missing + - confirm: True only if --confirm or --interactive flag present, False otherwise + - all_plans: True if --all flag present, False otherwise + - error_message: None if valid, error string if invalid args + + Examples: + >>> parse_close_command_args(["42"]) + ("42", False, False, None) + + >>> parse_close_command_args(["42", "--yes"]) + ("42", False, False, None) + + >>> parse_close_command_args(["42", "--confirm"]) + ("42", True, False, None) + + >>> parse_close_command_args(["42", "--interactive"]) + ("42", True, False, None) + + >>> parse_close_command_args(["--all"]) + (None, False, True, None) + + >>> parse_close_command_args(["--all", "--confirm"]) + (None, True, True, None) + + >>> parse_close_command_args([]) + (None, False, False, "Plan number or --all required") + """ + # Check for --all flag + all_plans = '--all' in args + + # Default: auto-confirm (confirm=False means no prompt) + # --confirm or --interactive explicitly requests a prompt + # --yes/-y kept for backwards compat (redundant, already auto-confirms) + confirm = '--confirm' in args or '--interactive' in args + + # If --all, plan_num is None + if all_plans: + return None, confirm, True, None + + # Otherwise, need plan number + # Filter out flag args to find the plan number + non_flag_args = [a for a in args if not a.startswith('--') and a not in ('-y',)] + if not non_flag_args: + return None, False, False, "Plan number or --all required" + + plan_num = non_flag_args[0] + return plan_num, confirm, False, None + + +def parse_restore_command_args(args: List[str]) -> Tuple[str | None, str | None]: + """ + Parse arguments for restore command + + Args: + args: Command arguments + + Returns: + Tuple of (plan_num, error_message) + - plan_num: Plan number from first arg, or None if missing + - error_message: None if valid, error string if plan_num missing + + Examples: + >>> parse_restore_command_args(["42"]) + ("42", None) + + >>> parse_restore_command_args(["0034"]) + ("0034", None) + + >>> parse_restore_command_args([]) + (None, "Plan number required") + """ + if len(args) < 1: + return None, "Plan number required" + + plan_num = args[0] + return plan_num, None diff --git a/src/aipass/flow/apps/handlers/plan/confirmation.py b/src/aipass/flow/apps/handlers/plan/confirmation.py new file mode 100644 index 00000000..4bdd20c4 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/confirmation.py @@ -0,0 +1,66 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: confirmation.py - Plan Confirmation Handler +# Date: 2025-11-15 +# Version: 0.4.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v0.4.0 (2026-01-22): Auto-confirm in non-TTY environments for autonomous workflows +# - v0.3.0 (2025-11-21): Removed Prax logging per 3-tier standard +# - v0.2.0 (2025-11-21): Added Prax logging for EOFError +# - v0.1.0 (2025-11-15): Initial handler - user confirmation prompts +# +# CODE STANDARDS: +# - Interactive user prompts +# - Simple boolean returns +# - No logging (3-tier architecture) +# ============================================= + +""" +Plan Confirmation Handler + +User interaction and confirmation prompts for plan operations. +""" + +import sys +from pathlib import Path + +# Infrastructure +_PKG_ROOT = Path(__file__).resolve().parents[4] + + +def confirm_plan_deletion(plan_key: str) -> bool: + """ + Prompt user to confirm plan deletion + + Displays interactive prompt asking user to confirm deletion. + Accepts "yes" or "y" (case-insensitive) as confirmation. + + In non-interactive environments (no TTY), auto-confirms to support + autonomous workflows and scripted operations. + + Args: + plan_key: Normalized plan number (e.g., "0001") + + Returns: + True if user confirmed deletion or non-interactive environment + False if user cancelled + + Example: + >>> if confirm_plan_deletion("0042"): + ... # User confirmed, proceed with deletion + ... pass + """ + # Auto-confirm in non-interactive environments (autonomous workflows, CI/CD) + if not sys.stdin.isatty(): + return True + + try: + response = input(f"Close FPLAN-{plan_key}? (yes/no): ").strip().lower() + return response in ['yes', 'y'] + except EOFError: + # Fallback for edge cases where isatty() returns True but input fails + return True diff --git a/src/aipass/flow/apps/handlers/plan/create.py b/src/aipass/flow/apps/handlers/plan/create.py new file mode 100644 index 00000000..13fab6a2 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/create.py @@ -0,0 +1,166 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: create.py - Plan creation handler with registry integration +# Date: 2025-11-16 +# Version: 1.1.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2025-11-21): Removed Prax logging per 3-tier standard +# - v1.0.0 (2025-11-16): Handler extraction from archive_temp +# +# CODE STANDARDS: +# - Seed v3.0 compliant (handler independence, imports, architecture) +# ============================================== + +""" +Plan Creation Handler + +Domain-specific handler for PLAN file operations. +Follows handler independence principles - no cross-domain imports. + +Handler Responsibilities: +- Write PLAN files to filesystem +- Create registry entry data structures +- Validate paths and filenames +- Handle domain-specific business logic + +NOT Handler Responsibilities (module's job): +- Loading/saving registry (cross-domain) +- Template generation (cross-domain) +- Orchestrating workflows + +Usage: + from aipass.flow.apps.handlers.plan.create import write_plan_file, create_registry_entry + + # Write plan file + success, error = write_plan_file(plan_file_path, content) + + # Create registry entry + entry = create_registry_entry(plan_number, target_dir, subject, template_type) +""" + +# INFRASTRUCTURE IMPORT PATTERN +from pathlib import Path +from datetime import datetime, timezone +from typing import Dict, Any, Tuple + +_PKG_ROOT = Path(__file__).resolve().parents[4] +FLOW_ROOT = _PKG_ROOT / "flow" + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "plan_create_handler" +ECOSYSTEM_ROOT = _PKG_ROOT + +# ============================================= +# HANDLER FUNCTIONS (Domain-specific operations) +# ============================================= + +def write_plan_file(plan_file: Path, content: str) -> Tuple[bool, str]: + """ + Write PLAN file to filesystem + + Pure handler function - no cross-domain dependencies. + + Args: + plan_file: Path to PLAN file + content: Template content to write + + Returns: + (success, error_message) + """ + try: + # Validate path doesn't already exist + if plan_file.exists(): + error_msg = f"{plan_file.name} already exists in {plan_file.parent.name}/" + return False, error_msg + + # Ensure parent directory exists + if not plan_file.parent.exists(): + error_msg = f"Directory {plan_file.parent} does not exist" + return False, error_msg + + # Write file + with open(plan_file, 'w', encoding='utf-8') as f: + f.write(content) + + return True, "" + + except Exception as e: + error_msg = f"Failed to write plan file: {e}" + return False, error_msg + + +def create_registry_entry( + plan_number: int, + target_dir: Path, + subject: str, + template_type: str +) -> Dict[str, Any]: + """ + Create registry entry data structure for a new plan + + Pure data transformation - no I/O operations. + + Args: + plan_number: Plan number + target_dir: Target directory path + subject: Plan subject + template_type: Template type used + + Returns: + Registry entry dict + """ + # Calculate relative location + try: + RELATIVE_LOCATION = str(target_dir.relative_to(ECOSYSTEM_ROOT)) + if RELATIVE_LOCATION == ".": + RELATIVE_LOCATION = "root" + except ValueError: + RELATIVE_LOCATION = str(target_dir) + + # Build plan file path + PLAN_FILE = target_dir / f"FPLAN-{plan_number:04d}.md" + + # Create entry + return { + "location": str(target_dir), + "relative_path": RELATIVE_LOCATION, + "created": datetime.now(timezone.utc).isoformat(), + "subject": subject, + "status": "open", + "file_path": str(PLAN_FILE), + "template_type": template_type + } + + +def auto_close_orphaned_plans(registry: Dict[str, Any]) -> Tuple[Dict[str, Any], int]: + """ + Auto-close plans whose files no longer exist + + Modifies registry dict in-place and returns it with count. + + Args: + registry: Registry dict to scan + + Returns: + (modified_registry, auto_closed_count) + """ + auto_closed_count = 0 + + for num, info in registry.get("plans", {}).items(): + if info["status"] == "open": + plan_file = Path(info.get("file_path", "")) + if plan_file and not plan_file.exists(): + # Auto-close missing plan + info["status"] = "closed" + info["closed"] = datetime.now(timezone.utc).isoformat() + info["closed_reason"] = "auto_closed_missing_file" + auto_closed_count += 1 + + return registry, auto_closed_count diff --git a/src/aipass/flow/apps/handlers/plan/create_file.py b/src/aipass/flow/apps/handlers/plan/create_file.py new file mode 100644 index 00000000..890288de --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/create_file.py @@ -0,0 +1,71 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: create_file.py - Plan File Creation Handler +# Date: 2025-11-15 +# Version: 0.1.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2025-11-15): Initial handler - plan file creation +# +# CODE STANDARDS: +# - Validates before writing +# - UTF-8 encoding +# ============================================= + +""" +Plan File Creation Handler + +Creates plan files with validation and error handling. +""" + +from pathlib import Path +from typing import Tuple + + +def create_plan_file( + plan_file: Path, + content: str +) -> Tuple[bool, str]: + """ + Create plan file with validation + + Validates that file doesn't already exist, then writes + content to the file using UTF-8 encoding. + + Args: + plan_file: Full path to plan file (e.g., /path/to/PLAN0001.md) + content: Formatted template content to write + + Returns: + Tuple of (success, error_message) + - success: True if file created successfully + - error_message: Empty on success, error details on failure + + Validation: + Returns failure if file already exists + + Example: + >>> success, error = create_plan_file( + ... Path("/tmp/PLAN0001.md"), + ... "# PLAN 0001\\n\\nContent here" + ... ) + >>> if not success: + ... print(error) + """ + # Validate file doesn't exist + if plan_file.exists(): + parent_name = plan_file.parent.name + error_msg = f"{plan_file.name} already exists in {parent_name}/" + return False, error_msg + + # Create file + try: + with open(plan_file, 'w', encoding='utf-8') as f: + f.write(content) + return True, "" + except Exception as e: + error_msg = f"Failed to create {plan_file}: {e}" + return False, error_msg diff --git a/src/aipass/flow/apps/handlers/plan/display.py b/src/aipass/flow/apps/handlers/plan/display.py new file mode 100644 index 00000000..68e491d3 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/display.py @@ -0,0 +1,394 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: display.py - Plan Display Handler +# Date: 2025-11-15 +# Version: 0.1.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2025-11-15): Initial handler - all display/formatting functions +# +# CODE STANDARDS: +# - Pure formatting logic +# - Returns strings for caller to print +# ============================================= + +""" +Plan Display Handler + +All display and formatting functions for plan operations. +Returns formatted strings - caller handles actual output. +""" + +from pathlib import Path +from typing import Dict, Any + + +# CREATE PLAN DISPLAY FUNCTIONS + +def display_plan_created( + plan_num: int, + relative_location: str, + subject: str, + template_type: str +) -> str: + """ + Format plan creation success messages + + Args: + plan_num: Created plan number + relative_location: Relative path to plan + subject: Plan subject + template_type: Template used + + Returns: + Multi-line formatted string for display + """ + lines = [ + f"[FLOW] Created FPLAN-{plan_num:04d} in {relative_location}", + f"[FLOW] Template: {template_type}", + f"[FLOW] Subject: {subject}" + ] + return "\n".join(lines) + + +def display_plan_result( + success: bool, + plan_num: int, + location: str, + template_type: str, + error: str +) -> str: + """ + Display plan creation result with rich formatting + + Args: + success: True if plan created successfully + plan_num: Plan number (ignored if not success) + location: Relative location (ignored if not success) + template_type: Template type (ignored if not success) + error: Error message (ignored if success) + + Returns: + Formatted result string with emoji and color markup + """ + if success: + return f"\n[green]✅ Created FPLAN-{plan_num:04d} in {location}/ using {template_type} template[/green]\n" + else: + return f"\n[red]❌ ERROR: {error}[/red]\n" + + +# DELETE PLAN DISPLAY FUNCTIONS + +def format_plan_deletion_header(plan_key: str, plan_info: Dict[str, Any]) -> str: + """ + Format plan information header for deletion confirmation + + Args: + plan_key: Normalized plan number (e.g., "0001") + plan_info: Plan metadata dictionary from registry + + Returns: + Formatted header string with Rich markup for plan details + """ + plan_file = Path(plan_info.get("file_path", "")) + + lines = [ + "", + "[bold cyan]╭─ Close FPLAN-" + plan_key + " ─╮[/bold cyan]", + "", + f" [dim]Location:[/dim] {plan_info.get('relative_path', 'unknown')}", + f" [dim]Subject:[/dim] {plan_info.get('subject', 'N/A')}", + f" [dim]Status:[/dim] {plan_info.get('status', 'unknown')}", + f" [dim]File:[/dim] {plan_file}", + "", + "[dim]─" + "─" * 68 + "[/dim]", + "" + ] + return "\n".join(lines) + + +def format_plan_error( + error_type: str, + plan_num: str | None = None, + details: str | None = None +) -> str: + """ + Format error messages for plan operations + + Args: + error_type: Type of error ("not_found", "invalid_number", "general") + plan_num: Plan number if relevant + details: Additional error details + + Returns: + Formatted error message + """ + if error_type == "not_found": + return f"[ERROR] FPLAN-{plan_num} not found in registry" + elif error_type == "invalid_number": + return f"[ERROR] Invalid plan number: {plan_num}" + elif error_type == "general": + return f"[ERROR] Error deleting plan: {details}" + else: + return "[ERROR] Unknown error" + + +def format_plan_deletion_success(plan_key: str) -> str: + """ + Format success message for completed plan deletion + + Args: + plan_key: Normalized plan number (e.g., "0001") + + Returns: + Formatted success message + """ + return f"\n[SUCCESS] FPLAN-{plan_key} deleted successfully\n" + + +def format_registry_removal_status(plan_key: str) -> str: + """ + Format status message for registry removal + + Args: + plan_key: Normalized plan number (e.g., "0001") + + Returns: + Formatted status message + """ + return f"[OK] Removed FPLAN-{plan_key} from registry" + + +def format_deletion_cancelled() -> str: + """ + Format cancellation message + + Returns: + Formatted cancellation message + """ + return "Deletion cancelled" + + +def format_delete_usage_error() -> str: + """ + Format usage error message for delete command + + Returns: + Formatted usage instructions + """ + lines = [ + "", + "ERROR: Plan number required", + "", + "Usage: delete [--yes]", + "" + ] + return "\n".join(lines) + + +# RESTORE PLAN DISPLAY FUNCTIONS + +def format_restore_header(plan_key: str, plan_info: Dict[str, Any]) -> str: + """ + Format plan information header for restore confirmation + + Args: + plan_key: Normalized plan number (e.g., "0001") + plan_info: Plan metadata dictionary from registry + + Returns: + Formatted header string with Rich markup for plan details + """ + plan_file = Path(plan_info.get("file_path", "")) + closed_date = plan_info.get("closed", "unknown") + closed_reason = plan_info.get("closed_reason", "N/A") + + lines = [ + "", + "[bold cyan]╭─ Restore FPLAN-" + plan_key + " ─╮[/bold cyan]", + "", + f" [dim]Location:[/dim] {plan_info.get('relative_path', 'unknown')}", + f" [dim]Subject:[/dim] {plan_info.get('subject', 'N/A')}", + f" [dim]Status:[/dim] {plan_info.get('status', 'unknown')}", + f" [dim]Closed:[/dim] {closed_date}", + f" [dim]Closed Reason:[/dim] {closed_reason}", + f" [dim]File:[/dim] {plan_file}", + "", + "[dim]─" + "─" * 68 + "[/dim]", + "" + ] + return "\n".join(lines) + + +def format_restore_success(plan_key: str, restored_location: str | None = None) -> str: + """ + Format success message for completed plan restore + + Args: + plan_key: Normalized plan number (e.g., "0001") + restored_location: Where the plan was restored to + + Returns: + Formatted success message + """ + if restored_location: + return f"\n[SUCCESS] FPLAN-{plan_key} restored to open status at: {restored_location}\n" + return f"\n[SUCCESS] FPLAN-{plan_key} restored to open status\n" + + +def format_restore_error(error_type: str, plan_key: str | None = None, details: str | None = None) -> str: + """ + Format error messages for restore operations + + Args: + error_type: Type of error ("not_found", "already_open", "file_missing", "invalid_number", "general") + plan_key: Plan number if relevant + details: Additional error details + + Returns: + Formatted error message + """ + if error_type == "not_found": + return f"[ERROR] FPLAN-{plan_key} not found in registry" + elif error_type == "already_open": + return f"[ERROR] FPLAN-{plan_key} is already open - cannot restore what isn't closed" + elif error_type == "file_missing": + return f"[ERROR] FPLAN-{plan_key} file not found at registered location - move file back first" + elif error_type == "invalid_number": + return f"[ERROR] Invalid plan number: {plan_key}" + elif error_type == "general": + return f"[ERROR] Error restoring plan: {details}" + else: + return "[ERROR] Unknown error" + + +def format_restore_usage_error() -> str: + """ + Format usage error message for restore command + + Returns: + Formatted usage instructions + """ + lines = [ + "", + "ERROR: Plan number required", + "", + "Usage: restore ", + "" + ] + return "\n".join(lines) + + +# LIST PLAN DISPLAY FUNCTIONS + +def format_plan_info(plan_key: str, plan_info: Dict[str, Any]) -> str: + """ + Format a single plan's information for display + + Args: + plan_key: Plan number (e.g., "0001") + plan_info: Plan metadata dictionary + + Returns: + Formatted string with plan details + """ + from datetime import datetime + + subject = plan_info.get("subject", "No subject") + location = plan_info.get("relative_path", "unknown") + status = plan_info.get("status", "unknown") + created = plan_info.get("created", "unknown") + + # Format created date if it's an ISO timestamp + if created != "unknown": + try: + dt = datetime.fromisoformat(created.replace('Z', '+00:00')) + created = dt.strftime("%Y-%m-%d %H:%M") + except (ValueError, AttributeError): + pass # Keep original value if parsing fails + + return f" FPLAN-{plan_key} [{status:>6}] {location:<30} {subject:<40} {created}" + + +def format_plans_list( + plans: Dict[str, Dict[str, Any]], + filter_status: str | None = None, + show_header: bool = True +) -> str: + """ + Format multiple plans for display with optional filtering + + Args: + plans: Dictionary of plan_key -> plan_info + filter_status: Filter by status ("open", "closed", None for all) + show_header: Whether to show column headers + + Returns: + Formatted multi-line string with plan list + """ + if not plans: + return "[dim]No plans found in registry[/dim]" + + # Filter plans if needed + if filter_status: + filtered_plans = { + k: v for k, v in plans.items() + if v.get("status") == filter_status + } + else: + filtered_plans = plans + + if not filtered_plans: + status_text = filter_status if filter_status else "all" + return f"[dim]No {status_text} plans found[/dim]" + + lines = [] + + if show_header: + lines.append("") + status_text = f" ({filter_status})" if filter_status else "" + lines.append(f"[bold cyan]╭─ PLAN Registry{status_text} ─╮[/bold cyan]") + lines.append("") + lines.append(f" [dim]{'Number':<8} {'Status':<8} {'Location':<30} {'Subject':<40} Created[/dim]") + lines.append("[dim]─" * 120 + "[/dim]") + + # Sort by plan number + sorted_plans = sorted(filtered_plans.items(), key=lambda x: x[0]) + + for plan_key, plan_info in sorted_plans: + lines.append(format_plan_info(plan_key, plan_info)) + + if show_header: + lines.append("[dim]─" * 120 + "[/dim]") + lines.append("") + + return "\n".join(lines) + + +def format_statistics_summary(stats: Dict[str, Any]) -> str: + """ + Format statistics summary + + Args: + stats: Statistics dictionary from get_registry_statistics + + Returns: + Formatted statistics string + """ + lines = [ + "", + f"[bold]Summary:[/bold]", + f" Total plans: {stats['total_plans']}", + f" Open: {stats['open_plans']}", + f" Closed: {stats['closed_plans']}" + ] + + if stats['other_plans'] > 0: + lines.append(f" Other: {stats['other_plans']}") + + lines.append("") + + return "\n".join(lines) diff --git a/src/aipass/flow/apps/handlers/plan/file_ops.py b/src/aipass/flow/apps/handlers/plan/file_ops.py new file mode 100644 index 00000000..355e1ae5 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/file_ops.py @@ -0,0 +1,54 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: file_ops.py - Plan File Operations Handler +# Date: 2025-11-15 +# Version: 0.1.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2025-11-15): Initial handler - file deletion operations +# +# CODE STANDARDS: +# - Check existence before operations +# - Return status messages for logging +# ============================================= + +""" +Plan File Operations Handler + +File system operations for plan files (deletion, etc.). +""" + +from pathlib import Path +from typing import Tuple + + +def delete_plan_file(plan_file: Path) -> Tuple[bool, str]: + """ + Delete plan file from filesystem + + Checks if file exists before attempting deletion. + Returns different status based on whether file existed. + + Args: + plan_file: Path to plan file + + Returns: + Tuple of (deleted, status_message) + - deleted: True if file existed and was deleted, + False if file didn't exist (warning case) + - status_message: Formatted message for display/logging + + Example: + >>> from pathlib import Path + >>> deleted, msg = delete_plan_file(Path("/tmp/PLAN0001.md")) + >>> if not deleted: + ... print(f"Warning: {msg}") + """ + if plan_file.exists(): + plan_file.unlink() + return True, f"[OK] Deleted file: {plan_file}" + else: + return False, f"[WARNING] File not found: {plan_file}" diff --git a/src/aipass/flow/apps/handlers/plan/get_closed_plans.py b/src/aipass/flow/apps/handlers/plan/get_closed_plans.py new file mode 100644 index 00000000..07d940a6 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/get_closed_plans.py @@ -0,0 +1,64 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: get_closed_plans.py - Get Closed Plans Handler +# Date: 2025-11-21 +# Version: 1.0.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-21): Initial handler - get list of closed plans +# +# CODE STANDARDS: +# - Handler: Pure logic, no logging, no Prax imports +# - Returns data structures for module orchestration +# ============================================= + +""" +Get Closed Plans Handler + +Returns list of closed plans from registry. + +Usage: + from aipass.flow.apps.handlers.plan.get_closed_plans import get_closed_plans + closed_plans = get_closed_plans() +""" + +from pathlib import Path +from typing import List, Tuple, Dict, Any + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] + +# Internal: Registry handler +from aipass.flow.apps.handlers.registry.load_registry import load_registry + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def get_closed_plans() -> List[Tuple[str, Dict[str, Any]]]: + """ + Get all closed plans from registry + + Returns: + List of tuples: [(plan_num, plan_info), ...] + Empty list if no closed plans found + + Example: + >>> plans = get_closed_plans() + >>> for plan_num, plan_info in plans: + ... print(f"PLAN{plan_num}: {plan_info['subject']}") + """ + # Load registry + registry = load_registry() + + # Filter for closed plans + closed_plans = [ + (plan_num, plan_info) + for plan_num, plan_info in registry.get("plans", {}).items() + if plan_info.get("status") == "closed" + ] + + return closed_plans diff --git a/src/aipass/flow/apps/handlers/plan/get_open_plans.py b/src/aipass/flow/apps/handlers/plan/get_open_plans.py new file mode 100644 index 00000000..e0ac702d --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/get_open_plans.py @@ -0,0 +1,64 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: get_open_plans.py - Get Open Plans Handler +# Date: 2025-11-21 +# Version: 1.0.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-21): Initial handler - get list of open plans +# +# CODE STANDARDS: +# - Handler: Pure logic, no logging, no Prax imports +# - Returns data structures for module orchestration +# ============================================= + +""" +Get Open Plans Handler + +Returns list of open plans from registry. + +Usage: + from aipass.flow.apps.handlers.plan.get_open_plans import get_open_plans + open_plans = get_open_plans() +""" + +from pathlib import Path +from typing import List, Tuple, Dict, Any + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] + +# Internal: Registry handler +from aipass.flow.apps.handlers.registry.load_registry import load_registry + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def get_open_plans() -> List[Tuple[str, Dict[str, Any]]]: + """ + Get all open plans from registry + + Returns: + List of tuples: [(plan_num, plan_info), ...] + Empty list if no open plans found + + Example: + >>> plans = get_open_plans() + >>> for plan_num, plan_info in plans: + ... print(f"PLAN{plan_num}: {plan_info['subject']}") + """ + # Load registry + registry = load_registry() + + # Filter for open plans + open_plans = [ + (plan_num, plan_info) + for plan_num, plan_info in registry.get("plans", {}).items() + if plan_info.get("status") == "open" + ] + + return open_plans diff --git a/src/aipass/flow/apps/handlers/plan/resolve_location.py b/src/aipass/flow/apps/handlers/plan/resolve_location.py new file mode 100644 index 00000000..83c20988 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/resolve_location.py @@ -0,0 +1,80 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: resolve_location.py - Plan Location Resolution Handler +# Date: 2025-11-29 +# Version: 1.0.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2025-11-29): Removed Drone dependency - @ symbols pre-resolved by Drone before Flow receives args +# - v1.0.0 (2025-11-29): Refactored to use drone module services +# - v0.1.0 (2025-11-15): Initial handler - location resolution with @folder syntax +# +# CODE STANDARDS: +# - Handler implements business logic +# - No external dependencies - @ symbols pre-resolved by Drone +# ============================================= + +""" +Plan Location Resolution Handler + +Resolves plan locations for explicit paths. +@ symbols are pre-resolved by Drone before Flow receives args. +Handles absolute paths, relative paths, and defaults to cwd. +""" + +from pathlib import Path +from typing import Tuple + + +def resolve_plan_location( + location: str | None, + ecosystem_root: Path +) -> Tuple[bool, Path, str]: + """ + Resolve plan location for explicit paths + + Handles two location types: + 1. Explicit path: Resolved to absolute path + 2. None: Defaults to current working directory + + Note: @ symbols are pre-resolved by Drone before Flow receives args. + Flow only handles absolute/relative paths. + + Validates that resolved directory exists. + + Args: + location: Target location (absolute/relative path, or None for cwd) + ecosystem_root: Root directory (kept for API compatibility, not used) + + Returns: + Tuple of (success, resolved_path, error_message) + - success: False if directory doesn't exist + - resolved_path: Absolute path to directory (or Path.cwd() on error) + - error_message: Empty string on success, error details on failure + + Examples: + >>> resolve_plan_location("/home/aipass/aipass_core/flow", Path("/home/aipass/aipass_core")) + (True, Path("/home/aipass/aipass_core/flow"), "") + + >>> resolve_plan_location(None, Path("/home/aipass/aipass_core")) + (True, Path.cwd(), "") + + >>> resolve_plan_location("/nonexistent", Path("/home/aipass/aipass_core")) + (False, Path.cwd(), "Directory /nonexistent does not exist") + """ + # Determine target directory + if location: + # Handle explicit path + target_dir = Path(location).resolve() + else: + # Default to current working directory + target_dir = Path.cwd() + + # Validate directory exists + if not target_dir.exists(): + return False, Path.cwd(), f"Directory {target_dir} does not exist" + + return True, target_dir, "" diff --git a/src/aipass/flow/apps/handlers/plan/update_registry.py b/src/aipass/flow/apps/handlers/plan/update_registry.py new file mode 100644 index 00000000..c6133184 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/update_registry.py @@ -0,0 +1,101 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: update_registry.py - Registry Update Handler +# Date: 2025-11-15 +# Version: 0.1.0 +# Category: flow/handlers/plan +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2025-11-15): Initial handler - registry add/remove operations +# +# CODE STANDARDS: +# - Pure data manipulation +# - Defensive programming (ensure keys exist) +# ============================================= + +""" +Registry Update Handler + +Add and remove plan entries from registry. +""" + +from typing import Dict, Any + + +def add_plan_to_registry( + registry: Dict[str, Any], + plan_num: int, + entry: Dict[str, Any] +) -> Dict[str, Any]: + """ + Add plan entry to registry and increment counter + + Ensures registry["plans"] exists, adds the entry with + formatted plan number as key, and increments next_number. + + Args: + registry: Flow registry dictionary + plan_num: Plan number (e.g., 1, 42, 101) + entry: Plan entry dict from build_plan_registry_entry() + + Returns: + Modified registry with new plan entry + + Side effects: + - Ensures registry["plans"] exists + - Adds entry to registry["plans"][formatted_num] + - Increments registry["next_number"] + + Example: + >>> registry = {"next_number": 1} + >>> entry = {"subject": "Test", "status": "open"} + >>> registry = add_plan_to_registry(registry, 1, entry) + >>> "0001" in registry["plans"] + True + >>> registry["next_number"] + 2 + """ + # Ensure plans dict exists + if "plans" not in registry: + registry["plans"] = {} + + # Add entry with formatted number as key + plan_key = f"{plan_num:04d}" + registry["plans"][plan_key] = entry + + # Increment counter + registry["next_number"] = plan_num + 1 + + return registry + + +def remove_plan_from_registry( + plan_key: str, + registry: Dict[str, Any] +) -> Dict[str, Any]: + """ + Remove plan entry from registry + + Args: + plan_key: Normalized plan number (e.g., "0001") + registry: Registry dictionary + + Returns: + Modified registry with plan removed + + Note: + Modifies registry in place and returns it for chaining. + Safe to call even if plan_key doesn't exist. + + Example: + >>> registry = {"plans": {"0001": {}, "0002": {}}} + >>> registry = remove_plan_from_registry("0001", registry) + >>> "0001" in registry.get("plans", {}) + False + """ + if plan_key in registry.get("plans", {}): + del registry["plans"][plan_key] + + return registry diff --git a/src/aipass/flow/apps/handlers/plan/validator.py b/src/aipass/flow/apps/handlers/plan/validator.py new file mode 100644 index 00000000..88392604 --- /dev/null +++ b/src/aipass/flow/apps/handlers/plan/validator.py @@ -0,0 +1,93 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: validator.py - Plan Validation Handler +# Date: 2025-11-30 +# Version: 0.3.0 +# Category: flow/handlers/plan +# CODE STANDARDS: Seed v3.0 +# +# CHANGELOG (Max 5 entries): +# - v0.3.0 (2026-01-30): Accept PLAN-XXXX and PLANXXXX prefixes (not just FPLAN-) +# - v0.2.0 (2025-11-30): Strip FPLAN- prefix for copy-paste UX (FPLAN-0240 → 0240) +# - v0.1.0 (2025-11-15): Initial handler - plan number validation +# +# CODE STANDARDS: +# - Handler implements validation logic +# - No module imports (handler independence) +# ============================================= + +""" +Plan Validation Handler + +Validates and normalizes plan numbers and registry entries. +""" + +from typing import Dict, Any, Tuple + + +def normalize_plan_number(plan_num: str) -> str: + """ + Normalize plan number to 4-digit format + + Accepts various formats ("1", "42", "0001") and normalizes + to standard 4-digit format ("0001", "0042", "0001"). + + Args: + plan_num: Plan number in any format + + Returns: + Normalized 4-digit plan number (e.g., "0001") + + Raises: + ValueError: If plan_num cannot be converted to integer + + Examples: + >>> normalize_plan_number("1") + "0001" + >>> normalize_plan_number("42") + "0042" + >>> normalize_plan_number("0001") + "0001" + >>> normalize_plan_number("FPLAN-0042") + "0042" + >>> normalize_plan_number("PLAN4444") + "4444" + """ + # Normalize prefix variations for robustness + if isinstance(plan_num, str): + upper = plan_num.upper() + # Strip FPLAN- prefix (standard format) + if upper.startswith("FPLAN-"): + plan_num = plan_num[6:] + # Strip PLAN- prefix (alternate format) + elif upper.startswith("PLAN-"): + plan_num = plan_num[5:] + # Strip PLAN prefix without dash (e.g., PLAN4444) + elif upper.startswith("PLAN"): + plan_num = plan_num[4:] + return f"{int(plan_num):04d}" + + +def validate_plan_exists(plan_key: str, registry: Dict[str, Any]) -> Tuple[bool, str | None]: + """ + Validate that a plan exists in the registry + + Args: + plan_key: Normalized plan number (e.g., "0001") + registry: Registry dictionary with 'plans' section + + Returns: + Tuple of (exists, error_message) + - exists: True if plan found in registry + - error_message: None if exists, error string if not found + + Examples: + >>> exists, error = validate_plan_exists("0001", registry) + >>> if not exists: + ... print(error) + """ + if plan_key not in registry.get("plans", {}): + return False, f"FPLAN-{plan_key} not found in registry" + return True, None diff --git a/src/aipass/flow/apps/handlers/registry/__init__.py b/src/aipass/flow/apps/handlers/registry/__init__.py new file mode 100644 index 00000000..6c9abf65 --- /dev/null +++ b/src/aipass/flow/apps/handlers/registry/__init__.py @@ -0,0 +1 @@ +"""Flow registry handlers package""" diff --git a/src/aipass/flow/apps/handlers/registry/load_registry.py b/src/aipass/flow/apps/handlers/registry/load_registry.py new file mode 100644 index 00000000..1fa46202 --- /dev/null +++ b/src/aipass/flow/apps/handlers/registry/load_registry.py @@ -0,0 +1,68 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: load_registry.py +# Date: 2025-11-07 +# Version: 1.1.0 +# Category: flow/handlers/registry +# +# CHANGELOG: +# - v1.1.0 (2025-11-21): Removed Prax logging per 3-tier standard +# - v1.0.0 (2025-11-07): Extracted from flow_registry_monitor.py +# ============================================= + +""" +Load Registry Handler + +Loads the Flow PLAN registry from JSON file with error handling. + +Features: +- Loads flow_registry.json +- Returns default structure if file missing +- Graceful error handling +- Reusable across Flow modules + +Usage: + from aipass.flow.apps.handlers.registry.load_registry import load_registry + registry = load_registry() +""" + +import json +from pathlib import Path +from typing import Dict, Any + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] +FLOW_ROOT = _PKG_ROOT / "flow" + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "load_registry" +FLOW_JSON_DIR = FLOW_ROOT / "flow_json" +REGISTRY_FILE = FLOW_JSON_DIR / "flow_registry.json" + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def load_registry() -> Dict[str, Any]: + """Load PLAN registry + + Returns: + Dict containing: + - plans: Dict of plan_number -> plan_info + - next_number: Next available plan number + + Returns default structure if file doesn't exist or on error. + """ + if not REGISTRY_FILE.exists(): + return {"plans": {}, "next_number": 1} + + try: + with open(REGISTRY_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return {"plans": {}, "next_number": 1} diff --git a/src/aipass/flow/apps/handlers/registry/save_registry.py b/src/aipass/flow/apps/handlers/registry/save_registry.py new file mode 100644 index 00000000..65afbf14 --- /dev/null +++ b/src/aipass/flow/apps/handlers/registry/save_registry.py @@ -0,0 +1,73 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: save_registry.py +# Date: 2025-11-07 +# Version: 1.1.0 +# Category: flow/handlers/registry +# +# CHANGELOG: +# - v1.1.0 (2025-11-21): Removed Prax logging per 3-tier standard +# - v1.0.0 (2025-11-07): Extracted from flow_registry_monitor.py +# ============================================= + +""" +Save Registry Handler + +Saves the Flow PLAN registry to JSON file with automatic timestamp updates. + +Features: +- Saves flow_registry.json +- Auto-updates last_updated timestamp +- Creates directory if missing +- Graceful error handling +- Reusable across Flow modules + +Usage: + from aipass.flow.apps.handlers.registry.save_registry import save_registry + registry = {"plans": {}, "next_number": 1} + save_registry(registry) +""" + +import json +from pathlib import Path +from datetime import datetime, timezone +from typing import Dict, Any + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] +FLOW_ROOT = _PKG_ROOT / "flow" + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "save_registry" +FLOW_JSON_DIR = FLOW_ROOT / "flow_json" +REGISTRY_FILE = FLOW_JSON_DIR / "flow_registry.json" + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def save_registry(registry: Dict[str, Any]) -> bool: + """Save PLAN registry + + Args: + registry: Dictionary containing registry data + + Returns: + True if save successful, False on error + + Automatically updates the last_updated timestamp before saving. + Creates the flow_json directory if it doesn't exist. + """ + try: + FLOW_JSON_DIR.mkdir(parents=True, exist_ok=True) + registry["last_updated"] = datetime.now(timezone.utc).isoformat() + with open(REGISTRY_FILE, 'w', encoding='utf-8') as f: + json.dump(registry, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False diff --git a/src/aipass/flow/apps/handlers/registry/statistics.py b/src/aipass/flow/apps/handlers/registry/statistics.py new file mode 100644 index 00000000..267daf53 --- /dev/null +++ b/src/aipass/flow/apps/handlers/registry/statistics.py @@ -0,0 +1,72 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: statistics.py +# Date: 2025-11-07 +# Version: 1.0.0 +# Category: flow/handlers/registry +# +# CHANGELOG: +# - v1.0.0 (2025-11-07): Extracted from flow_registry_monitor.py +# ============================================= + +""" +Registry Statistics Handler + +Calculates statistics from the Flow PLAN registry. + +Features: +- Counts total plans +- Counts plans by status (open, closed, etc.) +- Provides timestamp metadata +- Reusable across Flow modules + +Usage: + from aipass.flow.apps.handlers.registry.statistics import get_registry_statistics + from aipass.flow.apps.handlers.registry.load_registry import load_registry + + registry = load_registry() + stats = get_registry_statistics(registry) + print(f"Total plans: {stats['total_plans']}") +""" + +from pathlib import Path +from datetime import datetime, timezone +from typing import Dict, Any + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def get_registry_statistics(registry: Dict[str, Any]) -> Dict[str, Any]: + """Calculate statistics from registry + + Args: + registry: Registry dictionary containing plans data + + Returns: + Dict containing: + - total_plans: Total number of plans in registry + - open_plans: Number of plans with status="open" + - closed_plans: Number of plans with status="closed" + - other_plans: Number of plans with other statuses + - timestamp: ISO timestamp when statistics were calculated + """ + plans = registry.get("plans", {}) + + # Count plans by status + open_count = sum(1 for plan in plans.values() if plan.get("status") == "open") + closed_count = sum(1 for plan in plans.values() if plan.get("status") == "closed") + other_count = len(plans) - open_count - closed_count + + return { + "total_plans": len(plans), + "open_plans": open_count, + "closed_plans": closed_count, + "other_plans": other_count, + "timestamp": datetime.now(timezone.utc).isoformat() + } diff --git a/src/aipass/flow/apps/handlers/summary/__init__.py b/src/aipass/flow/apps/handlers/summary/__init__.py new file mode 100644 index 00000000..2d1cd450 --- /dev/null +++ b/src/aipass/flow/apps/handlers/summary/__init__.py @@ -0,0 +1 @@ +"""Flow summary handlers package""" diff --git a/src/aipass/flow/apps/handlers/summary/generate.py(disabled) b/src/aipass/flow/apps/handlers/summary/generate.py(disabled) new file mode 100644 index 00000000..caaee431 --- /dev/null +++ b/src/aipass/flow/apps/handlers/summary/generate.py(disabled) @@ -0,0 +1,646 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: generate.py - Plan Summary Generation Handler +# Date: 2025-11-21 +# Version: 1.2.0 +# Category: flow/handlers/summary +# +# CHANGELOG: +# - v1.2.0 (2026-02-14): Fix content extraction to handle all template types (master, proposal, default) +# - v1.1.0 (2026-02-14): Sequential processing with backoff - delay between API calls, stop on rate limit +# - v1.0.0 (2025-11-21): Extracted from archive_temp/flow_plan_summarizer.py +# +# CODE STANDARDS: +# - 3-tier compliant: NO Prax imports, NO logging +# - Raises exceptions for errors (module logs them) +# ============================================= +""" +Plan Summary Generation Handler + +Pure handler for generating AI-powered summaries of PLAN files. +Extracts content from PLAN files, generates summaries via OpenRouter API, +and manages summary cache. + +This handler does NOT write CLAUDE.json or CLAUDE.local.md files. +Output generation is handled by the dashboard/update_local handler. +""" + +from pathlib import Path +import sys +import json +import time +from datetime import datetime, timezone +from typing import Dict, Any, Optional + +AIPASS_ROOT = Path.home() / 'aipass_core' +sys.path.insert(0, str(AIPASS_ROOT)) + +# Import API provider for summarization (via module entry point - encapsulation) +try: + from api.apps.modules.openrouter_client import get_response + API_AVAILABLE = True +except ImportError: + API_AVAILABLE = False + +# ============================================= +# CONSTANTS +# ============================================= + +FLOW_ROOT = AIPASS_ROOT / "flow" +FLOW_JSON_DIR = FLOW_ROOT / "flow_json" +REGISTRY_FILE = FLOW_JSON_DIR / "flow_registry.json" +SUMMARIES_FILE = FLOW_JSON_DIR / "plan_summaries.json" +API_DELAY_SECONDS = 3 # Delay between sequential API calls to avoid rate limits +CONFIG_FILE = FLOW_JSON_DIR / "flow_plan_summarizer_config.json" +SHARED_API_CONFIG = FLOW_ROOT / "apps" / "handlers" / "json_templates" / "custom" / "api_config.json" + +# Template detection markers (matches flow_plan.py auto-delete logic) +# Covers default, master, and proposal template placeholders +TEMPLATE_INDICATORS = [ + # Default template markers + "[What do you want to achieve? Be specific about the end state.]", + "[Break down into 3-5 concrete goals. What must be accomplished?]", + "[How will you tackle this? Research first? Agents for broad analysis? Direct work?]", + "[Document each significant action with outcome]", + "[Working notes, discoveries, important context]", + "[What defines complete for this specific PLAN?]", + # Master plan template markers + "[What is the end state when ALL phases complete?]", + "[List planning docs, specs, existing code to reference]", + "[What defines DONE for the entire project?]", + "[What this phase accomplishes]", + "[What the agent will build]", + "[Patterns discovered that span multiple phases]", + "[Significant blockers and how resolved]", + "[What specifically defines the project complete?]", + # Proposal template markers + "[Clear description of the idea, feature, improvement, or fix]", + "[Why is this valuable? What problem does it solve? What does it enable?]", + "[Any other branches, services, or approvals needed?]", + "[How would I tackle this? High-level steps.]", + "[What exists now? What's the starting point?]", + "[Anything you need clarity on before proceeding?]", +] + +# ============================================= +# CONFIGURATION +# ============================================= + +def load_config() -> Dict[str, Any]: + """ + Load summarizer configuration from JSON file. + + If config file doesn't exist, creates it with default values. + This allows config changes by editing JSON, not Python code. + + Returns: + Config dict with structure: + { + "module_name": str, + "version": str, + "config": { + "enabled": bool, + "api_settings": {...}, + "cache_settings": {...}, + "summary_settings": {...} + } + } + """ + # Default configuration (model removed - loaded from shared config) + default_config = { + "module_name": "flow_plan_summarizer", + "version": "1.0.0", + "config": { + "enabled": True, + "api_settings": { + "timeout_seconds": 120, + "max_tokens": 500 + }, + "cache_settings": { + "enabled": True, + "max_cache_entries": 50 + }, + "summary_settings": { + "hide_empty_plans": True + } + } + } + + # If config doesn't exist, create it with defaults + if not CONFIG_FILE.exists(): + try: + FLOW_JSON_DIR.mkdir(parents=True, exist_ok=True) + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(default_config, f, indent=2, ensure_ascii=False) + return default_config + except Exception as e: + raise Exception(f"Failed to create config file: {e}") + + # Load existing config + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + raise Exception(f"Failed to load config: {e}") + +def load_shared_api_config() -> Optional[str]: + """ + Load model from shared API config. + + Reads from: json_templates/custom/api_config.json + Location priority: + 1. config.model (standard location) + 2. config.default_model (alternate location) + 3. api_settings.model (legacy fallback) + + Returns: + Model name from shared config, or None if config doesn't exist + """ + try: + if not SHARED_API_CONFIG.exists(): + return None + + with open(SHARED_API_CONFIG, 'r', encoding='utf-8') as f: + shared_config = json.load(f) + + # Try config.model first (standard location) + model = shared_config.get("config", {}).get("model") + + # Try config.default_model as alternate + if not model: + model = shared_config.get("config", {}).get("default_model") + + # Fall back to api_settings.model if needed + if not model: + model = shared_config.get("api_settings", {}).get("model") + + return model + except Exception: + return None + +# ============================================= +# REGISTRY & CACHE ACCESS +# ============================================= + +def load_registry() -> Dict[str, Any]: + """Load the flow registry""" + if not REGISTRY_FILE.exists(): + return {"plans": {}} + + try: + with open(REGISTRY_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + raise Exception(f"Failed to load registry: {e}") + +def load_summaries() -> Dict[str, Any]: + """Load existing summaries cache and clean up failed entries""" + if not SUMMARIES_FILE.exists(): + return {} + + try: + with open(SUMMARIES_FILE, 'r', encoding='utf-8') as f: + summaries = json.load(f) + + # Self-healing: Remove failed summaries so they get retried + cleaned_summaries = {} + for plan_num, summary_data in summaries.items(): + summary_text = summary_data.get("summary", "") + # Skip failed summaries (they'll be regenerated) + if "Summary generation failed" not in summary_text and "Unable to generate summary" not in summary_text: + cleaned_summaries[plan_num] = summary_data + + return cleaned_summaries + + except Exception as e: + raise Exception(f"Failed to load summaries cache: {e}") + +def save_summaries(summaries: Dict[str, Any]) -> None: + """Save summaries cache""" + try: + FLOW_JSON_DIR.mkdir(parents=True, exist_ok=True) + with open(SUMMARIES_FILE, 'w', encoding='utf-8') as f: + json.dump(summaries, f, indent=2, ensure_ascii=False) + except Exception as e: + raise Exception(f"Failed to save summaries cache: {e}") + +# ============================================= +# CONTENT EXTRACTION +# ============================================= + +def extract_content_from_plan(plan_path: Path) -> str: + """ + Extract meaningful content from a PLAN file (any template type). + + Works with all template types (default, master, proposal) by extracting + content after the metadata header and filtering out template boilerplate. + Instead of looking for specific section names, it identifies user-written + content by excluding known template text. + + Args: + plan_path: Path to PLAN file + + Returns: + First 500 chars of meaningful content or "Empty plan template" + """ + try: + content = plan_path.read_text(encoding='utf-8') + lines = content.split('\n') + + # Skip metadata header (everything before first --- separator) + content_start = 0 + for i, line in enumerate(lines): + if line.strip() == '---': + content_start = i + 1 + break + + # Sections that are pure template boilerplate (instructions, not user content) + # These appear in templates but never contain user-written content + boilerplate_sections = { + "## What Are Flow Plans?", + "## When to Use This vs Master Plan", + "## Master Plan vs Default Plan", + "## Branch Directory Structure", + "## Critical: Branch Manager Role", + "## Seek Branch Expertise", + "## Notepad", + "## Command Reference", + "## Agent Preparation (Before Deploying)", + "## Agent Instructions Template", + "## Execution Philosophy", + "## What is a Master Plan?", + "## Close Command", + "## Ready for Green Light", + } + + # Sections that contain user-editable content (these get extracted) + # Covers default, master, and proposal templates + content_sections = { + # Default template + "## Planning Phase", "## Execution Log", "## Notes", + "## Completion Checklist", + # Master plan template + "## Project Overview", "## Phase Definitions", "## Phase Tracking", + "## Issues Log", "## Master Plan Notes", "## Final Completion Checklist", + # Proposal template + "## What I Want to Work On", "## Why This Matters", "## What I'd Need", + "## My Approach", "## Current State", "## Questions for DEV_CENTRAL", + # Legacy section names (backward compatibility) + "## Work Log", "## Objectives", + } + + # Extract content from user-editable sections + in_content_section = False + meaningful_lines = [] + + for line in lines[content_start:]: + stripped = line.strip() + + # Detect section headers + if stripped.startswith("## "): + if stripped in boilerplate_sections: + in_content_section = False + continue + elif stripped in content_sections: + in_content_section = True + continue + else: + # Unknown section - assume it's user content (safer than ignoring) + in_content_section = True + continue + + # Skip --- separators + if stripped == '---': + in_content_section = False + continue + + # Only collect from content sections + if not in_content_section: + continue + + # Skip empty lines, sub-headers, and template placeholders + if not stripped: + continue + if stripped.startswith("### ") and stripped.endswith(": [Name]"): + continue + + # Skip template placeholder lines (text inside square brackets) + if stripped.startswith("[") and stripped.endswith("]"): + continue + + # Skip template indicator lines + is_indicator = False + for indicator in TEMPLATE_INDICATORS: + if indicator in line: + is_indicator = True + break + if is_indicator: + continue + + # Skip unchecked checkbox-only lines from templates (e.g. "- [ ] Agent deployed") + # but keep checked ones (e.g. "- [x] Agent deployed") as they indicate real work + if stripped.startswith("- [ ]"): + continue + + # Skip boilerplate meta lines + if stripped.startswith("**Status:**") and ("Pending" in stripped): + continue + if stripped.startswith("**Notes:**") and stripped.endswith("[Outcomes, issues, adjustments]"): + continue + + # Skip table header/separator rows + if stripped.startswith("|") and all(c in "|- " for c in stripped): + continue + + # This line has real content + meaningful_lines.append(stripped) + + # If no meaningful content found, it's a template + if not meaningful_lines: + return "Empty plan template" + + # Return first 500 chars of meaningful content + result = " ".join(meaningful_lines)[:500] + return result if result else "Empty plan template" + + except Exception as e: + raise Exception(f"Error reading plan file: {e}") + +def is_template_file(plan_path: Path) -> bool: + """ + Check if PLAN file is empty template using marker detection. + + Uses same logic as flow_plan.py auto-delete (4+ markers = empty). + + Args: + plan_path: Path to PLAN file + + Returns: + True if file is empty template (4+ markers present) + """ + try: + content = plan_path.read_text(encoding='utf-8') + markers_found = sum(1 for indicator in TEMPLATE_INDICATORS if indicator in content) + return markers_found >= 4 + except Exception: + return False + +# ============================================= +# AI SUMMARY GENERATION +# ============================================= + +def generate_ai_summary(content: str, plan_num: str) -> str: + """ + Generate ~20 word AI summary of plan content. + + Args: + content: Extracted plan content + plan_num: Plan number (e.g., "0001") + + Returns: + 15-20 word summary or error message + + Error formats: + - "API NOT AVAILABLE - no API modules found" + - "API CONNECTION ERROR - no internet or invalid key" + - "API RESPONSE ERROR - invalid response format" + - "INVALID KEY - wrong prefix or too short" + """ + # Check if it's an empty template + if "Empty plan template" in content: + return "Inactive - empty template, no work started" + + if not API_AVAILABLE: + return f"FPLAN-{plan_num} - API NOT AVAILABLE: Cannot generate summary without AI" + + try: + # Load model from shared config (json_templates/custom/api_config.json) + model = load_shared_api_config() + + if model is None: + # Shared config missing - raise error (no silent fallbacks) + raise Exception(f"Shared API config not found at: {SHARED_API_CONFIG}") + + # Load summary settings from config + config = load_config() + summary_settings = config.get("config", {}).get("summary_settings", {}) + content_max = summary_settings.get("content_max_chars", 10000) + word_min = summary_settings.get("word_limit_min", 30) + word_max = summary_settings.get("word_limit_max", 40) + word_truncate = summary_settings.get("word_truncate_at", 50) + + # Use API to generate summary + prompt = f"""Generate a concise {word_min}-{word_max} word summary of this PLAN content. +Focus on the main objective, key goals, and any notable outcomes or conclusions. +If the plan is empty or just template text, respond with "Empty plan, no work started yet" + +Content: +{content[:content_max]} + +Summary ({word_min}-{word_max} words):""" + + response = get_response(prompt, model=model, caller="flow_plan_summarizer") + + if response: + summary = response['content'].strip() # Extract content from dict first + # Ensure reasonable length + if len(summary.split()) > word_truncate: + summary = " ".join(summary.split()[:word_max]) + "..." + return summary + else: + # No response - API failed + return f"FPLAN-{plan_num} - API CONNECTION ERROR: Check API key and internet" + + except Exception as e: + # Distinguish between different error types + error_msg = str(e) + + # Check for specific error patterns + if "API CONNECTION ERROR" in error_msg: + return f"FPLAN-{plan_num} - API CONNECTION ERROR: No internet or invalid key" + elif "API RESPONSE ERROR" in error_msg: + return f"FPLAN-{plan_num} - API RESPONSE ERROR: Invalid response format" + elif "missing required prefix" in error_msg.lower(): + return f"FPLAN-{plan_num} - INVALID KEY: Wrong API key prefix" + elif "key too short" in error_msg.lower(): + return f"FPLAN-{plan_num} - INVALID KEY: API key too short" + else: + # Generic error + return f"FPLAN-{plan_num} - ERROR: {error_msg[:50]}" + +# ============================================= +# CACHE MANAGEMENT +# ============================================= + +def needs_summary_update(plan_info: Dict, cached_summary: Optional[Dict]) -> bool: + """ + Check if a plan needs its summary regenerated. + + Args: + plan_info: Plan metadata from registry + cached_summary: Cached summary data (or None) + + Returns: + True if summary needs regeneration + + Checks: + - File modified since last summary + - Registry metadata changed + - Plan status changed + """ + if not cached_summary: + return True + + # Check if plan file was modified since last summary + plan_path = Path(plan_info.get("file_path", "")) + if plan_path.exists(): + file_modified = datetime.fromtimestamp(plan_path.stat().st_mtime, timezone.utc).isoformat() + summary_generated = cached_summary.get("generated_at", "") + + if file_modified > summary_generated: + return True + + # Check if registry info was modified since last summary + plan_modified = plan_info.get("closed") or plan_info.get("created") + summary_generated = cached_summary.get("generated_at", "") + + if plan_modified and plan_modified > summary_generated: + return True + + # Check if status changed + if plan_info.get("status") != cached_summary.get("status"): + return True + + return False + +# ============================================= +# MAIN ENTRY POINT +# ============================================= + +def generate_summaries() -> Dict[str, Any]: + """ + Main entry point: Generate summaries for all plans. + + Returns: + Status dict with format: + { + 'success': True, + 'data': { + plan_num: { + 'summary': str, + 'status': str, + 'location': str, + 'subject': str, + 'file_path': str, + 'generated_at': str (ISO 8601), + 'is_empty': bool + } + } + } + + OR on error: + { + 'success': False, + 'error': 'Error message' + } + """ + try: + registry = load_registry() + cached_summaries = load_summaries() + updated_summaries = {} + + api_call_count = 0 + rate_limited = False + for plan_num, plan_info in registry.get("plans", {}).items(): + try: + # Check if summary needs update + cached = cached_summaries.get(plan_num) + if not needs_summary_update(plan_info, cached): + # Use cached summary but ensure file_path is current from registry + if cached is None: + # Defensive: if cache unexpectedly missing, initialize minimal record + cached = { + "summary": "", + "status": plan_info.get("status", "unknown"), + "location": plan_info.get("relative_path", "unknown"), + "subject": plan_info.get("subject", ""), + "file_path": plan_info.get("file_path", ""), + "generated_at": datetime.now(timezone.utc).isoformat(), + "is_empty": False + } + else: + cached['file_path'] = plan_info.get("file_path", "") + updated_summaries[plan_num] = cached + continue + + # Stop processing if we hit a rate limit - try again later + if rate_limited: + continue + + # Read plan file + plan_path = Path(plan_info["file_path"]) + if not plan_path.exists(): + # File missing - likely moved to backup after memory bank processing + # Remove from summaries cache so it doesn't show in CLAUDE outputs + if plan_num in cached_summaries: + del cached_summaries[plan_num] + continue + + # Check if this is an empty template BEFORE calling AI (save API calls) + if is_template_file(plan_path): + # Skip AI call for empty templates - use fixed summary + summary = "Empty plan template - no content added" + else: + # Add delay between API calls (not before the first one) + if api_call_count > 0: + time.sleep(API_DELAY_SECONDS) + + # Extract content + content = extract_content_from_plan(plan_path) + + # Generate summary with AI + summary = generate_ai_summary(content, plan_num) + api_call_count += 1 + + # Check if summary generation failed + if any(error in summary for error in ["ERROR:", "CONNECTION ERROR", "RESPONSE ERROR", "INVALID KEY", "API NOT AVAILABLE"]): + # Detect rate limit - stop processing remaining plans + if "429" in summary or "rate limit" in summary.lower(): + rate_limited = True + # Don't cache failed summaries - let them retry next time + continue + + # Store successful summary with metadata + updated_summaries[plan_num] = { + "summary": summary, + "status": plan_info.get("status", "unknown"), + "location": plan_info.get("relative_path", "unknown"), + "subject": plan_info.get("subject", ""), + "file_path": plan_info.get("file_path", ""), + "generated_at": datetime.now(timezone.utc).isoformat(), + "is_empty": "Empty plan" in summary or "Inactive" in summary + } + + except Exception as e: + # Detect rate limit in exceptions - stop processing + error_str = str(e) + if "429" in error_str or "rate limit" in error_str.lower(): + rate_limited = True + # Skip individual plan failures, continue with others + continue + + # Save updated summaries + save_summaries(updated_summaries) + + return { + 'success': True, + 'data': updated_summaries + } + + except Exception as e: + return { + 'success': False, + 'error': str(e) + } diff --git a/src/aipass/flow/apps/handlers/summary/write_plan_outputs.py b/src/aipass/flow/apps/handlers/summary/write_plan_outputs.py new file mode 100644 index 00000000..3c05177b --- /dev/null +++ b/src/aipass/flow/apps/handlers/summary/write_plan_outputs.py @@ -0,0 +1,348 @@ +#!/home/aipass/.venv/bin/python3 +# -*- coding: utf-8 -*- + +# ===================AIPASS==================== +# META DATA HEADER +# Name: write_plan_outputs.py +# Date: 2025-11-07 +# Version: 1.1.0 +# Category: flow/handlers/summary +# +# CHANGELOG: +# - v1.1.0 (2025-11-21): Removed Prax logging per 3-tier standard +# - v1.0.0 (2025-11-07): Extracted from flow_plan_summarizer.py +# ============================================= + +""" +Write Plan Outputs Handler + +Writes plan summaries to both global and branch-local files. + +Features: +- Writes CLAUDE.json (global system-wide file) +- Writes CLAUDE.local.md (per-branch files) +- Handles active and closed plans +- Generates clickable links with file_uri and vscode_uri +- Filters empty plans based on config +- Reusable across Flow modules + +Global vs Local Pattern: +- **Global:** /home/aipass/CLAUDE.json (all plans, all branches) +- **Local:** /home/aipass/aipass_core/[branch]/CLAUDE.local.md (branch-specific) + +Usage: + from aipass.flow.apps.handlers.summary.write_plan_outputs import write_plan_outputs + + summaries = { + "0001": {"summary": "...", "status": "open", "file_path": "...", ...}, + "0002": {"summary": "...", "status": "closed", "file_path": "...", ...} + } + write_plan_outputs(summaries) +""" + +import json +from pathlib import Path +from datetime import datetime, timezone +from typing import Dict, Any, Optional + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] +FLOW_ROOT = _PKG_ROOT / "flow" + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "write_plan_outputs" +CLAUDE_JSON_FILE = Path.home() / "CLAUDE.json" + +# ============================================= +# HELPER FUNCTIONS +# ============================================= + +def _normalize_plan_entry(plan_num: str, info: Dict[str, Any]) -> Optional[Dict[str, Any]]: + """ + Normalize plan metadata for downstream outputs. + + Args: + plan_num: Plan number (e.g., "0001") + info: Raw plan info from registry/summaries + + Returns: + Normalized plan entry dict or None if invalid + """ + plan_id = f"FPLAN-{plan_num}" + file_path = info.get("file_path", "") + path_obj: Optional[Path] = None + + if file_path: + path_obj = Path(file_path) + if not path_obj.is_absolute(): + path_obj = Path.home() / file_path + + branch_dir: Optional[Path] = None + branch_relative_path = "" + + if path_obj is not None: + if path_obj.is_file(): + branch_dir = path_obj.parent + elif path_obj.exists(): + branch_dir = path_obj + + try: + branch_relative_path = str(path_obj.relative_to(Path.home())) + except Exception: + branch_relative_path = str(path_obj) + else: + branch_relative_path = file_path + + branch_name = (info.get("location") or "").split("/", 1)[0] + + if not branch_name and branch_relative_path: + branch_name = branch_relative_path.split("/", 1)[0] + + if branch_dir is not None and not branch_name: + try: + branch_name = branch_dir.relative_to(Path.home()).parts[0] + except Exception: + branch_name = branch_dir.name if branch_dir.name else "unknown" + + entry = { + "plan": plan_id, + "status": info.get("status", "unknown"), + "summary": info.get("summary", ""), + "subject": info.get("subject", ""), + "branch": branch_name or "unknown", + "location": info.get("location", "unknown"), + "file_path": file_path, + "relative_path": branch_relative_path, + "generated_at": info.get("generated_at"), + "is_empty": info.get("is_empty", False), + "branch_path": None, + "branch_relative_path": "" + } + + if path_obj is not None and branch_dir is not None: + try: + entry["branch_relative_path"] = str(path_obj.relative_to(branch_dir)) + except Exception: + entry["branch_relative_path"] = entry["relative_path"] + + if path_obj is not None: + entry["absolute_path"] = str(path_obj) + try: + entry["file_uri"] = path_obj.as_uri() + except ValueError: + entry["file_uri"] = None + + entry["vscode_uri"] = f"vscode://file{entry['absolute_path']}" if entry.get("absolute_path") else None + + if branch_dir is not None: + try: + branch_dir.relative_to(Path.home()) + entry["branch_path"] = branch_dir + except Exception: + entry["branch_path"] = None + + return entry + + +def _build_plan_output_sets(summaries: Dict[str, Any]): + """ + Partition plan entries into central and branch-specific collections. + + Args: + summaries: Dict of plan_number -> plan_info + + Returns: + Tuple of (active_entries, closed_entries, branch_map) + """ + active_entries = [] + closed_entries = [] + branch_map: Dict[Path, Dict[str, Any]] = {} + + for plan_num in sorted(summaries.keys()): + entry = _normalize_plan_entry(plan_num, summaries[plan_num]) + if entry is None: + continue + + json_entry = {k: v for k, v in entry.items() if k not in {"branch_path"} and v is not None} + + if entry["status"] == "closed": + closed_entries.append(json_entry) + else: + active_entries.append(json_entry) + + branch_path = entry.get("branch_path") + if branch_path: + branch_bucket = branch_map.setdefault( + branch_path, + {"branch_name": entry["branch"], "active": [], "closed": []} + ) + branch_entry = {k: v for k, v in entry.items() if k != "branch_path" and v is not None} + if entry["status"] == "closed": + branch_bucket["closed"].append(branch_entry) + else: + branch_bucket["active"].append(branch_entry) + + return active_entries, closed_entries, branch_map + + +def _write_central_summary_json(active_entries: list, closed_entries: list) -> bool: + """ + Persist aggregated plan data to CLAUDE.json (global file). + + Args: + active_entries: List of active plan entries + closed_entries: List of closed plan entries + + Returns: + True if successful, False otherwise + """ + payload = { + "generated_at": datetime.now(timezone.utc).isoformat(), + "active_plans": active_entries, + "recently_closed": closed_entries[-5:], + "statistics": { + "active_count": len(active_entries), + "total_closed": len(closed_entries), + "recently_closed_included": min(len(closed_entries), 5) + } + } + + try: + with open(CLAUDE_JSON_FILE, 'w', encoding='utf-8') as f: + json.dump(payload, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def _format_plan_lines(entries: list, default_message: str) -> list: + """ + Format plan entries as markdown bullet lines. + + Args: + entries: List of plan entry dicts + default_message: Message to show if list is empty + + Returns: + List of markdown-formatted lines + """ + if not entries: + return [default_message] + + lines = [] + for entry in entries: + plan_id = entry["plan"] + icon = "✅" if entry["status"] == "closed" else ("⚪" if entry.get("is_empty") else "🟢") + + # Construct proper link with filename (not just directory) + relative_path = entry.get("branch_relative_path") or entry.get("relative_path") + if relative_path: + # Use relative path + plan_id.md for clean links + link_target = f"{relative_path}/{plan_id}.md" + else: + link_target = entry.get("file_path") + + if link_target: + plan_link = f"[{plan_id}]({link_target})" + else: + plan_link = plan_id + + lines.append(f"- {plan_link} ({entry.get('branch', 'unknown')}) {icon}") + lines.append(f" {entry.get('summary', '')}") + return lines + + +def _write_branch_local_files(branch_map: Dict[Path, Dict[str, Any]]) -> bool: + """ + Write CLAUDE.local.md files for each branch (local files). + + Args: + branch_map: Dict of branch_path -> {branch_name, active, closed} + + Returns: + True if all writes successful, False if any failed + """ + all_success = True + + for branch_path, data in branch_map.items(): + branch_name = data.get("branch_name") or branch_path.name + file_path = branch_path / "CLAUDE.local.md" + + lines = [ + "⚠️ WARNING: This file is automatically updated by the flow system. Manual edits will be overwritten.", + "", + f"## Plan Summaries — {branch_name}", + "" + ] + + lines.append("Active Plans:") + lines.extend(_format_plan_lines(data.get("active", []), "- None")) + lines.append("") + + lines.append("Recently Closed:") + recent_closed = data.get("closed", [])[-5:] + lines.extend(_format_plan_lines(recent_closed, "- None")) + lines.append("") + + content = "\n".join(lines).rstrip() + "\n" + + try: + branch_path.mkdir(parents=True, exist_ok=True) + with open(file_path, 'w', encoding='utf-8') as f: + f.write(content) + except Exception: + all_success = False + + return all_success + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def write_plan_outputs(summaries: Dict[str, Any], hide_empty: bool = True) -> bool: + """ + Write centralized JSON and branch-local markdown outputs. + + This is the main function that orchestrates writing to both: + - CLAUDE.json (global system-wide file) + - CLAUDE.local.md (per-branch files) + + Args: + summaries: Dict of plan_number -> plan_info + hide_empty: Whether to hide empty plans from output (default True) + + Returns: + True if all writes successful, False if any failed + + Example: + >>> summaries = { + ... "0001": { + ... "summary": "Task description", + ... "status": "open", + ... "file_path": "/home/aipass/aipass_core/flow/plans/FPLAN-0001.md", + ... "subject": "Flow restructuring", + ... "location": "flow", + ... "is_empty": False + ... } + ... } + >>> write_plan_outputs(summaries) + True + """ + # Filter empty plans if configured + filtered_summaries = {} + for plan_num, info in summaries.items(): + if hide_empty and info.get("is_empty") and info.get("status") != "closed": + continue + filtered_summaries[plan_num] = info + + # Build output sets + active_entries, closed_entries, branch_map = _build_plan_output_sets(filtered_summaries) + + # Write global and local files + global_success = _write_central_summary_json(active_entries, closed_entries) + local_success = _write_branch_local_files(branch_map) + + return global_success and local_success diff --git a/src/aipass/flow/apps/handlers/template/__init__.py b/src/aipass/flow/apps/handlers/template/__init__.py new file mode 100644 index 00000000..b0b0d96f --- /dev/null +++ b/src/aipass/flow/apps/handlers/template/__init__.py @@ -0,0 +1 @@ +"""Flow template handlers package""" diff --git a/src/aipass/flow/apps/handlers/template/get_template.py b/src/aipass/flow/apps/handlers/template/get_template.py new file mode 100644 index 00000000..5b696b15 --- /dev/null +++ b/src/aipass/flow/apps/handlers/template/get_template.py @@ -0,0 +1,148 @@ +#!/home/aipass/.venv/bin/python3 +# -*- coding: utf-8 -*- + +# ===================AIPASS==================== +# META DATA HEADER +# Name: get_template.py +# Date: 2025-11-30 +# Version: 1.2.0 +# Category: flow/handlers/template +# CODE STANDARDS: Seed v3.0 +# +# CHANGELOG: +# - v1.2.0 (2025-11-30): Changed TEMPLATES_DIR from .cached_templates to templates (Flow owns templates now) +# - v1.1.0 (2025-11-21): Removed Prax logging per 3-tier standard +# - v1.0.0 (2025-11-07): Extracted from flow_template_handler.py +# ============================================= + +""" +Get Template Handler + +Loads and formats PLAN templates from template directories. + +Features: +- Loads templates from /home/aipass/aipass_core/templates/flow/ +- Supports placeholder replacement ({number}, {subject}, {location}, {today}) +- Automatic fallback to default template +- Multi-directory search support +- Reusable across Flow modules + +Usage: + from aipass.flow.apps.handlers.template.get_template import get_template + + content = get_template("default", number=101, location="flow", subject="My Task") + content = get_template("master", number=102, location="flow/DOCUMENTS", subject="Big Project") +""" + +from pathlib import Path +from datetime import datetime +from typing import Optional + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "get_template" +FLOW_ROOT = _PKG_ROOT / "flow" +TEMPLATES_DIR = FLOW_ROOT / "templates" +DEFAULT_TEMPLATE = "default" + +# ============================================= +# HELPER FUNCTIONS +# ============================================= + +def _template_search_dirs() -> list[Path]: + """Determine the ordered list of directories to search for templates""" + return [TEMPLATES_DIR] + + +def _find_template_file(template_name: str) -> Path: + """ + Locate the requested template (or default fallback) across supported directories. + + Args: + template_name: Name of template (without .md extension) + + Returns: + Path to template file + + Raises: + FileNotFoundError: When neither the requested nor default template exists + """ + search_paths = _template_search_dirs() + + # Look for the requested template + candidate = search_paths[0] / f"{template_name}.md" + if candidate.exists(): + return candidate + + # Fallback to default template + default_candidate = search_paths[0] / f"{DEFAULT_TEMPLATE}.md" + if default_candidate.exists(): + return default_candidate + + # Nothing found – raise helpful error + searched = ", ".join(str(path) for path in search_paths) + error_msg = ( + f"Templates not found. Searched for '{template_name}.md' and " + f"'{DEFAULT_TEMPLATE}.md' in: {searched}" + ) + raise FileNotFoundError(error_msg) + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def get_template(template_name: str = "default", + number: int = 0, + location: str = "", + subject: str = "") -> str: + """ + Load and format a PLAN template from the configured template directories. + + Args: + template_name: Name of template file (without .md extension) + number: PLAN number for formatting + location: Plan location (relative path) + subject: Plan subject/title + + Returns: + Formatted template content with placeholders replaced + + Raises: + FileNotFoundError: If template not found + Exception: If template loading/formatting fails + + Examples: + >>> get_template("default", 101, "flow", "My Task") + # Returns default.md with {number}→101, {subject}→"My Task", etc. + + >>> get_template("master", 102, "flow/DOCUMENTS", "Big Project") + # Returns master.md with placeholders filled + """ + try: + # Resolve template file (with fallback handling across directories) + template_file = _find_template_file(template_name) + + # Read template file + with open(template_file, 'r', encoding='utf-8') as f: + template_content = f.read() + + # Get current date for {today} placeholder + today = datetime.now().strftime('%Y-%m-%d') + + # Format template with placeholders + formatted_content = template_content.format( + number=f"{number:04d}", # Format as 4-digit number (0001, 0042, 0101) + subject=subject, + location=location, + today=today + ) + + return formatted_content + + except Exception: + raise diff --git a/src/aipass/flow/apps/handlers/template/list_templates.py b/src/aipass/flow/apps/handlers/template/list_templates.py new file mode 100644 index 00000000..9907e90c --- /dev/null +++ b/src/aipass/flow/apps/handlers/template/list_templates.py @@ -0,0 +1,84 @@ +#!/home/aipass/.venv/bin/python3 +# -*- coding: utf-8 -*- + +# ===================AIPASS==================== +# META DATA HEADER +# Name: list_templates.py +# Date: 2025-11-07 +# Version: 1.1.0 +# Category: flow/handlers/template +# +# CHANGELOG: +# - v1.1.0 (2025-11-21): Removed Prax logging per 3-tier standard +# - v1.0.0 (2025-11-07): Extracted from flow_template_handler.py +# ============================================= + +""" +List Templates Handler + +Lists all available PLAN templates from template directories. + +Features: +- Scans /home/aipass/aipass_core/templates/flow/ +- Returns sorted list of template names +- Multi-directory support +- Reusable across Flow modules + +Usage: + from aipass.flow.apps.handlers.template.list_templates import list_templates + + templates = list_templates() + print(f"Available: {templates}") # ['default', 'master', ...] +""" + +from pathlib import Path + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[4] + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "list_templates" +TEMPLATES_DIR = _PKG_ROOT / "templates" / "flow" + +# ============================================= +# HELPER FUNCTIONS +# ============================================= + +def _template_search_dirs() -> list[Path]: + """Determine the ordered list of directories to search for templates""" + return [TEMPLATES_DIR] + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def list_templates() -> list[str]: + """ + List all available templates across the configured template directories. + + Returns: + List of template names (without .md extension), sorted alphabetically + + Example: + >>> list_templates() + ['default', 'master', 'api', 'webapp'] + """ + try: + template_names: set[str] = set() + search_dirs = _template_search_dirs() + + for base_dir in search_dirs: + if not base_dir.exists(): + continue + + for template_file in base_dir.glob("*.md"): + template_names.add(template_file.stem) + + sorted_templates = sorted(template_names) + return sorted_templates + + except Exception: + return [] diff --git a/src/aipass/flow/apps/json_templates/__init__.py b/src/aipass/flow/apps/json_templates/__init__.py new file mode 100644 index 00000000..5d00b535 --- /dev/null +++ b/src/aipass/flow/apps/json_templates/__init__.py @@ -0,0 +1 @@ +# JSON Templates package - Default JSON file templates diff --git a/src/aipass/flow/apps/json_templates/default/config.json b/src/aipass/flow/apps/json_templates/default/config.json new file mode 100644 index 00000000..d29d029f --- /dev/null +++ b/src/aipass/flow/apps/json_templates/default/config.json @@ -0,0 +1,9 @@ +{ + "module_name": "{{MODULE_NAME}}", + "version": "1.0.0", + "timestamp": "2025-11-13", + "config": { + "auto_save": true, + "enabled": true + } +} diff --git a/src/aipass/flow/apps/json_templates/default/data.json b/src/aipass/flow/apps/json_templates/default/data.json new file mode 100644 index 00000000..82912a72 --- /dev/null +++ b/src/aipass/flow/apps/json_templates/default/data.json @@ -0,0 +1,8 @@ +{ + "module_name": "{{MODULE_NAME}}", + "created": "2025-11-13", + "last_updated": "2025-11-13", + "operations_total": 0, + "operations_successful": 0, + "operations_failed": 0 +} diff --git a/src/aipass/flow/apps/json_templates/default/log.json b/src/aipass/flow/apps/json_templates/default/log.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/aipass/flow/apps/json_templates/default/log.json @@ -0,0 +1 @@ +[] diff --git a/src/aipass/flow/apps/modules/__init__.py b/src/aipass/flow/apps/modules/__init__.py old mode 100644 new mode 100755 index e69de29b..4cf76dce --- a/src/aipass/flow/apps/modules/__init__.py +++ b/src/aipass/flow/apps/modules/__init__.py @@ -0,0 +1 @@ +# Modules package - Branch-specific functionality modules diff --git a/src/aipass/flow/apps/modules/aggregate_central.py b/src/aipass/flow/apps/modules/aggregate_central.py new file mode 100755 index 00000000..edc27b84 --- /dev/null +++ b/src/aipass/flow/apps/modules/aggregate_central.py @@ -0,0 +1,560 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: aggregate_central.py +# Date: 2025-11-30 +# Version: 1.2.0 +# Category: flow/modules +# CODE STANDARDS: Seed v3.0 +# +# CHANGELOG: +# - v1.2.0 (2025-11-30): Comprehensive sync fix - update branch statistics, recently_closed, and global_statistics +# - v1.1.0 (2025-11-30): Fixed branch-level stale data - update branch_data["active_plans"] after validation +# - v1.0.0 (2025-11-21): Initial creation - self-healing central aggregator +# ============================================= + +""" +Aggregate Central Plans Module + +Self-healing central aggregator for PLANS.central.json that validates file +existence and rebuilds the active_plans list. + +Features: +- Validates all plans in branches.* sections have files on disk +- Auto-closes plans with missing files in their branch registries +- Aggregates active plans from all branches into top-level active_plans array +- Builds recently_closed array from all branches +- Updates statistics across all branches +- Preserves unknown branch sections + +Algorithm: +1. Load PLANS.central.json +2. For each branch section: + - For each plan in active_plans: + - Check if file_path exists on disk + - If missing: find branch registry, mark plan closed, save registry +3. Rebuild top-level active_plans from all valid branch plans +4. Rebuild recently_closed from all branches (last 5) +5. Update statistics +6. Save PLANS.central.json + +Usage: + from aipass.flow.apps.modules.aggregate_central import aggregate_central + success = aggregate_central() + +Standalone: + python3 apps/modules/aggregate_central.py aggregate + python3 apps/modules/aggregate_central.py aggregate --heal +""" + +import json +import sys +from pathlib import Path +from datetime import datetime, timezone +from typing import Dict, Any, List, Tuple, Optional + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/ +FLOW_ROOT = _PKG_ROOT / "flow" + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "aggregate_central" +AI_CENTRAL_DIR = Path.home() / "aipass_os" / "AI_CENTRAL" +CENTRAL_FILE = AI_CENTRAL_DIR / "PLANS.central.json" + +# ============================================= +# HELPER FUNCTIONS +# ============================================= + +def _find_branch_registry(branch_path: Path, branch_name: str) -> Optional[Path]: + """Find the registry file for a branch + + Args: + branch_path: Path to the branch directory + branch_name: Name of the branch + + Returns: + Path to registry file if found, None otherwise + + Checks common patterns: + - {branch_path}/flow_json/{branch}_registry.json + - {branch_path}/{branch}_json/{branch}_registry.json + - {branch_path}/registry.json + """ + if not branch_path.exists(): + return None + + # Pattern 1: flow_json/flow_registry.json + candidate = branch_path / "flow_json" / f"{branch_name}_registry.json" + if candidate.exists(): + return candidate + + # Pattern 2: branch_json/branch_registry.json + candidate = branch_path / f"{branch_name}_json" / f"{branch_name}_registry.json" + if candidate.exists(): + return candidate + + # Pattern 3: registry.json + candidate = branch_path / "registry.json" + if candidate.exists(): + return candidate + + return None + + +def _load_branch_registry(registry_path: Path) -> Dict[str, Any]: + """Load a branch registry file + + Args: + registry_path: Path to the registry file + + Returns: + Registry dict or empty structure on error + """ + try: + with open(registry_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.error(f"[{MODULE_NAME}] Failed to load registry {registry_path}: {e}") + return {"plans": {}, "next_number": 1} + + +def _save_branch_registry(registry_path: Path, registry: Dict[str, Any]) -> bool: + """Save a branch registry file + + Args: + registry_path: Path to the registry file + registry: Registry data to save + + Returns: + True on success, False on failure + """ + try: + registry["last_updated"] = datetime.now(timezone.utc).isoformat() + with open(registry_path, 'w', encoding='utf-8') as f: + json.dump(registry, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + logger.error(f"[{MODULE_NAME}] Failed to save registry {registry_path}: {e}") + return False + + +def _extract_plan_number(plan_id: str) -> Optional[str]: + """Extract plan number from plan_id (e.g., 'FPLAN-0148' -> '0148') + + Args: + plan_id: Plan ID string (e.g., 'FPLAN-0148') + + Returns: + Plan number string or None if invalid format + """ + if not plan_id or not plan_id.startswith("FPLAN-"): + return None + return plan_id[6:] # Skip 'FPLAN-' prefix + + +def _auto_close_plan(registry_path: Path, plan_id: str, branch_name: str) -> bool: + """Auto-close a plan in its branch registry + + Args: + registry_path: Path to the branch registry + plan_id: Plan ID (e.g., 'PLAN0148') + branch_name: Name of the branch for logging + + Returns: + True if plan was closed, False otherwise + """ + plan_num = _extract_plan_number(plan_id) + if not plan_num: + logger.warning(f"[{MODULE_NAME}] Invalid plan_id format: {plan_id}") + return False + + # Load registry + registry = _load_branch_registry(registry_path) + plans = registry.get("plans", {}) + + # Check if plan exists in registry + if plan_num not in plans: + logger.warning(f"[{MODULE_NAME}] {plan_id} not found in registry {registry_path}") + return False + + plan_info = plans[plan_num] + + # Skip if already closed + if plan_info.get("status") == "closed": + logger.info(f"[{MODULE_NAME}] {plan_id} already closed in {branch_name}") + return False + + # Close the plan + plan_info["status"] = "closed" + plan_info["closed"] = datetime.now(timezone.utc).isoformat() + plan_info["closed_reason"] = "auto_closed_missing_file" + + # Save registry + if _save_branch_registry(registry_path, registry): + logger.info(f"[{MODULE_NAME}] SUCCESS: Auto-closed {plan_id} in {branch_name} - file not found") + return True + else: + logger.error(f"[{MODULE_NAME}] Failed to save registry after closing {plan_id}") + return False + + +def _validate_and_heal_branch(branch_name: str, branch_data: Dict[str, Any], + heal: bool = True) -> Tuple[List[Dict], List[Dict]]: + """Validate plans in a branch and heal missing files + + Args: + branch_name: Name of the branch + branch_data: Branch data from PLANS.central.json + heal: If True, auto-close plans with missing files + + Returns: + Tuple of (valid_active_plans, all_closed_plans) + """ + branch_path = Path(branch_data.get("branch_path", "")) + active_plans = branch_data.get("active_plans", []) + recently_closed = branch_data.get("recently_closed", []) + + valid_active = [] + healed_plans = [] + + # Find branch registry for healing + registry_path = None + if heal and branch_path.exists(): + registry_path = _find_branch_registry(branch_path, branch_name) + + # Validate active plans + for plan in active_plans: + file_path = Path(plan.get("file_path", "")) + plan_id = plan.get("plan_id", plan.get("plan", "")) + + if file_path.exists(): + valid_active.append(plan) + else: + logger.info(f"[{MODULE_NAME}] Missing file for {plan_id} in {branch_name}: {file_path}") + + # Attempt to heal + if heal and registry_path: + if _auto_close_plan(registry_path, plan_id, branch_name): + # Add to healed plans for recently_closed + healed_plan = plan.copy() + healed_plan["status"] = "closed" + healed_plan["closed"] = datetime.now(timezone.utc).isoformat() + healed_plan["closed_reason"] = "auto_closed_missing_file" + healed_plans.append(healed_plan) + else: + if heal: + logger.warning(f"[{MODULE_NAME}] Cannot heal {plan_id} - registry not found for {branch_name}") + + # Combine recently_closed with healed plans + all_closed = recently_closed + healed_plans + + return valid_active, all_closed + + +def _load_central() -> Dict[str, Any]: + """Load PLANS.central.json + + Returns: + Central data or empty structure if file doesn't exist + """ + if not CENTRAL_FILE.exists(): + return { + "generated_at": "", + "active_plans": [], + "recently_closed": [], + "statistics": { + "active_count": 0, + "total_closed": 0, + "recently_closed_included": 0 + }, + "branches": {}, + "global_statistics": { + "total_active": 0, + "total_closed": 0, + "branches_reporting": 0 + } + } + + try: + with open(CENTRAL_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.error(f"[{MODULE_NAME}] Failed to load {CENTRAL_FILE}: {e}") + return { + "generated_at": "", + "active_plans": [], + "recently_closed": [], + "statistics": { + "active_count": 0, + "total_closed": 0, + "recently_closed_included": 0 + }, + "branches": {}, + "global_statistics": { + "total_active": 0, + "total_closed": 0, + "branches_reporting": 0 + } + } + + +def _save_central(central_data: Dict[str, Any]) -> bool: + """Save PLANS.central.json + + Args: + central_data: Central data to save + + Returns: + True on success, False on failure + """ + try: + AI_CENTRAL_DIR.mkdir(parents=True, exist_ok=True) + with open(CENTRAL_FILE, 'w', encoding='utf-8') as f: + json.dump(central_data, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + logger.error(f"[{MODULE_NAME}] Failed to save {CENTRAL_FILE}: {e}") + return False + + +# ============================================= +# MAIN AGGREGATION FUNCTION +# ============================================= + +def aggregate_central(heal: bool = True) -> bool: + """Aggregate and validate central plans + + Algorithm: + 1. Load PLANS.central.json + 2. For each branch in branches.*: + - Validate all active_plans have files on disk + - If heal=True and file missing: auto-close in branch registry + - Collect valid active plans and all closed plans + 3. Rebuild top-level active_plans (sorted by created, newest first) + 4. Rebuild top-level recently_closed (last 5, sorted by closed, newest first) + 5. Update statistics + 6. Save PLANS.central.json + + Args: + heal: If True, auto-close plans with missing files in their registries + + Returns: + True on success, False on failure + """ + try: + logger.info(f"[{MODULE_NAME}] Starting central aggregation") + + # Load central file + central_data = _load_central() + branches = central_data.get("branches", {}) + + if not branches: + logger.info(f"[{MODULE_NAME}] No branches found in PLANS.central.json") + return True + + # Track all active and closed plans across branches + all_active = [] + all_closed = [] + + # Process each branch + for branch_name, branch_data in branches.items(): + logger.info(f"[{MODULE_NAME}] Processing branch: {branch_name}") + + # Validate and heal + valid_active, closed_plans = _validate_and_heal_branch( + branch_name, branch_data, heal + ) + + # Update branch-level active_plans with validated list + branch_data["active_plans"] = valid_active + + # Update branch-level recently_closed (sorted, newest first) + branch_recently_closed = sorted( + closed_plans, + key=lambda x: x.get("closed", ""), + reverse=True + )[:5] + branch_data["recently_closed"] = branch_recently_closed + + # Update branch-level statistics to match validated arrays + branch_data["statistics"] = { + "active_count": len(valid_active), + "total_closed": len(closed_plans), + "recently_closed_included": len(branch_recently_closed) + } + + # Add branch name to each plan for identification + for plan in valid_active: + if "branch" not in plan: + plan["branch"] = branch_name + all_active.append(plan) + + for plan in closed_plans: + if "branch" not in plan: + plan["branch"] = branch_name + all_closed.append(plan) + + # Sort active by created date (newest first) + all_active.sort( + key=lambda x: x.get("created", ""), + reverse=True + ) + + # Sort closed by closed date (newest first) and limit to last 5 + all_closed.sort( + key=lambda x: x.get("closed", ""), + reverse=True + ) + recently_closed = all_closed[:5] + + # Update top-level arrays + central_data["active_plans"] = all_active + central_data["recently_closed"] = recently_closed + + # Update top-level statistics + central_data["statistics"] = { + "active_count": len(all_active), + "total_closed": len(all_closed), + "recently_closed_included": len(recently_closed) + } + + # Update global_statistics (aggregated from all branches) + central_data["global_statistics"] = { + "total_active": len(all_active), + "total_closed": len(all_closed), + "branches_reporting": len([ + b for b in branches.values() + if b.get("active_plans") or b.get("recently_closed") + ]) + } + + # Update generated_at timestamp + central_data["generated_at"] = datetime.now(timezone.utc).isoformat() + + # Save central file + if _save_central(central_data): + logger.info(f"[{MODULE_NAME}] SUCCESS: Aggregation complete: {len(all_active)} active, {len(recently_closed)} recently closed") + + # Fire trigger event + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire('central_aggregated', + active_count=len(all_active), + closed_count=len(recently_closed), + branches_count=len(branches)) + except ImportError: + pass + + return True + else: + logger.error(f"[{MODULE_NAME}] Failed to save central file") + return False + + except Exception as e: + logger.error(f"[{MODULE_NAME}] Aggregation failed: {e}") + return False + + +# ============================================= +# DISPLAY FUNCTIONS +# ============================================= + +def print_help(): + """Print help information for aggregate_central module""" + console.print() + console.print("[bold cyan]aggregate_central - Central Plans Aggregator[/bold cyan]") + console.print() + console.print("[yellow]DESCRIPTION:[/yellow]") + console.print(" Self-healing central aggregator for PLANS.central.json that validates") + console.print(" file existence and rebuilds the active_plans list.") + console.print() + console.print("[yellow]FEATURES:[/yellow]") + console.print(" • Validates all plans in branches have files on disk") + console.print(" • Auto-closes plans with missing files in their branch registries") + console.print(" • Aggregates active plans from all branches into central list") + console.print(" • Builds recently_closed array from all branches") + console.print(" • Updates statistics across all branches") + console.print() + console.print("[yellow]USAGE:[/yellow]") + console.print(" python3 aggregate_central.py aggregate [options]") + console.print(" python3 aggregate_central.py --help") + console.print() + console.print("[yellow]OPTIONS:[/yellow]") + console.print(" --heal Enable auto-closing of missing plans (default)") + console.print(" --no-heal Disable auto-closing (validation only)") + console.print() + console.print("[yellow]EXAMPLES:[/yellow]") + console.print(" # Aggregate with healing (default)") + console.print(" python3 aggregate_central.py aggregate") + console.print() + console.print(" # Aggregate with explicit healing") + console.print(" python3 aggregate_central.py aggregate --heal") + console.print() + console.print(" # Aggregate without healing (validation only)") + console.print(" python3 aggregate_central.py aggregate --no-heal") + console.print() + + +# ============================================= +# COMMAND INTERFACE +# ============================================= + +def handle_command(command: str, args: List[str]) -> bool: + """Handle module commands + + Args: + command: Command name ('aggregate' or 'aggregate_central') + args: Additional arguments (e.g., ['--heal']) + + Returns: + True if command handled successfully + """ + if command == "aggregate": + # Check for --heal flag (default is True) + heal = True + if "--no-heal" in args: + heal = False + + return aggregate_central(heal=heal) + + return False + + +# ============================================= +# MAIN ENTRY POINT +# ============================================= + +def main(): + """Main entry point for standalone execution""" + # Handle help flag + if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + if len(sys.argv) < 2: + console.print(f"Usage: python3 {sys.argv[0]} [options]") + console.print("Commands:") + console.print(" aggregate - Aggregate central plans (with healing)") + console.print(" aggregate --heal - Aggregate with explicit healing") + console.print(" aggregate --no-heal - Aggregate without healing") + console.print() + console.print("Run with --help for detailed information") + sys.exit(1) + + command = sys.argv[1] + args = sys.argv[2:] + + success = handle_command(command, args) + sys.exit(0 if success else 1) + + +if __name__ == "__main__": + main() diff --git a/src/aipass/flow/apps/modules/close_plan.py b/src/aipass/flow/apps/modules/close_plan.py new file mode 100644 index 00000000..c13fd220 --- /dev/null +++ b/src/aipass/flow/apps/modules/close_plan.py @@ -0,0 +1,549 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: close_plan.py - PLAN closure module with registry cleanup +# Date: 2025-11-25 +# Version: 3.4.0 +# Category: flow/modules +# +# CHANGELOG (Max 5 entries): +# - v3.4.0 (2026-02-25): FIX orphaned .md files - idempotency check now cleans up stranded files on re-close +# - v3.3.0 (2026-02-14): FIX race condition - close_all spawns ONE background process instead of N +# - v3.2.0 (2026-02-14): Auto-confirm by default, step-by-step progress, per-step error handling (Seed standards) +# - v3.1.0 (2026-02-14): FIX timeout - summary/archive now truly async via subprocess (was synchronous despite comments) +# - v3.0.0 (2026-02-14): DECOUPLE close from archive - close always succeeds, archive is non-blocking +# - v2.4.0 (2026-01-30): FIX close_all EOF error - handle non-interactive stdin in bulk close +# - v2.3.0 (2025-11-25): RE-ADDED template deletion - empty templates now deleted instead of archived +# +# CODE STANDARDS: +# - Seed v3.0 compliant (imports, architecture, error handling) +# ============================================== + +""" +Close PLAN Module + +Thin orchestrator for plan closure workflow. +All business logic delegated to handlers. + +Usage: + From flow.py: flow close + From flow.py: flow close --all + Standalone: python3 close_plan.py +""" + +import sys +import subprocess +from pathlib import Path +from typing import List +from datetime import datetime, timezone + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/ +FLOW_ROOT = _PKG_ROOT / "flow" + +# External: Prax logger +from aipass.prax.apps.modules.logger import system_logger as logger + +# JSON handler for operation tracking +from aipass.flow.apps.handlers.json import json_handler + +# CLI services for display and error handling +from aipass.cli.apps.modules import console + +# Internal: Registry handlers +from aipass.flow.apps.handlers.registry.load_registry import load_registry +from aipass.flow.apps.handlers.registry.save_registry import save_registry + +# Internal: Plan handlers +from aipass.flow.apps.handlers.plan.get_open_plans import get_open_plans +from aipass.flow.apps.handlers.plan.validator import normalize_plan_number, validate_plan_exists +from aipass.flow.apps.handlers.plan.confirmation import confirm_plan_deletion +from aipass.flow.apps.handlers.plan.display import ( + format_plan_deletion_header, + format_plan_error, + format_plan_deletion_success, + format_deletion_cancelled, + format_delete_usage_error +) + +# Internal: Dashboard handlers +from aipass.flow.apps.handlers.dashboard.update_local import update_dashboard_local +from aipass.flow.apps.handlers.dashboard.push_central import push_to_plans_central +from aipass.flow.apps.handlers.dashboard.push_branch_dashboard import push_flow_to_branch_dashboard + + +# Internal: Memory bank template check (lightweight, no API calls) +from aipass.flow.apps.handlers.mbank.process import is_template_content + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "close_plan" + + +# ============================================= +# INTROSPECTION +# ============================================= + +def print_introspection(): + """Display module info and connected handlers""" + console.print() + console.print("[bold cyan]close_plan Module[/bold cyan]") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + console.print(" [cyan]handlers/plan/[/cyan]") + console.print(" [dim]- get_open_plans.py[/dim]") + console.print(" [dim]- command_parser.py[/dim]") + console.print(" [dim]- confirmation.py[/dim]") + console.print(" [dim]- validator.py[/dim]") + console.print(" [dim]- display.py[/dim]") + console.print(" [dim]- file_ops.py[/dim]") + console.print(" [dim]- update_registry.py[/dim]") + console.print() + + console.print(" [cyan]handlers/registry/[/cyan]") + console.print(" [dim]- load_registry.py[/dim]") + console.print(" [dim]- save_registry.py[/dim]") + console.print() + + console.print(" [cyan]handlers/dashboard/[/cyan]") + console.print(" [dim]- update_local.py[/dim]") + console.print(" [dim]- push_central.py[/dim]") + console.print() + + console.print("[dim]Run 'python3 close_plan.py --help' for usage[/dim]") + console.print() + +# ============================================= +# HELPERS +# ============================================= + +def _spawn_background_runner(): + """Spawn post_close_runner.py as a fully detached background process""" + bg_runner = FLOW_ROOT / "apps" / "modules" / "post_close_runner.py" + subprocess.Popen( + [sys.executable, str(bg_runner)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True + ) + + +# ============================================= +# CLOSE PLAN WORKFLOW +# ============================================= + +def close_plan(plan_num: str | None = None, confirm: bool = False, all_plans: bool = False, spawn_background: bool = True) -> bool: + """ + Orchestrate plan closure workflow (thin orchestrator) + + Auto-confirms by default - running 'close' IS the intent. + Use confirm=True (--confirm/--interactive) to explicitly request a prompt. + + Delegates all business logic to handlers: + - Validation: validator handler + - Registry ops: registry handlers + - File ops: file_ops handler + - Confirmation: confirmation handler + - Display: display handler + + Args: + plan_num: Plan number (e.g., "0001" or "1" or "42") - required if all_plans=False + confirm: Whether to ask for confirmation (default False, auto-confirms) + all_plans: If True, close all open plans (default False) + spawn_background: Whether to spawn background post-processing (default True). + Set False when called from close_all_plans() to avoid race condition. + + Returns: + True if successful, False otherwise + """ + # Handle --all flag + if all_plans: + return close_all_plans(confirm) + + # Single plan closure + if not plan_num: + logger.warning(f"[{MODULE_NAME}] Plan number required for single plan closure") + console.print(format_plan_error("invalid_number", "")) + return False + + try: + # --- Internal validation (fast, no progress display) --- + + # 1. VALIDATE: Normalize plan number (handler) + plan_key = normalize_plan_number(plan_num) + + # 2. LOAD DATA: Get registry (service) + registry = load_registry() + + # 3. VALIDATE: Check plan exists (handler) + exists, error_msg = validate_plan_exists(plan_key, registry) + if not exists: + logger.warning(f"[{MODULE_NAME}] {error_msg}") + console.print(format_plan_error("not_found", plan_key)) + return False + + plan_info = registry["plans"][plan_key] + plan_file = Path(plan_info.get("file_path", "")) + + # 4. IDEMPOTENCY CHECK: Prevent double-closing (with orphan cleanup) + if plan_info['status'] == 'closed': + closed_date = plan_info.get('closed', 'unknown') + + # Check if .md file is orphaned on disk (registry-closed but file never moved) + if plan_file.exists(): + console.print(f"[yellow]FPLAN-{plan_key} already closed on {closed_date} — orphaned .md file detected[/yellow]") + console.print(f"[dim] Cleaning up: moving {plan_file.name} to processed_plans/[/dim]") + try: + from aipass.flow.apps.handlers.mbank.process import archive_plan + if archive_plan(plan_file): + logger.info(f"[{MODULE_NAME}] Cleaned up orphaned file for FPLAN-{plan_key}: {plan_file}") + console.print(f"[green] Orphaned file archived successfully[/green]") + else: + logger.warning(f"[{MODULE_NAME}] Failed to archive orphaned file for FPLAN-{plan_key}: {plan_file}") + console.print(f"[red] Failed to move orphaned file — manual cleanup required[/red]") + except Exception as e: + logger.warning(f"[{MODULE_NAME}] Error cleaning orphaned file for FPLAN-{plan_key}: {e}") + console.print(f"[red] Error during cleanup: {e}[/red]") + return True + + console.print(f"[yellow]FPLAN-{plan_key} already closed on {closed_date}[/yellow]") + console.print("[dim]Nothing to do - plan is already archived[/dim]") + return False + + # --- Step 1/5: Template check (may fast-delete) --- + console.print(f"[dim][1/5][/dim] Checking template status...") + try: + with open(plan_file, 'r', encoding='utf-8') as f: + content = f.read() + + if is_template_content(content): + console.print(f"[yellow] FPLAN-{plan_key} is empty template - fast-deleting (not archiving)[/yellow]") + + # Delete the file + plan_file.unlink() + logger.info(f"[{MODULE_NAME}] Deleted empty template file: {plan_file}") + + # Remove from registry + del registry["plans"][plan_key] + save_registry(registry) + logger.info(f"[{MODULE_NAME}] Removed FPLAN-{plan_key} from registry") + + console.print(f"[green] Empty template deleted - FPLAN-{plan_key} removed from system[/green]") + return True + + except FileNotFoundError as e: + logger.warning(f"[{MODULE_NAME}] Template check - file not found: {e}") + console.print(f"[yellow] Plan file not found, continuing with registry close[/yellow]") + except Exception as e: + logger.warning(f"[{MODULE_NAME}] Template check failed: {e}") + console.print(f"[yellow] Could not check template status, continuing with normal close[/yellow]") + + # DISPLAY: Show plan info header (handler) + console.print(format_plan_deletion_header(plan_key, plan_info)) + + # CONFIRM: Ask user only if explicitly requested (--confirm/--interactive) + if confirm: + if not confirm_plan_deletion(plan_key): + console.print(format_deletion_cancelled()) + logger.info(f"[{MODULE_NAME}] Closure cancelled by user for PLAN{plan_key}") + return False + + # --- Step 2/5: Mark as closed --- + console.print(f"[dim][2/5][/dim] Marking plan as closed...") + try: + # CRITICAL: Close ALWAYS succeeds from this point. Archive is non-blocking. + plan_info['status'] = 'closed' + plan_info['closed'] = datetime.now(timezone.utc).isoformat() + save_registry(registry) + logger.info(f"[{MODULE_NAME}] Marked FPLAN-{plan_key} as closed") + except Exception as e: + logger.error(f"[{MODULE_NAME}] Failed to mark plan as closed: {e}") + console.print(f"[red] Failed to update registry: {e}[/red]") + return False + + # --- Step 3/5: Background processing --- + if spawn_background: + console.print(f"[dim][3/5][/dim] Starting background processing...") + try: + _spawn_background_runner() + logger.info(f"[{MODULE_NAME}] Spawned background post-processing for FPLAN-{plan_key}") + console.print(f"[dim] Summary generation and archival running in background[/dim]") + except FileNotFoundError as e: + logger.warning(f"[{MODULE_NAME}] Background runner not found: {e}") + console.print(f"[yellow] Background runner not found - will retry on next close[/yellow]") + except Exception as e: + logger.warning(f"[{MODULE_NAME}] Failed to spawn background post-processing: {e}") + console.print(f"[yellow] Background archival failed to start - will retry on next close[/yellow]") + else: + console.print(f"[dim][3/5][/dim] Background processing deferred (batch mode)") + + # --- Step 4/5: Update dashboards --- + console.print(f"[dim][4/5][/dim] Updating dashboards...") + try: + dashboard_success = update_dashboard_local() + central_success = push_to_plans_central() + + # Log dashboard update results (3-tier: modules log, handlers don't) + if not dashboard_success: + logger.warning(f"[{MODULE_NAME}] Failed to update DASHBOARD.local.json") + if not central_success: + logger.warning(f"[{MODULE_NAME}] Failed to update PLANS.central.json") + + # Push flow section to branch's dashboard via write-through + plan_location = plan_info.get("location", "") + if plan_location: + branch_dashboard_success = push_flow_to_branch_dashboard(Path(plan_location)) + if not branch_dashboard_success: + logger.warning(f"[{MODULE_NAME}] Failed to push flow section to branch dashboard at {plan_location}") + except Exception as e: + logger.warning(f"[{MODULE_NAME}] Dashboard update error: {e}") + console.print(f"[yellow] Dashboard update failed (non-critical): {e}[/yellow]") + + # --- Step 5/5: Done --- + console.print(f"[dim][5/5][/dim] Finalizing...") + console.print(format_plan_deletion_success(plan_key)) + + # Append to branch's CLOSED_PLANS.local.json + try: + from aipass.flow.apps.handlers.plan.append_closed_plan import append_to_closed_plans + append_to_closed_plans(plan_key, plan_info, plan_file.parent) + except Exception as e: + logger.warning(f"[{MODULE_NAME}] CLOSED_PLANS update failed (non-critical): {e}") + + # Fire trigger event for plan closure (optional - trigger module may not be available) + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire('plan_closed', plan_number=plan_key, location=str(plan_file.parent)) + except ImportError: + logger.info(f"[{MODULE_NAME}] Trigger module not available, skipping event fire") + except Exception as e: + logger.warning(f"[{MODULE_NAME}] Trigger fire failed (non-critical): {e}") + + return True + + except ValueError as e: + logger.warning(f"[{MODULE_NAME}] Invalid plan number: {plan_num}: {e}") + console.print(format_plan_error("invalid_number", plan_num)) + return False + + except Exception as e: + logger.error(f"[{MODULE_NAME}] Unexpected error closing plan: {e}") + console.print(format_plan_error("general", details=str(e))) + return False + + +def close_all_plans(confirm: bool = False) -> bool: + """ + Close all open plans in one operation + + Args: + confirm: Whether to ask for bulk confirmation (default False, auto-confirms) + + Returns: + True if at least one plan closed successfully, False otherwise + """ + try: + # Get all open plans (handler) + open_plans = get_open_plans() + + if not open_plans: + console.print("\n[yellow]No open plans to close[/yellow]\n") + logger.info(f"[{MODULE_NAME}] close_all: No open plans found") + return False + + # Show what will be closed + console.print(f"\n[bold yellow]Found {len(open_plans)} open plan(s) to close:[/bold yellow]") + for plan_num, plan_info in open_plans: + subject = plan_info.get("subject", "No subject") + console.print(f" • FPLAN-{plan_num}: {subject}") + + # Confirm bulk close + if confirm: + console.print(f"\n[bold red]WARNING: This will close all {len(open_plans)} plans![/bold red]") + + # Auto-confirm in non-interactive environments (autonomous workflows) + if not sys.stdin.isatty(): + console.print("[dim]Non-interactive mode: auto-confirming[/dim]") + response = "yes" + else: + try: + response = input("Type 'yes' to confirm: ").strip().lower() + except EOFError: + # Handle EOF when stdin is not available + console.print("[dim]EOF detected: auto-confirming[/dim]") + response = "yes" + + if response != "yes": + console.print("\n[yellow]Close all cancelled[/yellow]\n") + logger.info(f"[{MODULE_NAME}] close_all cancelled by user") + return False + + console.print(f"\n[bold]Closing all {len(open_plans)} plan(s)...[/bold]") + console.print("─" * 60) + + # Close each plan + success_count = 0 + failure_count = 0 + + for plan_num, plan_info in open_plans: + console.print(f"\n[dim]Closing FPLAN-{plan_num}...[/dim]") + + # Call close_plan with spawn_background=False to avoid race condition + success = close_plan(plan_num=plan_num, confirm=False, all_plans=False, spawn_background=False) + + if success: + success_count += 1 + else: + failure_count += 1 + + # Spawn ONE background process for all closed plans + if success_count > 0: + try: + _spawn_background_runner() + logger.info(f"[{MODULE_NAME}] Spawned single background process for {success_count} closed plan(s)") + console.print(f"\n[dim]Background processing started for {success_count} plan(s)[/dim]") + except Exception as e: + logger.warning(f"[{MODULE_NAME}] Failed to spawn background post-processing: {e}") + console.print(f"\n[yellow]Background processing failed to start - will retry on next close[/yellow]") + + # Summary + console.print("\n" + "═" * 60) + console.print("[bold green]CLOSE ALL COMPLETE[/bold green]") + console.print(f" • Successfully closed: {success_count}") + console.print(f" • Failed to close: {failure_count}") + console.print(f" • Total processed: {len(open_plans)}") + console.print("═" * 60 + "\n") + + logger.info(f"[{MODULE_NAME}] close_all completed: {success_count} success, {failure_count} failures") + return success_count > 0 + + except Exception as e: + error_msg = f"Error in close_all: {e}" + logger.error(f"[{MODULE_NAME}] {error_msg}") + console.print(f"\n[bold red]ERROR:[/bold red] {error_msg}\n") + return False + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle command routing for close_plan module (thin orchestrator) + + Delegates to handlers: + - Argument parsing: command_parser handler + - Workflow execution: close_plan orchestrator + - Error display: display handler + + Args: + command: Command name + args: Additional arguments + + Returns: + bool indicating success or failure + """ + # Check if this is our command + if command != "close": + return False + + # Import parser here (after command check) + from aipass.flow.apps.handlers.plan.command_parser import parse_close_command_args + + # Log the operation + json_handler.log_operation( + "plan_closed", + {"command": command, "args": args} + ) + + # 1. PARSE ARGS: Use command_parser handler + plan_num, confirm, all_plans, error = parse_close_command_args(args) + + # 2. VALIDATE: Check for parsing errors + if error: + console.print(format_delete_usage_error()) + return False + + # 3. EXECUTE: Run workflow orchestrator + success = close_plan(plan_num=plan_num, confirm=confirm, all_plans=all_plans) + + # 4. RETURN: Result (close_plan already handles all output) + return success + + +# ============================================= +# STANDALONE EXECUTION (for testing) +# ============================================= + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + # Handle help flag + if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + import argparse + PARSER = argparse.ArgumentParser( + description='Close PLAN file', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +COMMANDS: + close, close_plan Close a single plan + close --all Close all open plans + +USAGE: + python3 close_plan.py close + python3 close_plan.py close --confirm + python3 close_plan.py close --all + python3 close_plan.py --help + +OPTIONS: + --confirm, --interactive Request confirmation prompt (off by default) + --yes, -y Backwards compat (redundant, already auto-confirms) + --all Close all open plans + +EXAMPLES: + # Close plan (auto-confirms) + python3 close_plan.py close 42 + + # Close with interactive confirmation prompt + python3 close_plan.py close 42 --confirm + + # Close all open plans (auto-confirms) + python3 close_plan.py close --all + """ + ) + PARSER.print_help() + sys.exit(0) + + # Confirm logger connection + logger.info("Prax logger connected to close_plan") + + # Log standalone execution + json_handler.log_operation( + "plan_closed", + {"command": "standalone"} + ) + + # Call handle_command with default + args = sys.argv[1:] if len(sys.argv) > 1 else [] + if not args: + console.print(format_delete_usage_error()) + console.print("Run with --help for usage information") + console.print() + sys.exit(1) + + # If first arg is not command, assume it's plan number (backward compatibility) + if args[0] not in ['close', 'close_plan']: + args.insert(0, 'close') + + result = handle_command(args[0], args[1:]) + # Result is True on success, False on failure + if result: + sys.exit(0) + else: + sys.exit(1) diff --git a/src/aipass/flow/apps/modules/create_plan.py b/src/aipass/flow/apps/modules/create_plan.py new file mode 100755 index 00000000..45273fc5 --- /dev/null +++ b/src/aipass/flow/apps/modules/create_plan.py @@ -0,0 +1,375 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: create_plan.py - PLAN creation module with location awareness +# Date: 2025-11-16 +# Version: 1.0.0 +# Category: flow/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-16): Refactored from archive_temp, handler-based architecture +# +# CODE STANDARDS: +# - Seed v3.0 compliant (imports, architecture, error handling) +# ============================================== + +""" +Create PLAN Module - Thin Orchestrator + +Orchestrates plan creation workflow by delegating to handlers. +Module contains NO business logic - only workflow coordination. + +Workflow: + 1. Parse arguments → command_parser handler + 2. Load registry → registry handlers + 3. Auto-cleanup → plan handlers + 4. Validate location → plan handlers + 5. Calculate paths → plan handlers + 6. Get template → template handlers + 7. Create file → plan handlers + 8. Update registry → plan handlers + 9. Update dashboards → dashboard handlers + 10. Display results → display handlers + +Usage: + From flow.py: flow plan create [location] [subject] [template] + Standalone: python3 create_plan.py [location] [subject] [template] +""" + +import re +import sys +from datetime import datetime +from pathlib import Path +from typing import Tuple, List + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/ +FLOW_ROOT = _PKG_ROOT / "flow" + +# External: Prax logger +from aipass.prax.apps.modules.logger import system_logger as logger + +# JSON handler for operation tracking +from aipass.flow.apps.handlers.json import json_handler + +# CLI services for display +from aipass.cli.apps.modules import console + +# Registry handlers (cross-domain - OK for modules) +from aipass.flow.apps.handlers.registry.load_registry import load_registry +from aipass.flow.apps.handlers.registry.save_registry import save_registry + +# Template handlers (cross-domain - OK for modules) +from aipass.flow.apps.handlers.template.get_template import get_template + +# Plan handlers (same-domain) +from aipass.flow.apps.handlers.plan.command_parser import parse_create_plan_args +from aipass.flow.apps.handlers.plan.auto_cleanup import auto_close_orphaned_plans +from aipass.flow.apps.handlers.plan.resolve_location import resolve_plan_location +from aipass.flow.apps.handlers.plan.calculate_relative_path import calculate_relative_location +from aipass.flow.apps.handlers.plan.create_file import create_plan_file +from aipass.flow.apps.handlers.plan.build_registry_entry import build_plan_registry_entry +from aipass.flow.apps.handlers.plan.display import display_plan_created, display_plan_result + +# Dashboard handlers (cross-domain - OK for modules) +from aipass.flow.apps.handlers.dashboard.update_local import update_dashboard_local +from aipass.flow.apps.handlers.dashboard.push_central import push_to_plans_central +from aipass.flow.apps.handlers.dashboard.push_branch_dashboard import push_flow_to_branch_dashboard + + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "create_plan" +ECOSYSTEM_ROOT = _PKG_ROOT + +# ============================================= +# INTROSPECTION +# ============================================= + +def print_introspection(): + """Display module info and connected handlers""" + console.print() + console.print("[bold cyan]create_plan Module[/bold cyan]") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + console.print(" [cyan]handlers/plan/[/cyan]") + console.print(" [dim]- command_parser.py[/dim]") + console.print(" [dim]- auto_cleanup.py[/dim]") + console.print(" [dim]- resolve_location.py[/dim]") + console.print(" [dim]- calculate_relative_path.py[/dim]") + console.print(" [dim]- create_file.py[/dim]") + console.print(" [dim]- build_registry_entry.py[/dim]") + console.print(" [dim]- display.py[/dim]") + console.print() + + console.print(" [cyan]handlers/registry/[/cyan]") + console.print(" [dim]- load_registry.py[/dim]") + console.print(" [dim]- save_registry.py[/dim]") + console.print() + + console.print(" [cyan]handlers/template/[/cyan]") + console.print(" [dim]- get_template.py[/dim]") + console.print() + + console.print(" [cyan]handlers/dashboard/[/cyan]") + console.print(" [dim]- update_local.py[/dim]") + console.print(" [dim]- push_central.py[/dim]") + console.print() + + console.print("[dim]Run 'python3 create_plan.py --help' for usage[/dim]") + console.print() + +def print_help(): + """Print help information for create_plan module""" + console.print() + console.print("[bold cyan]create_plan.py[/bold cyan] - Create new PLAN file") + console.print() + console.print("[yellow]COMMANDS:[/yellow]") + console.print(" create, create_plan") + console.print() + console.print("[yellow]USAGE:[/yellow]") + console.print(" python3 create_plan.py create [location] [subject] [template]") + console.print(" python3 create_plan.py --help") + console.print() + console.print("[yellow]EXAMPLES:[/yellow]") + console.print(" [dim]# Create in current directory[/dim]") + console.print(" python3 create_plan.py create") + console.print() + console.print(" [dim]# Create with location and subject[/dim]") + console.print(" python3 create_plan.py create @flow \"New feature implementation\"") + console.print() + console.print(" [dim]# Create with custom template[/dim]") + console.print(" python3 create_plan.py create @flow \"Research task\" master") + console.print() + +# ============================================= +# HELPERS +# ============================================= + +def _slugify_subject(subject: str) -> str: + """Sanitize subject for filename: lowercase, underscores, max 40 chars.""" + slug = re.sub(r'[^\w\s-]', '', subject.lower()) + slug = re.sub(r'[\s-]+', '_', slug) + return slug.strip('_')[:40] + + +# ============================================= +# ORCHESTRATION WORKFLOWS (No business logic) +# ============================================= + +def create_plan( + location: str | None = None, + subject: str = "", + template_type: str = "default" +) -> Tuple[bool, int, str, str, str]: + """ + Orchestrate plan creation workflow + + THIN ORCHESTRATOR - delegates all business logic to handlers. + This function only coordinates the workflow and passes data between handlers. + + Workflow Steps: + 1. Load registry → registry handler + 2. Auto-cleanup → plan handler + 3. Resolve location → plan handler + 4. Calculate relative path → plan handler + 5. Get template → template handler + 6. Create file → plan handler + 7. Build registry entry → plan handler + 8. Update registry → registry handler + 9. Update dashboards → dashboard handlers + 10. Log and return results + + Args: + location: Target directory for plan (@folder syntax supported) + subject: Plan subject/title + template_type: Template to use (default, master, etc.) + + Returns: + (success, plan_number, location_description, template_type, error_message) + """ + try: + # STEP 1: Load registry + registry = load_registry() + + # STEP 2: Auto-cleanup orphaned plans + registry, auto_closed_count = auto_close_orphaned_plans(registry) + if auto_closed_count > 0: + save_registry(registry) + console.print(f"[dim][AUTO-CLEANUP] Closed {auto_closed_count} orphaned plan(s)[/dim]") + + # STEP 3: Get next plan number + NEXT_NUM = registry["next_number"] + + # STEP 4: Resolve location (@folder syntax support) + success, target_dir, error_msg = resolve_plan_location(location, ECOSYSTEM_ROOT) + if not success: + return False, 0, "", "", error_msg + + # STEP 5: Calculate relative path for display + RELATIVE_LOCATION = calculate_relative_location(target_dir, ECOSYSTEM_ROOT) + + # STEP 6: Build plan file path (FPLAN-XXXX_topic_slug_YYYY-MM-DD.md) + topic_slug = _slugify_subject(subject) + date_str = datetime.now().strftime("%Y-%m-%d") + if topic_slug: + PLAN_FILE = target_dir / f"FPLAN-{NEXT_NUM:04d}_{topic_slug}_{date_str}.md" + else: + PLAN_FILE = target_dir / f"FPLAN-{NEXT_NUM:04d}_{date_str}.md" + + # STEP 7: Get template content + try: + CONTENT = get_template( + template_type, + number=NEXT_NUM, + location=RELATIVE_LOCATION, + subject=subject + ) + except Exception as e: + error_msg = f"Failed to load template '{template_type}': {e}" + logger.error(f"[{MODULE_NAME}] {error_msg}") + return False, 0, "", "", error_msg + + # STEP 8: Create plan file + success, error_msg = create_plan_file(PLAN_FILE, CONTENT) + if not success: + return False, 0, "", "", error_msg + + # STEP 9: Build registry entry + if "plans" not in registry: + registry["plans"] = {} + + registry["plans"][f"{NEXT_NUM:04d}"] = build_plan_registry_entry( + NEXT_NUM, target_dir, RELATIVE_LOCATION, subject, PLAN_FILE, template_type + ) + registry["next_number"] = NEXT_NUM + 1 + + # STEP 10: Save updated registry + if not save_registry(registry): + error_msg = "Failed to save registry after plan creation" + logger.error(f"[{MODULE_NAME}] {error_msg}") + console.print(f"[yellow][WARNING] {error_msg}[/yellow]") + + # STEP 11: Update dashboards (3-tier: modules log, handlers don't) + dashboard_success = update_dashboard_local() + central_success = push_to_plans_central() + + # Log dashboard update results + if not dashboard_success: + logger.warning(f"[{MODULE_NAME}] Failed to update DASHBOARD.local.json") + if not central_success: + logger.warning(f"[{MODULE_NAME}] Failed to update PLANS.central.json") + + # STEP 11b: Push flow section to branch's dashboard via write-through + branch_dashboard_success = push_flow_to_branch_dashboard(target_dir) + if not branch_dashboard_success: + console.print(f"[dim]⚠ No branch dashboard at {target_dir} — no branch is tracking this plan[/dim]") + + # STEP 12: Log success + logger.info(f"[{MODULE_NAME}] Created FPLAN-{NEXT_NUM:04d} in {RELATIVE_LOCATION}") + + # Display success messages + display_msg = display_plan_created(NEXT_NUM, RELATIVE_LOCATION, subject, template_type) + console.print(display_msg) + + # Fire trigger event for plan creation + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire('plan_created', plan_number=NEXT_NUM, location=RELATIVE_LOCATION, subject=subject) + except ImportError: + pass # Trigger not available, silent fallback + + return True, NEXT_NUM, RELATIVE_LOCATION, template_type, "" + + except Exception as e: + error_msg = f"Error creating plan: {e}" + logger.error(f"[{MODULE_NAME}] {error_msg}") + return False, 0, "", "", error_msg + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle command routing for create_plan module + + THIN ORCHESTRATOR - delegates to handlers for all operations. + + Workflow: + 1. Parse arguments → command_parser handler + 2. Execute create_plan workflow + 3. Display results → display handler + + Args: + command: Command name + args: Additional arguments + + Returns: + bool indicating success or failure of command handling + """ + # Check if this is our command + if command != "create": + return False + + # Log the operation + json_handler.log_operation( + "plan_created", + {"command": command, "args": args} + ) + + # STEP 1: Parse arguments (delegate to handler) + location, subject, template_type = parse_create_plan_args(args) + + # STEP 2: Execute workflow + success, num, loc, tmpl, error = create_plan(location, subject, template_type) + + # STEP 3: Display results (delegate to display handler) + result_msg = display_plan_result(success, num, loc, tmpl, error) + console.print(result_msg) + + # Return boolean result + if success: + return True + else: + return False + + +# ============================================= +# STANDALONE EXECUTION (for testing) +# ============================================= + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + # Handle help flag + if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + # Confirm logger connection + logger.info("Prax logger connected to create_plan") + + # Log standalone execution + json_handler.log_operation( + "plan_created", + {"command": "standalone"} + ) + + # Call handle_command with default + args = sys.argv[1:] if len(sys.argv) > 1 else [] + if args and args[0] not in ['create', 'create_plan']: + # If first arg is not command, assume it's location (backward compatibility) + args.insert(0, 'create') + + result = handle_command(args[0] if args else 'create', args[1:] if args else []) + if result: + sys.exit(0) + else: + sys.exit(1) diff --git a/src/aipass/flow/apps/modules/list_plans.py b/src/aipass/flow/apps/modules/list_plans.py new file mode 100755 index 00000000..a37d2bc6 --- /dev/null +++ b/src/aipass/flow/apps/modules/list_plans.py @@ -0,0 +1,289 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: list_plans.py - PLAN listing module with filtering +# Date: 2025-11-21 +# Version: 1.0.0 +# Category: flow/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-21): Initial implementation, handler-based architecture +# +# CODE STANDARDS: +# - Seed v3.0 compliant (imports, architecture, error handling) +# ============================================== + +""" +List PLAN Module - Thin Orchestrator + +Orchestrates plan listing workflow by delegating to handlers. +Module contains NO business logic - only workflow coordination. + +Workflow: + 1. Parse arguments → command_parser handler + 2. Load registry → registry handlers + 3. Get statistics → registry handlers + 4. Filter plans by status + 5. Format and display results + +Usage: + From flow.py: flow plan list [filter] + Standalone: python3 list_plans.py [filter] + +Filters: + list - List open plans only (default) + list open - List open plans only + list closed - List closed plans only + list all - List all plans +""" + +import sys +from pathlib import Path +from typing import List, Dict, Any + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/ +FLOW_ROOT = _PKG_ROOT / "flow" + +# External: Prax logger +from aipass.prax.apps.modules.logger import system_logger as logger + +# JSON handler for operation tracking +from aipass.flow.apps.handlers.json import json_handler + +# CLI services for display +from aipass.cli.apps.modules import console + +# Registry handlers +from aipass.flow.apps.handlers.registry.load_registry import load_registry +from aipass.flow.apps.handlers.registry.statistics import get_registry_statistics + +# Plan display handler +from aipass.flow.apps.handlers.plan.display import ( + format_plan_info, + format_plans_list, + format_statistics_summary +) + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "list_plans" + +# ============================================= +# INTROSPECTION FUNCTION +# ============================================= + +def print_introspection(): + """Display module info and connected handlers""" + console.print() + console.print("[bold cyan]list_plans Module[/bold cyan]") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + # List handlers this module actually imports/uses + console.print(" [cyan]handlers/registry/[/cyan]") + console.print(" [dim]- load_registry.py[/dim]") + console.print(" [dim]- statistics.py[/dim]") + console.print() + console.print(" [cyan]handlers/plan/[/cyan]") + console.print(" [dim]- display.py[/dim]") + console.print() + + console.print("[dim]Run 'python3 list_plans.py --help' for usage[/dim]") + console.print() + + +def print_help(): + """Print help information for list_plans module""" + console.print() + console.print("[bold cyan]list_plans.py[/bold cyan] - List PLAN files from registry") + console.print() + console.print("[yellow]COMMANDS:[/yellow]") + console.print(" list, list_plans") + console.print() + console.print("[yellow]USAGE:[/yellow]") + console.print(" python3 list_plans.py [filter]") + console.print(" python3 list_plans.py --help") + console.print() + console.print("[yellow]FILTERS:[/yellow]") + console.print(" (none) List open plans only (default)") + console.print(" open List open plans only") + console.print(" closed List closed plans only") + console.print(" all List all plans") + console.print() + console.print("[yellow]EXAMPLES:[/yellow]") + console.print(" [dim]# List open plans (default)[/dim]") + console.print(" python3 list_plans.py") + console.print(" python3 list_plans.py open") + console.print() + console.print(" [dim]# List closed plans[/dim]") + console.print(" python3 list_plans.py closed") + console.print() + console.print(" [dim]# List all plans[/dim]") + console.print(" python3 list_plans.py all") + console.print() + + +# ============================================= +# ORCHESTRATION WORKFLOWS +# ============================================= + +def list_plans(filter_type: str = "open") -> bool: + """ + Orchestrate plan listing workflow (thin orchestrator) + + Delegates all business logic to handlers: + - Registry loading: load_registry handler + - Statistics: get_registry_statistics handler + - Display: format functions above + + Args: + filter_type: Filter plans by status ("open", "closed", "all") + + Returns: + True if successful, False otherwise + """ + try: + # STEP 1: Load registry (handler) + registry = load_registry() + + # STEP 2: Get plans + plans = registry.get("plans", {}) + + if not plans: + console.print("[yellow]No plans found in registry[/yellow]") + logger.info(f"[{MODULE_NAME}] No plans in registry") + return True # Not an error, just empty + + # STEP 3: Determine filter + if filter_type == "all": + filter_status = None + else: + filter_status = filter_type # "open" or "closed" + + # STEP 4: Format and display plans + formatted_list = format_plans_list(plans, filter_status) + console.print(formatted_list) + + # STEP 5: Get and display statistics + stats = get_registry_statistics(registry) + summary = format_statistics_summary(stats) + console.print(summary) + + # STEP 6: Log success + logger.info(f"[{MODULE_NAME}] Listed plans (filter: {filter_type})") + + return True + + except BrokenPipeError: + # Pipe closed by reader (e.g. automated subprocesses, head) + # Not a real error - command likely completed + logger.info(f"[{MODULE_NAME}] Broken pipe (stdout closed early)") + return True + + except Exception as e: + error_msg = f"Error listing plans: {e}" + logger.error(f"[{MODULE_NAME}] {error_msg}") + try: + console.print(f"[red]ERROR: {error_msg}[/red]") + except BrokenPipeError: + pass + return False + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle command routing for list_plans module (thin orchestrator) + + Delegates to handlers: + - Argument parsing: handled locally (simple case) + - Workflow execution: list_plans orchestrator + + Args: + command: Command name ("list" or "list_plans") + args: Additional arguments (filter type) + + Returns: + bool indicating success or failure + """ + # Check if this is our command + if command != "list": + return False + + # Log the operation + json_handler.log_operation( + "plans_listed", + {"command": command, "args": args} + ) + + # STEP 1: Parse filter argument + filter_type = "open" # Default to open plans + + if args: + filter_arg = args[0].lower() + if filter_arg in ["open", "closed", "all"]: + filter_type = filter_arg + else: + console.print(f"[yellow]Unknown filter '{filter_arg}', defaulting to 'open'[/yellow]") + console.print("[dim]Valid filters: open, closed, all[/dim]") + + # STEP 2: Execute workflow + success = list_plans(filter_type) + + # STEP 3: Return result + return success + + +# ============================================= +# STANDALONE EXECUTION (for testing) +# ============================================= + +if __name__ == "__main__": + try: + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + # Handle help flag + if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + # Confirm logger connection + logger.info("Prax logger connected to list_plans") + + # Log standalone execution + json_handler.log_operation( + "plans_listed", + {"command": "standalone"} + ) + + # Call handle_command + args = sys.argv[1:] if len(sys.argv) > 1 else [] + + # If first arg is not our command, assume it's a filter (backward compatibility) + if args and args[0] not in ['list', 'list_plans']: + # First arg is filter + result = handle_command('list', args) + else: + # Standard command format + cmd = args[0] if args else 'list' + result = handle_command(cmd, args[1:] if len(args) > 1 else []) + + # Exit with appropriate code + sys.exit(0 if result else 1) + + except BrokenPipeError: + # Pipe closed by reader - exit cleanly + import os + try: + sys.stdout.close() + except Exception: + pass + os._exit(0) diff --git a/src/aipass/flow/apps/modules/post_close_runner.py b/src/aipass/flow/apps/modules/post_close_runner.py new file mode 100644 index 00000000..bb5518d6 --- /dev/null +++ b/src/aipass/flow/apps/modules/post_close_runner.py @@ -0,0 +1,88 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: post_close_runner.py - Background post-close processing +# Date: 2026-02-14 +# Version: 1.1.0 +# Category: flow/modules +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2026-02-14): Add lock file to prevent concurrent execution +# - v1.0.0 (2026-02-14): Created - runs summary generation and mbank archival in background +# +# CODE STANDARDS: +# - Seed v3.0 compliant (imports, architecture, error handling) +# ============================================== + +""" +Post-Close Background Runner + +Runs memory bank archival as a background process. +Called by close_plan.py via subprocess.Popen so the close command returns fast. + +Uses a lock file to prevent concurrent execution - if another instance is +already running, this one exits silently (the running instance will pick up +all unprocessed plans since it scans ALL of them). + +This script lives inside the flow branch so handler import guards allow it. + +Note: This is a background utility script, not a command-routable module. +It has no handle_command() or --help because it is never invoked by users +or drone directly - only by close_plan.py via subprocess. +""" + +import os +import sys +from pathlib import Path + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/ +FLOW_ROOT = _PKG_ROOT / "flow" + +# External: Prax logger +from aipass.prax.apps.modules.logger import system_logger as logger + +MODULE_NAME = "post_close_runner" + +LOCK_FILE = FLOW_ROOT / ".post_close_runner.lock" + +# AI summarization removed — plans vectorized directly from backup_system/processed_plans/ +# from aipass.flow.apps.handlers.summary.generate import generate_summaries +from aipass.flow.apps.handlers.mbank.process import process_closed_plans + + +def _acquire_lock() -> bool: + """Try to acquire lock file. Returns True if acquired, False if another instance is running.""" + if LOCK_FILE.exists(): + try: + pid = int(LOCK_FILE.read_text().strip()) + os.kill(pid, 0) # Signal 0 = check if process exists + logger.info(f"[{MODULE_NAME}] Another instance running (PID {pid}), exiting") + return False + except (ValueError, ProcessLookupError, PermissionError): + logger.info(f"[{MODULE_NAME}] Stale lock found, taking over") + + LOCK_FILE.write_text(str(os.getpid())) + return True + + +def _release_lock(): + """Release the lock file.""" + try: + LOCK_FILE.unlink(missing_ok=True) + except OSError as e: + logger.warning(f"[{MODULE_NAME}] Failed to release lock file: {e}") + + +if __name__ == "__main__": + if not _acquire_lock(): + sys.exit(0) + + try: + # generate_summaries() — removed, AI summarization no longer needed + process_closed_plans() + except Exception as e: + logger.error(f"[{MODULE_NAME}] Background processing failed: {e}") + finally: + _release_lock() diff --git a/src/aipass/flow/apps/modules/registry_monitor.py b/src/aipass/flow/apps/modules/registry_monitor.py new file mode 100644 index 00000000..750b78a2 --- /dev/null +++ b/src/aipass/flow/apps/modules/registry_monitor.py @@ -0,0 +1,683 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: registry_monitor.py - Registry auto-healing and file watching module +# Date: 2025-11-21 +# Version: 2.0.0 +# Category: flow/modules +# +# CHANGELOG (Max 5 entries): +# - v2.0.0 (2026-01-20): Migrated to Trigger event system - fires events, doesn't handle +# - v1.0.0 (2025-11-21): Initial port from archive_temp with Python watchdog integration +# +# CODE STANDARDS: +# - Seed v3.0 compliant (imports, architecture, error handling) +# - Module-level logging (3-tier pattern) +# - Event-driven: fires trigger events, handlers in Trigger branch +# ============================================== + +""" +Registry Monitor Module - Auto-Healing PLAN Registry + +Monitors filesystem for PLAN file changes and fires trigger events. + +Architecture (v2.0): +- PlanFileWatcher detects filesystem events via Python watchdog +- Events are fired via trigger.fire() (plan_file_created, plan_file_deleted, plan_file_moved) +- Trigger handlers in trigger/apps/handlers/events/plan_file.py update the registry +- Decoupled: Flow fires events, Trigger handles reactions + +Features: +- Real-time file watching via Python watchdog +- Auto-detect file create/move/delete events +- Scan and heal registry (orphaned entries, missing files) +- Duplicate plan detection with auto-renumbering +- Metadata preservation on file moves + +Usage: + From flow.py: flow registry_monitor [scan|start|stop|status] + Standalone: python3 registry_monitor.py [command] + +Commands: + scan - One-time scan and heal registry + heal - Alias for scan + start - Start watchdog monitoring (runs until Ctrl+C) + stop - Stop watchdog monitoring + status - Show monitoring status +""" + +import sys +import re +import os +import time +import threading +from pathlib import Path +from typing import Dict, Any, List, Tuple, Optional +from datetime import datetime, timezone +from watchdog.observers import Observer +from watchdog.events import FileSystemEventHandler + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/ +FLOW_ROOT = _PKG_ROOT / "flow" + +# External: Prax logger +from aipass.prax.apps.modules.logger import system_logger as logger + +# JSON handler for operation tracking +from aipass.flow.apps.handlers.json import json_handler + +# CLI services for display +from aipass.cli.apps.modules import console + +# Registry handlers +from aipass.flow.apps.handlers.registry.load_registry import load_registry +from aipass.flow.apps.handlers.registry.save_registry import save_registry + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "registry_monitor" +ECOSYSTEM_ROOT = Path("/home/aipass") # Start from /home/aipass, not root / +FLOW_JSON_DIR = FLOW_ROOT / "flow_json" + +# PLAN file pattern +PLAN_PATTERN = re.compile(r'^FPLAN-\d{4}\.md$') + +# Directories to ignore during monitoring +IGNORE_FOLDERS = { + # Development and version control + ".git", ".venv", "venv", "__pycache__", "node_modules", + ".pytest_cache", "dist", "build", ".idea", ".vscode", + + # Backup and archive + "backup", "backups", ".backup", "archive", ".archive", + "backup_system", "archive_temp", "processed_plans", + + # Memory and admin + "MEMORY_BANK", "admin", "aipass-help", + + # User directories + ".local", "Downloads", "downloads", + + # System directories (permission issues) + "proc", "sys", "dev", "run", "boot", "lost+found", + "timeshift", "snapshots", ".snapshots" +} + +# Global observer instance +_observer: Any = None +_observer_lock = threading.Lock() + +# Event deduplication +_recent_events: List[Tuple[str, str, float]] = [] # [(event_type, plan_num, timestamp)] +DEDUPE_WINDOW = 2.0 # seconds + +# ============================================= +# FILE WATCHER CLASS +# ============================================= + +class PlanFileWatcher(FileSystemEventHandler): + """Monitors PLAN file changes and fires trigger events""" + + def on_created(self, event): + """Handle file creation events - fires trigger event""" + if not event.is_directory and self._is_plan_file(str(event.src_path)): + file_path = Path(str(event.src_path)) + plan_num = self._get_plan_number(file_path) + + if plan_num and not self._is_duplicate_event("created", plan_num): + logger.info(f"[{MODULE_NAME}] New PLAN file detected: {file_path.name}") + self._schedule_fire_created(file_path) + + def on_deleted(self, event): + """Handle file deletion events - fires trigger event""" + if not event.is_directory and self._is_plan_file(str(event.src_path)): + file_path = Path(str(event.src_path)) + plan_num = self._get_plan_number(file_path) + + if plan_num and not self._is_duplicate_event("deleted", plan_num): + logger.info(f"[{MODULE_NAME}] PLAN file deleted: {file_path.name}") + self._schedule_fire_deleted(file_path) + + def on_moved(self, event): + """Handle file move/rename events - fires trigger event""" + if not event.is_directory and self._is_plan_file(str(event.dest_path)): + src_path = Path(str(event.src_path)) + dest_path = Path(str(event.dest_path)) + plan_num = self._get_plan_number(dest_path) + + if plan_num and not self._is_duplicate_event("moved", plan_num): + logger.info(f"[{MODULE_NAME}] PLAN file moved: {src_path.name} -> {dest_path}") + self._schedule_fire_moved(src_path, dest_path) + + def _is_plan_file(self, file_path: str) -> bool: + """Check if file is a PLAN file""" + return PLAN_PATTERN.match(Path(file_path).name) is not None + + def _get_plan_number(self, file_path: Path) -> Optional[str]: + """Extract plan number from filename (e.g., FPLAN-0001.md -> 0001)""" + match = re.search(r'FPLAN-(\d{4})\.md$', file_path.name) + return match.group(1) if match else None + + def _is_duplicate_event(self, event_type: str, plan_num: str) -> bool: + """Check if this is a duplicate recent event""" + global _recent_events + now = time.time() + + # Clean old events + _recent_events = [(et, pn, ts) for et, pn, ts in _recent_events + if now - ts < DEDUPE_WINDOW] + + # Check for duplicates + for et, pn, ts in _recent_events: + if et == event_type and pn == plan_num: + return True + + # Add to recent events + _recent_events.append((event_type, plan_num, now)) + return False + + def _schedule_fire_created(self, file_path: Path): + """Schedule trigger event with delay to avoid duplicate events""" + timer = threading.Timer(0.5, self._fire_plan_file_created, args=(file_path,)) + timer.start() + + def _schedule_fire_deleted(self, file_path: Path): + """Schedule trigger event with delay to avoid duplicate events""" + timer = threading.Timer(0.5, self._fire_plan_file_deleted, args=(file_path,)) + timer.start() + + def _schedule_fire_moved(self, src_path: Path, dest_path: Path): + """Schedule trigger event with delay to avoid duplicate events""" + timer = threading.Timer(0.5, self._fire_plan_file_moved, args=(src_path, dest_path)) + timer.start() + + def _fire_plan_file_created(self, file_path: Path): + """Fire plan_file_created event - Trigger handles registry update""" + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire('plan_file_created', path=str(file_path)) + except ImportError: + logger.warning(f"[{MODULE_NAME}] Trigger not available - plan_file_created event not fired for {file_path.name}") + + def _fire_plan_file_deleted(self, file_path: Path): + """Fire plan_file_deleted event - Trigger handles registry update""" + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire('plan_file_deleted', path=str(file_path)) + except ImportError: + logger.warning(f"[{MODULE_NAME}] Trigger not available - plan_file_deleted event not fired for {file_path.name}") + + def _fire_plan_file_moved(self, src_path: Path, dest_path: Path): + """Fire plan_file_moved event - Trigger handles registry update""" + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire('plan_file_moved', src_path=str(src_path), dest_path=str(dest_path)) + except ImportError: + logger.warning(f"[{MODULE_NAME}] Trigger not available - plan_file_moved event not fired for {dest_path.name}") + + +# ============================================= +# SCAN AND HEAL FUNCTION +# ============================================= + +def _fire_event(event_name: str, **kwargs) -> bool: + """ + Fire a trigger event (internal helper) + + Args: + event_name: Name of the event to fire + **kwargs: Event data + + Returns: + True if event fired successfully, False otherwise + """ + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire(event_name, **kwargs) + return True + except ImportError: + logger.warning(f"[{MODULE_NAME}] Trigger not available - {event_name} event not fired") + return False + + +def scan_plan_files() -> Dict[str, Any]: + """ + Scan ecosystem for PLAN files and fire events to heal registry + + Fires events for: + - Missing registry entries (plan_file_created) + - Orphaned entries (plan_file_deleted) + - Location mismatches (plan_file_moved) + - Duplicate plan numbers are auto-renumbered on filesystem, then fire plan_file_created + + Architecture (v2.0): + - This function DETECTS changes and FIRES events + - Trigger handlers in plan_file.py HANDLE the registry updates + - Flow never touches registry directly during scan + + Returns: + Dict with scan results and event stats + """ + logger.info(f"[{MODULE_NAME}] Starting PLAN file scan from: {ECOSYSTEM_ROOT}") + + # Find all PLAN files (detect duplicates) + plan_files: Dict[str, Path] = {} + duplicates: Dict[str, List[Path]] = {} + + def handle_walk_error(error): + """Handle permission errors during os.walk""" + if not isinstance(error, PermissionError): + logger.warning(f"[{MODULE_NAME}] Error during scan: {error}") + + # Use os.walk() with error handling + for root, dirs, files in os.walk(str(ECOSYSTEM_ROOT), topdown=True, onerror=handle_walk_error): + # Skip ignored directories (modify dirs in-place to prevent descent) + dirs[:] = [d for d in dirs if not any(ignored in d for ignored in IGNORE_FOLDERS)] + + # Check for PLAN files in this directory + for filename in files: + if PLAN_PATTERN.match(filename): + file_path = Path(root) / filename + match = re.search(r'FPLAN-(\d{4})\.md$', filename) + if match: + plan_number = match.group(1) + + # Duplicate detection + if plan_number in plan_files: + if plan_number not in duplicates: + duplicates[plan_number] = [plan_files[plan_number]] + duplicates[plan_number].append(file_path) + logger.warning(f"[{MODULE_NAME}] Duplicate FPLAN-{plan_number} found: {file_path}") + else: + plan_files[plan_number] = file_path + + # Auto-renumber duplicates (keep first, renumber rest) + renumbered: List[Dict[str, str]] = [] + if duplicates: + logger.warning(f"[{MODULE_NAME}] Found {len(duplicates)} duplicate PLAN files") + + # Get next available plan number + current_max = max(int(num) for num in plan_files.keys()) if plan_files else 0 + next_available = current_max + 1 + + for plan_num, paths in duplicates.items(): + # Keep first occurrence, renumber the rest + for dup_path in paths[1:]: # Skip first path (already in plan_files) + old_name = dup_path.name + new_num = f"{next_available:04d}" + new_name = f"FPLAN-{new_num}.md" + new_path = dup_path.parent / new_name + + try: + # Rename file on filesystem + dup_path.rename(new_path) + logger.info(f"[{MODULE_NAME}] Auto-renumbered: {old_name} -> {new_name} at {dup_path.parent}") + + # Add to plan_files with new number + plan_files[new_num] = new_path + renumbered.append({ + "old_number": plan_num, + "new_number": new_num, + "path": str(new_path) + }) + + next_available += 1 + except Exception as e: + logger.error(f"[{MODULE_NAME}] Failed to renumber {old_name}: {e}") + + # Load current registry to compare (read-only - we don't modify it here) + registry = load_registry() + plans = registry.get("plans", {}) + + # Track events fired + added: List[str] = [] + updated: List[str] = [] + removed: List[str] = [] + + # Fire events for missing files (not in registry) + for plan_number, file_path in plan_files.items(): + if plan_number not in plans: + # File exists but not in registry - fire created event + if _fire_event('plan_file_created', path=str(file_path)): + added.append(plan_number) + logger.info(f"[{MODULE_NAME}] Fired plan_file_created for FPLAN-{plan_number}") + else: + # Check if location changed (file moved) + current_path = plans[plan_number].get("file_path", "") + if current_path != str(file_path): + # Fire moved event + if _fire_event('plan_file_moved', src_path=current_path, dest_path=str(file_path)): + updated.append(plan_number) + logger.info(f"[{MODULE_NAME}] Fired plan_file_moved for FPLAN-{plan_number}") + + # Fire events for orphaned registry entries (in registry but file doesn't exist) + for plan_number in list(plans.keys()): + if plan_number not in plan_files: + # Registry entry but no file - fire deleted event + file_path = plans[plan_number].get("file_path", f"FPLAN-{plan_number}.md") + if _fire_event('plan_file_deleted', path=file_path): + removed.append(plan_number) + logger.info(f"[{MODULE_NAME}] Fired plan_file_deleted for FPLAN-{plan_number}") + + # Log event results + if added or updated or removed or renumbered: + logger.info(f"[{MODULE_NAME}] Events fired - Created: {len(added)}, Moved: {len(updated)}, Deleted: {len(removed)}, Renumbered: {len(renumbered)}") + + # Reload registry to get updated count (after handlers processed events) + registry = load_registry() + total_plans = len(registry.get("plans", {})) + + logger.info(f"[{MODULE_NAME}] Scan complete - {total_plans} PLAN files in registry") + + return { + "total_plans": total_plans, + "added": added, + "updated": updated, + "removed": removed, + "renumbered": renumbered, + "healing_performed": len(added) + len(updated) + len(removed) + len(renumbered) > 0 + } + + +# ============================================= +# MONITOR CONTROL +# ============================================= + +def start_monitoring(): + """Start PLAN file monitoring with watchdog""" + global _observer + + with _observer_lock: + if _observer and _observer.is_alive(): + logger.info(f"[{MODULE_NAME}] Monitor already running") + console.print("[yellow]Monitor is already running[/yellow]") + return False + + try: + observer = Observer() + observer.schedule(PlanFileWatcher(), str(ECOSYSTEM_ROOT), recursive=True) + observer.start() + _observer = observer + logger.info(f"[{MODULE_NAME}] PLAN file monitor started - watching {ECOSYSTEM_ROOT}") + console.print(f"[green]✓[/green] Monitor started - watching {ECOSYSTEM_ROOT}") + return True + + except Exception as e: + logger.error(f"[{MODULE_NAME}] Error starting monitor: {e}") + console.print(f"[red]Error starting monitor: {e}[/red]") + return False + + +def stop_monitoring(): + """Stop PLAN file monitoring""" + global _observer + + with _observer_lock: + if _observer and _observer.is_alive(): + _observer.stop() + _observer.join() + _observer = None + logger.info(f"[{MODULE_NAME}] PLAN file monitor stopped") + console.print("[green]✓[/green] Monitor stopped") + return True + else: + logger.info(f"[{MODULE_NAME}] Monitor is not running") + console.print("[yellow]Monitor is not running[/yellow]") + return False + + +def get_status() -> Dict[str, Any]: + """Get monitoring status""" + global _observer + + registry = load_registry() + total_plans = len(registry.get("plans", {})) + open_plans = sum(1 for p in registry.get("plans", {}).values() if p.get("status") == "open") + + with _observer_lock: + is_running = _observer and _observer.is_alive() + + return { + "module": MODULE_NAME, + "version": "2.0.0", + "monitoring_active": is_running, + "watch_location": str(ECOSYSTEM_ROOT), + "total_plans": total_plans, + "open_plans": open_plans, + "ignore_folders": len(IGNORE_FOLDERS) + } + + +# ============================================= +# COMMAND HANDLER +# ============================================= + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle command routing for registry_monitor module + + Commands: + scan - One-time scan and heal + heal - Alias for scan + start - Start watchdog monitoring + stop - Stop watchdog monitoring + status - Show monitoring status + + Args: + command: Command name + args: Additional arguments + + Returns: + True if command handled successfully, False otherwise + """ + # Check if this is our command + if command != "registry": + return False + + # Get subcommand + subcommand = args[0] if args else "status" + + # Log the operation + json_handler.log_operation( + "registry_monitor", + {"command": command, "subcommand": subcommand} + ) + + if subcommand in ["scan", "heal"]: + console.print(f"[bold]Scanning for PLAN files...[/bold]") + result = scan_plan_files() + + console.print() + console.print(f"[green]✓[/green] Scan complete") + console.print(f" • Total plans: {result['total_plans']}") + console.print(f" • Added: {len(result['added'])}") + console.print(f" • Updated: {len(result['updated'])}") + console.print(f" • Removed: {len(result['removed'])}") + console.print(f" • Renumbered: {len(result['renumbered'])}") + + if result['healing_performed']: + console.print(f"\n[yellow]Registry healed - {len(result['added']) + len(result['updated']) + len(result['removed'])} changes[/yellow]") + else: + console.print(f"\n[dim]No changes needed - registry is healthy[/dim]") + + console.print() + return True + + elif subcommand == "start": + console.print(f"[bold]Starting registry monitor...[/bold]") + console.print() + + # Run initial scan before starting monitor + console.print("[dim]Running initial scan...[/dim]") + scan_result = scan_plan_files() + console.print(f"[dim]Found {scan_result['total_plans']} PLAN files[/dim]") + console.print() + + success = start_monitoring() + if success: + console.print() + console.print("[bold yellow]Monitor is running. Press Ctrl+C to stop.[/bold yellow]") + console.print() + + # Keep script alive + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + console.print() + console.print("[bold]Stopping monitor...[/bold]") + stop_monitoring() + console.print() + + return success + + elif subcommand == "stop": + return stop_monitoring() + + elif subcommand == "status": + status = get_status() + + console.print() + console.print("[bold cyan]Registry Monitor Status[/bold cyan]") + console.print() + console.print(f" • Version: {status['version']}") + console.print(f" • Monitoring: {'[green]Active[/green]' if status['monitoring_active'] else '[yellow]Inactive[/yellow]'}") + console.print(f" • Watch location: {status['watch_location']}") + console.print(f" • Total plans: {status['total_plans']}") + console.print(f" • Open plans: {status['open_plans']}") + console.print(f" • Ignored folders: {status['ignore_folders']}") + console.print() + console.print("[dim]Commands: scan | start | stop | status[/dim]") + console.print() + + return True + + else: + console.print(f"[red]Unknown subcommand: {subcommand}[/red]") + console.print() + console.print("Available commands:") + console.print(" • scan - One-time scan and heal registry") + console.print(" • heal - Alias for scan") + console.print(" • start - Start watchdog monitoring") + console.print(" • stop - Stop watchdog monitoring") + console.print(" • status - Show monitoring status") + console.print() + return False + + +# ============================================= +# INTROSPECTION +# ============================================= + +def print_introspection(): + """Display module info and usage""" + console.print() + console.print("[bold cyan]registry_monitor Module[/bold cyan]") + console.print() + + console.print("[yellow]Purpose:[/yellow]") + console.print(" Auto-healing registry that keeps PLAN files synchronized with filesystem") + console.print() + + console.print("[yellow]Features:[/yellow]") + console.print(" • Real-time file watching (watchdog)") + console.print(" • Auto-detect create/move/delete events") + console.print(" • Scan and heal registry") + console.print(" • Duplicate detection with auto-renumbering") + console.print(" • Metadata preservation on moves") + console.print() + + console.print("[yellow]Commands:[/yellow]") + console.print(" • scan - One-time scan and heal registry") + console.print(" • heal - Alias for scan") + console.print(" • start - Start watchdog monitoring (runs until Ctrl+C)") + console.print(" • stop - Stop watchdog monitoring") + console.print(" • status - Show monitoring status") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print(" • [cyan]handlers/registry/load_registry.py[/cyan]") + console.print(" • [cyan]handlers/registry/save_registry.py[/cyan]") + console.print() + + console.print("[dim]Run 'python3 registry_monitor.py --help' for detailed usage[/dim]") + console.print() + + +def print_help(): + """Print help information for registry_monitor module""" + console.print() + console.print("[bold cyan]registry_monitor.py[/bold cyan] - Auto-healing PLAN registry") + console.print() + console.print("[yellow]COMMANDS:[/yellow]") + console.print(" scan - One-time scan and heal registry") + console.print(" heal - Alias for scan") + console.print(" start - Start watchdog monitoring (runs until Ctrl+C)") + console.print(" stop - Stop watchdog monitoring") + console.print(" status - Show monitoring status") + console.print() + console.print("[yellow]USAGE:[/yellow]") + console.print(" python3 registry_monitor.py scan") + console.print(" python3 registry_monitor.py start") + console.print(" python3 registry_monitor.py status") + console.print(" python3 registry_monitor.py --help") + console.print() + console.print("[yellow]EXAMPLES:[/yellow]") + console.print(" [dim]# Run one-time scan and heal[/dim]") + console.print(" python3 registry_monitor.py scan") + console.print() + console.print(" [dim]# Start persistent monitoring[/dim]") + console.print(" python3 registry_monitor.py start") + console.print() + console.print(" [dim]# Check monitoring status[/dim]") + console.print(" python3 registry_monitor.py status") + console.print() + console.print(" [dim]# Stop monitoring[/dim]") + console.print(" python3 registry_monitor.py stop") + console.print() + console.print("[yellow]FEATURES:[/yellow]") + console.print(" - Auto-detect PLAN file changes (create/move/delete)") + console.print(" - Preserve metadata on file moves (status, closed date, etc.)") + console.print(" - Detect and fix orphaned registry entries") + console.print(" - Auto-renumber duplicate plan numbers") + console.print(" - System-wide scanning from /home/aipass") + console.print(" - Ignore common directories (.git, backups, etc.)") + console.print() + + +# ============================================= +# STANDALONE EXECUTION +# ============================================= + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + # Handle help flag + if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + # Confirm logger connection + logger.info("Prax logger connected to registry_monitor") + + # Log standalone execution + json_handler.log_operation( + "registry_monitor", + {"command": "standalone"} + ) + + # Call handle_command + args = sys.argv[1:] if len(sys.argv) > 1 else [] + result = handle_command("registry_monitor", args) + + if result: + sys.exit(0) + else: + sys.exit(1) diff --git a/src/aipass/flow/apps/modules/restore_plan.py b/src/aipass/flow/apps/modules/restore_plan.py new file mode 100644 index 00000000..6f1f6650 --- /dev/null +++ b/src/aipass/flow/apps/modules/restore_plan.py @@ -0,0 +1,449 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: restore_plan.py - PLAN restore module (reopen closed plans) +# Date: 2025-11-22 +# Version: 1.3.0 +# Category: flow/modules +# +# CHANGELOG (Max 5 entries): +# - v1.3.0 (2025-11-22): Use NEWEST backup when multiple exist, show restore location in output +# - v1.2.1 (2025-11-22): Fixed relative path handling (convert "flow" → absolute path) +# - v1.2.0 (2025-11-22): Fixed recovery to restore plans to ORIGINAL location (not hardcoded flow) +# - v1.1.0 (2025-11-22): Added auto-recovery from processed_plans +# - v1.0.0 (2025-11-21): Initial creation - restore closed plans to open status +# +# CODE STANDARDS: +# - Seed v3.0 compliant (imports, architecture, error handling) +# ============================================== + +""" +Restore PLAN Module + +Thin orchestrator for plan restore workflow (reopening closed plans). +All business logic delegated to handlers. + +Usage: + From flow.py: flow restore + Standalone: python3 restore_plan.py +""" + +import sys +from pathlib import Path +from typing import List +from shutil import copy2 +from datetime import datetime, timezone + +# INFRASTRUCTURE IMPORT PATTERN +_PKG_ROOT = Path(__file__).resolve().parents[3] # file.py → modules/ → apps/ → flow/ → aipass/ +FLOW_ROOT = _PKG_ROOT / "flow" + +# External: Prax logger +from aipass.prax.apps.modules.logger import system_logger as logger + +# JSON handler for operation tracking +from aipass.flow.apps.handlers.json import json_handler + +# CLI services for display and error handling +from aipass.cli.apps.modules import console + +# Internal: Registry handlers +from aipass.flow.apps.handlers.registry.load_registry import load_registry +from aipass.flow.apps.handlers.registry.save_registry import save_registry + +# Internal: Plan handlers +from aipass.flow.apps.handlers.plan.validator import normalize_plan_number, validate_plan_exists +from aipass.flow.apps.handlers.plan.display import ( + format_restore_header, + format_restore_error, + format_restore_success, + format_restore_usage_error +) + +# Internal: Dashboard handlers +from aipass.flow.apps.handlers.dashboard.update_local import update_dashboard_local +from aipass.flow.apps.handlers.dashboard.push_central import push_to_plans_central + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "restore_plan" + +# ============================================= +# INTROSPECTION +# ============================================= + +def print_introspection(): + """Display module info and connected handlers""" + console.print() + console.print("[bold cyan]restore_plan Module[/bold cyan]") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + console.print(" [cyan]handlers/plan/[/cyan]") + console.print(" [dim]- command_parser.py[/dim]") + console.print(" [dim]- validator.py[/dim]") + console.print(" [dim]- display.py[/dim]") + console.print() + + console.print(" [cyan]handlers/registry/[/cyan]") + console.print(" [dim]- load_registry.py[/dim]") + console.print(" [dim]- save_registry.py[/dim]") + console.print() + + console.print(" [cyan]handlers/dashboard/[/cyan]") + console.print(" [dim]- update_local.py[/dim]") + console.print(" [dim]- push_central.py[/dim]") + console.print() + + console.print("[dim]Run 'python3 restore_plan.py --help' for usage[/dim]") + console.print() + +# ============================================= +# RECOVERY FUNCTIONS +# ============================================= + +def recover_plan_from_backup(plan_key: str) -> tuple[bool, str]: + """ + Attempt to recover a plan from processed_plans backup + + Args: + plan_key: Normalized plan number (e.g., "0165") + + Returns: + (success, message) + """ + # Check processed_plans directory + processed_plans = _PKG_ROOT / "backup_system" / "processed_plans" + plan_file = processed_plans / f"FPLAN-{plan_key}.md" + + # CRITICAL: If base file doesn't exist, or if timestamp variants exist, use the NEWEST backup + # This handles cases where plan was closed multiple times from different locations + variants = list(processed_plans.glob(f"FPLAN-{plan_key}*.md")) + if variants: + # Sort by modification time, newest first + variants.sort(key=lambda p: p.stat().st_mtime, reverse=True) + plan_file = variants[0] # Use most recent backup + elif not plan_file.exists(): + return False, f"FPLAN-{plan_key} not found in backups" + + # Read plan file to extract original location from header + try: + with open(plan_file, 'r', encoding='utf-8') as f: + content = f.read() + + # Parse location from header (e.g., "**Location**: /home/aipass") + original_location = None + for line in content.split('\n')[:20]: # Check first 20 lines + if line.startswith("**Location**:"): + original_location = line.split("**Location**:")[1].strip() + break + + # If location not found in header, default to FLOW_ROOT + if not original_location: + original_location = str(FLOW_ROOT) + + # CRITICAL: Convert relative paths to absolute paths + # If location is relative (like "flow"), resolve it + if not original_location.startswith('/'): + # Relative path - resolve it + if original_location == "flow": + original_location = str(FLOW_ROOT) + elif original_location == "aipass_core": + original_location = str(_PKG_ROOT) + elif original_location == "seed": + original_location = str(Path.home() / "seed") + else: + # Try resolving relative to _PKG_ROOT + potential_path = _PKG_ROOT / original_location + if potential_path.exists(): + original_location = str(potential_path) + else: + # Try relative to home + potential_path = Path.home() / original_location + if potential_path.exists(): + original_location = str(potential_path) + else: + # Fallback to FLOW_ROOT + original_location = str(FLOW_ROOT) + + # Determine relative path + original_path = Path(original_location) + if original_path == FLOW_ROOT: + relative_path = "flow" + elif original_path == _PKG_ROOT: + relative_path = "aipass_core" + elif original_path == Path.home(): + relative_path = str(Path.home()) + else: + try: + relative_path = str(original_path.relative_to(Path.home())) + except ValueError: + relative_path = str(original_path) + + except Exception as e: + # If parsing fails, default to FLOW_ROOT + original_location = str(FLOW_ROOT) + relative_path = "flow" + + # Copy file to ORIGINAL location (preserve backup) + target = Path(original_location) / f"FPLAN-{plan_key}.md" + copy2(plan_file, target) + + # Create minimal registry entry + registry = load_registry() + registry["plans"][plan_key] = { + "location": original_location, + "relative_path": relative_path, + "file_path": str(target), + "status": "closed", + "created": datetime.now(timezone.utc).isoformat(), + "subject": "Recovered from backup", + "closed": datetime.now(timezone.utc).isoformat(), + "closed_reason": "recovered_from_backup", + "template_type": "default" + } + save_registry(registry) + + return True, f"Recovered FPLAN-{plan_key} from {plan_file.name} to {original_location}" + +# ============================================= +# RESTORE PLAN WORKFLOW +# ============================================= + +def restore_plan(plan_num: str | None) -> bool: + """ + Orchestrate plan restore workflow (thin orchestrator) + + Restores a closed plan back to open status by updating registry metadata. + Does NOT move files - file must already be at registered location. + + Delegates all business logic to handlers: + - Validation: validator handler + - Registry ops: registry handlers + - Display: display handler + + Args: + plan_num: Plan number (e.g., "0001" or "1" or "42") + + Returns: + True if successful, False otherwise + """ + if not plan_num: + logger.warning(f"[{MODULE_NAME}] Plan number required for restore") + console.print(format_restore_error("invalid_number", "")) + return False + + try: + # 0. AUTO-HEAL: Run registry scan to detect moved files (self-healing) + from aipass.flow.apps.modules.registry_monitor import scan_plan_files + scan_plan_files() # Auto-detects files not in registry + logger.info(f"[{MODULE_NAME}] Auto-heal scan completed") + + # 1. VALIDATE: Normalize plan number (handler) + plan_key = normalize_plan_number(plan_num) + + # 2. LOAD DATA: Get registry (service) + registry = load_registry() + + # 3. VALIDATE: Check plan exists (handler) + exists, error_msg = validate_plan_exists(plan_key, registry) + if not exists: + # AUTO-RECOVERY: Try to recover from processed_plans + console.print(f"[yellow]FPLAN-{plan_key} not in registry - attempting recovery...[/yellow]") + recovered, recovery_msg = recover_plan_from_backup(plan_key) + + if recovered: + console.print(f"[green]✓ {recovery_msg}[/green]") + # Reload registry with recovered plan + registry = load_registry() + plan_info = registry["plans"][plan_key] + plan_file = Path(plan_info.get("file_path", "")) + else: + logger.warning(f"[{MODULE_NAME}] {error_msg} - Recovery failed: {recovery_msg}") + console.print(format_restore_error("not_found", plan_key)) + console.print(f"[dim]Recovery attempt: {recovery_msg}[/dim]") + return False + else: + plan_info = registry["plans"][plan_key] + plan_file = Path(plan_info.get("file_path", "")) + + # 4. VALIDATE: Check plan is closed + if plan_info.get("status") != "closed": + logger.warning(f"[{MODULE_NAME}] FPLAN-{plan_key} is already open") + console.print(format_restore_error("already_open", plan_key)) + return False + + # 5. VALIDATE: Check file exists at registered location + if not plan_file.exists(): + logger.warning(f"[{MODULE_NAME}] File not found at {plan_file}") + console.print(format_restore_error("file_missing", plan_key)) + return False + + # 6. DISPLAY: Show plan info before restore (handler) + console.print(format_restore_header(plan_key, plan_info)) + + # 7. UPDATE REGISTRY: Restore to open status + plan_info['status'] = 'open' + + # Remove all close-related metadata + plan_info.pop('closed', None) + plan_info.pop('closed_reason', None) + plan_info.pop('memory_created', None) + plan_info.pop('memory_created_date', None) + plan_info.pop('memory_file', None) + + save_registry(registry) + logger.info(f"[{MODULE_NAME}] Restored FPLAN-{plan_key} to open status") + + # 8. UPDATE DASHBOARDS: Sync dashboard files (handlers) + dashboard_success = update_dashboard_local() + central_success = push_to_plans_central() + + # Log dashboard update results (3-tier: modules log, handlers don't) + if not dashboard_success: + logger.warning(f"[{MODULE_NAME}] Failed to update DASHBOARD.local.json") + if not central_success: + logger.warning(f"[{MODULE_NAME}] Failed to update PLANS.central.json") + + # 9. DISPLAY: Success message with location (handler) + restored_location = plan_info.get("location", "unknown") + console.print(format_restore_success(plan_key, restored_location)) + + # Fire trigger event for plan restore (optional - trigger module may not be available) + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire('plan_restored', plan_number=plan_key, location=restored_location) + except ImportError: + logger.info(f"[{MODULE_NAME}] Trigger module not available, skipping event fire") + + return True + + except ValueError: + error_msg = f"Invalid plan number: {plan_num}" + logger.warning(f"[{MODULE_NAME}] {error_msg}") + console.print(format_restore_error("invalid_number", plan_num)) + return False + + except Exception as e: + error_msg = f"Error restoring plan: {e}" + logger.error(f"[{MODULE_NAME}] {error_msg}") + console.print(format_restore_error("general", details=str(e))) + return False + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle command routing for restore_plan module (thin orchestrator) + + Delegates to handlers: + - Argument parsing: command_parser handler + - Workflow execution: restore_plan orchestrator + - Error display: display handler + + Args: + command: Command name + args: Additional arguments + + Returns: + bool indicating success or failure + """ + # Check if this is our command + if command != "restore": + return False + + # Import parser here (after command check) + from aipass.flow.apps.handlers.plan.command_parser import parse_restore_command_args + + # Log the operation + json_handler.log_operation( + "plan_restored", + {"command": command, "args": args} + ) + + # 1. PARSE ARGS: Use command_parser handler + plan_num, error = parse_restore_command_args(args) + + # 2. VALIDATE: Check for parsing errors + if error: + console.print(format_restore_usage_error()) + return False + + # 3. EXECUTE: Run workflow orchestrator + success = restore_plan(plan_num=plan_num) + + # 4. RETURN: Result (restore_plan already handles all output) + return success + + +# ============================================= +# STANDALONE EXECUTION (for testing) +# ============================================= + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + # Handle help flag + if len(sys.argv) > 1 and sys.argv[1] in ['--help', '-h', 'help']: + import argparse + PARSER = argparse.ArgumentParser( + description='Restore PLAN file to open status', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +COMMANDS: + restore, restore_plan Restore a closed plan to open status + +USAGE: + python3 restore_plan.py restore + python3 restore_plan.py --help + +EXAMPLES: + # Restore a closed plan + python3 restore_plan.py restore 42 + + # Using plan number directly + python3 restore_plan.py 42 + +NOTES: + - Plan must be closed to restore + - Plan file must exist at registered location + - Only updates registry metadata (does not move files) + """ + ) + PARSER.print_help() + sys.exit(0) + + # Confirm logger connection + logger.info("Prax logger connected to restore_plan") + + # Log standalone execution + json_handler.log_operation( + "plan_restored", + {"command": "standalone"} + ) + + # Call handle_command with default + args = sys.argv[1:] if len(sys.argv) > 1 else [] + if not args: + console.print(format_restore_usage_error()) + console.print("Run with --help for usage information") + console.print() + sys.exit(1) + + # If first arg is not command, assume it's plan number (backward compatibility) + if args[0] not in ['restore', 'restore_plan']: + args.insert(0, 'restore') + + result = handle_command(args[0], args[1:]) + # Result is True on success, False on failure + if result: + sys.exit(0) + else: + sys.exit(1) diff --git a/src/aipass/flow/apps/plugins/__init__.py b/src/aipass/flow/apps/plugins/__init__.py index e69de29b..69b056dd 100644 --- a/src/aipass/flow/apps/plugins/__init__.py +++ b/src/aipass/flow/apps/plugins/__init__.py @@ -0,0 +1 @@ +# Plugins package - Pluggable components for branch capabilities diff --git a/src/aipass/prax/apps/__init__.py b/src/aipass/prax/apps/__init__.py old mode 100644 new mode 100755 index 9b40196a..73ab12a7 --- a/src/aipass/prax/apps/__init__.py +++ b/src/aipass/prax/apps/__init__.py @@ -1 +1 @@ -# PRAX apps package +# Apps package - Branch application modules and handlers diff --git a/src/aipass/prax/apps/branch.py b/src/aipass/prax/apps/branch.py deleted file mode 100644 index 4836e0d7..00000000 --- a/src/aipass/prax/apps/branch.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -PRAX Branch - Main Orchestrator - -Auto-discovery architecture: -- Scans modules/ directory for .py files with handle_command() -- Routes commands to discovered modules automatically -- No manual imports or routing needed -""" - -import sys -import importlib -from pathlib import Path -from typing import List, Any - -from aipass.prax import logger - -# ============================================================================= -# MODULE DISCOVERY -# ============================================================================= - -MODULES_DIR = Path(__file__).parent / "modules" - - -def discover_modules() -> List[Any]: - """Auto-discover modules in modules/ directory.""" - modules = [] - - if not MODULES_DIR.exists(): - return modules - - for file_path in MODULES_DIR.glob("*.py"): - if file_path.name.startswith("_"): - continue - - module_name = f"apps.modules.{file_path.stem}" - - try: - module = importlib.import_module(module_name) - if hasattr(module, "handle_command"): - modules.append(module) - except Exception as e: - logger.error(f"[PRAX] Failed to load module {module_name}: {e}") - - return modules - - -def route_command(command: str, args: List[str], modules: List[Any]) -> bool: - """Route command to appropriate module.""" - for module in modules: - try: - if module.handle_command(command, args): - return True - except Exception as e: - logger.error(f"[PRAX] Module {module.__name__} error: {e}") - return False - - -# ============================================================================= -# MAIN ENTRY POINT -# ============================================================================= - -def main(): - """Main entry point - routes commands or shows help.""" - modules = discover_modules() - args = sys.argv[1:] - - if len(args) == 0 or args[0] in ["--help", "-h", "help"]: - print(f"PRAX - {len(modules)} modules discovered") - for module in modules: - name = module.__name__.split(".")[-1] - desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" - print(f" {name:20} {desc}") - return 0 - - command = args[0] - remaining = args[1:] if len(args) > 1 else [] - - if route_command(command, remaining, modules): - return 0 - - print(f"Unknown command: {command}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/aipass/prax/apps/extensions/__init__.py b/src/aipass/prax/apps/extensions/__init__.py new file mode 100755 index 00000000..95322c94 --- /dev/null +++ b/src/aipass/prax/apps/extensions/__init__.py @@ -0,0 +1 @@ +# Extensions package - Drop-in extensions for branch functionality diff --git a/src/aipass/prax/apps/handlers/__init__.py b/src/aipass/prax/apps/handlers/__init__.py old mode 100644 new mode 100755 index e69de29b..ae41690f --- a/src/aipass/prax/apps/handlers/__init__.py +++ b/src/aipass/prax/apps/handlers/__init__.py @@ -0,0 +1,132 @@ +"""Prax handlers package - Security protected.""" + +import inspect +from pathlib import Path + +MY_BRANCH = "prax" + + +def _find_real_caller(): + """ + Walk the stack to find the actual file that triggered this import. + + Skips: + - This file (handlers/__init__.py) + - Python's importlib internals + - Frozen modules + + Returns tuple: (file_path, import_line) or (None, None) + """ + stack = inspect.stack() + this_file = str(Path(__file__).resolve()) + + for frame_info in stack: + filename = frame_info.filename + + # Skip this file + if this_file in str(Path(filename).resolve()): + continue + + # Skip Python internals + if filename.startswith("<") or "importlib" in filename: + continue + + # Found a real file - try to get the import line + import_line = None + if frame_info.code_context: + import_line = frame_info.code_context[0].strip() + + return str(Path(filename).resolve()), import_line + + return None, None + + +def _extract_branch_name(filepath: str) -> str: + """Extract branch name from a file path.""" + parts = filepath.split("/") + for i, part in enumerate(parts): + if part in ("aipass_core", "MEMORY_BANK", "Nexus"): + if i + 1 < len(parts): + return parts[i + 1] + return "unknown" + + +def _guard_branch_access(): + """ + Block cross-branch handler imports. + + Only code from within the 'prax' branch can import these handlers. + External branches must use prax.apps.modules instead. + """ + caller_file, import_line = _find_real_caller() + + # DEBUG: Print what we found + import os + if os.environ.get("AIPASS_DEBUG_GUARD"): + import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) + print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) + + if caller_file is None: + # Can't determine caller from real files + # Check if we're being run from command line (external) + # by looking at the raw stack for or + stack = inspect.stack() + for frame in stack: + if frame.filename in ("", ""): + # Try to get the import line from the frame + target_line = "unknown" + if frame.code_context: + target_line = frame.code_context[0].strip() + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller: interactive/script\n" + f" Blocked: {target_line}\n" + f"\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"\n" + f" Example:\n" + f" from {MY_BRANCH}.apps.modules.logger import logger\n" + f"\n" + f" For full standards guide:\n" + f" drone @seed handlers\n" + f"{'='*60}" + ) + return # Allow if truly can't determine + + # Check if caller is from our branch + if f"/{MY_BRANCH}/" in caller_file: + return # Same branch, allowed + + # External caller - block access + caller_branch = _extract_branch_name(caller_file) + caller_filename = Path(caller_file).name + blocked_import = import_line if import_line else "unknown" + + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller branch: {caller_branch}\n" + f" Caller file: {caller_filename}\n" + f" Blocked: {blocked_import}\n" + f"\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from {MY_BRANCH}.apps.modules. import \n" + f"\n" + f" Example:\n" + f" from {MY_BRANCH}.apps.modules.logger import logger\n" + f"\n" + f" For full standards guide:\n" + f" drone @seed handlers\n" + f"{'='*60}" + ) + + +# Run guard at import time +_guard_branch_access() diff --git a/src/aipass/prax/apps/handlers/config/__init__.py b/src/aipass/prax/apps/handlers/config/__init__.py new file mode 100755 index 00000000..32c6b22d --- /dev/null +++ b/src/aipass/prax/apps/handlers/config/__init__.py @@ -0,0 +1 @@ +"""Prax config handlers package""" diff --git a/src/aipass/prax/apps/handlers/config/ignore_patterns.py b/src/aipass/prax/apps/handlers/config/ignore_patterns.py new file mode 100755 index 00000000..c8550b10 --- /dev/null +++ b/src/aipass/prax/apps/handlers/config/ignore_patterns.py @@ -0,0 +1,87 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: ignore_patterns.py +# Date: 2025-11-07 +# Version: 1.0.0 +# Category: prax/handlers/config +# +# CHANGELOG: +# - v1.0.0 (2025-11-07): Extracted from prax_config.py - ignore patterns loading +# ============================================= + +""" +Load Ignore Patterns Handler + +Loads ignore patterns for module discovery from prax_logger_config.json. +Returns set of folder names to ignore during Python module scanning. + +Features: +- Loads ignore_patterns from prax_logger_config.json +- Fallback to hardcoded defaults if config missing +- Returns as Set for fast lookup +- Used by module discovery system + +Usage: + from aipass.prax.apps.handlers.config.ignore_patterns import load_ignore_patterns_from_config + + patterns = load_ignore_patterns_from_config() + if 'node_modules' in patterns: + print("Will ignore node_modules") +""" + +import json +from pathlib import Path +from typing import Set + +from aipass.prax.apps.handlers.config.load import PRAX_ROOT + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "ignore_patterns" +PRAX_JSON_DIR = PRAX_ROOT / "prax_json" +PRAX_LOGGER_CONFIG_FILE = PRAX_JSON_DIR / "prax_logger_config.json" + +# Hardcoded fallback patterns +DEFAULT_IGNORE_FOLDERS = { + '.git', '__pycache__', '.venv', 'vendor', 'node_modules', + 'Archive', 'Backups', 'External_Code_Sources', 'WorkShop', + '.claude-server-commander-logs', + 'backup_system', 'backups', 'archive.local' +} + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def load_ignore_patterns_from_config() -> Set[str]: + """Load ignore patterns from prax_logger config file + + Returns: + Set of folder names to ignore during module discovery. + Falls back to DEFAULT_IGNORE_FOLDERS if config missing or invalid. + + The ignore patterns are used by should_ignore_path() to filter + directories during recursive module scanning. + + Example: + >>> patterns = load_ignore_patterns_from_config() + >>> if '.git' in patterns: + >>> print("Will skip .git directories") + """ + try: + if PRAX_LOGGER_CONFIG_FILE.exists(): + with open(PRAX_LOGGER_CONFIG_FILE, 'r', encoding='utf-8') as f: + config = json.load(f) + patterns = config.get('config', {}).get('ignore_patterns', []) + if patterns: + return set(patterns) + except Exception: + # Silently fall back to defaults - logging not available at this level + pass + + # Fallback to hardcoded if config missing/invalid + return DEFAULT_IGNORE_FOLDERS diff --git a/src/aipass/prax/apps/handlers/config/load.py b/src/aipass/prax/apps/handlers/config/load.py new file mode 100755 index 00000000..31ac46a1 --- /dev/null +++ b/src/aipass/prax/apps/handlers/config/load.py @@ -0,0 +1,159 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: load.py +# Date: 2025-11-07 +# Version: 1.0.1 +# Category: prax/handlers/config +# CODE STANDARDS: Seed v1.1 +# +# CHANGELOG: +# - v1.0.1 (2026-02-02): Added self-healing SYSTEM_LOGS_DIR auto-creation +# - v1.0.0 (2025-11-07): Extracted from prax_config.py - logging configuration loading +# ============================================= + +""" +Load Logging Configuration Handler + +Loads logging configuration from prax_logger_config.json. +Returns configuration for system logs and local logs with fallback to defaults. + +Features: +- Loads log config from prax_logger_config.json +- Returns system_logs and local_logs settings +- Fallback to code defaults if config missing +- Includes log_format and date_format +- Self-healing: auto-creates SYSTEM_LOGS_DIR if missing + +Usage: + from aipass.prax.apps.handlers.config.load import load_log_config + + config = load_log_config() + system_logs = config['system_logs'] + max_lines = system_logs['max_lines'] +""" + +import json +import logging +from pathlib import Path +from typing import Dict, Any + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "load" + +# Package path resolution (no hardcoded paths) +PRAX_ROOT = Path(__file__).resolve().parents[3] # config/load.py → handlers/ → apps/ → prax/ +ECOSYSTEM_ROOT = PRAX_ROOT.parent # prax/ → aipass/ (contains all sibling modules) +SYSTEM_LOGS_DIR = Path.home() / "system_logs" +PRAX_JSON_DIR = PRAX_ROOT / "prax_json" + +# Self-healing: ensure SYSTEM_LOGS_DIR exists +SYSTEM_LOGS_DIR.mkdir(parents=True, exist_ok=True) + +# Config file +PRAX_LOGGER_CONFIG_FILE = PRAX_JSON_DIR / "prax_logger_config.json" + +# Default configuration constants +LOG_FORMAT = '%(asctime)s - %(name)s - %(levelname)s - %(message)s' +DATE_FORMAT = '%Y-%m-%d %H:%M:%S' +DEFAULT_LOG_LEVEL = "INFO" + +DEFAULT_SYSTEM_LOGS = { + "max_lines": 1000, + "backup_count": 1, + "log_level": "INFO" +} + +DEFAULT_LOCAL_LOGS = { + "max_lines": 250, + "backup_count": 1, + "log_level": "INFO" +} + +# ============================================= +# HANDLER FUNCTIONS +# ============================================= + +def lines_to_bytes(num_lines: int, avg_line_length: int = 200) -> int: + """Convert number of lines to approximate bytes for log rotation + + Args: + num_lines: Number of lines to convert + avg_line_length: Average line length in characters (default 200) + + Returns: + Approximate number of bytes + """ + return num_lines * avg_line_length + +def get_debug_prints_enabled() -> bool: + """Check if debug prints are enabled in config + + Returns: + True if debug prints enabled, False otherwise + """ + try: + if PRAX_LOGGER_CONFIG_FILE.exists(): + with open(PRAX_LOGGER_CONFIG_FILE, 'r', encoding='utf-8') as f: + config = json.load(f) + return config.get('config', {}).get('debug_prints_enabled', False) + except (json.JSONDecodeError, OSError) as e: + logging.debug(f"Config load error (using defaults): {e}") + return False + +def load_log_config() -> Dict[str, Any]: + """Load logging config from JSON, fallback to defaults + + Returns: + Dict with system_logs and local_logs settings: + { + "system_logs": { + "max_lines": 1000, + "backup_count": 1, + "log_level": "INFO" + }, + "local_logs": { + "max_lines": 250, + "backup_count": 1, + "log_level": "INFO" + }, + "log_format": "%(asctime)s - ...", + "date_format": "%Y-%m-%d %H:%M:%S" + } + + If config file missing or invalid, returns code defaults. + + Example: + >>> config = load_log_config() + >>> max_lines = config['system_logs']['max_lines'] + >>> print(f"System logs max lines: {max_lines}") + """ + try: + if PRAX_LOGGER_CONFIG_FILE.exists(): + with open(PRAX_LOGGER_CONFIG_FILE, 'r', encoding='utf-8') as f: + config = json.load(f) + + # Extract system and local log settings + system_logs = config.get('config', {}).get('system_logs', DEFAULT_SYSTEM_LOGS) + local_logs = config.get('config', {}).get('local_logs', DEFAULT_LOCAL_LOGS) + + return { + 'system_logs': system_logs, + 'local_logs': local_logs, + 'log_format': config.get('config', {}).get('log_format', LOG_FORMAT), + 'date_format': config.get('config', {}).get('date_format', DATE_FORMAT) + } + except (json.JSONDecodeError, OSError) as e: + logging.debug(f"Log config load error (using defaults): {e}") + + # Fallback to code defaults + return { + 'system_logs': DEFAULT_SYSTEM_LOGS, + 'local_logs': DEFAULT_LOCAL_LOGS, + 'log_format': LOG_FORMAT, + 'date_format': DATE_FORMAT + } diff --git a/src/aipass/prax/apps/handlers/dashboard/__init__.py b/src/aipass/prax/apps/handlers/dashboard/__init__.py new file mode 100644 index 00000000..13d196dd --- /dev/null +++ b/src/aipass/prax/apps/handlers/dashboard/__init__.py @@ -0,0 +1,11 @@ +#!/home/aipass/.venv/bin/python3 + +""" +Dashboard Handlers Package + +Provides dashboard write-through for PRAX-managed sections. +""" + +from .agent_status_writer import push_agent_status_dashboard + +__all__ = ['push_agent_status_dashboard'] diff --git a/src/aipass/prax/apps/handlers/dashboard/agent_status_writer.py b/src/aipass/prax/apps/handlers/dashboard/agent_status_writer.py new file mode 100644 index 00000000..e11d12ea --- /dev/null +++ b/src/aipass/prax/apps/handlers/dashboard/agent_status_writer.py @@ -0,0 +1,323 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: agent_status_writer.py - Agent Status Dashboard Write-Through +# Date: 2026-02-25 +# Version: 0.1.0 +# Category: prax/handlers +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2026-02-25): FPLAN-0374 Phase 3 - agent_status dashboard section +# +# CODE STANDARDS: +# - Handler tier 3: Pure functions, no CLI/Prax imports +# - Dashboard write failures are silent (return False, never raise) +# - Uses subprocess for cross-branch dashboard writes (no cross-package imports) +# - BYPASS: Direct json/Path reads required for lock files, registry, /proc +# ============================================= + +""" +Agent Status Dashboard Write-Through Handler + +Pushes the 'agent_status' section to branch dashboards via DevPulse write_section(). +Scans dispatch lock files and /proc to detect active and stale agents, then pushes +to ALL branch dashboards (agent status is system-wide info). + +Data sources: + - Dispatch lock files: {branch}/ai_mail.local/.dispatch.lock (JSON: pid, timestamp, branch) + - Process validation: /proc/{pid}/cmdline to confirm agent is still alive + - Stale threshold: agents running > 120 minutes +""" + +import json +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List + + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +BRANCH_REGISTRY = Path.home() / "BRANCH_REGISTRY.json" +STALE_THRESHOLD_MINUTES = 120 + + +# ============================================================================= +# DATA COLLECTION +# ============================================================================= + +def _get_all_branches() -> List[Dict[str, Any]]: + """ + Load all branches from BRANCH_REGISTRY.json. + + Returns: + List of dicts with 'name' and 'path' keys + """ + try: + if not BRANCH_REGISTRY.exists(): + return [] + + data = json.loads(BRANCH_REGISTRY.read_text(encoding="utf-8")) + branches = [] + for branch in data.get("branches", []): + branch_path = Path(branch.get("path", "")) + if branch_path.exists(): + branches.append({ + "name": branch.get("name", ""), + "path": branch_path + }) + return branches + except Exception: + return [] + + +def _is_pid_alive(pid: int) -> bool: + """ + Check if a process is still running by reading /proc/{pid}/cmdline. + + Args: + pid: Process ID to check + + Returns: + True if process exists and looks like a claude agent + """ + try: + cmdline_path = Path(f"/proc/{pid}/cmdline") + if not cmdline_path.exists(): + return False + cmdline = cmdline_path.read_bytes().decode("utf-8", errors="replace") + return "claude" in cmdline.lower() + except (PermissionError, OSError): + return False + + +def _read_lock_file(lock_path: Path) -> Dict[str, Any]: + """ + Read a dispatch lock file. + + Args: + lock_path: Path to .dispatch.lock file + + Returns: + Lock data dict with pid, timestamp, branch — or empty dict on failure + """ + try: + if not lock_path.exists(): + return {} + return json.loads(lock_path.read_text(encoding="utf-8")) + except (json.JSONDecodeError, OSError): + return {} + + +def _calculate_runtime_minutes(timestamp_str: str) -> float: + """ + Calculate minutes elapsed since a timestamp. + + Args: + timestamp_str: ISO format timestamp string + + Returns: + Minutes elapsed, or 0.0 on parse failure + """ + try: + started = datetime.fromisoformat(timestamp_str) + elapsed = datetime.now() - started + return elapsed.total_seconds() / 60.0 + except (ValueError, TypeError): + return 0.0 + + +def _scan_active_agents() -> tuple[List[Dict[str, Any]], List[Dict[str, Any]]]: + """ + Scan all branches for active dispatch agents. + + Reads .dispatch.lock files, validates PIDs against /proc, and + classifies agents as active or stale based on runtime. + + Returns: + Tuple of (active_agents, stale_agents) — each a list of dicts + """ + active_agents: List[Dict[str, Any]] = [] + stale_agents: List[Dict[str, Any]] = [] + + branches = _get_all_branches() + + for branch in branches: + lock_path = branch["path"] / "ai_mail.local" / ".dispatch.lock" + lock_data = _read_lock_file(lock_path) + + if not lock_data: + continue + + pid = lock_data.get("pid", 0) + timestamp = lock_data.get("timestamp", "") + runtime_minutes = _calculate_runtime_minutes(timestamp) + + # Validate PID is still alive + if not _is_pid_alive(pid): + # Stale lock — process died without cleanup + stale_agents.append({ + "branch": branch["name"], + "pid": pid, + "started": timestamp, + "runtime_minutes": round(runtime_minutes, 1), + "status": "dead_process" + }) + continue + + agent_info = { + "branch": branch["name"], + "pid": pid, + "started": timestamp, + "runtime_minutes": round(runtime_minutes, 1) + } + + if runtime_minutes > STALE_THRESHOLD_MINUTES: + agent_info["status"] = "overtime" + stale_agents.append(agent_info) + else: + active_agents.append(agent_info) + + return active_agents, stale_agents + + +# ============================================================================= +# PUBLIC API +# ============================================================================= + +def build_agent_status_section() -> Dict[str, Any]: + """ + Build the agent_status dashboard section data. + + Scans for active dispatch agents and returns section dict ready for + write_section(). + + Returns: + Dict with managed_by, active_agents, agent_count, stale_agents, + last_updated + """ + active_agents, stale_agents = _scan_active_agents() + + return { + "managed_by": "prax", + "active_agents": active_agents, + "agent_count": len(active_agents), + "stale_agents": stale_agents, + "last_updated": datetime.now().isoformat() + } + + +def _get_all_branch_paths() -> List[Path]: + """ + Get paths for all active branches from BRANCH_REGISTRY.json. + + Returns: + List of Path objects for all registered branches + """ + return [b["path"] for b in _get_all_branches()] + + +def _write_section_to_all_branches(section_name: str, section_data: Dict, + branch_paths: List[Path]) -> int: + """ + Write a dashboard section to multiple branches via a single subprocess. + + Uses one subprocess call for all branches to avoid spawning N processes. + Pattern from MEMORY_BANK/apps/handlers/dashboard_push.py. + + Args: + section_name: Dashboard section key (e.g., "agent_status") + section_data: Section data dict to write + branch_paths: List of branch root directory paths + + Returns: + Number of branches successfully updated + """ + try: + script = ( + "import sys, json\n" + "from pathlib import Path\n" + f"sys.path.insert(0, '{Path.home() / 'aipass_os' / 'dev_central'}')\n" + "from aipass.devpulse.apps.modules.dashboard import write_section\n" + "data = json.loads(sys.stdin.read())\n" + "section_name = data['section_name']\n" + "section_data = data['section_data']\n" + "ok = 0\n" + "for bp in data['branch_paths']:\n" + " try:\n" + " if write_section(Path(bp), section_name, dict(section_data)):\n" + " ok += 1\n" + " except Exception:\n" + " continue\n" + "print(ok)\n" + ) + + input_data = json.dumps({ + "section_name": section_name, + "section_data": section_data, + "branch_paths": [str(p) for p in branch_paths] + }) + + result = subprocess.run( + [sys.executable, "-c", script], + input=input_data, + capture_output=True, + text=True, + timeout=60 + ) + + if result.returncode == 0 and result.stdout.strip().isdigit(): + return int(result.stdout.strip()) + return 0 + except Exception: + return 0 + + +def push_agent_status_dashboard() -> bool: + """ + Push the agent_status section to ALL branch dashboards. + + Main entry point. Scans for active/stale dispatch agents and pushes + results to every branch dashboard. Agent status is system-wide info + so all branches benefit from knowing what's running. + + Uses a single subprocess to call devpulse write_section() for all + branches. Dashboard write failures are silent — best-effort operation. + + Returns: + True if at least one dashboard was updated, False on total failure + """ + try: + section_data = build_agent_status_section() + branch_paths = _get_all_branch_paths() + + if not branch_paths: + return False + + success_count = _write_section_to_all_branches( + "agent_status", section_data, branch_paths + ) + + return success_count > 0 + + except Exception: + return False + + +# ============================================================================= +# CLI ENTRY POINT (for testing) +# ============================================================================= + +if __name__ == "__main__": + print("Building agent_status dashboard section...") + section = build_agent_status_section() + print(json.dumps(section, indent=2)) + + print() + print("Pushing to all branch dashboards...") + result = push_agent_status_dashboard() + print(f"Result: {'success' if result else 'failed'}") diff --git a/src/aipass/prax/apps/handlers/discovery/__init__.py b/src/aipass/prax/apps/handlers/discovery/__init__.py new file mode 100755 index 00000000..ad644fe8 --- /dev/null +++ b/src/aipass/prax/apps/handlers/discovery/__init__.py @@ -0,0 +1,6 @@ +""" +PRAX Discovery Handlers + +Module discovery, scanning, and file watching. +Used by modules/prax_discovery.py - not imported directly by other branches. +""" diff --git a/src/aipass/prax/apps/handlers/discovery/filtering.py b/src/aipass/prax/apps/handlers/discovery/filtering.py new file mode 100755 index 00000000..2d8fa17a --- /dev/null +++ b/src/aipass/prax/apps/handlers/discovery/filtering.py @@ -0,0 +1,44 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: filtering.py - Path Filtering +# Date: 2025-11-10 +# Version: 1.0.0 +# Category: prax/handlers/discovery +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-10): Extracted from archive.temp/prax_discovery.py +# ============================================= + +""" +PRAX Discovery Filtering + +Path filtering for module discovery using ignore patterns. +""" + +from pathlib import Path + +# Import from prax config +from aipass.prax.apps.handlers.config.ignore_patterns import load_ignore_patterns_from_config + +def should_ignore_path(path: Path) -> bool: + """Check if path should be ignored based on patterns from config + + Args: + path: Path to check against ignore patterns + + Returns: + True if path should be ignored, False otherwise + """ + path_parts = path.parts # Keep original case for exact matching + + # Load ignore patterns from config (with fallback to hardcoded) + ignore_patterns = load_ignore_patterns_from_config() + + # Check against ignore patterns + for part in path_parts: + if part in ignore_patterns: + return True + + return False diff --git a/src/aipass/prax/apps/handlers/discovery/scanner.py b/src/aipass/prax/apps/handlers/discovery/scanner.py new file mode 100755 index 00000000..389c1392 --- /dev/null +++ b/src/aipass/prax/apps/handlers/discovery/scanner.py @@ -0,0 +1,94 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: scanner.py - Directory Scanning +# Date: 2025-11-10 +# Version: 1.1.0 +# Category: prax/handlers/discovery +# +# CODE STANDARDS: +# - HANDLERS: Pure handler - no console output, returns data only +# - IMPORTS: Uses package imports (aipass.prax...) +# - ERROR_HANDLING: Silent operation, no logger imports +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2025-11-29): Removed console output - pure handler pattern +# - v1.0.0 (2025-11-10): Extracted from archive.temp/prax_discovery.py +# ============================================= + +""" +PRAX Discovery Scanner + +Safe directory scanning for Python module discovery. +""" + +from pathlib import Path + +from datetime import datetime, timezone +from typing import Dict, Any + +# Import from prax config +from aipass.prax.apps.handlers.config.load import ( + ECOSYSTEM_ROOT, + SYSTEM_LOGS_DIR +) + +# Import filtering +from aipass.prax.apps.handlers.discovery.filtering import should_ignore_path + +def scan_directory_safely(directory: Path, modules: Dict, max_depth: int = 10): + """Safely scan directory with depth limit + + Recursively scans directory for Python files, respecting ignore patterns + and depth limits to prevent infinite loops. + + Args: + directory: Directory to scan + modules: Dict to populate with discovered modules + max_depth: Maximum recursion depth (default 10) + """ + if max_depth <= 0: + return + + try: + for item in directory.iterdir(): + if should_ignore_path(item): + continue + + if item.is_file() and item.suffix == '.py': + module_name = item.stem + relative_path = item.relative_to(ECOSYSTEM_ROOT) + + modules[module_name] = { + "file_path": str(item), + "relative_path": str(relative_path), + "log_file": str(SYSTEM_LOGS_DIR / f"prax_{module_name}.log"), + "discovered_time": datetime.now(timezone.utc).isoformat(), + "size": item.stat().st_size, + "modified_time": datetime.fromtimestamp(item.stat().st_mtime).isoformat(), + "enabled": True + } + + elif item.is_dir(): + scan_directory_safely(item, modules, max_depth - 1) + + except PermissionError: + # Silent operation - permission denied directories are skipped + pass + except Exception: + # Silent operation - errors are skipped + pass + +def discover_python_modules() -> Dict[str, Dict[str, Any]]: + """Discover all Python modules in the ecosystem + + Returns: + Dict mapping module names to their metadata + """ + modules = {} + + # Scan entire ecosystem recursively + scan_directory_safely(ECOSYSTEM_ROOT, modules) + + return modules diff --git a/src/aipass/prax/apps/handlers/discovery/watcher.py b/src/aipass/prax/apps/handlers/discovery/watcher.py new file mode 100755 index 00000000..ba3d05f7 --- /dev/null +++ b/src/aipass/prax/apps/handlers/discovery/watcher.py @@ -0,0 +1,127 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: watcher.py - File System Watching +# Date: 2025-11-26 +# Version: 1.2.0 +# Category: prax/handlers/discovery +# +# CHANGELOG (Max 5 entries): +# - v1.2.0 (2025-11-26): Removed MEMORY_BANK coupling - prax only watches Python files +# - v1.1.0 (2025-11-26): Refactored as pure handler (no console output) +# - v1.0.0 (2025-11-10): Extracted from archive.temp/prax_discovery.py +# ============================================= + +""" +PRAX File Watcher (Handler) + +Pure worker that watches for new Python files and updates module registry. +Memory file handling moved to MEMORY_BANK's own watcher. + +No console output - follows 3-tier handler pattern. +""" + +from pathlib import Path + +from datetime import datetime, timezone +from typing import Any + +from watchdog.observers import Observer as WatchdogObserver +from watchdog.events import FileSystemEventHandler + +# Import from prax config +from aipass.prax.apps.handlers.config.load import ( + ECOSYSTEM_ROOT, + SYSTEM_LOGS_DIR +) + +# Import from prax registry handlers +from aipass.prax.apps.handlers.registry.load import load_module_registry +from aipass.prax.apps.handlers.registry.save import save_module_registry + +# Import filtering +from aipass.prax.apps.handlers.discovery.filtering import should_ignore_path + +# Global observer instance +_observer: Any = None + + +class PythonFileWatcher(FileSystemEventHandler): + """Watch for new Python files (pure handler)""" + + def on_created(self, event): + """Handle new file creation events""" + if not event.is_directory and str(event.src_path).endswith('.py'): + py_file = Path(str(event.src_path)) + + # Skip ignored paths + if should_ignore_path(py_file): + return + + module_name = py_file.stem + + # Skip if already in registry + modules = load_module_registry() + if module_name in modules: + return + + # Add new module to registry + try: + relative_path = py_file.relative_to(ECOSYSTEM_ROOT) + except ValueError: + # File is outside ECOSYSTEM_ROOT, skip + return + + modules[module_name] = { + "file_path": str(py_file), + "relative_path": str(relative_path), + "log_file": str(SYSTEM_LOGS_DIR / f"prax_{module_name}.log"), + "discovered_time": datetime.now(timezone.utc).isoformat(), + "size": py_file.stat().st_size, + "modified_time": datetime.fromtimestamp(py_file.stat().st_mtime).isoformat(), + "enabled": True + } + + # Save updated registry + save_module_registry(modules) + + +def start_file_watcher(): + """Start watching for new Python files + + Starts watchdog observer to monitor ECOSYSTEM_ROOT for new Python modules. + """ + global _observer + + if _observer and _observer.is_alive(): + return + + # Create watcher instance + watcher = PythonFileWatcher() + + new_observer = WatchdogObserver() + # Watch ecosystem root for Python files (recursive) + new_observer.schedule(watcher, str(ECOSYSTEM_ROOT), recursive=True) + + new_observer.start() + _observer = new_observer + + +def stop_file_watcher(): + """Stop the file watcher""" + global _observer + + if _observer and _observer.is_alive(): + _observer.stop() + _observer.join() + _observer = None + + +def is_file_watcher_active() -> bool: + """Check if file watcher is currently active + + Returns: + True if watcher is running, False otherwise + """ + return _observer is not None and _observer.is_alive() diff --git a/src/aipass/prax/apps/handlers/json/__init__.py b/src/aipass/prax/apps/handlers/json/__init__.py new file mode 100755 index 00000000..4d5cab16 --- /dev/null +++ b/src/aipass/prax/apps/handlers/json/__init__.py @@ -0,0 +1 @@ +"""JSON Handlers - Universal JSON operations for PRAX branch""" diff --git a/src/aipass/prax/apps/handlers/json/initialize.py b/src/aipass/prax/apps/handlers/json/initialize.py new file mode 100755 index 00000000..e4880d75 --- /dev/null +++ b/src/aipass/prax/apps/handlers/json/initialize.py @@ -0,0 +1,100 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: initialize.py +# Date: 2025-11-07 +# Version: 1.0.0 +# Category: prax/handlers/json +# +# CHANGELOG: +# - v1.0.0 (2025-11-07): Extracted from prax_json_handler.py - JSON structure initialization +# ============================================= + +""" +JSON Structure Initialization Handler + +Initializes complete 3-file JSON structure for a module. +Creates config, data, and log files with standard structure. + +Features: +- Create all 3 JSON files at once +- Accept optional initial config and data +- Creates initial log entry +- Returns success status + +Usage: + from aipass.prax.apps.handlers.json.initialize import initialize_json_structure + + initial_config = {"enabled": True, "max_items": 100} + initial_data = {"count": 0, "last_run": None} + + success = initialize_json_structure("my_module", json_dir, initial_config, initial_data) +""" + +from pathlib import Path +from typing import Dict, Any, Optional + +# Import other JSON handlers +from aipass.prax.apps.handlers.json.save import save_config, save_data +from aipass.prax.apps.handlers.json.log import log_operation + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "json_initialize" + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def initialize_json_structure( + module_name: str, + json_dir: Path, + initial_config: Optional[Dict[str, Any]] = None, + initial_data: Optional[Dict[str, Any]] = None +) -> bool: + """Initialize complete 3-file JSON structure for a module + + Args: + module_name: Name of the module + json_dir: Directory where JSON files are stored + initial_config: Initial config data (optional, uses empty dict if None) + initial_data: Initial data (optional, uses empty dict if None) + + Returns: + True if initialization successful, False otherwise + + Creates: + - {module_name}_config.json with standard structure + - {module_name}_data.json with standard structure + - {module_name}_log.json with initialization entry + + Example: + >>> config = {"enabled": True, "debug": False} + >>> data = {"counter": 0, "items": []} + >>> success = initialize_json_structure("my_module", json_dir, config, data) + >>> if success: + >>> print("Module JSON structure initialized") + """ + try: + # Create config + if initial_config: + save_config(module_name, json_dir, initial_config) + else: + save_config(module_name, json_dir, {}) + + # Create data + if initial_data: + save_data(module_name, json_dir, initial_data) + else: + save_data(module_name, json_dir, {}) + + # Create initial log entry + log_operation(module_name, json_dir, "Module initialized", True, {"status": "ready"}) + + return True + + except Exception as e: + return False diff --git a/src/aipass/prax/apps/handlers/json/json_handler.py b/src/aipass/prax/apps/handlers/json/json_handler.py new file mode 100755 index 00000000..76a6f6e3 --- /dev/null +++ b/src/aipass/prax/apps/handlers/json/json_handler.py @@ -0,0 +1,277 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: json_handler.py - Auto-Creating & Self-Healing JSON System +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: prax/handlers/json +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Initial implementation - JSON auto-creation and validation +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Auto-creates JSON files from templates if missing +# - Validates JSON structure before use +# - Implements config-controlled log rotation (FIFO) +# ============================================= + +""" +JSON Handler - Auto-Creating & Self-Healing JSON System + +Handles default JSON files (config, data, log) for prax modules. +Never manually create JSONs - they build themselves. +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, Any, Optional +import inspect + +from aipass.prax.apps.handlers.config.load import PRAX_ROOT +PRAX_JSON_DIR = PRAX_ROOT / "prax_json" +JSON_TEMPLATES_DIR = PRAX_ROOT / "apps" / "json_templates" + + +def _get_caller_module_name() -> str: + """ + Auto-detect calling module name from call stack + + Returns: + Module name (e.g., "imports_standard" from imports_standard.py) + """ + try: + stack = inspect.stack() + # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller + if len(stack) > 2: + caller_frame = stack[2] + caller_path = Path(caller_frame.filename) + module_name = caller_path.stem + + # Validate module name + if module_name and not module_name.startswith('_'): + return module_name + + # Fallback + return "unknown" + except Exception: + return "unknown" + + +def load_template(json_type: str, module_name: str) -> Any: + """Load JSON template from template file""" + template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json" + + if not template_path.exists(): + return None + + try: + with open(template_path, 'r', encoding='utf-8') as f: + template = json.load(f) + + # Replace placeholders + template_str = json.dumps(template) + template_str = template_str.replace("{{MODULE_NAME}}", module_name) + template_str = template_str.replace("{{TIMESTAMP}}", datetime.now().date().isoformat()) + + return json.loads(template_str) + except Exception: + return None + + +def validate_json_structure(data: Any, json_type: str) -> bool: + """Validate JSON structure matches expected type""" + if json_type == "config": + if not isinstance(data, dict): + return False + required = ["module_name", "version", "config"] + return all(key in data for key in required) + + elif json_type == "data": + if not isinstance(data, dict): + return False + required = ["created", "last_updated"] + return all(key in data for key in required) + + elif json_type == "log": + return isinstance(data, list) + + return False + + +def get_json_path(module_name: str, json_type: str) -> Path: + """Get path for module JSON file""" + filename = f"{module_name}_{json_type}.json" + return PRAX_JSON_DIR / filename + + +def ensure_json_exists(module_name: str, json_type: str) -> bool: + """Ensure JSON file exists, create from template if missing""" + PRAX_JSON_DIR.mkdir(parents=True, exist_ok=True) + + json_path = get_json_path(module_name, json_type) + + if json_path.exists(): + try: + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if validate_json_structure(data, json_type): + return True + else: + pass # Corrupted - will regenerate + except Exception: + pass # Unreadable - will regenerate + + template = load_template(json_type, module_name) + if template is None: + return False + + try: + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(template, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def load_json(module_name: str, json_type: str) -> Optional[Any]: + """Load JSON file, auto-create if missing""" + if not ensure_json_exists(module_name, json_type): + return None + + json_path = get_json_path(module_name, json_type) + + try: + with open(json_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return None + + +def save_json(module_name: str, json_type: str, data: Any) -> bool: + """Save JSON file""" + json_path = get_json_path(module_name, json_type) + + if not validate_json_structure(data, json_type): + return False + + if json_type == "data" and isinstance(data, dict): + data["last_updated"] = datetime.now().date().isoformat() + + try: + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except Exception: + return False + + +def ensure_module_jsons(module_name: str) -> bool: + """Ensure all 3 JSON files exist for a module""" + ensure_json_exists(module_name, "config") + ensure_json_exists(module_name, "data") + ensure_json_exists(module_name, "log") + return True + + +def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool: + """ + Add entry to module log with automatic rotation + + Auto-detects calling module if module_name not provided. + Implements config-controlled log limits to prevent unbounded growth. + When max_log_entries is reached, removes oldest entries (FIFO). + + Args: + operation: Operation name to log + data: Optional data dict + module_name: Optional module name (auto-detected if not provided) + + Returns: + True if successful, False otherwise + """ + # Auto-detect module name if not provided + if module_name is None: + module_name = _get_caller_module_name() + + ensure_module_jsons(module_name) + + # Load config to get max_log_entries + config = load_json(module_name, "config") + max_entries = 100 # Default + if config and "config" in config: + max_entries = config["config"].get("max_log_entries", 100) + + # Load existing log + log = load_json(module_name, "log") + if log is None: + log = [] + + # Create new entry + entry: Dict[str, Any] = { + "timestamp": datetime.now().isoformat(), + "operation": operation + } + + if data: + entry["data"] = data + + # Add new entry + log.append(entry) + + # Rotate if exceeds max (keep most recent entries) + if len(log) > max_entries: + log = log[-max_entries:] + + return save_json(module_name, "log", log) + + +def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool: + """Increment a counter in data JSON""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + if counter_name not in data: + data[counter_name] = 0 + + data[counter_name] += amount + + return save_json(module_name, "data", data) + + +def update_data_metrics(module_name: str, **metrics) -> bool: + """Update data metrics""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + for key, value in metrics.items(): + data[key] = value + + return save_json(module_name, "data", data) + + +if __name__ == "__main__": + print("\n" + "="*70) + print("JSON HANDLER - Working Implementation") + print("="*70) + print("\n[TESTING] Creating prax JSONs...") + + # Test auto-creation + log_operation("test_operation", {"test": "data"}, "prax") + increment_counter("prax", "test_counter", 1) + update_data_metrics("prax", test_metric="working") + + print("\nCheck /home/aipass/aipass_core/prax/prax_json/ for created files:") + print(" - prax_config.json") + print(" - prax_data.json") + print(" - prax_log.json") + print("\n" + "="*70 + "\n") diff --git a/src/aipass/prax/apps/handlers/json/load.py b/src/aipass/prax/apps/handlers/json/load.py new file mode 100755 index 00000000..ad2e3f3f --- /dev/null +++ b/src/aipass/prax/apps/handlers/json/load.py @@ -0,0 +1,140 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: load.py +# Date: 2025-11-07 +# Version: 1.0.0 +# Category: prax/handlers/json +# +# CHANGELOG: +# - v1.0.0 (2025-11-07): Extracted from prax_json_handler.py - JSON loading utilities +# ============================================= + +""" +JSON Load Handler + +Universal JSON loading utilities for 3-file pattern (config/data/log). +Provides standardized loading with graceful degradation. + +Features: +- Load config files with standard structure +- Load data files with standard structure +- Graceful fallback to defaults on error +- Auto-creates default structure if missing + +Usage: + from aipass.prax.apps.handlers.json.load import load_config, load_data + + config = load_config("my_module", json_dir) + data = load_data("my_module", json_dir) +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, Any + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "json_load" + +# Standard JSON structures +DEFAULT_CONFIG_STRUCTURE = { + "module_name": "", + "version": "1.0.0", + "timestamp": "", + "config": {} +} + +DEFAULT_DATA_STRUCTURE = { + "module_name": "", + "timestamp": "", + "data": {} +} + +# ============================================= +# HANDLER FUNCTIONS +# ============================================= + +def load_config(module_name: str, json_dir: Path) -> Dict[str, Any]: + """Load module config file with standard structure + + Args: + module_name: Name of the module (e.g., "branch_create") + json_dir: Directory where JSON files are stored + + Returns: + Config dict with standard structure: + { + "module_name": "...", + "version": "1.0.0", + "timestamp": "2025-11-07T...", + "config": {...} + } + + Returns default structure if file doesn't exist or on error. + + Example: + >>> config = load_config("my_module", Path("/path/to/json")) + >>> settings = config.get("config", {}) + """ + config_file = json_dir / f"{module_name}_config.json" + + try: + if config_file.exists(): + with open(config_file, 'r', encoding='utf-8') as f: + return json.load(f) + else: + # Return default structure if file doesn't exist + default = DEFAULT_CONFIG_STRUCTURE.copy() + default["module_name"] = module_name + default["timestamp"] = datetime.now().isoformat() + return default + except Exception: + default = DEFAULT_CONFIG_STRUCTURE.copy() + default["module_name"] = module_name + default["timestamp"] = datetime.now().isoformat() + return default + + +def load_data(module_name: str, json_dir: Path) -> Dict[str, Any]: + """Load module data file with standard structure + + Args: + module_name: Name of the module + json_dir: Directory where JSON files are stored + + Returns: + Data dict with standard structure: + { + "module_name": "...", + "timestamp": "2025-11-07T...", + "data": {...} + } + + Returns default structure if file doesn't exist or on error. + + Example: + >>> data = load_data("my_module", Path("/path/to/json")) + >>> runtime_data = data.get("data", {}) + """ + data_file = json_dir / f"{module_name}_data.json" + + try: + if data_file.exists(): + with open(data_file, 'r', encoding='utf-8') as f: + return json.load(f) + else: + # Return default structure + default = DEFAULT_DATA_STRUCTURE.copy() + default["module_name"] = module_name + default["timestamp"] = datetime.now().isoformat() + return default + except Exception: + default = DEFAULT_DATA_STRUCTURE.copy() + default["module_name"] = module_name + default["timestamp"] = datetime.now().isoformat() + return default diff --git a/src/aipass/prax/apps/handlers/json/log.py b/src/aipass/prax/apps/handlers/json/log.py new file mode 100755 index 00000000..6635abb6 --- /dev/null +++ b/src/aipass/prax/apps/handlers/json/log.py @@ -0,0 +1,122 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: log.py +# Date: 2025-11-07 +# Version: 1.0.0 +# Category: prax/handlers/json +# +# CHANGELOG: +# - v1.0.0 (2025-11-07): Extracted from prax_json_handler.py - JSON logging utilities +# ============================================= + +""" +JSON Log Handler + +Universal logging utility for module operations. +Maintains last 100 log entries per module in JSON format. + +Features: +- Log module operations to _log.json file +- Keep last 100 entries (newest first) +- Include timestamp, operation, success, details, error +- Graceful error handling + +Usage: + from aipass.prax.apps.handlers.json.log import log_operation + + log_operation("my_module", json_dir, "save_config", success=True, details="Config saved") + log_operation("my_module", json_dir, "load_data", success=False, error="File not found") +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Any, Optional + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "json_log" +MAX_LOG_ENTRIES = 100 + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def log_operation( + module_name: str, + json_dir: Path, + operation: str, + success: bool = True, + details: Any = None, + error: Optional[str] = None +) -> bool: + """Log module operation to log file + + Args: + module_name: Name of the module + json_dir: Directory where JSON files are stored + operation: Operation description (e.g., "save_config", "load_data") + success: Whether operation succeeded (default True) + details: Additional details (any JSON-serializable data) + error: Error message if failed (optional) + + Returns: + True if log successful, False otherwise + + Log entries are stored as array in {module_name}_log.json: + [ + { + "timestamp": "2025-11-07T...", + "operation": "save_config", + "success": true, + "details": "Config saved successfully", + "error": null + }, + ... + ] + + Only last 100 entries are kept (newest first). + + Example: + >>> log_operation("my_module", json_dir, "save_config", True, "Saved 10 items") + >>> log_operation("my_module", json_dir, "load_data", False, error="File not found") + """ + log_file = json_dir / f"{module_name}_log.json" + + try: + # Ensure directory exists + json_dir.mkdir(parents=True, exist_ok=True) + + # Load existing log + if log_file.exists(): + with open(log_file, 'r', encoding='utf-8') as f: + log_entries = json.load(f) + else: + log_entries = [] + + # Create new entry + entry = { + "timestamp": datetime.now().isoformat(), + "operation": operation, + "success": success, + "details": details, + "error": error + } + + # Add to log (newest first) + log_entries.insert(0, entry) + + # Keep only last 100 entries + log_entries = log_entries[:MAX_LOG_ENTRIES] + + # Save log + with open(log_file, 'w', encoding='utf-8') as f: + json.dump(log_entries, f, indent=2, ensure_ascii=False) + + return True + except Exception: + return False diff --git a/src/aipass/prax/apps/handlers/json/save.py b/src/aipass/prax/apps/handlers/json/save.py new file mode 100755 index 00000000..c6539903 --- /dev/null +++ b/src/aipass/prax/apps/handlers/json/save.py @@ -0,0 +1,134 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: save.py +# Date: 2025-11-07 +# Version: 1.0.0 +# Category: prax/handlers/json +# +# CHANGELOG: +# - v1.0.0 (2025-11-07): Extracted from prax_json_handler.py - JSON saving utilities +# ============================================= + +""" +JSON Save Handler + +Universal JSON saving utilities for 3-file pattern (config/data/log). +Provides standardized saving with automatic timestamp updates. + +Features: +- Save config files with standard structure +- Save data files with standard structure +- Auto-update timestamps +- Create directories if missing +- Graceful error handling + +Usage: + from aipass.prax.apps.handlers.json.save import save_config, save_data + + save_config("my_module", json_dir, {"setting": "value"}) + save_data("my_module", json_dir, {"runtime": "data"}) +""" + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, Any + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "json_save" + +# ============================================= +# HANDLER FUNCTIONS +# ============================================= + +def save_config(module_name: str, json_dir: Path, config_data: Dict[str, Any]) -> bool: + """Save module config file with standard structure + + Args: + module_name: Name of the module + json_dir: Directory where JSON files are stored + config_data: Config data dict (will be wrapped in standard structure) + + Returns: + True if successful, False otherwise + + The config is saved with this structure: + { + "module_name": "...", + "version": "1.0.0", + "timestamp": "2025-11-07T...", + "config": {...} # your config_data goes here + } + + Example: + >>> settings = {"enabled": True, "max_items": 100} + >>> success = save_config("my_module", json_dir, settings) + """ + config_file = json_dir / f"{module_name}_config.json" + + try: + # Ensure directory exists + json_dir.mkdir(parents=True, exist_ok=True) + + # Wrap data in standard structure + output = { + "module_name": module_name, + "version": config_data.get("version", "1.0.0"), + "timestamp": datetime.now().isoformat(), + "config": config_data + } + + with open(config_file, 'w', encoding='utf-8') as f: + json.dump(output, f, indent=2, ensure_ascii=False) + + return True + except Exception: + return False + + +def save_data(module_name: str, json_dir: Path, data: Dict[str, Any]) -> bool: + """Save module data file with standard structure + + Args: + module_name: Name of the module + json_dir: Directory where JSON files are stored + data: Data dict (will be wrapped in standard structure) + + Returns: + True if successful, False otherwise + + The data is saved with this structure: + { + "module_name": "...", + "timestamp": "2025-11-07T...", + "data": {...} # your data goes here + } + + Example: + >>> runtime_data = {"last_run": "2025-11-07", "count": 42} + >>> success = save_data("my_module", json_dir, runtime_data) + """ + data_file = json_dir / f"{module_name}_data.json" + + try: + # Ensure directory exists + json_dir.mkdir(parents=True, exist_ok=True) + + # Wrap data in standard structure + output = { + "module_name": module_name, + "timestamp": datetime.now().isoformat(), + "data": data + } + + with open(data_file, 'w', encoding='utf-8') as f: + json.dump(output, f, indent=2, ensure_ascii=False) + + return True + except Exception: + return False diff --git a/src/aipass/prax/apps/handlers/json_templates/__init__.py b/src/aipass/prax/apps/handlers/json_templates/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/src/aipass/prax/apps/handlers/json_templates/default/config.json b/src/aipass/prax/apps/handlers/json_templates/default/config.json new file mode 100644 index 00000000..9f7e5454 --- /dev/null +++ b/src/aipass/prax/apps/handlers/json_templates/default/config.json @@ -0,0 +1,9 @@ +{ + "module_name": "{{MODULE_NAME}}", + "version": "1.0.0", + "timestamp": "{{TIMESTAMP}}", + "config": { + "auto_save": true, + "enabled": true + } +} diff --git a/src/aipass/prax/apps/handlers/json_templates/default/data.json b/src/aipass/prax/apps/handlers/json_templates/default/data.json new file mode 100644 index 00000000..c88b23de --- /dev/null +++ b/src/aipass/prax/apps/handlers/json_templates/default/data.json @@ -0,0 +1,8 @@ +{ + "module_name": "{{MODULE_NAME}}", + "created": "{{TIMESTAMP}}", + "last_updated": "{{TIMESTAMP}}", + "operations_total": 0, + "operations_successful": 0, + "operations_failed": 0 +} diff --git a/src/aipass/prax/apps/handlers/json_templates/default/log.json b/src/aipass/prax/apps/handlers/json_templates/default/log.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/aipass/prax/apps/handlers/json_templates/default/log.json @@ -0,0 +1 @@ +[] diff --git a/src/aipass/prax/apps/handlers/logging/__init__.py b/src/aipass/prax/apps/handlers/logging/__init__.py new file mode 100755 index 00000000..cf472a74 --- /dev/null +++ b/src/aipass/prax/apps/handlers/logging/__init__.py @@ -0,0 +1,6 @@ +""" +PRAX Logging Handlers + +Internal implementation for logging system. +Used by modules/prax_logger.py - not imported directly by other branches. +""" diff --git a/src/aipass/prax/apps/handlers/logging/cli_commands.py b/src/aipass/prax/apps/handlers/logging/cli_commands.py new file mode 100755 index 00000000..e70da7ac --- /dev/null +++ b/src/aipass/prax/apps/handlers/logging/cli_commands.py @@ -0,0 +1,90 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: cli_commands.py - CLI Command Handlers +# Date: 2025-11-10 +# Version: 1.0.0 +# Category: prax/handlers/logging +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-10): Extracted from archive.temp/prax_logger.py +# ============================================= + +""" +PRAX CLI Command Handlers + +Command handlers for prax_logger CLI commands (init, status, test, run). +These are called by modules/prax_logger.py main() function. +""" + +# NOTE: These handlers will be used by modules/prax_logger.py once it's created. +# They reference functions that will be exported from that module. +# For now, this file serves as the handler structure. + +def handle_init(args): + """Handle init command + + Initializes the prax logging system: + - Creates config file if missing + - Discovers all Python modules + - Sets up system logger + - Installs logger override + - Starts file watcher + """ + # Implementation will import from modules.prax_logger + # from aipass.prax.apps.modules.prax_logger import initialize_logging_system + # initialize_logging_system() + pass + +def handle_status(args): + """Handle status command + + Displays current system status: + - Total modules discovered + - Individual loggers created + - System logs directory + - Registry file location + - File watcher status + - Logger override status + """ + # Implementation will import from modules.prax_logger + # from aipass.prax.apps.modules.prax_logger import get_system_status + # status = get_system_status() + # print("\n" + "="*60) + # print("PRAX LOGGING SYSTEM STATUS") + # print("="*60) + # for key, value in status.items(): + # print(f"{key:.<40} {value}") + # print("="*60 + "\n") + pass + +def handle_test(args): + """Handle test command + + Runs system self-test: + 1. Initialize logging system + 2. Check system status + 3. Test logger capture + 4. Check log files created + 5. Check module registry + 6. Test file watcher + 7. Clean shutdown + """ + # Implementation will import from modules.prax_logger + # Full test implementation goes here + pass + +def handle_run(args): + """Handle run command + + Starts continuous logging in background mode: + - Enables terminal output + - Initializes logging system + - Runs until Ctrl+C + - Displays status updates every 5 minutes + """ + # Implementation will import from modules.prax_logger + # from aipass.prax.apps.modules.prax_logger import start_continuous_logging + # start_continuous_logging() + pass diff --git a/src/aipass/prax/apps/handlers/logging/direct.py b/src/aipass/prax/apps/handlers/logging/direct.py new file mode 100644 index 00000000..c2db0ba5 --- /dev/null +++ b/src/aipass/prax/apps/handlers/logging/direct.py @@ -0,0 +1,256 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: direct.py - Direct Logger (Event Pipeline Bypass) +# Date: 2026-02-27 +# Version: 1.0.0 +# Category: prax/handlers/logging +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-27): Initial implementation - FPLAN-0382 Phase 2 +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Same format/rotation as regular system_logger +# - NO event pipeline involvement (no watchers, no triggers) +# - Used ONLY by infrastructure handlers that watch logs/events +# - stdlib logging.getLogger used for RotatingFileHandler management only +# (propagate=False, no root logger leakage - same pattern as setup.py) +# - Cross-handler imports: config.load + introspection (same as setup.py) +# ============================================= + +""" +PRAX Direct Logger + +Writes to the same log locations with the same format and rotation as the +regular system_logger, but WITHOUT triggering the event pipeline. + +The regular system_logger flow: + caller -> SystemLogger -> setup_individual_logger -> RotatingFileHandler + + starts file watchers -> log_watcher detects changes -> MonitoringEvent + +The direct logger flow: + caller -> DirectLogger -> RotatingFileHandler + (no watchers, no triggers, no event queue) + +Use this ONLY for infrastructure handlers that would cause recursion: + - log_watcher.py (watches the same log files it would write to) + - error_registry.py (called by log_watcher on every error) + - log_streamer.py (tails system_logs/ for Telegram streaming) + - base_bot.py (owns log_streamer, writes to tailed directory) + +Everyone else should use the regular system_logger. +""" + +from pathlib import Path + +import inspect +import logging +from logging.handlers import RotatingFileHandler +from typing import Dict, Optional, Tuple + +from aipass.prax.apps.handlers.config.load import ( + SYSTEM_LOGS_DIR, + DEFAULT_LOG_LEVEL, + load_log_config, + lines_to_bytes +) +from aipass.prax.apps.handlers.logging.introspection import detect_branch_from_path + +# Cache for direct loggers - keyed by "branch_module" +_direct_loggers: Dict[str, logging.Logger] = {} + + +def _get_direct_caller_info() -> Tuple[str, Optional[str]]: + """Stack introspection for direct logger callers. + + Unlike the regular introspection (which skips ALL prax handler frames), + this only skips frames from this file and the public API (logger.py). + This is necessary because the direct logger IS designed for prax handlers. + + Returns: + Tuple of (module_name, branch_path) where branch_path may be None. + """ + frame = inspect.currentframe() + try: + current = frame + for _ in range(15): + if current is None: + break + current = current.f_back + if current is None: + break + path = current.f_globals.get('__file__', '') + if not path: + continue + # Only skip our own internal frames + if '/logging/direct.py' in path or '/modules/logger.py' in path: + continue + module_name = Path(path).stem + branch_path = detect_branch_from_path(path) + return module_name, branch_path + return 'unknown_module', None + finally: + del frame + + +def _create_direct_logger( + module_name: str, + branch_name: str, + branch_path: Optional[str] +) -> logging.Logger: + """Create a standalone logger with RotatingFileHandlers. + + Same dual-logging setup as setup_individual_logger but with NO + connection to the event pipeline. Uses logging.Logger internally + only for RotatingFileHandler management - no root logger propagation. + + Args: + module_name: Name of the calling module (e.g., 'log_watcher') + branch_name: Short branch name (e.g., 'prax', 'trigger', 'api') + branch_path: Full branch path (e.g., 'aipass_core/prax') or None + + Returns: + Configured logger with dual file handlers and no propagation. + """ + # Namespaced to avoid collision with regular captured_ loggers + LOGGER_KEY = f"direct_{branch_name}_{module_name}" + logger = logging.getLogger(LOGGER_KEY) + logger.setLevel(DEFAULT_LOG_LEVEL) + logger.handlers.clear() + logger.propagate = False # Critical: no root logger propagation + + config = load_log_config() + formatter = logging.Formatter( + config['log_format'], + config['date_format'] + ) + + # Handler 1: System-wide log + SYS_LOG_FILE = SYSTEM_LOGS_DIR / f"{branch_name}_{module_name}.log" + SYS_LIMITS = config['system_logs'] + sys_handler = RotatingFileHandler( + SYS_LOG_FILE, + maxBytes=lines_to_bytes(SYS_LIMITS['max_lines']), + backupCount=SYS_LIMITS['backup_count'], + encoding='utf-8' + ) + sys_handler.setFormatter(formatter) + logger.addHandler(sys_handler) + + # Handler 2: Branch-local log + if branch_path: + LOCAL_LOGS_DIR = Path.home() / branch_path / "logs" + LOCAL_LOGS_DIR.mkdir(parents=True, exist_ok=True) + LOCAL_LOG_FILE = LOCAL_LOGS_DIR / f"{module_name}.log" + LOCAL_LIMITS = config['local_logs'] + local_handler = RotatingFileHandler( + LOCAL_LOG_FILE, + maxBytes=lines_to_bytes(LOCAL_LIMITS['max_lines']), + backupCount=LOCAL_LIMITS['backup_count'], + encoding='utf-8' + ) + local_handler.setFormatter(formatter) + logger.addHandler(local_handler) + + return logger + + +def _get_or_create_logger( + module_name: str, + branch_path: Optional[str] +) -> logging.Logger: + """Get cached logger or create a new one. + + Args: + module_name: Name of the calling module. + branch_path: Full branch path or None. + + Returns: + Cached or newly created direct logger. + """ + branch_name = branch_path.split('/')[-1] if branch_path else "unknown" + key = f"{branch_name}_{module_name}" + if key not in _direct_loggers: + _direct_loggers[key] = _create_direct_logger( + module_name, branch_name, branch_path + ) + return _direct_loggers[key] + + +class DirectLogger: + """Logger that bypasses the event pipeline. + + Writes to the same locations with the same format and rotation + as the regular system_logger. Module and branch are resolved + once at creation time via stack introspection. + + Usage: + from aipass.prax.apps.modules.logger import get_direct_logger + direct_logger = get_direct_logger() + direct_logger.info("Safe to log from infrastructure handlers") + """ + + def __init__(self, module_name: str, branch_path: Optional[str]): + self._module_name = module_name + self._branch_path = branch_path + self._logger: Optional[logging.Logger] = None + + def _ensure_logger(self) -> logging.Logger: + """Lazily create the underlying logger on first use. + + Returns: + The cached direct logger instance. + """ + if self._logger is None: + self._logger = _get_or_create_logger( + self._module_name, self._branch_path + ) + return self._logger + + def info(self, message: str, *args, **kwargs) -> None: + """Log an INFO level message directly to file.""" + self._ensure_logger().info(message, *args, **kwargs) + + def warning(self, message: str, *args, **kwargs) -> None: + """Log a WARNING level message directly to file.""" + self._ensure_logger().warning(message, *args, **kwargs) + + def error(self, message: str, *args, **kwargs) -> None: + """Log an ERROR level message directly to file.""" + self._ensure_logger().error(message, *args, **kwargs) + + def debug(self, message: str, *args, **kwargs) -> None: + """Log a DEBUG level message directly to file.""" + self._ensure_logger().debug(message, *args, **kwargs) + + +def get_direct_logger() -> DirectLogger: + """Get a DirectLogger bound to the calling module. + + Resolves module name and branch path at creation time via + stack introspection. The returned logger can be stored as a + module-level variable and reused. + + Returns: + DirectLogger instance bound to the caller's module/branch. + """ + module_name, branch_path = _get_direct_caller_info() + return DirectLogger(module_name, branch_path) + + +def direct_log(level: str, message: str) -> None: + """Log a single message directly without event pipeline. + + Resolves the calling module automatically. For repeated use, + prefer get_direct_logger() to avoid repeated stack walks. + + Args: + level: Log level string ('info', 'warning', 'error', 'debug') + message: The log message + """ + module_name, branch_path = _get_direct_caller_info() + logger = _get_or_create_logger(module_name, branch_path) + log_fn = getattr(logger, level.lower(), logger.info) + log_fn(message) diff --git a/src/aipass/prax/apps/handlers/logging/introspection.py b/src/aipass/prax/apps/handlers/logging/introspection.py new file mode 100755 index 00000000..2a781957 --- /dev/null +++ b/src/aipass/prax/apps/handlers/logging/introspection.py @@ -0,0 +1,125 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: introspection.py - Stack Introspection +# Date: 2025-11-10 +# Version: 1.0.0 +# Category: prax/handlers/logging +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-10): Extracted from archive.temp/prax_handlers.py +# ============================================= + +""" +PRAX Logging Introspection + +Stack introspection for detecting calling modules and branch paths. +Used by logger_setup.py to route logs to correct files. +""" + +from pathlib import Path +from typing import Optional + +def get_calling_module() -> str: + """Detect calling module from stack trace + + Returns: + Module name (e.g., 'drone', 'flow', 'cortex') or 'unknown_module' + """ + import inspect + + frame = inspect.currentframe() + try: + # Walk up the stack to find the calling module + current_frame = frame + frame_count = 0 + while current_frame and frame_count < 10: # Limit to prevent infinite loop + current_frame = current_frame.f_back + frame_count += 1 + if current_frame: + module_path = current_frame.f_globals.get('__file__', '') + if module_path and module_path != __file__: + # Skip any frame that's from prax internal files + if ('/prax/apps/modules/logger.py' not in module_path and + '/prax/apps/handlers/' not in module_path and + 'prax_logger.py' not in module_path and + 'prax_handlers.py' not in module_path): + module_name = Path(module_path).stem + return module_name + return 'unknown_module' + finally: + del frame + +def get_calling_module_path() -> Optional[str]: + """Detect calling module path from stack trace + + Returns: + Full path to calling module file or None + """ + import inspect + + frame = inspect.currentframe() + try: + # Walk up the stack to find the calling module + current_frame = frame + frame_count = 0 + while current_frame and frame_count < 10: + current_frame = current_frame.f_back + frame_count += 1 + if current_frame: + module_path = current_frame.f_globals.get('__file__', '') + if module_path and module_path != __file__: + # Skip any frame that's from prax internal files + if ('/prax/apps/modules/logger.py' not in module_path and + '/prax/apps/handlers/' not in module_path and + 'prax_logger.py' not in module_path and + 'prax_handlers.py' not in module_path): + return module_path + return None + finally: + del frame + +def detect_branch_from_path(module_path: str) -> Optional[str]: + """Detect branch name from module file path + + Examples: + /home/aipass/aipass_core/flow/apps/module.py → "aipass_core/flow" + /home/aipass/aipass_core/cortex/apps/module.py → "aipass_core/cortex" + /home/aipass/aipass_core/prax/apps/module.py → "aipass_core/prax" + /home/aipass/other_dir/module.py → "other_dir" + + Returns: + Branch path (e.g., "aipass_core/flow") or None + """ + if not module_path: + return None + + path = Path(module_path) + parts = path.parts + + # Find /home/aipass in path + try: + aipass_idx = parts.index('aipass') + + # Check if this is aipass_core structure + if aipass_idx + 1 < len(parts) and parts[aipass_idx + 1] == 'aipass_core': + # For aipass_core, we want aipass_core/module_name format + # e.g., /home/aipass/aipass_core/flow/apps/module.py → "aipass_core/flow" + if aipass_idx + 2 < len(parts): + module_folder = parts[aipass_idx + 2] + # Make sure it's not just a file in aipass_core root + if aipass_idx + 3 < len(parts): + return f"aipass_core/{module_folder}" + else: + # For other structures, return the folder after aipass + # e.g., /home/aipass/some_project/module.py → "some_project" + if aipass_idx + 1 < len(parts): + potential_branch = parts[aipass_idx + 1] + # Skip if it's a file directly in /home/aipass + if aipass_idx + 2 < len(parts): + return potential_branch + except ValueError: + pass + + return None diff --git a/src/aipass/prax/apps/handlers/logging/log_watchdog.py b/src/aipass/prax/apps/handlers/logging/log_watchdog.py new file mode 100644 index 00000000..e456eb59 --- /dev/null +++ b/src/aipass/prax/apps/handlers/logging/log_watchdog.py @@ -0,0 +1,276 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: log_watchdog.py - System Log Size Watchdog +# Date: 2026-02-26 +# Version: 0.1.0 +# Category: prax/handlers/logging +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2026-02-26): DPLAN-037 Phase 4 - log size monitoring and enforcement +# +# CODE STANDARDS: +# - Handler tier 3: Pure functions, no CLI imports +# - Returns data, does not print — callers handle display +# - BYPASS: Direct Path operations on system_logs (not cross-branch) +# ============================================= + +""" +System Log Size Watchdog + +Scans /home/aipass/system_logs/ for oversized log files and enforces size limits. +Catches ALL log files regardless of how they were created — even those bypassing +PRAX's RotatingFileHandler (e.g., telegram bots using plain FileHandler). + +This is the safety net: even if a branch misconfigures logging, the watchdog +prevents unbounded growth that caused the 2026-02-26 system crash (DPLAN-037). + +Two modes: + - audit: Report oversized files without changing anything + - enforce: Truncate oversized files to keep last max_lines +""" + +import sys +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List, Tuple + + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +SYSTEM_LOGS_DIR = Path.home() / "system_logs" + +# Thresholds +WARN_THRESHOLD_LINES = 5000 # Fire warning at this line count +DEFAULT_MAX_LINES = 1000 # Truncate to this many lines (matches prax config) +CRITICAL_THRESHOLD_LINES = 10000 # Immediate action recommended + + +# ============================================================================= +# SCANNING +# ============================================================================= + +def _count_lines(filepath: Path) -> int: + """ + Count lines in a file efficiently. + + Args: + filepath: Path to the file + + Returns: + Line count, or 0 on error + """ + try: + with open(filepath, 'rb') as f: + return sum(1 for _ in f) + except OSError: + return 0 + + +def _get_file_size_kb(filepath: Path) -> float: + """ + Get file size in kilobytes. + + Args: + filepath: Path to the file + + Returns: + Size in KB, or 0.0 on error + """ + try: + return filepath.stat().st_size / 1024.0 + except OSError: + return 0.0 + + +def scan_log_files() -> List[Dict[str, Any]]: + """ + Scan all .log files in system_logs/ and report their sizes. + + Returns: + List of dicts with path, name, lines, size_kb, status + Status: 'ok', 'warning', 'critical' + """ + results: List[Dict[str, Any]] = [] + + if not SYSTEM_LOGS_DIR.exists(): + return results + + for log_file in sorted(SYSTEM_LOGS_DIR.glob("*.log")): + lines = _count_lines(log_file) + size_kb = _get_file_size_kb(log_file) + + if lines >= CRITICAL_THRESHOLD_LINES: + status = "critical" + elif lines >= WARN_THRESHOLD_LINES: + status = "warning" + else: + status = "ok" + + results.append({ + "path": str(log_file), + "name": log_file.name, + "lines": lines, + "size_kb": round(size_kb, 1), + "status": status + }) + + # Sort by line count descending (biggest problems first) + results.sort(key=lambda x: x["lines"], reverse=True) + return results + + +def get_oversized_files(threshold: int = WARN_THRESHOLD_LINES) -> List[Dict[str, Any]]: + """ + Get only files exceeding the threshold. + + Args: + threshold: Line count threshold + + Returns: + List of file info dicts for files exceeding threshold + """ + return [f for f in scan_log_files() if f["lines"] >= threshold] + + +# ============================================================================= +# ENFORCEMENT +# ============================================================================= + +def truncate_log_file(filepath: Path, keep_lines: int = DEFAULT_MAX_LINES) -> Tuple[int, int]: + """ + Truncate a log file to keep only the last N lines. + + Reads the file, keeps the tail, writes it back. This is the nuclear option + for files that grew unbounded because they bypassed RotatingFileHandler. + + Args: + filepath: Path to the log file + keep_lines: Number of lines to keep from the end + + Returns: + Tuple of (original_lines, new_lines) + """ + try: + with open(filepath, 'r', encoding='utf-8', errors='replace') as f: + all_lines = f.readlines() + + original_count = len(all_lines) + + if original_count <= keep_lines: + return original_count, original_count + + # Keep only the last keep_lines + kept_lines = all_lines[-keep_lines:] + + # Prepend a truncation marker + marker = ( + f"--- LOG TRUNCATED by PRAX watchdog at {datetime.now().isoformat()} " + f"| was {original_count} lines, kept last {keep_lines} ---\n" + ) + + with open(filepath, 'w', encoding='utf-8') as f: + f.write(marker) + f.writelines(kept_lines) + + return original_count, keep_lines + 1 # +1 for marker line + + except OSError: + return 0, 0 + + +def enforce_log_limits(max_lines: int = DEFAULT_MAX_LINES, + threshold: int = WARN_THRESHOLD_LINES) -> List[Dict[str, Any]]: + """ + Scan and truncate all oversized log files. + + Only truncates files exceeding the threshold. Files within limits are + left untouched. + + Args: + max_lines: Truncate to this many lines + threshold: Only truncate files exceeding this many lines + + Returns: + List of dicts describing what was truncated + """ + actions: List[Dict[str, Any]] = [] + + oversized = get_oversized_files(threshold) + + for file_info in oversized: + filepath = Path(file_info["path"]) + original, new = truncate_log_file(filepath, max_lines) + + actions.append({ + "name": file_info["name"], + "original_lines": original, + "new_lines": new, + "truncated": original != new + }) + + return actions + + +# ============================================================================= +# HEALTH CHECK (for monitoring integration) +# ============================================================================= + +def log_health_summary() -> Dict[str, Any]: + """ + Generate a health summary of system logs. + + Returns: + Dict with total_files, total_lines, oversized_count, + critical_count, largest_file, healthy + """ + files = scan_log_files() + + if not files: + return { + "total_files": 0, + "total_lines": 0, + "oversized_count": 0, + "critical_count": 0, + "largest_file": None, + "healthy": True + } + + total_lines = sum(f["lines"] for f in files) + oversized = [f for f in files if f["status"] in ("warning", "critical")] + critical = [f for f in files if f["status"] == "critical"] + largest = files[0] if files else None # Already sorted by lines desc + + return { + "total_files": len(files), + "total_lines": total_lines, + "oversized_count": len(oversized), + "critical_count": len(critical), + "largest_file": largest["name"] if largest else None, + "largest_lines": largest["lines"] if largest else 0, + "healthy": len(oversized) == 0 + } + + +# ============================================================================= +# CLI ENTRY POINT (for testing) +# ============================================================================= + +if __name__ == "__main__": + import json + + if len(sys.argv) > 1 and sys.argv[1] == "enforce": + print("Enforcing log limits...") + actions = enforce_log_limits() + print(json.dumps(actions, indent=2)) + else: + print("Log health summary:") + summary = log_health_summary() + print(json.dumps(summary, indent=2)) + print() + print("Oversized files:") + oversized = get_oversized_files() + print(json.dumps(oversized, indent=2)) diff --git a/src/aipass/prax/apps/handlers/logging/monitoring.py b/src/aipass/prax/apps/handlers/logging/monitoring.py new file mode 100755 index 00000000..eb83bc6f --- /dev/null +++ b/src/aipass/prax/apps/handlers/logging/monitoring.py @@ -0,0 +1,80 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: monitoring.py - PRAX Logging Handler +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: prax/handlers/logging +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Extracted monitoring loop from modules/logger.py +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Uses Prax logger for system-wide logging +# - Implements graceful shutdown with KeyboardInterrupt handling +# ============================================= + +""" +PRAX Logging Monitoring Handler + +Implements the continuous monitoring loop for the logging system. +HANDLER pattern: Contains the business logic for continuous monitoring. + +This is the thick implementation that modules/logger.py calls. +""" + +import sys +import time +from pathlib import Path +from typing import Callable, Dict, Any + +from aipass.prax.apps.modules.logger import system_logger as logger + + +def run_monitoring_loop( + status_callback: Callable[[], Dict[str, Any]], + interval: int = 5, + status_interval: int = 300 +) -> None: + """Run continuous monitoring loop - HANDLER implements logic + + This is the thick handler that contains the actual control loop. + + Args: + status_callback: Function to call to get system status + interval: Check interval in seconds (default: 5) + status_interval: Status update interval in seconds (default: 300 = 5 minutes) + + Raises: + KeyboardInterrupt: When user presses Ctrl+C (caught and handled gracefully) + """ + module_name = "prax_logger" + + try: + logger.info(f"[{module_name}] Logger capture active - monitoring all modules") + logger.info(f"[{module_name}] Terminal output enabled - you'll see live logs below") + logger.info("Press Ctrl+C to stop logging...") + logger.info("=" * 60) + sys.stdout.flush() + + counter = 0 + while True: + time.sleep(interval) + counter += interval + + # Status update at specified interval + if counter % status_interval == 0: + logger.info("\n" + "=" * 60) + status = status_callback() + modules_count = status.get('total_modules', 0) + loggers_count = status.get('individual_loggers', 0) + logger.info(f"[{module_name}] Status: {modules_count} modules discovered, {loggers_count} loggers active") + logger.info("=" * 60) + sys.stdout.flush() + + except KeyboardInterrupt: + logger.info(f"\n[{module_name}] Shutting down continuous logging...") + sys.stdout.flush() + raise # Re-raise to let caller handle cleanup diff --git a/src/aipass/prax/apps/handlers/logging/operations.py b/src/aipass/prax/apps/handlers/logging/operations.py new file mode 100755 index 00000000..fa76fcf2 --- /dev/null +++ b/src/aipass/prax/apps/handlers/logging/operations.py @@ -0,0 +1,99 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: operations.py - Logging Operations +# Date: 2025-11-10 +# Version: 1.0.0 +# Category: prax/handlers/logging +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-10): Extracted from archive.temp/prax_logger.py +# ============================================= + +""" +PRAX Logging Operations + +Operation logging and configuration management for prax logger. +""" + +from pathlib import Path + +import json +from datetime import datetime, timezone +from typing import Dict, Optional + +# Import from prax config +from aipass.prax.apps.handlers.config.load import PRAX_JSON_DIR +from aipass.prax.apps.handlers.logging.direct import get_direct_logger + +_logger = get_direct_logger() + +# Module constants +MODULE_NAME = "prax_logger" +CONFIG_FILE = PRAX_JSON_DIR / f"{MODULE_NAME}_config.json" +DATA_FILE = PRAX_JSON_DIR / f"{MODULE_NAME}_data.json" +LOG_FILE = PRAX_JSON_DIR / f"{MODULE_NAME}_log.json" + +def log_operation(message: str, data: Optional[Dict] = None): + """Log prax_logging operations to JSON log file + + Args: + message: Operation description + data: Optional operation data dict + """ + entry = { + "timestamp": datetime.now(timezone.utc).isoformat(), + "operation": message, + "data": data or {} + } + + # Load existing log + log_entries = [] + if LOG_FILE.exists(): + try: + with open(LOG_FILE, 'r', encoding='utf-8') as f: + log_entries = json.load(f) + except Exception: + log_entries = [] + + # Add new entry + log_entries.append(entry) + + # Keep only last 1000 entries + if len(log_entries) > 1000: + log_entries = log_entries[-1000:] + + # Save log + with open(LOG_FILE, 'w', encoding='utf-8') as f: + json.dump(log_entries, f, indent=2, ensure_ascii=False) + +def create_config_file(): + """Create default config file if it doesn't exist""" + if not CONFIG_FILE.exists(): + default_config = { + "module_name": MODULE_NAME, + "timestamp": datetime.now(timezone.utc).isoformat(), + "config": { + "log_level": "INFO", + "max_log_size_mb": 10, + "backup_count": 5, + "log_format": "%(asctime)s | %(name)s | %(levelname)s | %(message)s", + "date_format": "%Y-%m-%d %H:%M:%S", + "console_output": True, + "file_output": True, + "rotation_enabled": True, + "debug_prints": False, + "log_directories": [ + "system_logs", + "skill_logs", + "error_logs" + ] + } + } + try: + with open(CONFIG_FILE, 'w', encoding='utf-8') as f: + json.dump(default_config, f, indent=2, ensure_ascii=False) + _logger.info("Config file created: %s", CONFIG_FILE) + except Exception as e: + _logger.warning("Failed to create config file: %s", e) diff --git a/src/aipass/prax/apps/handlers/logging/override.py b/src/aipass/prax/apps/handlers/logging/override.py new file mode 100755 index 00000000..ab6a5f2c --- /dev/null +++ b/src/aipass/prax/apps/handlers/logging/override.py @@ -0,0 +1,115 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: override.py - Logger Override System +# Date: 2025-11-10 +# Version: 1.0.0 +# Category: prax/handlers/logging +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-10): Extracted from archive.temp/prax_handlers.py +# ============================================= + +""" +PRAX Logger Override + +Global logging.getLogger() override for automatic log routing. +Intercepts logging.getLogger() calls and routes to module-specific logs. +""" + +from pathlib import Path + +import logging +from typing import Optional + +# Import from prax config +from aipass.prax.apps.handlers.config.load import ( + DEFAULT_LOG_LEVEL, + get_debug_prints_enabled +) + +# Import logging setup +from aipass.prax.apps.handlers.logging.setup import setup_individual_logger + +# Import introspection +from aipass.prax.apps.handlers.logging.introspection import get_calling_module + +from rich.console import Console +console = Console() + +# Store original logging functions for restoration +_original_getLogger = logging.getLogger +_original_basicConfig = logging.basicConfig + +def enhanced_getLogger(name: Optional[str] = None) -> logging.Logger: + """Enhanced getLogger that redirects to our individual module loggers + + This function replaces the standard logging.getLogger() globally. + When any module calls logging.getLogger(), it automatically routes + to the appropriate module-specific log file. + + Args: + name: Logger name (usually ignored, we detect from stack) + + Returns: + Logger configured to write to module-specific log files + """ + # Only print debug info if enabled in config + if get_debug_prints_enabled(): + print(f"[DEBUG] enhanced_getLogger called with name: {name}") + + # Get the original logger first + original_logger = _original_getLogger(name) + + # Get calling module for our routing + module_name = get_calling_module() + if get_debug_prints_enabled(): + print(f"[DEBUG] Detected calling module: {module_name}") + + # If we can detect the module, add our custom handler + if module_name != 'unknown_module': + if get_debug_prints_enabled(): + print(f"[DEBUG] Setting up individual logger for: {module_name}") + # Clear existing handlers to prevent console output + original_logger.handlers.clear() + + # Get or create our individual logger for this module + individual_logger = setup_individual_logger(module_name) + + # Copy the handler from our individual logger to the original logger + for handler in individual_logger.handlers: + original_logger.addHandler(handler) + + # Set appropriate level + original_logger.setLevel(DEFAULT_LOG_LEVEL) + + # Prevent propagation to root logger (stops console output) + original_logger.propagate = False + + return original_logger + +def install_logger_override(): + """Install the enhanced getLogger function globally + + Replaces logging.getLogger with our enhanced version. + After this, all logging.getLogger() calls are intercepted. + """ + logging.getLogger = enhanced_getLogger + print("[prax] Global logger override installed") + +def restore_original_logger(): + """Restore original getLogger function + + Removes the override and restores standard Python logging behavior. + """ + logging.getLogger = _original_getLogger + print("[prax] Original logger function restored") + +def is_override_active() -> bool: + """Check if logger override is currently active + + Returns: + True if override is installed, False if using original + """ + return logging.getLogger != _original_getLogger diff --git a/src/aipass/prax/apps/handlers/logging/setup.py b/src/aipass/prax/apps/handlers/logging/setup.py new file mode 100755 index 00000000..b872cf29 --- /dev/null +++ b/src/aipass/prax/apps/handlers/logging/setup.py @@ -0,0 +1,242 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: setup.py - Logger Setup & Management +# Date: 2025-11-10 +# Version: 1.0.0 +# Category: prax/handlers/logging +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-10): Extracted from archive.temp/prax_handlers.py +# ============================================= + +""" +PRAX Logger Setup + +Creates and configures individual loggers for modules. +Handles dual logging (system-wide + branch-local) and terminal output. +""" + +from pathlib import Path + +import logging +from typing import Dict, Optional +from logging.handlers import RotatingFileHandler + +# Import from prax config +from aipass.prax.apps.handlers.config.load import ( + SYSTEM_LOGS_DIR, + DEFAULT_LOG_LEVEL, + load_log_config, + lines_to_bytes +) + +# Import introspection functions +from aipass.prax.apps.handlers.logging.introspection import ( + get_calling_module_path, + detect_branch_from_path +) + +# CLI import +from aipass.cli.apps.modules import console + +# Global state for logging system +_system_logger: Optional[logging.Logger] = None +_captured_loggers: Dict[str, logging.Logger] = {} +_terminal_output_enabled = False + +# Try to import terminal handler support +_terminal_module_available = False +try: + from aipass.prax.apps.handlers.logging.terminal.formatting import create_terminal_handler + from aipass.prax.apps.handlers.logging.terminal.filtering import should_display_terminal + _terminal_module_available = True +except ImportError: + pass # Terminal module not available yet + +def setup_individual_logger(module_name: str) -> logging.Logger: + """Setup individual logger for a specific module with dual logging support + + Creates: + - System-wide log: /home/aipass/system_logs/prax_{module_name}.log + - Branch-local log: /home/aipass/{branch}/logs/{module_name}.log (if in a branch) + - Terminal handler (if enabled) + + Args: + module_name: Name of the module requesting a logger + + Returns: + Configured logger instance + """ + if module_name in _captured_loggers: + return _captured_loggers[module_name] + + # Log new logger creation + if _system_logger: + _system_logger.info(f"Creating logger for module: {module_name}") + + # Create individual logger for this module + logger = logging.getLogger(f"captured_{module_name}") + logger.setLevel(DEFAULT_LOG_LEVEL) + logger.handlers.clear() + + # Load config-driven limits + log_config = load_log_config() + + # Detect calling branch FIRST (needed for both system and branch logs) + module_path = get_calling_module_path() + branch_path = detect_branch_from_path(module_path) if module_path else None + + # Extract branch name from path + # "aipass_core/cortex" → "cortex" + # "aipass_core/flow" → "flow" + # "some_project" → "some_project" + if branch_path: + branch_name = branch_path.split('/')[-1] + else: + branch_name = "prax" # Fallback to prax if no branch detected + + # Create formatter (shared by all handlers) + formatter = logging.Formatter( + log_config['log_format'], + log_config['date_format'] + ) + + # HANDLER 1: System-wide log (named after calling branch) + system_log_file = SYSTEM_LOGS_DIR / f"{branch_name}_{module_name}.log" + system_limits = log_config['system_logs'] + system_max_bytes = lines_to_bytes(system_limits['max_lines']) + system_handler = RotatingFileHandler( + system_log_file, + maxBytes=system_max_bytes, + backupCount=system_limits['backup_count'], + encoding='utf-8' + ) + system_handler.setFormatter(formatter) + logger.addHandler(system_handler) + + # HANDLER 2: Branch-local log (if module is in a branch) + branch = branch_path # Use already detected branch_path + + if branch: + # Create branch logs directory if it doesn't exist + # Branch format is now "aipass_core/module" or "project_name" + branch_logs_dir = Path("/home/aipass") / branch / "logs" + branch_logs_dir.mkdir(parents=True, exist_ok=True) + + # Create branch-local log file + branch_log_file = branch_logs_dir / f"{module_name}.log" + local_limits = log_config['local_logs'] + local_max_bytes = lines_to_bytes(local_limits['max_lines']) + branch_handler = RotatingFileHandler( + branch_log_file, + maxBytes=local_max_bytes, + backupCount=local_limits['backup_count'], + encoding='utf-8' + ) + branch_handler.setFormatter(formatter) + logger.addHandler(branch_handler) + + # Log to system logger + if _system_logger: + _system_logger.info(f"Logger created for {module_name} → system: {system_log_file} ({system_limits['max_lines']} lines), branch: {branch_log_file} ({local_limits['max_lines']} lines)") + else: + # Log to system logger + if _system_logger: + _system_logger.info(f"Logger created for {module_name} → {system_log_file} ({system_limits['max_lines']} lines)") + + # HANDLER 3: Terminal output (if enabled) + if _terminal_output_enabled and _terminal_module_available: + if should_display_terminal(module_name): + terminal_handler = create_terminal_handler() + logger.addHandler(terminal_handler) + + # Store for reuse + _captured_loggers[module_name] = logger + + return logger + +def setup_system_logger() -> logging.Logger: + """Setup prax_logger's own logging + + Creates the system logger used by prax itself. + + Returns: + Prax system logger instance + """ + global _system_logger + + if _system_logger: + return _system_logger + + # Load config-driven limits + log_config = load_log_config() + + # Create prax_logger's own logger + _system_logger = logging.getLogger("prax_system_logger") + _system_logger.setLevel(DEFAULT_LOG_LEVEL) + _system_logger.handlers.clear() + + # Create prax_logger's own log file + log_file = SYSTEM_LOGS_DIR / "prax_logger.log" + + # Create rotating file handler with config-driven limits + system_limits = log_config['system_logs'] + system_max_bytes = lines_to_bytes(system_limits['max_lines']) + handler = RotatingFileHandler( + log_file, + maxBytes=system_max_bytes, + backupCount=system_limits['backup_count'], + encoding='utf-8' + ) + + # Set formatter + formatter = logging.Formatter( + log_config['log_format'], + log_config['date_format'] + ) + handler.setFormatter(formatter) + _system_logger.addHandler(handler) + + # Log system logger creation + _system_logger.info("Prax system logger initialized successfully") + _system_logger.info(f"System logger writing to: {log_file} ({system_limits['max_lines']} lines max)") + + return _system_logger + +def get_captured_loggers_count() -> int: + """Get count of captured loggers + + Returns: + Number of module loggers created + """ + return len(_captured_loggers) + +def get_captured_loggers() -> Dict[str, logging.Logger]: + """Get dictionary of captured loggers + + Returns: + Copy of captured loggers dict + """ + return _captured_loggers.copy() + +def enable_terminal_output(): + """Enable terminal output for all future loggers""" + global _terminal_output_enabled + _terminal_output_enabled = True + console.print("[prax] Terminal output enabled") + +def disable_terminal_output(): + """Disable terminal output""" + global _terminal_output_enabled + _terminal_output_enabled = False + console.print("[prax] Terminal output disabled") + +def is_terminal_output_enabled() -> bool: + """Check if terminal output is enabled + + Returns: + True if terminal output is enabled + """ + return _terminal_output_enabled diff --git a/src/aipass/prax/apps/handlers/logging/terminal/__init__.py b/src/aipass/prax/apps/handlers/logging/terminal/__init__.py new file mode 100755 index 00000000..7b1e5017 --- /dev/null +++ b/src/aipass/prax/apps/handlers/logging/terminal/__init__.py @@ -0,0 +1,5 @@ +""" +PRAX Terminal Handlers + +Terminal output formatting and filtering. +""" diff --git a/src/aipass/prax/apps/handlers/logging/terminal/filtering.py b/src/aipass/prax/apps/handlers/logging/terminal/filtering.py new file mode 100755 index 00000000..63934c75 --- /dev/null +++ b/src/aipass/prax/apps/handlers/logging/terminal/filtering.py @@ -0,0 +1,72 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: filtering.py - Terminal Output Filtering +# Date: 2025-11-10 +# Version: 1.0.0 +# Category: prax/handlers/logging/terminal +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-10): Extracted from archive.temp/prax_terminal.py +# ============================================= + +""" +PRAX Terminal Filtering + +Filters terminal output to reduce noise from internal modules. +""" + +from pathlib import Path + +import json +from typing import Set, Optional + +# Import from prax config +from aipass.prax.apps.handlers.config.load import PRAX_JSON_DIR + +# Module constants +MODULE_NAME = "prax_terminal" +CONFIG_FILE = PRAX_JSON_DIR / f"{MODULE_NAME}_config.json" + +# Default modules to filter (prax internal modules) +DEFAULT_FILTERED_MODULES = { + 'prax_logger', 'prax_handlers', 'prax_config', + 'prax_registry', 'prax_discovery', 'prax_terminal' +} + +def load_filtered_modules() -> Set[str]: + """Load filtered modules from config + + Returns filtered modules set from config file, + or default set if config doesn't exist. + + Returns: + Set of module names to filter from terminal output + """ + if CONFIG_FILE.exists(): + try: + with open(CONFIG_FILE, 'r', encoding='utf-8') as f: + config = json.load(f) + return set(config.get('filtered_modules', DEFAULT_FILTERED_MODULES)) + except Exception: + pass + + return DEFAULT_FILTERED_MODULES + +def should_display_terminal(module_name: str, filtered_modules: Optional[Set[str]] = None) -> bool: + """Determine if module should be displayed in terminal output + + Filters out prax internal modules by default to reduce noise. + + Args: + module_name: Module name to check + filtered_modules: Optional set of filtered modules (loads from config if None) + + Returns: + True if module should be displayed, False if filtered + """ + if filtered_modules is None: + filtered_modules = load_filtered_modules() + + return module_name not in filtered_modules diff --git a/src/aipass/prax/apps/handlers/logging/terminal/formatting.py b/src/aipass/prax/apps/handlers/logging/terminal/formatting.py new file mode 100755 index 00000000..dacb1e49 --- /dev/null +++ b/src/aipass/prax/apps/handlers/logging/terminal/formatting.py @@ -0,0 +1,120 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: formatting.py - Terminal Output Formatting +# Date: 2025-11-10 +# Version: 1.0.0 +# Category: prax/handlers/logging/terminal +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-10): Extracted from archive.temp/prax_terminal.py +# ============================================= + +""" +PRAX Terminal Formatting + +Terminal output formatting with branch-aware display. +""" + +import sys +from pathlib import Path + +import logging +from typing import Optional + +# Import from prax config +from aipass.prax.apps.handlers.config.load import DEFAULT_LOG_LEVEL + +# Import filtering +from aipass.prax.apps.handlers.logging.terminal.filtering import should_display_terminal + +def detect_branch_from_logger_name(logger_name: str) -> Optional[str]: + """Detect branch from logger name + + Logger names follow pattern: captured_{module_name} + We need to check if the module has a branch in its path + + Args: + logger_name: Logger name (e.g., "captured_drone") + + Returns: + Branch name or None + """ + # This will be enhanced when we have access to module registry + # For now, return None (will show as SYSTEM) + return None + +def format_terminal_message(record: logging.LogRecord, branch: Optional[str] = None) -> str: + """Format log record for terminal output + + Format: [BRANCH] module - LEVEL: message + Example: [prax] test_module - INFO: Test message + + Args: + record: Logging record to format + branch: Optional branch name + + Returns: + Formatted message string + """ + # Extract module name from logger name + # Logger names are like "captured_{module_name}" + logger_name = record.name + if logger_name.startswith("captured_"): + module_name = logger_name[9:] # Remove "captured_" prefix + else: + module_name = logger_name + + # Determine branch label + branch_label = branch if branch else "SYSTEM" + + # Format level name (fixed width for alignment) + level = record.levelname + + # Format message + return f"[{branch_label}] {module_name} - {level}: {record.getMessage()}" + +class TerminalFormatter(logging.Formatter): + """Custom formatter for terminal output with branch information""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + def format(self, record: logging.LogRecord) -> str: + """Format the record for terminal output + + Args: + record: Logging record to format + + Returns: + Formatted string or empty string if filtered + """ + # Extract module name + logger_name = record.name + if logger_name.startswith("captured_"): + module_name = logger_name[9:] + else: + module_name = logger_name + + # Check if should display + if not should_display_terminal(module_name): + return "" # Skip this message + + # Format and return + return format_terminal_message(record) + +def create_terminal_handler() -> logging.StreamHandler: + """Create StreamHandler for terminal output + + Returns: + Configured stream handler with custom formatter + """ + handler = logging.StreamHandler(sys.stdout) + handler.setLevel(DEFAULT_LOG_LEVEL) + + # Use custom formatter + formatter = TerminalFormatter() + handler.setFormatter(formatter) + + return handler diff --git a/src/aipass/prax/apps/handlers/monitoring/INTEGRATION_EXAMPLE.py b/src/aipass/prax/apps/handlers/monitoring/INTEGRATION_EXAMPLE.py new file mode 100644 index 00000000..1068d873 --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/INTEGRATION_EXAMPLE.py @@ -0,0 +1,225 @@ +#!/home/aipass/.venv/bin/python3 + +""" +Example: File Watcher Integration with monitor_module.py + +This shows how to integrate the file watcher with the monitoring module. +This code would go in monitor_module.py's handle_command() function. +""" + +import sys +from pathlib import Path + +from aipass.prax.apps.handlers.monitoring import ( + start_file_watcher, + stop_file_watcher, + is_file_watcher_running, + get_file_watcher_stats, + FileWatcherManager, + MonitoringEvent, + global_queue, + print_event, +) + + +def example_monitor_with_file_watcher(branch_filter=None): + """ + Example monitoring loop with file watcher integration + + Args: + branch_filter: Optional list of branches to watch (e.g., ['PRAX', 'CLI']) + None = watch all branches (may hit inotify limits) + """ + print("Starting PRAX Monitor with File Watcher") + print("=" * 60) + print() + + # Start file watcher + if branch_filter: + print(f"Starting file watcher for branches: {', '.join(branch_filter)}") + watcher = FileWatcherManager(branch_filter=branch_filter) + success = watcher.start() + else: + print("Starting file watcher for all branches") + success = start_file_watcher() + + if not success: + print("Failed to start file watcher") + return + + # Show stats + stats = get_file_watcher_stats() + print(f"Watching {stats['branches_watched']} branches:") + for branch in stats['branch_names']: + print(f" - {branch}") + print() + + print("Monitoring active - press Ctrl+C to stop") + print("-" * 60) + print() + + try: + # Main monitoring loop + while True: + # Get next event from queue (blocks for 0.5 seconds) + event = global_queue.dequeue(timeout=0.5) + + if event: + # Handle different event types + if event.event_type == 'file': + # File change event + print_event(event.event_type, event.branch, event.message, event.level) + + elif event.event_type == 'log': + # Log event (from log monitor - future) + print_event(event.event_type, event.branch, event.message, event.level) + + elif event.event_type == 'module': + # Module execution event (from module tracker) + print_event(event.event_type, event.branch, event.message, event.level) + + # You can also handle events directly: + # print(f"[{event.branch}] {event.action}: {event.message}") + + except KeyboardInterrupt: + print() + print("-" * 60) + print("Stopping monitor...") + + finally: + # Cleanup + stop_file_watcher() + print("File watcher stopped") + print("Monitor stopped") + + +def example_filtered_monitoring(): + """ + Example: Monitor only PRAX and CLI branches + + Recommended approach to avoid inotify limits + """ + example_monitor_with_file_watcher(branch_filter=['PRAX', 'CLI']) + + +def example_all_branches_monitoring(): + """ + Example: Monitor all branches + + WARNING: May hit inotify limits on systems with many branches + """ + example_monitor_with_file_watcher(branch_filter=None) + + +def example_custom_event_handling(): + """ + Example: Custom event handling logic + """ + print("Custom Event Handling Example") + print("=" * 60) + print() + + # Start watcher for specific branches + watcher = FileWatcherManager(branch_filter=['PRAX']) + watcher.start() + + print("Watching PRAX branch for 30 seconds...") + print("Try modifying a file in /home/aipass/aipass_core/prax/") + print() + + import time + start_time = time.time() + event_count = 0 + + try: + while time.time() - start_time < 30: + event = global_queue.dequeue(timeout=0.5) + + if event: + event_count += 1 + + # Custom handling based on action + if event.action == 'created': + print(f"✨ NEW FILE: {event.message}") + print(f" Branch: {event.branch}") + print(f" Time: {event.timestamp}") + print() + + elif event.action == 'modified': + print(f"📝 MODIFIED: {event.message}") + print() + + elif event.action == 'deleted': + print(f"🗑️ DELETED: {event.message}") + print() + + elif event.action == 'moved': + print(f"📦 MOVED: {event.message}") + print() + + except KeyboardInterrupt: + print("\nStopping...") + + finally: + watcher.stop() + print(f"\nCaptured {event_count} events in total") + + +# For integration into monitor_module.py's handle_command(): +""" +def handle_command(command: str, args: List[str]) -> bool: + if command != 'monitor': + return False + + # Parse branch filter from args + branch_filter = None + if args: + branch_arg = args[0] + if branch_arg.lower() != 'all': + branch_filter = [b.strip().upper() for b in branch_arg.split(',')] + + # Start file watcher + if branch_filter: + watcher = FileWatcherManager(branch_filter=branch_filter) + watcher.start() + else: + start_file_watcher() + + # Main monitoring loop + try: + while True: + event = global_queue.dequeue(timeout=0.5) + if event: + print_event(event) + except KeyboardInterrupt: + pass + finally: + stop_file_watcher() + + return True +""" + + +if __name__ == '__main__': + import sys + + if len(sys.argv) > 1: + if sys.argv[1] == 'custom': + example_custom_event_handling() + elif sys.argv[1] == 'all': + example_all_branches_monitoring() + else: + # Parse branch list + branches = [b.strip().upper() for b in sys.argv[1].split(',')] + example_monitor_with_file_watcher(branch_filter=branches) + else: + # Default: monitor PRAX only + print("Usage:") + print(" python3 INTEGRATION_EXAMPLE.py # Monitor PRAX only") + print(" python3 INTEGRATION_EXAMPLE.py PRAX,CLI # Monitor specific branches") + print(" python3 INTEGRATION_EXAMPLE.py all # Monitor all branches (may fail)") + print(" python3 INTEGRATION_EXAMPLE.py custom # Custom event handling demo") + print() + print("Running default: PRAX only") + print() + example_monitor_with_file_watcher(branch_filter=['PRAX']) diff --git a/src/aipass/prax/apps/handlers/monitoring/MONITOR_MODULE_INTEGRATION.py b/src/aipass/prax/apps/handlers/monitoring/MONITOR_MODULE_INTEGRATION.py new file mode 100644 index 00000000..77411b84 --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/MONITOR_MODULE_INTEGRATION.py @@ -0,0 +1,229 @@ +#!/home/aipass/.venv/bin/python3 + +""" +EXAMPLE: How to integrate log_watcher into monitor_module.py + +This is a reference implementation showing the minimal changes needed +to add log monitoring to monitor_module.py's handle_command() function. + +Copy the relevant sections into monitor_module.py as needed. +""" + +import sys +from pathlib import Path +from typing import List + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console, header, success, error, warning + +# Import monitoring components +from aipass.prax.apps.handlers.monitoring import ( + start_log_watcher, # NEW: Log watcher integration + stop_log_watcher, # NEW: Log watcher cleanup + is_log_watcher_active, # NEW: Status check + MonitoringQueue, + MonitoringEvent, + print_event, + FilterState, + should_display_event, +) + +# Global state +_log_observer = None +_event_queue = None + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Example handle_command with log watcher integration + + This shows the minimal changes to add real-time log monitoring + to the existing monitor_module.py structure. + """ + if command != 'monitor': + return False + + global _log_observer, _event_queue + + logger.info(f"Starting unified monitoring (args: {args})") + + # Display header + console.print() + header("PRAX Mission Control - Unified Monitoring") + console.print() + + # Initialize event queue + _event_queue = MonitoringQueue() + console.print("[green]✓ Event queue initialized[/green]") + + # Start log watcher - NEW INTEGRATION + try: + _log_observer = start_log_watcher(_event_queue) + console.print("[green]✓ Log watcher started[/green]") + console.print(f"[dim] Monitoring: /home/aipass/system_logs/*.log[/dim]") + except Exception as e: + error(f"Failed to start log watcher: {e}") + logger.error(f"Log watcher startup failed: {e}") + return False + + console.print() + console.print("[yellow]Monitoring active - type 'quit' to exit[/yellow]") + console.print() + + # Initialize filter state + filter_state = FilterState() + + # Parse branch filters from args + if args: + branches = args[0].split(',') if args[0] != 'all' else [] + if branches: + filter_state.watched_branches = {b.strip().upper() for b in branches} + console.print(f"[cyan]Filtering branches: {', '.join(filter_state.watched_branches)}[/cyan]") + console.print() + + try: + # Main event loop + while True: + # Dequeue next event (with timeout to allow Ctrl+C) + event = _event_queue.dequeue(timeout=0.5) if _event_queue else None + + if event: + # Apply filters + if should_display_event(event.event_type, event.branch, event.level, filter_state): + + # Handle command separator events + if event.event_type == 'command': + console.print(f"\n[bold green]{event.message}[/bold green]\n") + + # Handle log events + elif event.event_type == 'log': + # Format timestamp + timestamp = event.timestamp.strftime('%H:%M:%S') + + # Branch column (right-aligned, fixed width) + branch_col = f"[{event.branch:>8}]" + + # Color by level + level_colors = { + 'error': 'red', + 'warning': 'yellow', + 'info': 'white', + 'debug': 'dim', + } + color = level_colors.get(event.level, 'white') + + # Display event + console.print( + f"[dim]{timestamp}[/dim] " + f"[cyan]{branch_col}[/cyan] " + f"[{color}]{event.message}[/{color}]" + ) + + # Check for keyboard input (simplified - use proper input handling) + # TODO: Add interactive command handling here + + except KeyboardInterrupt: + console.print("\n[yellow]Stopping monitoring...[/yellow]") + + finally: + # Cleanup + if _log_observer: + stop_log_watcher() + console.print("[green]✓ Log watcher stopped[/green]") + + if _event_queue: + _event_queue.stop() + console.print("[green]✓ Event queue stopped[/green]") + + console.print() + success("Monitoring stopped") + + return True + + +def example_with_interactive_commands(): + """ + Example showing interactive command handling + + This is a more complete version with command input handling. + Requires threading to read both events and keyboard input. + """ + import threading + import select + + def input_handler(running_flag): + """Thread for reading keyboard input""" + while running_flag[0]: + # Use select for non-blocking input on Unix + if select.select([sys.stdin], [], [], 0.5)[0]: + try: + user_input = sys.stdin.readline().strip() + + if user_input in ['quit', 'exit']: + running_flag[0] = False + elif user_input == 'help': + console.print("\n[bold]Commands:[/bold]") + console.print(" help - Show this help") + console.print(" status - Show monitoring status") + console.print(" quit - Exit monitoring\n") + elif user_input == 'status': + console.print(f"\n[bold]Status:[/bold]") + console.print(f" Log watcher: {'active' if is_log_watcher_active() else 'inactive'}") + console.print(f" Queue size: {_event_queue.size() if _event_queue else 0}\n") + + except: + pass + + # Running flag for threads + running = [True] + + # Start input handler thread + input_thread = threading.Thread(target=input_handler, args=(running,)) + input_thread.daemon = True + input_thread.start() + + # Main event loop + while running[0]: + event = _event_queue.dequeue(timeout=0.5) if _event_queue else None + if event: + # Display event (same as above) + pass + + +# Example of filter adjustment +def example_filter_adjustment(): + """Show how to dynamically adjust filters""" + + filter_state = FilterState() + + # Watch specific branches + filter_state.watched_branches = {'SEED', 'FLOW', 'PRAX'} + + # Apply filter + event = MonitoringEvent( + priority=1, + event_type='log', + branch='SEED', + level='error', + message='ERROR: Something went wrong' + ) + + if should_display_event(event.event_type, event.branch, event.level, filter_state): + print("Event passed filters") + + +if __name__ == '__main__': + console.print("[bold cyan]Monitor Module Integration Examples[/bold cyan]") + console.print() + console.print("This file shows examples of integrating log_watcher.py") + console.print("into monitor_module.py's handle_command() function.") + console.print() + console.print("[yellow]Key changes:[/yellow]") + console.print(" 1. Import start_log_watcher, stop_log_watcher") + console.print(" 2. Initialize MonitoringQueue") + console.print(" 3. Start log watcher with queue") + console.print(" 4. Main loop dequeues and displays events") + console.print(" 5. Cleanup on exit") + console.print() + console.print("[dim]See code above for full implementation details.[/dim]") + console.print() diff --git a/src/aipass/prax/apps/handlers/monitoring/__init__.py b/src/aipass/prax/apps/handlers/monitoring/__init__.py new file mode 100644 index 00000000..c93aea37 --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/__init__.py @@ -0,0 +1,66 @@ +#!/home/aipass/.venv/bin/python3 + +""" +Monitoring Handlers Package + +Provides unified monitoring capabilities for PRAX system. +Handlers for file watching, log monitoring, branch detection, and filtering. +""" + +# Export main handler interfaces +from .unified_stream import print_event, print_command_separator +from .branch_detector import detect_branch_from_path +from .interactive_filter import ( + FilterState, + parse_command, + apply_filter, + get_status_text, + get_help_text, + should_display_event, +) +from .monitoring_filters import ( + should_monitor, + get_priority, + get_content_filter, + apply_content_filter, + filter_log_content, +) +from .event_queue import MonitoringEvent, MonitoringQueue, global_queue +from .module_tracker import ModuleTracker +from .file_watcher_integration import ( + start_file_watcher, + stop_file_watcher, + is_file_watcher_running, + get_file_watcher_stats, + FileWatcherManager +) +from .log_watcher import start_log_watcher, stop_log_watcher, is_log_watcher_active + +__all__ = [ + 'print_event', + 'print_command_separator', + 'detect_branch_from_path', + 'FilterState', + 'parse_command', + 'apply_filter', + 'get_status_text', + 'get_help_text', + 'should_display_event', + 'should_monitor', + 'get_priority', + 'get_content_filter', + 'apply_content_filter', + 'filter_log_content', + 'MonitoringEvent', + 'MonitoringQueue', + 'global_queue', + 'ModuleTracker', + 'start_file_watcher', + 'stop_file_watcher', + 'is_file_watcher_running', + 'get_file_watcher_stats', + 'FileWatcherManager', + 'start_log_watcher', + 'stop_log_watcher', + 'is_log_watcher_active', +] diff --git a/src/aipass/prax/apps/handlers/monitoring/branch_detector.py b/src/aipass/prax/apps/handlers/monitoring/branch_detector.py new file mode 100644 index 00000000..95fcc445 --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/branch_detector.py @@ -0,0 +1,453 @@ +#!/home/aipass/.venv/bin/python3 + +# META DATA HEADER +# Name: branch_detector.py - Branch Attribution Handler +# Version: 0.1.0 +# Purpose: Detect which branch owns a file/log/module +# Created: 2025-11-23 +# Location: /home/aipass/aipass_core/prax/apps/handlers/monitoring/branch_detector.py + +""" +Branch Detection Handler + +Detects which branch owns an event by analyzing: +- File paths +- Log filenames +- Module names + +Uses BRANCH_REGISTRY.json for accurate mapping with caching for performance. +""" + +from pathlib import Path +from typing import Optional, Dict, Set +import json +import logging +from functools import lru_cache + +logger = logging.getLogger(__name__) + + +class BranchDetector: + """ + Detects branch ownership for files, logs, and modules. + + Uses multiple strategies: + 1. Direct path lookup from BRANCH_REGISTRY.json + 2. Parent directory traversal + 3. Filename pattern matching + 4. Caching for repeated lookups + """ + + def __init__(self): + """Initialize detector with empty caches""" + self.branch_map: Dict[str, str] = {} # path -> branch + self.log_map: Dict[str, str] = {} # log file -> branch + self.module_map: Dict[str, str] = {} # module -> branch + self.known_branches: Set[str] = set() + self._load_registry() + + def _load_registry(self): + """ + Load BRANCH_REGISTRY.json and build lookup tables. + + Builds: + - branch_map: Full path to branch name mapping + - known_branches: Set of all branch names for pattern matching + """ + try: + registry_path = Path.home() / "BRANCH_REGISTRY.json" + + if not registry_path.exists(): + logger.warning(f"Registry not found: {registry_path}") + self._load_fallback_branches() + return + + with open(registry_path, encoding='utf-8') as f: + data = json.load(f) + + branches = data.get('branches', []) + if not branches: + logger.warning("No branches found in registry") + self._load_fallback_branches() + return + + for branch in branches: + branch_name = branch.get('name', '').upper() + branch_path = branch.get('path', '') + + if not branch_name or not branch_path: + continue + + # Store normalized path + path = Path(branch_path).resolve() + self.branch_map[str(path)] = branch_name + self.known_branches.add(branch_name) + + # Also store with trailing slash for matching + self.branch_map[str(path) + '/'] = branch_name + + logger.info(f"Loaded {len(self.known_branches)} branches from registry") + + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in registry: {e}") + self._load_fallback_branches() + except Exception as e: + logger.error(f"Error loading registry: {e}") + self._load_fallback_branches() + + def _load_fallback_branches(self): + """Load fallback branch names when registry is unavailable""" + fallback = ['SEED', 'CLI', 'FLOW', 'PRAX', 'CORTEX', 'DRONE', + 'BACKUP_SYSTEM', 'SECURITY', 'AIPASS'] + self.known_branches.update(fallback) + logger.info(f"Using fallback branches: {fallback}") + + def detect_from_path(self, file_path: str) -> str: + """ + Detect branch from file path. + + Strategy: + 1. Check exact path match in branch_map + 2. Walk up parent directories for match + 3. Parse path for known branch names + 4. Return 'UNKNOWN' if no match found + + Args: + file_path: Absolute or relative file path + + Returns: + Branch name in uppercase (e.g., 'SEED', 'PRAX') + """ + try: + path = Path(file_path).resolve() + path_str = str(path) + + # Check cache first + if path_str in self.log_map: + return self.log_map[path_str] + + # Strategy 1: Exact match + if path_str in self.branch_map: + result = self.branch_map[path_str] + self.log_map[path_str] = result + return result + + # Strategy 2: Walk up parents + for parent in path.parents: + parent_str = str(parent) + if parent_str in self.branch_map: + result = self.branch_map[parent_str] + self.log_map[path_str] = result + return result + + # Strategy 3: Claude Code project files + # Path: ~/.claude/projects/-home-aipass-aipass-core-trigger/session.jsonl + # Folder name encodes the project path with - replacing / + if '.claude/projects/' in path_str: + projects_idx = path_str.index('.claude/projects/') + len('.claude/projects/') + remaining = path_str[projects_idx:] + # Get the project folder name (first path segment after projects/) + project_folder = remaining.split('/')[0] + # Convert folder name back to path: -home-aipass-aipass-core-trigger -> /home/aipass/aipass_core/trigger + project_path = '/' + project_folder.replace('-', '/') + # Check against branch_map (registered branch paths) + for registered_path, branch_name in self.branch_map.items(): + # Normalize for comparison: aipass_core vs aipass-core + registered_normalized = registered_path.replace('_', '/') + project_normalized = project_path.replace('_', '/') + if registered_normalized == project_normalized or registered_path == project_path: + self.log_map[path_str] = branch_name + return branch_name + # Fallback: extract last segment as branch name + segments = [s for s in project_folder.split('-') if s] + # Try matching from end (last meaningful segment) + if segments: + last = segments[-1].upper() + # Check for compound names by trying progressively longer matches from end + for i in range(len(segments) - 1, 0, -1): + candidate = '_'.join(segments[i:]).upper() + if candidate in self.known_branches: + self.log_map[path_str] = candidate + return candidate + if last in self.known_branches: + self.log_map[path_str] = last + return last + + # Strategy 4: AI_CENTRAL files - {BRANCH}.central.json or {BRANCH}_central.json + # Path: /home/aipass/aipass_os/AI_CENTRAL/AI_MAIL.central.json -> AI_MAIL + if 'AI_CENTRAL' in path_str or 'ai_central' in path_str.lower(): + name = path.name + # Extract branch from filename patterns + branch_candidate = None + if '.central.json' in name: + branch_candidate = name.replace('.central.json', '').upper() + elif '_central.json' in name: + branch_candidate = name.replace('_central.json', '').upper() + if branch_candidate: + self.log_map[path_str] = branch_candidate + return branch_candidate + + # Strategy 5: Root-level system files + home = str(Path.home()) + if path.parent == Path(home) or path.parent == Path(home) / '.claude': + self.log_map[path_str] = 'SYSTEM' + return 'SYSTEM' + + # Strategy 6: Parse path for known branch names + path_parts = path_str.lower().split('/') + for part in path_parts: + branch_upper = part.upper() + if branch_upper in self.known_branches: + self.log_map[path_str] = branch_upper + return branch_upper + + # Strategy 6: Check for compound names (aipass_core -> check for CORE patterns) + for part in path_parts: + if '_' in part: + subparts = part.split('_') + for subpart in subparts: + branch_upper = subpart.upper() + if branch_upper in self.known_branches: + self.log_map[path_str] = branch_upper + return branch_upper + + # No match found + logger.info(f"Could not detect branch for path: {file_path}") + return 'UNKNOWN' + + except Exception as e: + logger.error(f"Error detecting branch from path {file_path}: {e}") + return 'UNKNOWN' + + def detect_from_log(self, log_file: str) -> str: + """ + Detect branch from log filename. + + Supports patterns: + - seed_audit.log -> SEED + - seed_standards_checklist.log -> SEED + - flow_plan.log -> FLOW + - prax_monitor_20251123.log -> PRAX + + Args: + log_file: Log filename or path + + Returns: + Branch name in uppercase + """ + try: + # Get just the filename + name = Path(log_file).stem + + # Check cache + if name in self.log_map: + return self.log_map[name] + + # Check known branches first (longest match wins) + # Handles compound names like ai_mail, backup_system, memory_bank + for branch_name in sorted(self.known_branches, key=len, reverse=True): + prefix = branch_name.lower() + '_' + if name.lower().startswith(prefix) or name.lower() == branch_name.lower(): + self.log_map[name] = branch_name + return branch_name + + # Fallback: split on underscore for branches not in registry + # Log files follow pattern: branch_operation.log + if '_' in name: + parts = name.split('_') + first_part = parts[0].upper() + self.log_map[name] = first_part + return first_part + + # Try full name (no underscore) + name_upper = name.upper() + if name_upper in self.known_branches: + self.log_map[name] = name_upper + return name_upper + + # If we have a full path, try path detection + if '/' in log_file: + return self.detect_from_path(log_file) + + logger.info(f"Could not detect branch from log: {log_file}") + return 'UNKNOWN' + + except Exception as e: + logger.info(f"Error detecting branch from log {log_file}: {e}") + return 'UNKNOWN' + + def detect_from_module(self, module_name: str) -> str: + """ + Detect branch from Python module name. + + Supports patterns: + - prax.apps.handlers.monitoring -> PRAX + - seed.core.validator -> SEED + + Args: + module_name: Python module dotted name + + Returns: + Branch name in uppercase + """ + try: + # Check cache + if module_name in self.module_map: + return self.module_map[module_name] + + # Split on dots and check first part + parts = module_name.split('.') + if parts: + first_part = parts[0].upper() + + if first_part in self.known_branches: + self.module_map[module_name] = first_part + return first_part + + logger.info(f"Could not detect branch from module: {module_name}") + return 'UNKNOWN' + + except Exception as e: + logger.error(f"Error detecting branch from module {module_name}: {e}") + return 'UNKNOWN' + + def reload_registry(self): + """ + Reload BRANCH_REGISTRY.json. + + Clears caches and rebuilds lookup tables. + Useful when registry is updated during runtime. + """ + self.branch_map.clear() + self.log_map.clear() + self.module_map.clear() + self.known_branches.clear() + self._load_registry() + logger.info("Registry reloaded") + + def get_stats(self) -> Dict[str, int]: + """ + Get cache statistics. + + Returns: + Dictionary with cache sizes + """ + return { + 'branch_paths': len(self.branch_map), + 'cached_lookups': len(self.log_map), + 'cached_modules': len(self.module_map), + 'known_branches': len(self.known_branches) + } + + +# Singleton instance for module-level access +_detector_instance: Optional[BranchDetector] = None + + +def get_detector() -> BranchDetector: + """ + Get singleton detector instance. + + Returns: + BranchDetector instance + """ + global _detector_instance + if _detector_instance is None: + _detector_instance = BranchDetector() + return _detector_instance + + +def detect_branch_from_path(file_path: str) -> str: + """ + Public API - detect branch from path. + + Args: + file_path: File or directory path + + Returns: + Branch name in uppercase + """ + return get_detector().detect_from_path(file_path) + + +def detect_branch_from_log(log_file: str) -> str: + """ + Public API - detect branch from log filename. + + Args: + log_file: Log filename or path + + Returns: + Branch name in uppercase + """ + return get_detector().detect_from_log(log_file) + + +def detect_branch_from_module(module_name: str) -> str: + """ + Public API - detect branch from module name. + + Args: + module_name: Python module dotted name + + Returns: + Branch name in uppercase + """ + return get_detector().detect_from_module(module_name) + + +def reload_registry(): + """Public API - reload BRANCH_REGISTRY.json""" + get_detector().reload_registry() + + +def get_detector_stats() -> Dict[str, int]: + """Public API - get cache statistics""" + return get_detector().get_stats() + + +if __name__ == '__main__': + # Quick test + detector = BranchDetector() + + print("Branch Detector Test") + print("=" * 50) + + test_paths = [ + "/home/aipass/aipass_core/prax/apps/handlers/monitoring/branch_detector.py", + "/home/aipass/aipass_core/seed/core/validator.py", + "/home/aipass/aipass_os/dev_central/flow/planners/", + ] + + print("\nPath Detection:") + for path in test_paths: + branch = detector.detect_from_path(path) + print(f" {path}") + print(f" -> {branch}\n") + + test_logs = [ + "seed_audit.log", + "flow_plan.log", + "prax_monitor_20251123.log", + ] + + print("\nLog Detection:") + for log in test_logs: + branch = detector.detect_from_log(log) + print(f" {log} -> {branch}") + + test_modules = [ + "prax.apps.handlers.monitoring", + "seed.core.validator", + "flow.planners.daily", + ] + + print("\nModule Detection:") + for module in test_modules: + branch = detector.detect_from_module(module) + print(f" {module} -> {branch}") + + print("\nCache Stats:") + stats = detector.get_stats() + for key, value in stats.items(): + print(f" {key}: {value}") diff --git a/src/aipass/prax/apps/handlers/monitoring/event_queue.py b/src/aipass/prax/apps/handlers/monitoring/event_queue.py new file mode 100644 index 00000000..9c7bc781 --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/event_queue.py @@ -0,0 +1,119 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: event_queue.py - Thread-Safe Event Queue +# Date: 2025-11-23 +# Version: 0.1.1 +# Category: prax/handlers/monitoring +# +# CHANGELOG (Max 5 entries): +# - v0.1.1 (2026-02-27): Added pid field to MonitoringEvent +# - v0.1.0 (2025-11-23): Created - thread-safe event queue with deduplication +# +# DESCRIPTION: +# Thread-safe event coordination for monitoring system. +# Provides MonitoringEvent dataclass and MonitoringQueue with deduplication. +# ============================================= + +"""Thread-safe event coordination for monitoring system""" + +from pathlib import Path + +from queue import Empty, PriorityQueue +from dataclasses import dataclass, field +from datetime import datetime +from typing import Optional +import threading + +@dataclass(order=True) +class MonitoringEvent: + """Unified event structure for all monitoring sources""" + priority: int = field(compare=True) + timestamp: datetime = field(compare=False, default_factory=datetime.now) + event_type: str = field(compare=False, default='') # 'file', 'log', 'module', 'command' + branch: str = field(compare=False, default='') + action: str = field(compare=False, default='') # 'created', 'modified', 'deleted', 'executed' + message: str = field(compare=False, default='') + level: str = field(compare=False, default='info') # 'info', 'warning', 'error' + caller: Optional[str] = field(compare=False, default=None) # Branch that initiated command + pid: Optional[int] = field(compare=False, default=None) # Process ID of the agent + + def __post_init__(self): + # Convert level to priority number for queue ordering + if self.priority == 0: # Not set + priority_map = { + 'error': 1, + 'warning': 2, + 'info': 3, + 'debug': 4 + } + self.priority = priority_map.get(self.level, 3) + +class MonitoringQueue: + """Thread-safe event queue with deduplication""" + + def __init__(self, maxsize: int = 1000): + self.queue = PriorityQueue(maxsize=maxsize) + self.recent_events = [] # For deduplication + self.lock = threading.Lock() + self.running = True + + def enqueue(self, event: MonitoringEvent) -> bool: + """Add event to queue (thread-safe)""" + if not self.running: + return False + + # Simple deduplication + if not self._is_duplicate(event): + try: + self.queue.put(event, block=False) + with self.lock: + self.recent_events.append(event) + if len(self.recent_events) > 100: + self.recent_events.pop(0) + return True + except: + return False + return False + + def dequeue(self, timeout: float = 0.1) -> Optional[MonitoringEvent]: + """Get next event from queue (thread-safe)""" + try: + return self.queue.get(timeout=timeout) + except Empty: + return None + + def flush(self): + """Clear all events from queue""" + with self.lock: + while not self.queue.empty(): + try: + self.queue.get_nowait() + except Empty: + break + self.recent_events.clear() + + def stop(self): + """Stop accepting new events""" + self.running = False + self.flush() + + def _is_duplicate(self, event: MonitoringEvent) -> bool: + """Check if event duplicates recent event""" + with self.lock: + for recent in self.recent_events[-10:]: + if (recent.event_type == event.event_type and + recent.branch == event.branch and + recent.action == event.action and + recent.message == event.message and + abs((event.timestamp - recent.timestamp).total_seconds()) < 1): + return True + return False + + def size(self) -> int: + """Get current queue size""" + return self.queue.qsize() + +# Global instance for the monitoring system +global_queue = MonitoringQueue() diff --git a/src/aipass/prax/apps/handlers/monitoring/file_watcher_integration.py b/src/aipass/prax/apps/handlers/monitoring/file_watcher_integration.py new file mode 100644 index 00000000..bb8f6c25 --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/file_watcher_integration.py @@ -0,0 +1,399 @@ +#!/home/aipass/.venv/bin/python3 + +# META DATA HEADER +# Name: file_watcher_integration.py - File Watcher Integration +# Version: 0.1.0 +# Purpose: Connect file watcher to monitoring event queue +# Created: 2025-11-23 +# Location: /home/aipass/aipass_core/prax/apps/handlers/monitoring/file_watcher_integration.py + +""" +File Watcher Integration Handler + +Connects the file watcher (apps/handlers/watcher/monitor.py) to the +monitoring event queue (event_queue.py). + +Flow: +1. Load branches from BRANCH_REGISTRY.json +2. Set up BranchFileHandler for each branch +3. Start watchdog observers +4. File events -> MonitoringEvent -> MonitoringQueue +5. Include branch attribution in all events + +Thread Safety: +- Designed to run in monitor_module.py's file watcher thread +- Uses MonitoringQueue's thread-safe enqueue +- Watchdog observers run in their own threads + +Linux Limitations: +- inotify has limits on watched files/directories +- Watching 18+ branches recursively may exceed system limits +- To increase: sudo sysctl fs.inotify.max_user_watches=524288 +- Or filter branches to watch only active ones +""" + +import json +import logging +from pathlib import Path +from typing import List, Tuple, Optional, TYPE_CHECKING, Any +from datetime import datetime + +logger = logging.getLogger(__name__) + +# ============================================================================= +# IMPORTS - File watcher and event queue +# ============================================================================= + +try: + # Import file watcher handler + from aipass.prax.apps.handlers.watcher.monitor import ( + BranchFileHandler, + start_monitoring, + stop_monitoring, + WATCHDOG_AVAILABLE, + Observer + ) + + from aipass.prax.apps.handlers.monitoring.event_queue import ( + MonitoringEvent, + MonitoringQueue, + global_queue + ) + + from aipass.prax.apps.handlers.monitoring.branch_detector import ( + detect_branch_from_path + ) + +except ImportError as e: + logger.error(f"Import error in file_watcher_integration: {e}") + WATCHDOG_AVAILABLE = False + + +# ============================================================================= +# BRANCH REGISTRY LOADER +# ============================================================================= + +def load_branch_paths(branch_filter: Optional[List[str]] = None) -> List[Tuple[str, Path]]: + """ + Load branch paths from BRANCH_REGISTRY.json + + Args: + branch_filter: Optional list of branch names to watch (e.g., ['PRAX', 'CLI']) + If None, loads all branches from registry + + Returns: + List of (branch_name, path) tuples + Empty list if registry not found or error + """ + try: + registry_path = Path.home() / "BRANCH_REGISTRY.json" + + if not registry_path.exists(): + logger.warning(f"BRANCH_REGISTRY.json not found at {registry_path}") + return [] + + with open(registry_path) as f: + data = json.load(f) + + branches = data.get('branches', []) + if not branches: + logger.warning("No branches found in BRANCH_REGISTRY.json") + return [] + + # Normalize filter to uppercase + if branch_filter: + branch_filter = [b.upper() for b in branch_filter] + + # Extract (name, path) tuples + branch_paths = [] + for branch in branches: + name = branch.get('name', '').upper() + path_str = branch.get('path', '') + + if not name or not path_str: + logger.warning(f"Skipping invalid branch entry: {branch}") + continue + + # Apply filter if provided + if branch_filter and name not in branch_filter: + continue + + path = Path(path_str) + if not path.exists(): + logger.warning(f"Branch path does not exist: {name} -> {path}") + continue + + branch_paths.append((name, path)) + + logger.info(f"Loaded {len(branch_paths)} branch paths from registry") + return branch_paths + + except json.JSONDecodeError as e: + logger.error(f"Invalid JSON in BRANCH_REGISTRY.json: {e}") + return [] + except Exception as e: + logger.error(f"Error loading BRANCH_REGISTRY.json: {e}") + return [] + + +# ============================================================================= +# EVENT CALLBACK - File events to MonitoringQueue +# ============================================================================= + +def file_event_callback(branch_name: str, event_type: str, file_path: str): + """ + Callback for file system events from BranchFileHandler + + Converts file events to MonitoringEvent and enqueues to global queue. + + Args: + branch_name: Name of branch where event occurred + event_type: Event type ('CREATED', 'MODIFIED', 'DELETED', 'MOVED') + file_path: Path to file (or "src -> dest" for MOVED) + """ + try: + # Map event type to action + action_map = { + 'CREATED': 'created', + 'MODIFIED': 'modified', + 'DELETED': 'deleted', + 'MOVED': 'moved' + } + action = action_map.get(event_type, event_type.lower()) + + # Determine priority based on event type + # Deleted/Created are higher priority than modified + priority_map = { + 'deleted': 2, + 'created': 2, + 'moved': 2, + 'modified': 3 + } + priority = priority_map.get(action, 3) + + # Create monitoring event + event = MonitoringEvent( + priority=priority, + timestamp=datetime.now(), + event_type='file', + branch=branch_name, + action=action, + message=file_path, + level='info' + ) + + # Enqueue to global queue (thread-safe) + success = global_queue.enqueue(event) + + if not success: + logger.debug(f"Failed to enqueue file event: {branch_name} {action} {file_path}") + + except Exception as e: + logger.error(f"Error in file_event_callback: {e}") + + +# ============================================================================= +# FILE WATCHER MANAGER +# ============================================================================= + +class FileWatcherManager: + """ + Manages file watcher lifecycle and observer threads + + Responsibilities: + - Load branches from registry + - Start/stop watchdog observers + - Connect file events to monitoring queue + """ + + def __init__(self, queue: MonitoringQueue | None = None, branch_filter: List[str] | None = None): + """ + Initialize file watcher manager + + Args: + queue: MonitoringQueue to use (defaults to global_queue) + branch_filter: Optional list of branch names to watch (e.g., ['PRAX', 'CLI']) + """ + self.queue = queue or global_queue + self.observer: Any = None + self.branch_paths: List[Tuple[str, Path]] = [] + self.branch_filter = branch_filter + self.running = False + + def start(self) -> bool: + """ + Start file watching for all branches (or filtered branches) + + Returns: + True if started successfully, False otherwise + """ + if not WATCHDOG_AVAILABLE: + logger.error("Cannot start file watcher - watchdog not installed") + return False + + if self.running: + logger.warning("File watcher already running") + return True + + # Load branch paths from registry + self.branch_paths = load_branch_paths(self.branch_filter) + + if not self.branch_paths: + logger.error("No valid branches found - cannot start file watcher") + return False + + # Start monitoring with callback + logger.info(f"Starting file watcher for {len(self.branch_paths)} branches") + self.observer = start_monitoring(self.branch_paths, file_event_callback) + + if self.observer: + self.running = True + logger.info("File watcher started successfully") + return True + else: + logger.error("Failed to start file watcher") + return False + + def stop(self): + """Stop file watching""" + if not self.running: + return + + if self.observer: + logger.info("Stopping file watcher") + stop_monitoring(self.observer) + self.observer = None + + self.running = False + logger.info("File watcher stopped") + + def is_running(self) -> bool: + """Check if file watcher is running""" + return self.running + + def get_stats(self) -> dict: + """Get file watcher statistics""" + return { + 'running': self.running, + 'branches_watched': len(self.branch_paths), + 'branch_names': [name for name, _ in self.branch_paths], + 'watchdog_available': WATCHDOG_AVAILABLE + } + + +# ============================================================================= +# MODULE-LEVEL API +# ============================================================================= + +# Global file watcher instance +_file_watcher: Optional[FileWatcherManager] = None + + +def get_file_watcher() -> FileWatcherManager: + """ + Get singleton file watcher instance + + Returns: + FileWatcherManager instance + """ + global _file_watcher + if _file_watcher is None: + _file_watcher = FileWatcherManager() + return _file_watcher + + +def start_file_watcher() -> bool: + """ + Start file watching + + Returns: + True if started successfully + """ + return get_file_watcher().start() + + +def stop_file_watcher(): + """Stop file watching""" + get_file_watcher().stop() + + +def is_file_watcher_running() -> bool: + """Check if file watcher is running""" + return get_file_watcher().is_running() + + +def get_file_watcher_stats() -> dict: + """Get file watcher statistics""" + return get_file_watcher().get_stats() + + +# ============================================================================= +# STANDALONE TEST +# ============================================================================= + +if __name__ == '__main__': + import time + + # Set up basic logging + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(message)s' + ) + + print("File Watcher Integration Test") + print("=" * 60) + print() + + # Test 1: Load branch paths + print("Test 1: Loading branch paths from registry") + paths = load_branch_paths() + print(f"Found {len(paths)} branches:") + for name, path in paths[:5]: # Show first 5 + print(f" - {name}: {path}") + if len(paths) > 5: + print(f" ... and {len(paths) - 5} more") + print() + + # Test 2: Start file watcher (LIMITED to PRAX only to avoid inotify limits) + print("Test 2: Starting file watcher (PRAX branch only)") + watcher = FileWatcherManager(branch_filter=['PRAX']) + + if watcher.start(): + print("File watcher started successfully") + print() + + # Show stats + stats = watcher.get_stats() + print("Watcher stats:") + for key, value in stats.items(): + print(f" {key}: {value}") + print() + + # Monitor for a bit + print("Monitoring for 10 seconds... (modify a file to test)") + print("Watching queue for events...") + print() + + start_time = time.time() + event_count = 0 + + while time.time() - start_time < 10: + event = global_queue.dequeue(timeout=0.5) + if event: + event_count += 1 + print(f"Event #{event_count}: {event.branch} - {event.action} - {event.message}") + + print() + print(f"Captured {event_count} events in 10 seconds") + print() + + # Stop watcher + print("Test 3: Stopping file watcher") + watcher.stop() + print("File watcher stopped") + + else: + print("Failed to start file watcher") + + print() + print("Test complete") diff --git a/src/aipass/prax/apps/handlers/monitoring/interactive_filter.py b/src/aipass/prax/apps/handlers/monitoring/interactive_filter.py new file mode 100644 index 00000000..60f6a986 --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/interactive_filter.py @@ -0,0 +1,252 @@ +#!/home/aipass/.venv/bin/python3 + +# META DATA HEADER +# Name: interactive_filter.py - Command Parser and Filter State +# Version: 0.1.0 + +"""Parse commands and maintain filtering state""" + +from dataclasses import dataclass, field +from typing import Set, List, Tuple, Optional + +@dataclass +class FilterState: + """Current monitoring filter state""" + watched_branches: Set[str] = field(default_factory=set) + show_all: bool = False + show_errors: bool = True + show_warnings: bool = True + show_info: bool = False + verbosity: str = 'low' + + def is_watching(self, branch: str) -> bool: + """Check if branch is being watched. + + Handles subagent labels like 'DEV_CENTRAL AGENT' by also checking + the base branch name (without ' AGENT' suffix). + """ + if self.show_all: + return True + if not self.watched_branches: + return True + if branch in self.watched_branches: + return True + # Check base branch name for subagent labels (e.g. 'DEV_CENTRAL AGENT' → 'DEV_CENTRAL') + if branch.endswith(' AGENT'): + return branch[:-6] in self.watched_branches + return False + +def parse_command(cmd: str) -> Tuple[Optional[str], List[str]]: + """Parse user command into action and arguments + + Supported commands: + watch - Watch specific branch(es) + watch all - Watch all branches + watch errors - Only show errors + filter - Set verbosity level (high/low) + monitor errors - Add errors to current view + monitor warnings - Add warnings to current view + status - Show current filter state + help - Show help text + quit/exit - Stop monitoring + clear - Reset filters to quiet mode + + Args: + cmd: Raw command string from user input + + Returns: + Tuple of (command_name, arguments) or (None, []) for empty/invalid input + + Example: + parse_command("watch seed") -> ("watch", ["seed"]) + parse_command("filter high") -> ("filter", ["high"]) + parse_command("") -> (None, []) + """ + if not cmd: + return None, [] + + parts = cmd.strip().split() + if not parts: + return None, [] + + command = parts[0].lower() + args = parts[1:] if len(parts) > 1 else [] + + # Normalize command aliases + if command in ['exit', 'q']: + command = 'quit' + + return command, args + +def apply_filter(state: FilterState, command: str, args: List[str]): + """Update filter state based on command + + Modifies the FilterState object in-place based on the command. + + Commands: + watch - Exclusive watch (clears previous filters) + watch all - Watch all branches + watch errors - Only show errors + filter - Set verbosity (high/low) + monitor errors - Add errors to current view (inclusive) + monitor warnings - Add warnings to current view (inclusive) + clear - Reset to quiet mode + + Args: + state: FilterState object to modify + command: Command name (from parse_command) + args: Command arguments (from parse_command) + """ + if command == 'watch': + # Exclusive watch (clear previous) + if not args or args[0] == 'all': + state.show_all = True + state.watched_branches.clear() + state.show_info = True + elif args[0] == 'errors': + state.show_errors = True + state.show_warnings = False + state.show_info = False + else: + # Watch specific branches + state.show_all = False + state.watched_branches = set(b.upper() for b in args) + state.show_info = True + + elif command == 'filter': + # Set verbosity level + if args and args[0] in ['high', 'low']: + state.verbosity = args[0] + + elif command == 'monitor': + # Inclusive monitor (add to current) + if args and args[0] == 'errors': + state.show_errors = True + elif args and args[0] == 'warnings': + state.show_warnings = True + + elif command == 'verbosity': + # Legacy support - redirect to filter + if args and args[0] in ['high', 'low']: + state.verbosity = args[0] + + elif command == 'clear': + # Reset to quiet mode + state.show_all = False + state.watched_branches.clear() + state.show_info = False + state.show_errors = True + state.show_warnings = True + state.verbosity = 'low' + +def should_display_event(event_type: str, branch: str, level: str, state: FilterState, message: str = '') -> bool: + """Determine if event should be displayed based on filters + + Args: + event_type: Type of event (log, file, command, etc.) + branch: Branch name + level: Event level (error, warning, info, debug) + state: Current FilterState + message: Event message content (for noise filtering) + + Returns: + True if event should be displayed + """ + # Check branch filter + if not state.is_watching(branch): + return False + + # File, command, and agent events always show when watching a branch + # (level filter only applies to log events) + if event_type in ['file', 'command', 'agent']: + return True + + # Check level filter (for log events only) + if level == 'error' and not state.show_errors: + return False + if level == 'warning' and not state.show_warnings: + return False + if level == 'info' and not state.show_info: + return False + + # Verbosity filter - skip noise patterns when verbosity is 'low' + if state.verbosity == 'low' and level == 'info' and message: + noise_patterns = [ + 'Discovered module:', + 'Module loaded:', + 'Initializing', + 'Configuration loaded', + 'Registry loaded', + 'Data loaded', + 'check complete:', + 'Running ', # "Running X standard check" + ] + for pattern in noise_patterns: + if pattern in message: + return False + + return True + +def get_status_text(state: FilterState) -> str: + """Get current filter status as formatted text + + Args: + state: Current FilterState + + Returns: + Multi-line string showing current filter configuration + + Example: + >>> state = FilterState(watched_branches={'SEED', 'CLI'}) + >>> print(get_status_text(state)) + Current Monitoring Status: + Branches: SEED, CLI + Levels: errors, warnings + Verbosity: low + """ + lines = ["Current Monitoring Status:"] + + # Branch filter + if state.show_all: + lines.append(" Branches: ALL") + elif state.watched_branches: + branches = ", ".join(sorted(state.watched_branches)) + lines.append(f" Branches: {branches}") + else: + lines.append(" Branches: (quiet mode - errors/warnings only)") + + # Level filter + levels = [] + if state.show_errors: + levels.append("errors") + if state.show_warnings: + levels.append("warnings") + if state.show_info: + levels.append("info") + + if levels: + lines.append(f" Levels: {', '.join(levels)}") + else: + lines.append(" Levels: (none - all filtered)") + + # Verbosity + lines.append(f" Verbosity: {state.verbosity}") + + return "\n".join(lines) + + +def get_help_text() -> str: + """Get help text for interactive commands""" + return """ +Available Commands: + watch - Watch specific branch(es) (comma-separated) + watch all - Watch all branches + watch errors - Only show errors + filter - Set verbosity (high/low) + monitor errors - Add errors to current view + monitor warnings - Add warnings to current view + clear - Reset to quiet mode + status - Show current filters + help - Show this help + quit/exit - Stop monitoring +""" diff --git a/src/aipass/prax/apps/handlers/monitoring/log_watcher.py b/src/aipass/prax/apps/handlers/monitoring/log_watcher.py new file mode 100644 index 00000000..536feded --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/log_watcher.py @@ -0,0 +1,603 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: log_watcher.py - Log File Monitor +# Date: 2025-11-23 +# Version: 1.0.0 +# Category: prax/handlers/monitoring +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-23): Created - adapted from discovery/watcher.py +# +# DESCRIPTION: +# Real-time log file monitoring integrated with event queue. +# Reuses production-ready log tailing from discovery/watcher.py. +# Pushes events to MonitoringQueue instead of console output. +# ============================================= + +""" +PRAX Log Watcher - Event Queue Integration + +Monitors log files in real-time and pushes events to the monitoring queue. + +Features: +- Real-time log tailing with position tracking +- Color coding detection by log level +- Command detection and formatting +- Branch detection from log file path +- Thread-safe event queue integration + +Based on: apps/handlers/discovery/watcher.py (production-ready log tailing) +""" + +from pathlib import Path + +from datetime import datetime, timezone +from typing import Optional, Dict, Any, Union +import re +import logging + +from watchdog.observers import Observer as WatchdogObserver +from watchdog.events import FileSystemEventHandler + +# Import from prax config +from aipass.prax.apps.handlers.config.load import SYSTEM_LOGS_DIR + +# Import monitoring infrastructure +from aipass.prax.apps.handlers.monitoring.event_queue import MonitoringEvent, MonitoringQueue +from aipass.prax.apps.handlers.monitoring.branch_detector import detect_branch_from_log + +# Trigger integration - graceful fallback if trigger not available +try: + from trigger import trigger + HAS_TRIGGER = True +except ImportError: + HAS_TRIGGER = False + +# Logger +logger = logging.getLogger(__name__) + + +def _generate_error_hash(module_name: str, message: str) -> str: + """ + Generate a hash for error deduplication. + + Same module + message = same hash, allowing trigger rules to + deduplicate repeated errors. + + Args: + module_name: Name of the module that generated the error + message: The error message content + + Returns: + 8-character hash string for deduplication + """ + import hashlib + content = f"{module_name}:{message}" + return hashlib.md5(content.encode()).hexdigest()[:8] + +# Global observer instance +_log_observer: Any = None + + +class LogFileWatcher(FileSystemEventHandler): + """ + Watch log files and push events to monitoring queue. + + Adapted from discovery/watcher.py PythonFileWatcher.on_modified() + with event queue integration instead of console output. + """ + + def __init__(self, event_queue: MonitoringQueue): + """ + Initialize log watcher. + + Args: + event_queue: MonitoringQueue instance for event delivery + """ + super().__init__() + self.event_queue = event_queue + # Track file positions for log tailing + self.log_positions: Dict[str, int] = {} + # Track last command execution for command detection + self.last_command: Optional[str] = None + # Track command per branch to avoid duplicate command separators + self.last_command_per_branch: Dict[str, str] = {} + + def on_modified(self, event): + """ + Watch for log file modifications and push events to queue. + + Adapted from discovery/watcher.py with these changes: + 1. Pushes to event_queue instead of console.print + 2. Detects branch from log file name/path + 3. Preserves color coding info in event level field + 4. Emits command separator events when command detected + """ + if event.is_directory: + return + + file_path = str(event.src_path) + + # Only watch .log files + if not file_path.endswith('.log'): + return + + # Only watch files in system_logs directory + if str(SYSTEM_LOGS_DIR) not in file_path: + return + + try: + # Get current file size + current_size = Path(file_path).stat().st_size + + # Get last known position + last_pos = self.log_positions.get(file_path, 0) + + # If file shrunk (rotated), reset position + if current_size < last_pos: + last_pos = 0 + + # Read new content + if current_size > last_pos: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + f.seek(last_pos) + new_lines = f.read() + + if new_lines.strip(): + # Extract branch from log file path + branch = detect_branch_from_log(file_path) + + # Process new log lines + for line in new_lines.strip().split('\n'): + if line.strip(): + # Check if this is a new command execution + command_info = self._extract_command_info(line) + if command_info: + self._emit_command_separator(branch, command_info) + continue # Skip regular log output - separator IS the display + + # Filter out initialization noise + if self._should_display_log(line): + # Detect log level and create event + level = self._detect_log_level(line) + self._emit_log_event(branch, line, level, file_path) + + # Update position + self.log_positions[file_path] = f.tell() + + except Exception as e: + # Log error but don't crash watcher + logger.debug(f"Error reading log file {file_path}: {e}") + + def _should_display_log(self, log_line: str) -> bool: + """ + Filter out initialization noise - only show meaningful task logs. + + Copied directly from discovery/watcher.py + """ + # Common initialization patterns to skip + noise_patterns = [ + "Initializing ", + "Module initialized", + "Module initialization completed", + "Configuration loaded", + "Data loaded", + "Registry loaded", + "loaded config from", + "Cleanup completed - Removed 0", + ] + + for pattern in noise_patterns: + if pattern in log_line: + return False + + return True + + def _detect_log_level(self, log_line: str) -> str: + """ + Detect log level from log line. + + Adapted from discovery/watcher.py _format_log_with_color() + Returns level string instead of formatting with color codes. + + Returns: + 'error', 'warning', 'info', or 'debug' + """ + # Check for error markers + if ' - ERROR - ' in log_line or ' ERROR ' in log_line or '[ERROR]' in log_line: + return 'error' + + # Check for warning markers + elif ' - WARNING - ' in log_line or ' WARNING ' in log_line or '[WARNING]' in log_line: + return 'warning' + + # Check for critical markers + elif ' - CRITICAL - ' in log_line or ' CRITICAL ' in log_line or '[CRITICAL]' in log_line: + return 'error' # Map critical to error for priority + + # Check for debug markers + elif ' - DEBUG - ' in log_line or ' DEBUG ' in log_line or '[DEBUG]' in log_line: + return 'debug' + + # Default to info + else: + return 'info' + + def _extract_command_info(self, log_line: str) -> Optional[Dict[str, Optional[str]]]: + """ + Extract command information from log line if it's a new command execution. + + Returns dict with keys: command, caller (optional), target (optional) + or None if not a command line. + """ + # Pattern 1: Drone commands - "Drone started with args: ['close', 'plan', '0098']" + if "Drone started with args:" in log_line or "[drone] Drone started with args:" in log_line: + match = re.search(r"args:\s*\[([^\]]+)\]", log_line) + if match: + args = match.group(1).replace("'", "").replace('"', '') + return {'command': f"drone {args}", 'caller': None, 'target': None} + + # Pattern 2: Flow plan commands + if "FLOW_PLAN]" in log_line and ("Creating" in log_line or "Closing" in log_line or "Opening" in log_line): + if "Creating" in log_line: + return {'command': "flow create plan", 'caller': None, 'target': None} + elif "Closing" in log_line: + match = re.search(r"PLAN(\d+)", log_line) + if match: + return {'command': f"flow close plan {match.group(1)}", 'caller': None, 'target': None} + elif "Opening" in log_line: + match = re.search(r"PLAN(\d+)", log_line) + if match: + return {'command': f"flow open plan {match.group(1)}", 'caller': None, 'target': None} + + # Pattern 3: Seed audit commands - extract target branch + if "[seed]" in log_line.lower() and "audit" in log_line.lower(): + match = re.search(r"Auditing\s+(\w+)", log_line) + if match: + target = match.group(1).upper() + return {'command': f"seed audit @{target.lower()}", 'caller': None, 'target': target} + + # Pattern 4: Seed checklist commands + if "standards_checklist" in log_line.lower() and "Running" in log_line: + match = re.search(r"Running\s+(\w+)\s+standard\s+check\s+on\s+(.+)", log_line) + if match: + return {'command': f"seed checklist {match.group(2)}", 'caller': None, 'target': None} + + # Pattern 5: AI Mail commands - extract target + if "[ai_mail]" in log_line.lower(): + if "Sending" in log_line: + # Try to extract recipient + target_match = re.search(r"to\s+@?(\w+)", log_line, re.IGNORECASE) + target = target_match.group(1).upper() if target_match else None + return {'command': "ai_mail send", 'caller': None, 'target': target} + elif "inbox" in log_line.lower(): + return {'command': "ai_mail inbox", 'caller': None, 'target': None} + + # Pattern 6: Prax commands + if "[prax]" in log_line.lower(): + if "monitor" in log_line.lower(): + return {'command': "prax monitor", 'caller': None, 'target': None} + elif "status" in log_line.lower(): + return {'command': "prax status", 'caller': None, 'target': None} + + # Pattern 8: Backup system operations (direct python3 calls) + if "[backup" in log_line.lower(): + if "snapshot" in log_line.lower() and ("Starting" in log_line or "Running" in log_line or "Complete" in log_line): + return {'command': "backup_system snapshot", 'caller': None, 'target': None} + elif "versioned" in log_line.lower() and ("Starting" in log_line or "Running" in log_line): + return {'command': "backup_system versioned", 'caller': None, 'target': None} + elif "sync" in log_line.lower() and ("Starting" in log_line or "Running" in log_line): + return {'command': "backup_system sync", 'caller': None, 'target': None} + + # Pattern 9: Memory Bank operations (direct python3 calls) + if "[memory_bank]" in log_line.lower() or "memory_bank" in log_line.lower(): + if "rollover" in log_line.lower() and ("Starting" in log_line or "Processing" in log_line): + return {'command': "memory_bank rollover", 'caller': None, 'target': None} + elif "search" in log_line.lower() and "query" in log_line.lower(): + return {'command': "memory_bank search", 'caller': None, 'target': None} + + # Pattern 10: Cortex operations (direct python3 calls) + if "[cortex]" in log_line.lower(): + if "Creating" in log_line and "branch" in log_line.lower(): + match = re.search(r"Creating\s+(?:branch\s+)?(\w+)", log_line) + target = match.group(1).upper() if match else None + return {'command': "cortex create branch", 'caller': None, 'target': target} + + # Pattern 11: Trigger operations (direct python3 calls) + if "[trigger]" in log_line.lower(): + if "fired" in log_line.lower() or "triggered" in log_line.lower(): + return {'command': "trigger fire", 'caller': None, 'target': None} + + # Pattern 7: ALL drone command executions - HIGH PRIORITY + # Format: "Executing command [CALLER:PRAX]: seed.py audit @prax" + if "Executing" in log_line and "command" in log_line: + caller_match = re.search(r"\[CALLER:(\w+)\]", log_line) + caller = caller_match.group(1) if caller_match else None + + cmd_match = re.search(r"Executing(?:\s+activated)?\s+command(?:\s*\[CALLER:\w+\])?:\s*(.+)", log_line) + if cmd_match: + cmd = cmd_match.group(1).strip() + # Extract target from command (e.g., "seed.py audit @prax" -> PRAX) + # Or "seed.py audit /home/aipass/aipass_core/prax" -> PRAX + target = None + target_match = re.search(r'@(\w+)', cmd) + if target_match: + target = target_match.group(1).upper() + else: + # Check for full path target + path_match = re.search(r'/home/aipass/(?:aipass_core|aipass_os)/(\w+)', cmd) + if path_match: + target = path_match.group(1).upper() + + # Clean up command display - simplify paths + display_cmd = cmd + # Replace full paths with @branch notation + display_cmd = re.sub(r'/home/aipass/aipass_core/(\w+)/apps/\w+\.py', lambda m: f"@{m.group(1)}", display_cmd) + display_cmd = re.sub(r'/home/aipass/aipass_core/(\w+)', lambda m: f"@{m.group(1)}", display_cmd) + + return {'command': display_cmd, 'caller': caller, 'target': target} + + return None + + def _emit_command_separator(self, branch: str, command_info) -> None: + """ + Emit command separator event to queue with caller and target attribution. + Deduplicates consecutive identical commands per branch. + """ + # Handle dict format (new) and legacy string/tuple formats + if isinstance(command_info, dict): + command = command_info.get('command', '') + caller = command_info.get('caller') + target = command_info.get('target') + elif isinstance(command_info, tuple): + command, caller = command_info + target = None + else: + command = command_info + caller = None + target = None + + # Deduplicate: skip if same command just emitted for this branch + dedup_key = f"{branch}:{command}" + if self.last_command_per_branch.get(branch) == dedup_key: + return + self.last_command_per_branch[branch] = dedup_key + + separator_event = MonitoringEvent( + priority=2, + event_type='command', + branch=branch, + action='executed', + message=command, + level='info', + timestamp=datetime.now(), + caller=caller + ) + + # Store target in action field since MonitoringEvent doesn't have a target field + if target: + separator_event.action = f"executed:{target}" + + self.event_queue.enqueue(separator_event) + + def _parse_log_message(self, log_line: str) -> str: + """ + Parse raw log line to extract just the message content. + + Raw format: [BRANCH_NAME] TIMESTAMP | SOURCE | LEVEL | MESSAGE + Returns just the MESSAGE part to avoid duplicate prefixes. + + Args: + log_line: Raw log line from log file + + Returns: + Cleaned message content + """ + # Try to extract message after last pipe separator + if ' | ' in log_line: + parts = log_line.split(' | ') + if len(parts) >= 4: + # Format: [BRANCH] TIMESTAMP | SOURCE | LEVEL | MESSAGE + # Return everything after the LEVEL part + return ' | '.join(parts[3:]).strip() + elif len(parts) >= 2: + # Simpler format, return last part + return parts[-1].strip() + + # Fallback: return as-is if can't parse + return log_line.strip() + + def _emit_log_event(self, branch: str, log_line: str, level: str, + log_file_path: Optional[str] = None) -> None: + """ + Create and emit log event to monitoring queue. + + Also fires trigger event for ERROR level logs to enable + automated error response workflows. + + Args: + branch: Branch name detected from log file + log_line: Raw log line content + level: Log level (error, warning, info, debug) + log_file_path: Path to the log file (for trigger events) + """ + # Parse to extract clean message (remove embedded prefix) + clean_message = self._parse_log_message(log_line) + current_time = datetime.now() + + # Fire trigger event for ERROR level logs + if HAS_TRIGGER and level == 'error': + # Extract module name from log line if possible + module_name = 'unknown' + if ' | ' in log_line: + parts = log_line.split(' | ') + if len(parts) >= 2: + module_name = parts[1].strip() + + trigger.fire('error_detected', + branch=branch, + message=clean_message, + error_hash=_generate_error_hash(module_name, clean_message), + timestamp=current_time.isoformat(), + log_file=log_file_path or 'unknown', + module_name=module_name + ) + + # Create monitoring event + log_event = MonitoringEvent( + priority=0, # Auto-calculated from level + event_type='log', + branch=branch, + action='logged', + message=clean_message, + level=level, + timestamp=current_time + ) + + # Push to queue + self.event_queue.enqueue(log_event) + + def initialize_positions(self): + """ + Initialize log positions to END of existing files. + + Only show NEW entries after watcher starts. + Copied from discovery/watcher.py start_file_watcher() + """ + if not SYSTEM_LOGS_DIR.exists(): + logger.warning(f"System logs directory not found: {SYSTEM_LOGS_DIR}") + return + + for log_file in SYSTEM_LOGS_DIR.glob("*.log"): + try: + self.log_positions[str(log_file)] = log_file.stat().st_size + except Exception as e: + logger.debug(f"Could not get size for {log_file}: {e}") + + +def start_log_watcher(event_queue: MonitoringQueue) -> Any: + """ + Start watching log files and pushing events to queue. + + Args: + event_queue: MonitoringQueue instance for event delivery + + Returns: + Observer instance (caller must keep alive and call .stop() on shutdown) + """ + global _log_observer + + # Stop existing observer if running + if _log_observer and _log_observer.is_alive(): + logger.warning("Log watcher already running, stopping existing instance") + stop_log_watcher() + + # Create watcher instance + watcher = LogFileWatcher(event_queue) + + # Initialize log positions to END of existing files + watcher.initialize_positions() + + # Create and start observer + observer = WatchdogObserver() + observer.schedule(watcher, str(SYSTEM_LOGS_DIR), recursive=False) + observer.start() + + _log_observer = observer + + logger.info(f"Log watcher started, monitoring: {SYSTEM_LOGS_DIR}") + + return observer + + +def stop_log_watcher(): + """Stop the log watcher""" + global _log_observer + + if _log_observer and _log_observer.is_alive(): + _log_observer.stop() + _log_observer.join(timeout=5.0) + _log_observer = None + logger.info("Log watcher stopped") + + +def is_log_watcher_active() -> bool: + """ + Check if log watcher is currently active. + + Returns: + True if watcher is running, False otherwise + """ + return _log_observer is not None and _log_observer.is_alive() + + +# ============================================================================= +# STANDALONE TEST +# ============================================================================= + +if __name__ == '__main__': + """ + Standalone test - starts log watcher and prints events from queue. + + Usage: + python3 log_watcher.py + + Then trigger some log activity in another terminal: + prax [command] + + Press Ctrl+C to stop. + """ + import time + from aipass.cli.apps.modules import console + + console.print("[bold cyan]PRAX Log Watcher Test[/bold cyan]") + console.print() + console.print(f"Monitoring: {SYSTEM_LOGS_DIR}") + console.print("Press Ctrl+C to stop") + console.print() + + # Create event queue + queue = MonitoringQueue() + + # Start watcher + observer = start_log_watcher(queue) + + try: + # Event processing loop + while True: + # Check for events + event = queue.dequeue(timeout=0.5) + + if event: + # Format and display event + timestamp = event.timestamp.strftime('%H:%M:%S') + + # Color by level + level_colors = { + 'error': 'red', + 'warning': 'yellow', + 'info': 'white', + 'debug': 'dim', + } + color = level_colors.get(event.level, 'white') + + # Print event + if event.event_type == 'command': + console.print(f"\n[bold green]{event.message}[/bold green]\n") + else: + console.print( + f"[dim]{timestamp}[/dim] " + f"[cyan][{event.branch:>8}][/cyan] " + f"[{color}]{event.message}[/{color}]" + ) + + # Small delay to prevent CPU spinning + time.sleep(0.1) + + except KeyboardInterrupt: + console.print("\n[yellow]Stopping log watcher...[/yellow]") + stop_log_watcher() + queue.stop() + console.print("[green]Log watcher stopped[/green]") diff --git a/src/aipass/prax/apps/handlers/monitoring/module_tracker.py b/src/aipass/prax/apps/handlers/monitoring/module_tracker.py new file mode 100644 index 00000000..9c28e84f --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/module_tracker.py @@ -0,0 +1,105 @@ +#!/home/aipass/.venv/bin/python3 + +# META DATA HEADER +# Name: module_tracker.py - Module Execution Tracker +# Version: 0.1.0 + +"""Track module execution and drone commands""" + +from typing import Dict, List, Optional +from datetime import datetime +import re + +class ModuleTracker: + """Track active modules and their execution""" + + def __init__(self): + self.active_modules: Dict[str, Dict] = {} + self.completed_modules: List[Dict] = [] + self.max_history = 100 + + def track_start(self, module_name: str, command: str, pid: Optional[int] = None): + """Track module start""" + self.active_modules[module_name] = { + 'command': command, + 'pid': pid, + 'start_time': datetime.now(), + 'status': 'running' + } + + def track_stop(self, module_name: str, exit_code: int = 0): + """Track module completion""" + if module_name in self.active_modules: + module_info = self.active_modules.pop(module_name) + module_info['end_time'] = datetime.now() + module_info['exit_code'] = exit_code + module_info['status'] = 'completed' if exit_code == 0 else 'failed' + + # Add to history + self.completed_modules.append(module_info) + if len(self.completed_modules) > self.max_history: + self.completed_modules.pop(0) + + def get_active(self) -> List[Dict]: + """Get list of active modules""" + return [ + { + 'name': name, + **info + } + for name, info in self.active_modules.items() + if info['status'] == 'running' + ] + + def parse_log_for_commands(self, log_line: str) -> Optional[Dict]: + """Parse log line for command execution patterns""" + + # Pattern: "Drone started with args: ['seed', 'audit']" + if 'Drone started with args:' in log_line: + match = re.search(r"args:\s*\[([^\]]+)\]", log_line) + if match: + args = match.group(1).replace("'", "").replace('"', '') + return { + 'type': 'drone', + 'command': f"drone {args}", + 'timestamp': datetime.now() + } + + # Pattern: "Executing command: flow create PLAN0001" + if 'Executing command:' in log_line: + match = re.search(r"Executing command:\s*(.+)", log_line) + if match: + return { + 'type': 'command', + 'command': match.group(1), + 'timestamp': datetime.now() + } + + # Pattern: Module start/stop indicators + if 'Module started:' in log_line: + match = re.search(r"Module started:\s*(\w+)", log_line) + if match: + self.track_start(match.group(1), "unknown") + return { + 'type': 'module_start', + 'module': match.group(1), + 'timestamp': datetime.now() + } + + return None + + def format_active_modules(self) -> str: + """Format active modules for display""" + active = self.get_active() + if not active: + return "No modules currently running" + + lines = ["Active Modules:"] + for module in active: + duration = (datetime.now() - module['start_time']).seconds + lines.append(f" • {module['name']}: {module['command']} ({duration}s)") + + return "\n".join(lines) + +# Global instance +tracker = ModuleTracker() diff --git a/src/aipass/prax/apps/handlers/monitoring/monitoring_filters.py b/src/aipass/prax/apps/handlers/monitoring/monitoring_filters.py new file mode 100644 index 00000000..f611c370 --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/monitoring_filters.py @@ -0,0 +1,610 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: monitoring_filters.py - Monitoring Filter Patterns +# Date: 2025-11-23 +# Version: 1.0.0 +# Category: handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-23): Initial creation based on backup_system config_handler.py +# * Adapted backup system's pattern organization for monitoring +# * Created MONITOR_IGNORE_PATTERNS from GLOBAL_IGNORE_PATTERNS +# * Created MONITOR_ALWAYS_PATTERNS from IGNORE_EXCEPTIONS +# * Added new CONTENT_FILTER_PATTERNS for log filtering +# * Added new HIGHLIGHT_PATTERNS for priority-based event highlighting +# +# CODE STANDARDS: +# - Follow seed 3-layer architecture +# - Handlers must be independent and transportable +# - No cross-handler imports except within same domain +# ============================================= + +""" +Monitoring Filter Patterns + +Centralized filter configuration for the prax monitoring system. +Defines what to watch, ignore, and prioritize during filesystem monitoring. +Based on backup_system/config_handler.py's excellent pattern organization. +""" + +# ============================================= +# IMPORTS +# ============================================= + +from pathlib import Path +from typing import List, Set, Dict, Optional, Any + +# ============================================= +# MONITORING PATTERNS +# ============================================= + +# Files/folders to NEVER monitor (adapted from GLOBAL_IGNORE_PATTERNS) +# These generate too many events, are non-essential, or are system directories +MONITOR_IGNORE_PATTERNS = [ + # Python cache and temp files (constant changes) + "__pycache__", + "*.pyc", + "*.pyo", + "*.pyd", + + # Virtual environments (massive file count, not user code) + ".venv", + "venv", + "env", + + # Node.js (massive file count) + "node_modules", + "npm-debug.log", + "yarn-error.log", + + # Version control (Git manages its own changes) + ".git", + + # Backup directories (prevent circular monitoring) + "backups", + "backup_system/backups", + "*/backups", + "system_snapshot", + "versioned_backup", + "deleted_branches", + + # System logs (prevent feedback loop - log watcher handles these separately) + "system_logs", + "*.log", + + # System backups and trash + "TimeShift*", + "timeshift*", + ".local/share/Trash", + "Trash", + + # Linux system directories (not code, constant changes) + ".cache", + ".local", + ".config", + ".mozilla", + ".gnupg", + ".ssh", + ".pki", + ".dotnet", + ".var", + ".backup", + ".antigravity", + ".gemini", + + # IDE and editor directories (auto-generated, large) + ".vscode/cli", + ".vscode/extensions", + ".vscode-server", + ".idea", + ".eclipse", + + # Claude Code internal files (change constantly, not user code) + ".claude/todos", + ".claude/shell-snapshots", + ".claude/ide", + ".claude/statsig", + ".claude/.credentials.json", + ".claude/debug", + ".claude/file-history", + ".claude/history.jsonl", + ".claude/.update.lock", + ".claude/plugins", + ".claude/telemetry", + ".claude.json.backup", + ".claude.json.tmp", + ".last_diagnostics_file", + ".serena/logs", + ".code", + + # Development cache/build directories + ".npm", + ".cargo", + ".rustup", + ".gem", + ".gradle", + ".m2", + "build", + "dist", + "install", + "lib", + "bin", + + # Application data + ".thunderbird", + ".wine", + ".steam", + ".zoom", + + # User directories (not code) + "Downloads", + "Videos", + "Pictures", + "Dropbox", + + # Large binary/image files + "*.img", + "*.iso", + "*.vmdk", + "*.vdi", + "*.qcow2", + + # Archive and compressed files + "*.zip", + "*.tar", + "*.gz", + "*.bz2", + "*.rar", + "*.7z", + "*.whl", + + # HuggingFace model cache (sentence-transformers etc.) + "huggingface", + + # Temporary files (includes Claude Code atomic writes: file.py.tmp.PID.TIMESTAMP) + "*.tmp", + "*.temp", + ".tmp.", + "*.swp", + "*.swo", + "*~", + + # Operating system files + ".DS_Store", + "Thumbs.db", + "desktop.ini", + "nul", +] + +# Files to ALWAYS monitor (adapted from IGNORE_EXCEPTIONS) +# These are critical files that override ignore patterns +MONITOR_ALWAYS_PATTERNS = [ + # AIPass memory files - CRITICAL SYSTEM FILES! + "*.id.json", + "*.local.json", + "*.observations.json", + "*.ai_mail.json", + + # Configuration files + "*_config.json", + ".claude.json", + ".mcp.json", + ".commands.json", + ".gitignore", + ".gitattributes", + + # Python source code + "*.py", + + # Documentation + "README.md", + "CLAUDE.md", + "*.local.md", + + # VS Code settings + ".vscode/settings.json", + ".vscode/settings.local.json", + + # Templates (everything in templates is important) + "templates/**", + "*/templates/**", + + # Marker files + ".gitkeep", +] + +# ============================================= +# CONTENT FILTERING PATTERNS +# ============================================= + +# Files to watch but filter their content (NEW for monitoring) +# Instead of showing all changes, apply filters to reduce noise +CONTENT_FILTER_PATTERNS = { + # Log files: only show errors/warnings, not every append + "*.log": { + "filter_mode": "errors_only", + "show_patterns": ["ERROR", "CRITICAL", "WARNING", "Failed", "Exception"], + "description": "Only show error-level log entries" + }, + + # Data JSON files: only show structural changes, not data updates + "*_data.json": { + "filter_mode": "structure_only", + "description": "Show only structural changes, not data updates" + }, + + # Registry JSON files: only show new/deleted keys + "*_registry.json": { + "filter_mode": "keys_only", + "description": "Show only new/deleted keys, not value changes" + }, + + # Snapshot files: summarize instead of full content + "snapshot_*.json": { + "filter_mode": "summary", + "description": "Show summary of changes, not full content" + }, +} + +# ============================================= +# PRIORITY/HIGHLIGHT PATTERNS +# ============================================= + +# Files to highlight in output based on importance (NEW for monitoring) +# Determines how prominently events are displayed to user +HIGHLIGHT_PATTERNS = { + # CRITICAL: System-breaking changes + "critical": [ + "*.id.json", # Branch identity - system core + "CLAUDE.md", # System instructions + ".gitignore", # Version control rules + "*_config.json deletion", # Config deletion is critical + ], + + # HIGH: Important files that should stand out + "high": [ + "*.py deletion", # Source code deletion + "*.py creation", # New source code + "README.md", # Documentation + "*_config.json", # Configuration changes + ], + + # MEDIUM: Files worth noting + "medium": [ + "*.local.json", # Session files + "*.observations.json", # Collaboration patterns + "*.py modification", # Source code edits + "*.md", # Documentation + ], + + # LOW: Normal files (default) + # Everything else not matched above +} + +# ============================================= +# EVENT TYPE CONFIGURATIONS +# ============================================= + +# Which event types to monitor for each pattern +EVENT_TYPES = { + "all": ["created", "modified", "deleted", "moved"], + "changes_only": ["modified", "deleted"], + "structure_only": ["created", "deleted", "moved"], +} + +# Default event types to monitor (can be overridden per pattern) +DEFAULT_EVENT_TYPES = EVENT_TYPES["all"] + +# ============================================= +# HELPER FUNCTIONS +# ============================================= + +def should_monitor(path: Path) -> bool: + """Check if path should be monitored + + Checks ALWAYS patterns first (exceptions that override ignores), + then checks IGNORE patterns. Default is to monitor if no match. + + Args: + path: Path object to check + + Returns: + True if path should be monitored, False otherwise + + Example: + should_monitor(Path("/home/user/test.py")) # True + should_monitor(Path("/home/user/.cache/data")) # False + should_monitor(Path("/home/user/FLOW.id.json")) # True (ALWAYS) + """ + import os + + path_str = str(path) + parts = set(path_str.split(os.sep)) + name = path.name + + # Early exit: Claude Code atomic writes and backups (override ALWAYS patterns) + # These contain .claude.json as substring, which would match the ALWAYS pattern + if '.claude.json.backup' in name or '.claude.json.tmp' in name: + return False + + # Check ALWAYS patterns first (exceptions that override ignores) + for pattern in MONITOR_ALWAYS_PATTERNS: + # Template wildcard patterns + if "**" in pattern: + exception_parts = pattern.split("/**")[0] + if exception_parts in path_str or exception_parts in "/".join(parts): + return True # Force monitoring + # Wildcard patterns (*.py, *.json, etc) + elif pattern.startswith('*') and name.endswith(pattern[1:]): + return True + # Exact name match + elif pattern == name: + return True + # Pattern in full path + elif pattern in path_str: + return True + + # Check IGNORE patterns + for pattern in MONITOR_IGNORE_PATTERNS: + # Directory name matching (must be in path parts) + if pattern in ["backups", ".cache", ".git", "node_modules"]: + if pattern in parts: + return False + # Exact name match + elif pattern == name: + return False + # Wildcard patterns + elif pattern.startswith('*') and name.endswith(pattern[1:]): + return False + # Pattern in path + elif pattern in parts or pattern in path_str: + return False + + # Default: monitor it (inclusive approach) + return True + + +def get_priority(path: Path, event_type: str) -> str: + """Get priority level for an event + + Determines how prominently to display an event to the user. + Checks patterns with event type suffix first, then base patterns. + + Args: + path: Path object for the event + event_type: Type of event (created, modified, deleted, moved) + + Returns: + Priority level: "critical", "high", "medium", or "low" + + Example: + get_priority(Path("FLOW.id.json"), "modified") # "critical" + get_priority(Path("test.py"), "deleted") # "high" + get_priority(Path("data.txt"), "modified") # "low" + """ + name = path.name + path_str = str(path) + + # Check each priority level + for level, patterns in HIGHLIGHT_PATTERNS.items(): + for pattern in patterns: + # Pattern with event type (e.g., "*.py deletion") + if " " in pattern: + pattern_base, pattern_event = pattern.split(" ", 1) + if event_type == pattern_event: + # Check if path matches pattern + if pattern_base.startswith('*') and name.endswith(pattern_base[1:]): + return level + elif pattern_base == name: + return level + # Pattern without event type (all events) + else: + # Wildcard patterns + if pattern.startswith('*') and name.endswith(pattern[1:]): + return level + # Exact name match + elif pattern == name: + return level + # Pattern in path + elif pattern in path_str: + return level + + # Default: low priority + return "low" + + +def get_content_filter(path: Path) -> Optional[Dict[str, Any]]: + """Get content filter configuration for a path + + Determines if content should be filtered and how. + + Args: + path: Path object to check + + Returns: + Filter config dict if path should be filtered, None otherwise + + Example: + get_content_filter(Path("system.log")) # {"filter_mode": "errors_only", ...} + get_content_filter(Path("test.py")) # None + """ + name = path.name + + # Check content filter patterns + for pattern, config in CONTENT_FILTER_PATTERNS.items(): + # Wildcard patterns + if pattern.startswith('*') and name.endswith(pattern[1:]): + return config + # Exact match + elif pattern == name: + return config + # Pattern in name + elif pattern.replace('*', '') in name: + return config + + return None + + +def get_ignore_patterns() -> List[str]: + """Get the monitor ignore patterns list + + Returns: + Copy of monitor ignore patterns list + """ + return MONITOR_IGNORE_PATTERNS.copy() + + +def get_always_patterns() -> List[str]: + """Get the monitor always patterns list + + Returns: + Copy of monitor always patterns list + """ + return MONITOR_ALWAYS_PATTERNS.copy() + + +def should_filter_content(path: Path, content: str, filter_mode: str = "errors_only") -> bool: + """Determine if content should be displayed based on filter mode + + Used for log files and other high-volume content files where we want + to reduce noise by only showing relevant entries. + + Args: + path: Path to the file + content: Content to filter (single line or chunk) + filter_mode: Filter mode to apply ("errors_only", "structure_only", etc.) + + Returns: + True if content should be shown, False if it should be filtered out + + Example: + should_filter_content(Path("system.log"), "ERROR: Failed", "errors_only") # True + should_filter_content(Path("system.log"), "INFO: Starting", "errors_only") # False + """ + if filter_mode == "errors_only": + # Only show lines with error/warning keywords + error_keywords = ["ERROR", "CRITICAL", "WARNING", "Failed", "Exception", + "Traceback", "error:", "warning:", "WARN"] + content_upper = content.upper() + return any(keyword.upper() in content_upper for keyword in error_keywords) + + elif filter_mode == "structure_only": + # For JSON files - would need to parse and compare structure + # For now, show everything (implementation placeholder) + return True + + elif filter_mode == "keys_only": + # For registry files - would need to compare keys + # For now, show everything (implementation placeholder) + return True + + elif filter_mode == "summary": + # For snapshot files - would need to summarize + # For now, show everything (implementation placeholder) + return True + + # Default: show everything + return True + + +def filter_log_content(content: str, show_errors: bool = True, + show_warnings: bool = True, + show_info: bool = False) -> Optional[str]: + """Filter log content based on level preferences + + Extracts relevant lines from log content based on user's level preferences. + Useful for processing log file changes without overwhelming the user. + + Args: + content: Log content to filter (can be multi-line) + show_errors: Include ERROR and CRITICAL level messages + show_warnings: Include WARNING level messages + show_info: Include INFO level messages + + Returns: + Filtered content string with only relevant lines, or None if nothing matches + + Example: + >>> content = "INFO: Starting\\nERROR: Failed\\nINFO: Done" + >>> filter_log_content(content, show_errors=True, show_info=False) + "ERROR: Failed" + """ + lines = content.split('\n') + filtered_lines = [] + + for line in lines: + line_upper = line.upper() + + # Check for errors + if show_errors and any(kw in line_upper for kw in + ["ERROR", "CRITICAL", "EXCEPTION", "TRACEBACK", "FAILED"]): + filtered_lines.append(line) + continue + + # Check for warnings + if show_warnings and any(kw in line_upper for kw in + ["WARNING", "WARN"]): + filtered_lines.append(line) + continue + + # Check for info + if show_info and "INFO" in line_upper: + filtered_lines.append(line) + continue + + # Return filtered content or None if nothing matched + if filtered_lines: + return '\n'.join(filtered_lines) + return None + + +def apply_content_filter(path: Path, content: str, + show_errors: bool = True, + show_warnings: bool = True, + show_info: bool = False) -> Optional[str]: + """Apply appropriate content filter based on file type + + Main entry point for content filtering. Checks if file has a content + filter pattern defined, then applies the appropriate filter. + + Args: + path: Path to the file + content: File content to filter + show_errors: Include error-level content + show_warnings: Include warning-level content + show_info: Include info-level content + + Returns: + Filtered content string, original content if no filter, or None if all filtered + + Example: + >>> apply_content_filter(Path("system.log"), "INFO: test\\nERROR: fail") + "ERROR: fail" + >>> apply_content_filter(Path("test.py"), "print('hello')") + "print('hello')" # No filter for .py files + """ + # Get content filter config for this file + filter_config = get_content_filter(path) + + # No filter defined - return original content + if not filter_config: + return content + + filter_mode = filter_config.get("filter_mode") + + # Apply errors_only filter (for log files) + if filter_mode == "errors_only": + return filter_log_content(content, show_errors, show_warnings, show_info) + + # Other filter modes not yet implemented - return original + # TODO: Implement structure_only, keys_only, summary filters + return content + + +# ============================================= +# MODULE INITIALIZATION +# ============================================= + +# No initialization needed - pure configuration diff --git a/src/aipass/prax/apps/handlers/monitoring/telegram_command_bot.py b/src/aipass/prax/apps/handlers/monitoring/telegram_command_bot.py new file mode 100644 index 00000000..2b83bddf --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/telegram_command_bot.py @@ -0,0 +1,388 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: telegram_command_bot.py - Telegram Command Bot for Prax Monitor +# Date: 2026-02-23 +# Version: 1.0.0 +# Category: prax/handlers/monitoring +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-23): Initial implementation +# * Polls scheduler bot for commands (10s interval) +# * /prax_on - Start headless file/log monitoring with Telegram relay +# * /prax_off - Stop monitoring +# * /status - System health report (daemon, VERA, PRs, errors) +# * Runs as long-lived background process +# +# CODE STANDARDS: +# - No cross-branch imports (standalone) +# - Uses urllib (same pattern as daemon.py) +# - Silent Telegram failures (non-blocking) +# ============================================= + +""" +Telegram Command Bot for Prax Monitor + +Polls the scheduler bot for commands from Patrick. Enables phone-based +control of Prax monitoring and system status checks. + +Commands: + /prax_on - Start monitoring (file changes + daemon log → Telegram) + /prax_off - Stop monitoring + /status - System health snapshot + /help - Show available commands + +Usage: + # Start as background process + nohup python3 telegram_command_bot.py & + + # Or run directly + python3 telegram_command_bot.py + +Architecture: + Long-running poller → reads Telegram updates → dispatches commands + Monitoring runs in background threads when active. +""" + +from pathlib import Path + +import json +import os +import signal +import subprocess +import threading +import time +from datetime import datetime +from urllib.request import Request, urlopen +from urllib.error import URLError + +from aipass.prax.apps.modules.logger import get_direct_logger + +logger = get_direct_logger() + +# ============================================= +# CONSTANTS +# ============================================= + +AIPASS_HOME = Path.home() +CONFIG_PATH = AIPASS_HOME / ".aipass" / "scheduler_config.json" +DAEMON_LOG = AIPASS_HOME / "aipass_core" / "ai_mail" / "ai_mail.local" / "dispatch_daemon.log" +VERA_NOTEPAD = AIPASS_HOME / "aipass_business" / "vera" / "NOTEPAD.md" +MONITOR_PID_FILE = AIPASS_HOME / ".aipass" / "prax_monitor.pid" +POLL_INTERVAL = 10 # seconds +TELEGRAM_MAX_LENGTH = 4000 +PATRICK_CHAT_ID = "7235222625" + +# ============================================= +# MODULE STATE +# ============================================= + +_running = True +_monitoring_active = False +_monitor_thread = None +_last_update_id = 0 +_last_log_pos = 0 + + +# ============================================= +# TELEGRAM API +# ============================================= + + +def _load_config(): + """Load scheduler bot config.""" + try: + with open(CONFIG_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + logger.warning("Config not found") + return None + + +def _send_message(text, config=None): + """Send a message to Patrick via scheduler bot.""" + if config is None: + config = _load_config() + if not config: + return False + + url = f"https://api.telegram.org/bot{config['telegram_bot_token']}/sendMessage" + payload = json.dumps({ + "chat_id": config.get("telegram_chat_id", PATRICK_CHAT_ID), + "text": text[:TELEGRAM_MAX_LENGTH], + "parse_mode": "HTML", + "disable_notification": False + }).encode("utf-8") + req = Request(url, data=payload, headers={"Content-Type": "application/json"}) + + try: + with urlopen(req, timeout=10) as resp: + return json.loads(resp.read()).get("ok", False) + except (URLError, Exception) as e: + logger.warning("Send failed: %s", e) + return False + + +def _get_updates(config, offset=0): + """Poll for new messages.""" + url = ( + f"https://api.telegram.org/bot{config['telegram_bot_token']}" + f"/getUpdates?offset={offset}&timeout=5&allowed_updates=[\"message\"]" + ) + try: + with urlopen(url, timeout=15) as resp: + data = json.loads(resp.read()) + if data.get("ok"): + return data.get("result", []) + except (URLError, Exception) as e: + logger.warning("Poll failed: %s", e) + return [] + + +# ============================================= +# MONITORING +# ============================================= + + +def _monitor_daemon_log(): + """Background thread: tail daemon log and relay important events.""" + global _last_log_pos, _monitoring_active + + config = _load_config() + if not config: + return + + _send_message("🟢 Prax Monitor ON\nWatching daemon log for events.", config) + + try: + if DAEMON_LOG.exists(): + _last_log_pos = DAEMON_LOG.stat().st_size + except OSError: + _last_log_pos = 0 + + while _monitoring_active: + try: + if DAEMON_LOG.exists(): + current_size = DAEMON_LOG.stat().st_size + if current_size > _last_log_pos: + with open(DAEMON_LOG, "r", encoding="utf-8") as f: + f.seek(_last_log_pos) + new_lines = f.readlines() + _last_log_pos = current_size + + # Filter for important events + important = [] + for line in new_lines: + line = line.strip() + if any(kw in line for kw in ["SPAWN", "ERROR", "STOPPED", "STARTED", "error", "failed"]): + important.append(line) + + if important: + text = "\n".join(important[-10:]) # Last 10 important lines + _send_message(f"📡 Daemon Activity\n
{text}
", config) + + except Exception as e: + logger.warning("Monitor error: %s", e) + + time.sleep(15) # Check every 15 seconds + + +def _start_monitoring(): + """Start Prax monitoring.""" + global _monitoring_active, _monitor_thread + + if _monitoring_active: + return "Already monitoring." + + _monitoring_active = True + _monitor_thread = threading.Thread(target=_monitor_daemon_log, daemon=True) + _monitor_thread.start() + + # Write PID file + try: + MONITOR_PID_FILE.write_text(str(os.getpid())) + except OSError: + pass + + return "Prax monitoring started. Watching daemon log." + + +def _stop_monitoring(): + """Stop Prax monitoring.""" + global _monitoring_active + + if not _monitoring_active: + return "Not currently monitoring." + + _monitoring_active = False + config = _load_config() + _send_message("🔴 Prax Monitor OFF", config) + + # Remove PID file + try: + MONITOR_PID_FILE.unlink(missing_ok=True) + except OSError: + pass + + return "Prax monitoring stopped." + + +# ============================================= +# COMMANDS +# ============================================= + + +def _cmd_status(): + """Generate system status report.""" + lines = ["📊 AIPass System Status", ""] + + # Daemon check + try: + result = subprocess.run( + ["pgrep", "-f", "daemon.py"], + capture_output=True, text=True, timeout=5 + ) + if result.stdout.strip(): + pids = result.stdout.strip().split("\n") + lines.append(f"✅ Daemon: Running (PID {pids[0]})") + else: + lines.append("❌ Daemon: NOT running") + except Exception: + lines.append("⚠️ Daemon: Check failed") + + # VERA check - last NOTEPAD entry + try: + with open(VERA_NOTEPAD, "r", encoding="utf-8") as f: + content = f.read() + # Find first "What Just Happened" line + for line in content.split("\n"): + if "What Just Happened" in line: + session = line.strip().replace("### ", "") + lines.append(f"🤖 VERA: {session}") + break + except Exception: + lines.append("⚠️ VERA: Could not read NOTEPAD") + + # PR check + try: + result = subprocess.run( + ["gh", "pr", "list", "--repo", "AIOSAI/AIPass", "--state", "open", "--json", "number,title"], + capture_output=True, text=True, timeout=10 + ) + if result.returncode == 0: + prs = json.loads(result.stdout) + if prs: + lines.append(f"📋 Open PRs: {len(prs)}") + for pr in prs[:3]: + lines.append(f" • #{pr['number']}: {pr['title'][:40]}") + else: + lines.append("📋 Open PRs: 0") + except Exception: + lines.append("⚠️ PRs: Check failed") + + # Monitor status + if _monitoring_active: + lines.append("📡 Prax Monitor: ON") + else: + lines.append("📡 Prax Monitor: OFF") + + # Timestamp + lines.append(f"\n🕐 {datetime.now().strftime('%Y-%m-%d %H:%M')}") + + return "\n".join(lines) + + +def _cmd_help(): + """Show available commands.""" + return ( + "🤖 AIPass Prax Bot\n\n" + "/prax_on — Start monitoring (daemon events → Telegram)\n" + "/prax_off — Stop monitoring\n" + "/status — System health check\n" + "/help — This message" + ) + + +def _handle_command(text, config): + """Route command to handler.""" + text = text.strip().lower() + + if text in ("/prax_on", "/praxon", "/monitor_on", "/start"): + result = _start_monitoring() + _send_message(f"🟢 {result}", config) + + elif text in ("/prax_off", "/praxoff", "/monitor_off", "/stop"): + result = _stop_monitoring() + _send_message(f"🔴 {result}", config) + + elif text in ("/status", "/s"): + result = _cmd_status() + _send_message(result, config) + + elif text in ("/help", "/h"): + result = _cmd_help() + _send_message(result, config) + + else: + # Ignore non-commands + pass + + +# ============================================= +# MAIN LOOP +# ============================================= + + +def _signal_handler(sig, frame): + """Handle shutdown signals.""" + global _running, _monitoring_active + logger.info("Received signal %s, shutting down...", sig) + _monitoring_active = False + _running = False + + +def main(): + """Main polling loop.""" + global _running, _last_update_id + + signal.signal(signal.SIGTERM, _signal_handler) + signal.signal(signal.SIGINT, _signal_handler) + + config = _load_config() + if not config: + logger.warning("No config found. Exiting.") + return + + logger.info("Prax Command Bot started. Polling every %ss.", POLL_INTERVAL) + _send_message("🤖 Prax Command Bot started\nType /help for commands.", config) + + # Get latest update_id to skip old messages + updates = _get_updates(config, offset=0) + if updates: + _last_update_id = updates[-1]["update_id"] + 1 + + while _running: + updates = _get_updates(config, offset=_last_update_id) + + for update in updates: + _last_update_id = update["update_id"] + 1 + msg = update.get("message", {}) + text = msg.get("text", "") + chat_id = str(msg.get("chat", {}).get("id", "")) + + # Only respond to Patrick + if chat_id == PATRICK_CHAT_ID and text.startswith("/"): + logger.info("Command: %s", text) + _handle_command(text, config) + + time.sleep(POLL_INTERVAL) + + # Cleanup + _stop_monitoring() + MONITOR_PID_FILE.unlink(missing_ok=True) + logger.info("Prax Command Bot stopped.") + + +if __name__ == "__main__": + main() diff --git a/src/aipass/prax/apps/handlers/monitoring/telegram_relay.py b/src/aipass/prax/apps/handlers/monitoring/telegram_relay.py new file mode 100644 index 00000000..0dac85cd --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/telegram_relay.py @@ -0,0 +1,198 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: telegram_relay.py - Telegram Relay for Mission Control +# Date: 2026-02-18 +# Version: 1.0.0 +# Category: prax/handlers/monitoring +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2026-03-03): Separated from scheduler bot +# * Now uses dedicated prax_monitor_config.json (aipass_prax_monitor_bot) +# - v1.0.0 (2026-02-18): Initial implementation +# * Buffers monitor events and sends to Telegram scheduler bot +# * 5-second batch interval, silent notifications +# * Same urllib pattern as daemon.py (no cross-branch imports) +# * 4096-char Telegram limit handled via message splitting +# +# CODE STANDARDS: +# - Handlers implement logic, modules orchestrate +# - No cross-branch imports +# - Self-contained: reads config directly, uses urllib +# ============================================= + +""" +Telegram relay for Mission Control events. + +Buffers monitoring events and sends them to the dedicated Prax Monitor Telegram bot +in batches every 5 seconds. Uses silent notifications (disable_notification=True). + +Architecture: + monitor_module.py calls queue_event() for each displayed event. + A background flush thread sends batched events to Telegram. + Start/stop lifecycle tied to monitor start/stop. + +Usage (from monitor_module.py): + from aipass.prax.apps.handlers.monitoring.telegram_relay import ( + telegram_start, telegram_stop, telegram_queue_event + ) + telegram_start() # When monitor starts + telegram_queue_event('file', 'PRAX', '...') # Each displayed event + telegram_stop() # When monitor stops +""" + +from pathlib import Path + +# Standard library +import json +import threading +from collections import deque +from datetime import datetime +from urllib.request import Request, urlopen +from urllib.error import URLError + + +# ============================================= +# CONSTANTS +# ============================================= + +PRAX_MONITOR_CONFIG = Path.home() / ".aipass" / "prax_monitor_config.json" +BATCH_INTERVAL = 5.0 # Seconds between flushes +TELEGRAM_MAX_LENGTH = 4000 # Leave margin under 4096 limit + +# ============================================= +# MODULE STATE (mutable runtime state, not constants) +# ============================================= + +_buffer = deque(maxlen=200) +_lock = threading.Lock() +_relay_active = False +_relay_flush_thread = None + +# ============================================= +# PUBLIC API +# ============================================= + + +def telegram_start(): + """Start the Telegram relay. Call when monitor starts.""" + global _relay_active, _relay_flush_thread + _relay_active = True + _relay_flush_thread = threading.Thread(target=_flush_worker, daemon=True) + _relay_flush_thread.start() + _send_telegram("🟢 Mission Control monitoring started") + print("[telegram_relay] Started") + + +def telegram_stop(): + """Stop the Telegram relay. Call when monitor stops.""" + global _relay_active + _flush() # Send remaining buffered events + _send_telegram("🔴 Mission Control monitoring stopped") + _relay_active = False + print("[telegram_relay] Stopped") + + +def telegram_queue_event(event_type, branch, message, caller=None, target=None): + """ + Queue a monitoring event for Telegram delivery. + + Args: + event_type: Event type (file, command, agent, log) + branch: Branch name + message: Event message (plain text, no Rich markup) + caller: Caller branch for command events + target: Target branch for command events + """ + timestamp = datetime.now().strftime("%H:%M:%S") + + if event_type == 'command': + # Format command separator + parts = [] + if caller and caller.upper() != 'UNKNOWN': + parts.append(caller) + if target: + if parts: + parts.append(f"→ {target}") + else: + parts.append(f"→ {target}") + context = " ".join(parts) + if context: + line = f"─── {context}: {message} ───" + else: + line = f"─── {branch}: {message} ───" + else: + line = f"{timestamp} [{branch}] {message}" + + with _lock: + _buffer.append(line) + + +# ============================================= +# INTERNAL +# ============================================= + + +def _flush_worker(): + """Background thread: flush buffer every BATCH_INTERVAL seconds.""" + while _relay_active: + threading.Event().wait(BATCH_INTERVAL) + if _relay_active: # Check again after wait + _flush() + + +def _flush(): + """Send all buffered events as one or more Telegram messages.""" + with _lock: + if not _buffer: + return + lines = list(_buffer) + _buffer.clear() + + text = "\n".join(lines) + + # Split if exceeding Telegram limit + if len(text) <= TELEGRAM_MAX_LENGTH: + _send_telegram(text) + else: + # Split at line boundaries + chunk = [] + chunk_len = 0 + for line in lines: + if chunk_len + len(line) + 1 > TELEGRAM_MAX_LENGTH: + _send_telegram("\n".join(chunk)) + chunk = [] + chunk_len = 0 + chunk.append(line) + chunk_len += len(line) + 1 + if chunk: + _send_telegram("\n".join(chunk)) + + +def _send_telegram(message): + """Send a message to Telegram via the Prax Monitor bot. Silent failure.""" + try: + with open(PRAX_MONITOR_CONFIG, "r", encoding="utf-8") as f: + config = json.load(f) + bot_token = config["telegram_bot_token"] + chat_id = config["telegram_chat_id"] + except (FileNotFoundError, KeyError, json.JSONDecodeError): + print("[telegram_relay] Config not found, skipping") + return False + + url = f"https://api.telegram.org/bot{bot_token}/sendMessage" + payload = json.dumps({ + "chat_id": chat_id, + "text": message, + "disable_notification": True + }).encode("utf-8") + req = Request(url, data=payload, headers={"Content-Type": "application/json"}) + + try: + with urlopen(req, timeout=10) as resp: + result = json.loads(resp.read()) + return result.get("ok", False) + except (URLError, Exception): + print("[telegram_relay] Send failed: %s", message[:60]) + return False diff --git a/src/aipass/prax/apps/handlers/monitoring/unified_stream.py b/src/aipass/prax/apps/handlers/monitoring/unified_stream.py new file mode 100644 index 00000000..5774d2c3 --- /dev/null +++ b/src/aipass/prax/apps/handlers/monitoring/unified_stream.py @@ -0,0 +1,418 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: unified_stream.py - Unified Display Handler +# Date: 2025-11-23 +# Version: 0.1.1 +# Category: prax/handlers/monitoring +# +# CHANGELOG (Max 5 entries): +# - v0.1.1 (2026-02-27): Added PID to branch labels in print_event() +# - v0.1.0 (2025-11-23): Created - unified terminal output for monitoring +# +# DESCRIPTION: +# Single point for all monitoring terminal output with event formatting, +# branch attribution, color coding, and thread-safe console output. +# ============================================= + +""" +Unified Stream Display Handler + +Single point for all monitoring terminal output with: +- Event formatting with branch attribution +- Color coding by event type +- Thread-safe console output +- Status displays and headers +""" + +from pathlib import Path + +from datetime import datetime +from typing import Optional, Dict, List +from threading import Lock + +try: + from aipass.cli.apps.modules import console +except ImportError: + from rich.console import Console + console = Console() + +# Thread safety +_print_lock = Lock() + +# Color schemes by event type and level +COLORS = { + 'file_created': 'green', + 'file_modified': 'yellow', + 'file_deleted': 'red', + 'file_moved': 'blue', + 'log_info': 'white', + 'log_warning': 'yellow', + 'log_error': 'red', + 'log_critical': 'bold red', + 'module_loaded': 'cyan', + 'module_error': 'red', + 'system_info': 'blue', + 'system_warning': 'yellow', + 'system_error': 'bold red', +} + +# Symbols for different event types +SYMBOLS = { + 'file': '📁', + 'log': '📝', + 'module': '⚡', + 'system': '🔧', + 'error': '❌', + 'warning': '⚠️', + 'success': '✅', + 'info': 'ℹ️', +} + +# Branch display width +BRANCH_WIDTH = 8 + +# Level-based color mapping (simplified) +LEVEL_COLORS = { + 'error': 'red', + 'warning': 'yellow', + 'critical': 'bold red', + 'info': 'white', + 'success': 'green', +} + +# Branch-specific colors for visual distinction +BRANCH_COLORS = { + 'SEED': 'green', + 'DRONE': 'cyan', + 'FLOW': 'blue', + 'PRAX': 'magenta', + 'CLI': 'yellow', + 'CORTEX': 'bright_blue', + 'AI_MAIL': 'bright_cyan', + 'BACKUP_SYSTEM': 'bright_green', + 'MEMORY_BANK': 'bright_magenta', + 'DEVPULSE': 'bright_yellow', + 'API': 'bright_red', + 'SECURITY': 'red', + 'AIPASS': 'bold white', + 'TRIGGER': 'bright_red', + 'SPEAKEASY': 'bright_white', + 'THE_COMMONS': 'bright_green', + 'ASSISTANT': 'bright_yellow', +} + + +def print_event(event_type: str, branch: str, message: str, level: str = 'info', pid: Optional[int] = None): + """ + Format and print event with branch attribution + + Format: + [HH:MM:SS] [BRANCH:PID] Message text + + Color coding: + - Timestamp: dim + - Branch name: unique color per branch + - Message: colored by level (error=red, warning=yellow, info=white) + + Args: + event_type: Type of event (file, log, module, system) + branch: Branch name for attribution + message: Event message + level: Event level (info, warning, error, critical) + pid: Optional process ID for the active agent + """ + with _print_lock: + # Timestamp + timestamp = datetime.now().strftime("%H:%M:%S") + + # Get branch color (unique per branch) + # For subagent labels like 'DEV_CENTRAL AGENT', use base branch color + branch_upper = branch.upper() + base_branch = branch_upper[:-6] if branch_upper.endswith(' AGENT') else branch_upper + branch_color = BRANCH_COLORS.get(base_branch, 'white') + + # Format branch label with optional PID + if pid: + branch_label = f"{branch_upper}:{pid}" + else: + branch_label = branch_upper + branch_formatted = f"[{branch_color}][{branch_label:<{BRANCH_WIDTH}}][/{branch_color}]" + + # Get message color based on level + msg_color = LEVEL_COLORS.get(level, 'white') + + # Format and print - timestamp, branch colored, message colored by level + console.print(f"[dim]{timestamp}[/dim] {branch_formatted} [{msg_color}]{message}[/{msg_color}]") + + +def print_command_separator(branch: str, command: str, caller: Optional[str] = None, target: Optional[str] = None): + """ + Print prominent command separator/header with caller attribution. + + Args: + branch: Branch that executed the command (log location) + command: The command that was run + caller: Branch that initiated the command (optional) + target: Branch being acted upon (optional, e.g. audit target) + """ + with _print_lock: + branch_color = BRANCH_COLORS.get(branch.upper(), 'white') + console.print() + console.print(f"[bold {branch_color}]{'─' * 60}[/bold {branch_color}]") + + # Build context line: CALLER → TARGET + context_parts = [] + if caller and caller.upper() != 'UNKNOWN': + caller_color = BRANCH_COLORS.get(caller.upper(), 'cyan') + context_parts.append(f"[{caller_color}]{caller}[/{caller_color}]") + if target: + target_color = BRANCH_COLORS.get(target.upper(), 'cyan') + if context_parts: + context_parts.append(f"→ [{target_color}]{target}[/{target_color}]") + else: + context_parts.append(f"→ [{target_color}]{target}[/{target_color}]") + + if context_parts: + console.print(f" {' '.join(context_parts)}") + + console.print(f"[bold {branch_color}]▶ {command}[/bold {branch_color}]") + console.print(f"[bold {branch_color}]{'─' * 60}[/bold {branch_color}]") + + +def get_file_category(filename: str) -> str: + """Categorize a file by its type for display context. + + Args: + filename: Just the filename (not full path) + + Returns: + Short category tag like 'code', 'memory', 'config', etc. + """ + name_lower = filename.lower() + + # Dashboard (check before general memory) + if name_lower == 'dashboard.local.json': + return 'dashboard' + + # Memory files + if name_lower.endswith('.local.json') or name_lower.endswith('.id.json') or name_lower.endswith('.observations.json'): + return 'memory' + + # Dev notes + if name_lower == 'dev.local.md': + return 'devnotes' + + # Config + if name_lower.endswith('_config.json') or name_lower.endswith('config.json'): + return 'config' + + # Documentation + if name_lower.endswith('.md'): + return 'docs' + + # Code + if name_lower.endswith('.py'): + return 'code' + + # Data/JSON + if name_lower.endswith('.json'): + return 'data' + + # Mail + if 'ai_mail' in name_lower or 'mail' in name_lower: + return 'mail' + + return '' + + +def print_file_event(event_type: str, branch: str, file_path: str, details: Optional[str] = None): + """ + Print file system event + + Args: + event_type: created, modified, deleted, moved + branch: Branch name + file_path: Path to file + details: Optional additional details + """ + # Get file category for context + filename = file_path.split('/')[-1] if '/' in file_path else file_path + category = get_file_category(filename) + + # Build message with category context + category_tag = f"[dim]\\[{category}][/dim] " if category else "" + message = f"{category_tag}{event_type.upper()}: {file_path}" + if details: + message += f" ({details})" + + # Map file event types to levels for color coding + level_map = { + 'created': 'success', + 'modified': 'info', + 'deleted': 'warning', + 'moved': 'info' + } + level = level_map.get(event_type, 'info') + print_event('file', branch, message, level) + + +def print_log_event(branch: str, level: str, message: str, source: Optional[str] = None): + """ + Print log file event + + Args: + branch: Branch name + level: Log level (info, warning, error, critical) + message: Log message + source: Optional source file/module + """ + # Add level prefix for errors and warnings + if level in ['error', 'critical']: + log_msg = f"ERROR: {message}" + elif level == 'warning': + log_msg = f"WARNING: {message}" + else: + log_msg = message + + if source: + log_msg = f"[{source}] {log_msg}" + + print_event('log', branch, log_msg, level) + + +def print_module_event(branch: str, module_name: str, status: str, details: Optional[str] = None): + """ + Print module loading event + + Args: + branch: Branch name + module_name: Name of module + status: loaded, error, reloaded, started, stopped + details: Optional error or status details + """ + message = f"Module {status}: {module_name}" + if details: + message += f" - {details}" + + # Map status to level for color coding + level_map = { + 'error': 'error', + 'failed': 'error', + 'loaded': 'success', + 'started': 'success', + 'stopped': 'warning', + 'reloaded': 'info' + } + level = level_map.get(status, 'info') + print_event('module', branch, message, level) + + +def print_header(): + """Print monitoring system header""" + with _print_lock: + console.print("\n[bold cyan]═══════════════════════════════════════════[/bold cyan]") + console.print("[bold cyan] PRAX Monitoring System v0.1.0[/bold cyan]") + console.print("[bold cyan]═══════════════════════════════════════════[/bold cyan]") + console.print("[dim]Type 'help' for commands, 'quit' to exit[/dim]\n") + + +def print_status(watched_branches: List[str], verbosity: int, filters: Optional[Dict] = None): + """ + Display current monitoring status + + Args: + watched_branches: List of branches being monitored + verbosity: Current verbosity level (0-2) + filters: Optional filter configuration + """ + with _print_lock: + console.print("\n[bold]Current Status:[/bold]") + console.print(f" Watching: [cyan]{', '.join(watched_branches) if watched_branches else 'All branches'}[/cyan]") + console.print(f" Verbosity: [yellow]{verbosity}[/yellow]") + + if filters: + console.print(" Filters:") + if filters.get('file_types'): + console.print(f" File types: {', '.join(filters['file_types'])}") + if filters.get('log_levels'): + console.print(f" Log levels: {', '.join(filters['log_levels'])}") + if filters.get('exclude_patterns'): + console.print(f" Excluded: {', '.join(filters['exclude_patterns'])}") + console.print() + + +def print_help(): + """Display help information""" + with _print_lock: + console.print("\n[bold]Available Commands:[/bold]") + console.print(" [cyan]help[/cyan] - Show this help") + console.print(" [cyan]status[/cyan] - Show monitoring status") + console.print(" [cyan]clear[/cyan] - Clear screen") + console.print(" [cyan]filter [/cyan] - Add filter") + console.print(" [cyan]verbosity <0-2>[/cyan] - Set verbosity level") + console.print(" [cyan]watch [/cyan] - Watch specific branch") + console.print(" [cyan]unwatch [/cyan] - Stop watching branch") + console.print(" [cyan]quit/exit[/cyan] - Exit monitoring\n") + + +def print_error(message: str, details: Optional[str] = None): + """ + Print error message + + Args: + message: Error message + details: Optional error details + """ + with _print_lock: + console.print(f"[bold red]ERROR:[/bold red] {message}") + if details: + console.print(f"[dim]{details}[/dim]") + + +def print_warning(message: str): + """Print warning message""" + with _print_lock: + console.print(f"[yellow]WARNING:[/yellow] {message}") + + +def print_success(message: str): + """Print success message""" + with _print_lock: + console.print(f"[green]SUCCESS:[/green] {message}") + + +def print_info(message: str): + """Print info message""" + with _print_lock: + console.print(f"[blue]INFO:[/blue] {message}") + + +def clear_screen(): + """Clear terminal screen""" + with _print_lock: + console.clear() + + +def print_separator(): + """Print visual separator""" + with _print_lock: + console.print("[dim]─────────────────────────────────────────[/dim]") + + +def format_event_summary(events: Dict[str, int]) -> str: + """ + Format event summary statistics + + Args: + events: Dictionary of event type counts + + Returns: + Formatted summary string + """ + parts = [] + for event_type, count in events.items(): + if count > 0: + parts.append(f"{event_type}: {count}") + return ", ".join(parts) if parts else "No events" diff --git a/src/aipass/prax/apps/handlers/registry/__init__.py b/src/aipass/prax/apps/handlers/registry/__init__.py new file mode 100755 index 00000000..8fd6fe17 --- /dev/null +++ b/src/aipass/prax/apps/handlers/registry/__init__.py @@ -0,0 +1 @@ +# Registry handlers - Template registry and metadata operations diff --git a/src/aipass/prax/apps/handlers/registry/ignore.py b/src/aipass/prax/apps/handlers/registry/ignore.py new file mode 100755 index 00000000..9b0d8287 --- /dev/null +++ b/src/aipass/prax/apps/handlers/registry/ignore.py @@ -0,0 +1,133 @@ +#!/home/aipass/.venv/bin/python3 +# -*- coding: utf-8 -*- + +# ===================AIPASS==================== +# META DATA HEADER +# Name: ignore.py - Registry Ignore Pattern Handler +# Date: 2025-11-06 +# Version: 1.0.0 +# Category: cortex/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-06): Initial implementation - template file exclusion patterns +# ============================================= + +""" +Registry Ignore Handler + +Manages patterns for excluding files from: +- Branch creation (don't copy template-internal files) +- Registry regeneration (don't track template-internal files) + +Loads patterns from registry_ignore.json in template directory. +""" + +import json +from pathlib import Path +from typing import List, Set +from fnmatch import fnmatch + + +def load_ignore_patterns(template_dir: Path) -> dict: + """ + Load ignore patterns from registry_ignore.json + + Args: + template_dir: Path to template directory + + Returns: + Dict with ignore_files and ignore_patterns lists + Returns empty lists if file not found + """ + ignore_file = template_dir / "registry_ignore.json" + + if not ignore_file.exists(): + # Return empty patterns if file doesn't exist + return { + "ignore_files": [], + "ignore_patterns": [] + } + + try: + with open(ignore_file, 'r', encoding='utf-8') as f: + data = json.load(f) + return { + "ignore_files": data.get("ignore_files", []), + "ignore_patterns": data.get("ignore_patterns", []) + } + except Exception: + return { + "ignore_files": [], + "ignore_patterns": [] + } + + +def should_ignore( + file_path: Path, + template_dir: Path, + ignore_files: List[str], + ignore_patterns: List[str] +) -> bool: + """ + Check if a file should be ignored based on patterns + + Args: + file_path: Path to file to check + template_dir: Template directory (for relative path calculation) + ignore_files: List of exact filenames to ignore + ignore_patterns: List of glob patterns to ignore + + Returns: + True if file should be ignored, False otherwise + """ + filename = file_path.name + + # Check exact filename matches + if filename in ignore_files: + return True + + # Check glob patterns + for pattern in ignore_patterns: + if fnmatch(filename, pattern): + return True + + # Check if any parent directory matches patterns + try: + relative_path = file_path.relative_to(template_dir) + for part in relative_path.parts: + for pattern in ignore_patterns: + if fnmatch(part, pattern): + return True + except ValueError: + # file_path not relative to template_dir + pass + + return False + + +def get_ignored_files(template_dir: Path) -> Set[str]: + """ + Get set of all ignored filenames for quick lookup + + Args: + template_dir: Path to template directory + + Returns: + Set of filenames to ignore + """ + patterns = load_ignore_patterns(template_dir) + return set(patterns.get("ignore_files", [])) + + +def get_ignore_patterns(template_dir: Path) -> List[str]: + """ + Get list of ignore glob patterns + + Args: + template_dir: Path to template directory + + Returns: + List of glob patterns to ignore + """ + patterns = load_ignore_patterns(template_dir) + return patterns.get("ignore_patterns", []) diff --git a/src/aipass/prax/apps/handlers/registry/load.py b/src/aipass/prax/apps/handlers/registry/load.py new file mode 100755 index 00000000..b9b77fe6 --- /dev/null +++ b/src/aipass/prax/apps/handlers/registry/load.py @@ -0,0 +1,87 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: load.py +# Date: 2025-11-07 +# Version: 1.0.0 +# Category: prax/handlers/registry +# +# CHANGELOG: +# - v1.0.0 (2025-11-07): Extracted from prax_registry.py - module registry loading +# ============================================= + +""" +Load Module Registry Handler + +Loads the Prax system-wide module discovery registry (prax_registry.json). +Returns empty dict if file doesn't exist or on error. + +Features: +- Loads prax_registry.json from prax_json directory +- Extracts modules dict from registry structure +- Graceful error handling with logger +- Returns empty dict as fallback + +Usage: + from aipass.prax.apps.handlers.registry.load import load_module_registry + + modules = load_module_registry() + print(f"Loaded {len(modules)} modules") +""" + +import json +from pathlib import Path +from typing import Dict, Any + +from aipass.prax.apps.handlers.config.load import PRAX_ROOT + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "load" +PRAX_JSON_DIR = PRAX_ROOT / "prax_json" +REGISTRY_FILE = PRAX_JSON_DIR / "prax_registry.json" + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def load_module_registry() -> Dict[str, Dict[str, Any]]: + """Load module registry from prax_registry.json (system registry) + + Returns: + Dict mapping module names to module info dicts. + Returns empty dict if file doesn't exist or on error. + + The registry structure is: + { + "registry_version": "1.0.0", + "timestamp": "2025-11-07T...", + "modules": { + "module_name": { + "relative_path": "...", + "size": 1234, + "modified_time": "..." + } + }, + "statistics": {...} + } + + Example: + >>> modules = load_module_registry() + >>> if modules: + >>> print(f"Found {len(modules)} modules") + """ + if not REGISTRY_FILE.exists(): + return {} + + try: + with open(REGISTRY_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + modules = data.get('modules', {}) + return modules + except Exception: + # Silently return empty dict - logging not available at this level + return {} diff --git a/src/aipass/prax/apps/handlers/registry/meta_ops.py b/src/aipass/prax/apps/handlers/registry/meta_ops.py new file mode 100755 index 00000000..578f8a7f --- /dev/null +++ b/src/aipass/prax/apps/handlers/registry/meta_ops.py @@ -0,0 +1,354 @@ +#!/home/aipass/.venv/bin/python3 +# -*- coding: utf-8 -*- + +# ===================AIPASS==================== +# META DATA HEADER +# Name: meta_ops.py - Metadata Operations Handler +# Date: 2025-11-04 +# Version: 1.0.0 +# Category: cortex/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-04): Metadata operations for branch updates +# ============================================= + +""" +Metadata Operations Handler + +Functions for branch and template metadata: +- Load template registry +- Load branch metadata +- Generate metadata for existing branches +""" + +import json +import hashlib +from pathlib import Path +from datetime import datetime +from typing import Dict, Optional + + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +TEMPLATE_DIR = Path.home() / "aipass_core" / "cortex" / "templates" / "branch_ template" + +# Files that get renamed during branch creation +FILE_RENAMES = { + "PROJECT.json": "{BRANCHNAME}.json", + "LOCAL..json": "{BRANCHNAME}.local.json", + "OBSERVATIONS.json": "{BRANCHNAME}.observations.json", + "AI_MAIL.json": "{BRANCHNAME}.ai_mail.json", + "BRANCH.ID.json": "{BRANCHNAME}.id.json", + "BRANCH.py": "{branchname}.py", +} + + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + +def calculate_file_hash(file_path: Path) -> str: + """ + Calculate SHA-256 hash of file content + + Args: + file_path: Path to file + + Returns: + Hex string of file hash (first 12 characters for readability) + """ + if not file_path.is_file(): + return "" + + try: + sha256 = hashlib.sha256() + with open(file_path, 'rb') as f: + # Read in chunks for large files + for chunk in iter(lambda: f.read(8192), b''): + sha256.update(chunk) + # Return first 12 chars of hash (enough for uniqueness) + return sha256.hexdigest()[:12] + except Exception: + return "" + + +# ============================================================================= +# TEMPLATE REGISTRY OPERATIONS +# ============================================================================= + +def load_template_registry() -> Optional[Dict]: + """ + Load template_registry.json from template directory + + Returns: + Template registry dict or None if not found/error + """ + registry_path = TEMPLATE_DIR / "template_registry.json" + + if not registry_path.exists(): + return None + + try: + with open(registry_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception: + return None + + +# ============================================================================= +# BRANCH METADATA OPERATIONS +# ============================================================================= + +def load_branch_meta(branch_dir: Path) -> Optional[Dict]: + """ + Load .branch_meta.json from branch directory + + Args: + branch_dir: Path to branch directory + + Returns: + Branch metadata dict or None if not found/error + """ + meta_path = branch_dir / ".branch_meta.json" + + if not meta_path.exists(): + # This is normal for old branches that predate ID tracking + return None + + try: + with open(meta_path, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + print(f"ERROR loading .branch_meta.json: {e}") + return None + + +def heal_branch_meta( + branch_dir: Path, + branch_meta: Optional[Dict], + template_registry: Dict, + template_version: str +) -> Optional[Dict]: + """ + Auto-heal branch metadata if format is outdated or missing + + Handles: + - Missing .branch_meta.json (returns None - caller should regenerate) + - Old format: name→id mapping (auto-converts to new id→file_info format) + - Corrupted/invalid data (regenerates from scratch) + + Args: + branch_dir: Path to branch directory + branch_meta: Loaded metadata (or None if missing) + template_registry: Template registry for regeneration + template_version: Current template version + + Returns: + Healed metadata dict, or None if should regenerate from scratch + """ + # If no metadata exists, signal caller to regenerate + if branch_meta is None: + print(" No branch_meta - treating all template files as potential additions") + return None + + # Check if file_tracking exists and needs healing + if "file_tracking" not in branch_meta: + print(" Missing file_tracking - regenerating metadata") + return None + + file_tracking = branch_meta.get("file_tracking", {}) + if not file_tracking: + # Empty tracking is fine + return branch_meta + + # Detect old format: first value is string (name→id) vs dict (id→file_info) + first_value = next(iter(file_tracking.values())) + + if isinstance(first_value, str): + # OLD FORMAT DETECTED - Auto-heal to new format AND remap IDs + print(" Old branch_meta format detected - auto-healing...") + + # Build hash→template_id lookup for ID remapping + hash_to_template_id = {} + for file_id, file_info in template_registry.get("files", {}).items(): + if file_info.get("content_hash"): + hash_to_template_id[file_info["content_hash"]] = file_id + + # Old format: {"filename.py": "f001"} + # New format: {"f001": {"current_name": "filename.py", "content_hash": "abc123"}} + + # Invert the mapping: name→id becomes id→file_info + healed_tracking = {} + id_remapping = {} # Track old_id → new_id for reporting + + for filename, old_file_id in file_tracking.items(): + # Calculate content hash if file exists + file_path = branch_dir / filename + content_hash = None + if file_path.exists() and file_path.is_file(): + content_hash = calculate_file_hash(file_path) + + # Try to remap ID using content hash + new_file_id = old_file_id # Default to old ID + + # Skip remapping for empty files (hash: e3b0c44298fc = empty file) + # Multiple empty files share same hash, causing false matches + if content_hash and content_hash != "e3b0c44298fc" and content_hash in hash_to_template_id: + new_file_id = hash_to_template_id[content_hash] + if new_file_id != old_file_id: + id_remapping[old_file_id] = new_file_id + + healed_tracking[new_file_id] = { + "current_name": filename, + "content_hash": content_hash + } + + # Update metadata with healed format + branch_meta["file_tracking"] = healed_tracking + + # Save healed version + save_branch_meta(branch_dir, branch_meta) + + if id_remapping: + print(f" ✅ Branch metadata auto-healed and saved ({len(id_remapping)} IDs remapped)") + else: + print(" ✅ Branch metadata auto-healed and saved") + + return branch_meta + + # Format is already correct - but check if IDs need remapping + print(" Checking for ID reassignments...") + + # Build hash→template_id lookup + hash_to_template_id = {} + for file_id, file_info in template_registry.get("files", {}).items(): + if file_info.get("content_hash"): + hash_to_template_id[file_info["content_hash"]] = file_id + + # Check each tracked file for ID reassignment + remapped_tracking = {} + id_remapping = {} + + for current_id, file_info in file_tracking.items(): + content_hash = file_info.get("content_hash") + + # Try to remap ID using content hash + new_id = current_id # Default to current ID + + # Skip remapping for empty files (hash: e3b0c44298fc = empty file) + # Multiple empty files share same hash, causing false matches + if content_hash and content_hash != "e3b0c44298fc" and content_hash in hash_to_template_id: + new_id = hash_to_template_id[content_hash] + if new_id != current_id: + id_remapping[current_id] = new_id + + remapped_tracking[new_id] = file_info + + # If IDs were remapped, save updated metadata + if id_remapping: + print(f" ⚠️ Detected ID reassignments - remapping {len(id_remapping)} files...") + branch_meta["file_tracking"] = remapped_tracking + branch_meta["last_updated"] = datetime.now().isoformat() + save_branch_meta(branch_dir, branch_meta) + print(" ✅ Branch metadata updated with current template IDs") + else: + print(" ✅ All IDs current - no remapping needed") + + # Format is already correct + return branch_meta + + +def save_branch_meta(branch_dir: Path, metadata: Dict) -> bool: + """ + Save .branch_meta.json to branch directory + + Args: + branch_dir: Path to branch directory + metadata: Metadata dict to save + + Returns: + True if successful, False otherwise + """ + meta_path = branch_dir / ".branch_meta.json" + + try: + with open(meta_path, 'w', encoding='utf-8') as f: + json.dump(metadata, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + print(f"ERROR saving .branch_meta.json: {e}") + return False + + +def generate_branch_meta_for_existing_branch( + target_path: Path, + branch_name: str, + template_registry: Dict +) -> Optional[Dict]: + """ + Generate .branch_meta.json for existing branch that predates ID tracking + + Scans the branch directory and maps existing files to template IDs with content hashes. + + Args: + target_path: Path to existing branch + branch_name: Branch name for placeholder substitution + template_registry: Loaded template registry + + Returns: + Dict with metadata structure, or None if failed + """ + # Build reverse lookup: template_filename -> (template_id, file_info) + template_name_to_info = {} + + # Map files + for file_id, file_info in template_registry.get("files", {}).items(): + template_name_to_info[file_info["current_name"]] = (file_id, file_info) + + # Map directories + for dir_id, dir_info in template_registry.get("directories", {}).items(): + template_name_to_info[dir_info["current_name"]] = (dir_id, dir_info) + + # Build file tracking with new structure: file_id -> {current_name, path, content_hash} + file_tracking = {} + branch_upper = branch_name.upper().replace("-", "_") + + # Map files (handle projects placeholder substitution) + for template_name, (template_id, template_info) in template_name_to_info.items(): + # Handle placeholder patterns + if "projects" in template_name: + actual_name = template_name.replace("projects", branch_upper) + else: + # Check if this file gets renamed by FILE_RENAMES pattern + if template_name in FILE_RENAMES: + actual_name = FILE_RENAMES[template_name].replace("{BRANCHNAME}", branch_upper) + else: + actual_name = template_name + + # Check if file/directory exists in branch + file_path = target_path / actual_name + if file_path.exists(): + # Calculate content hash for files (not directories) + content_hash = "" + if file_path.is_file(): + content_hash = calculate_file_hash(file_path) + + # Use new structure matching template_registry.json + file_tracking[template_id] = { + "current_name": actual_name, + "path": actual_name, # Relative path from branch root + "content_hash": content_hash, + "has_branch_placeholder": "projects" in template_name or "PROJECTS" in template_name + } + + # Create metadata structure + meta_data = { + "template_version": template_registry.get("metadata", {}).get("version", "1.0.0"), + "branch_created": "unknown", # Can't determine for existing branches + "last_updated": datetime.now().isoformat(), + "file_tracking": file_tracking + } + + return meta_data diff --git a/src/aipass/prax/apps/handlers/registry/reader.py b/src/aipass/prax/apps/handlers/registry/reader.py new file mode 100755 index 00000000..24e6f123 --- /dev/null +++ b/src/aipass/prax/apps/handlers/registry/reader.py @@ -0,0 +1,109 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: reader.py - Branch Registry Reader Handler +# Date: 2025-11-15 +# Version: 0.1.0 +# Category: aipass/handlers/registry +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2025-11-15): Initial version - read branch registry +# +# CODE STANDARDS: +# - Error handling: Return None on errors, log failures +# - Logging: Use Prax logger +# ============================================= + +""" +Branch Registry Reader Handler + +Reads BRANCH_REGISTRY.json and returns branch information. +Used by branch watcher to discover which branches to monitor. +""" + +import json +from pathlib import Path +from typing import List, Dict, Any + + +# ============================================================================= +# CONSTANTS +# ============================================================================= + +BRANCH_REGISTRY_PATH = Path.home() / "BRANCH_REGISTRY.json" + +# ============================================================================= +# CORE FUNCTIONS +# ============================================================================= + +def read_registry() -> List[Dict[str, Any]] | None: + """ + Read BRANCH_REGISTRY.json and return list of branches + + Returns: + List of branch dictionaries, or None on error + """ + try: + if not BRANCH_REGISTRY_PATH.exists(): + return None + + with open(BRANCH_REGISTRY_PATH, 'r', encoding='utf-8') as f: + data = json.load(f) + + branches = data.get('branches', []) + return branches + + except json.JSONDecodeError: + return None + except Exception: + return None + + +def get_branch_paths(branch_names: List[str] | None = None) -> List[Path] | None: + """ + Get paths for specified branches (or all branches if None) + + Args: + branch_names: List of branch names to get paths for, or None for all + + Returns: + List of Path objects, or None on error + """ + branches = read_registry() + if branches is None: + return None + + paths = [] + for branch in branches: + # Filter by branch names if specified + if branch_names: + if branch.get('name') not in branch_names: + continue + + branch_path = branch.get('path') + if branch_path: + paths.append(Path(branch_path)) + + return paths + + +def get_branch_info(branch_name: str) -> Dict[str, Any] | None: + """ + Get detailed information for a specific branch + + Args: + branch_name: Name of the branch + + Returns: + Branch dictionary, or None if not found + """ + branches = read_registry() + if branches is None: + return None + + for branch in branches: + if branch.get('name') == branch_name: + return branch + + return None diff --git a/src/aipass/prax/apps/handlers/registry/save.py b/src/aipass/prax/apps/handlers/registry/save.py new file mode 100755 index 00000000..afdf00f7 --- /dev/null +++ b/src/aipass/prax/apps/handlers/registry/save.py @@ -0,0 +1,104 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: save.py +# Date: 2025-11-07 +# Version: 1.0.0 +# Category: prax/handlers/registry +# +# CHANGELOG: +# - v1.0.0 (2025-11-07): Extracted from prax_registry.py - module registry saving +# ============================================= + +""" +Save Module Registry Handler + +Saves the Prax system-wide module discovery registry with statistics. +Auto-updates timestamp and calculates statistics before saving. + +Features: +- Saves registry to prax_registry.json +- Auto-updates timestamp to current UTC time +- Includes statistics (total modules, last updated, scan location) +- Creates directory if missing +- Logs save operation + +Usage: + from aipass.prax.apps.handlers.registry.save import save_module_registry + + modules = {"module1": {...}, "module2": {...}} + save_module_registry(modules) +""" + +import json +from pathlib import Path +from datetime import datetime, timezone +from typing import Dict, Any + +from aipass.prax.apps.handlers.config.load import PRAX_ROOT, ECOSYSTEM_ROOT + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "save" +PRAX_JSON_DIR = PRAX_ROOT / "prax_json" +REGISTRY_FILE = PRAX_JSON_DIR / "prax_registry.json" + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def save_module_registry(modules: Dict[str, Dict[str, Any]]) -> bool: + """Save module registry to prax_registry.json (system registry) + + Args: + modules: Dict mapping module names to module info dicts + + Returns: + True if save successful, False on error + + The registry is saved with this structure: + { + "registry_version": "1.0.0", + "timestamp": "2025-11-07T...", + "modules": {...}, + "statistics": { + "total_modules": 42, + "last_updated": "2025-11-07T...", + "scan_location": "/home/aipass/aipass_core" + } + } + + Example: + >>> modules = {"test_module": {"relative_path": "test/module.py"}} + >>> success = save_module_registry(modules) + >>> if success: + >>> print(f"Saved {len(modules)} modules") + """ + try: + # Ensure directory exists + PRAX_JSON_DIR.mkdir(parents=True, exist_ok=True) + + # Build registry structure with statistics + registry_structure = { + "registry_version": "1.0.0", + "timestamp": datetime.now(timezone.utc).isoformat(), + "modules": modules, + "statistics": { + "total_modules": len(modules), + "last_updated": datetime.now(timezone.utc).isoformat(), + "scan_location": str(ECOSYSTEM_ROOT) + } + } + + # Save to file + with open(REGISTRY_FILE, 'w', encoding='utf-8') as f: + json.dump(registry_structure, f, indent=2, ensure_ascii=False) + + return True + + except Exception: + # Silently return False - logging not available at this level + return False diff --git a/src/aipass/prax/apps/handlers/registry/statistics.py b/src/aipass/prax/apps/handlers/registry/statistics.py new file mode 100755 index 00000000..d12e9118 --- /dev/null +++ b/src/aipass/prax/apps/handlers/registry/statistics.py @@ -0,0 +1,102 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: statistics.py +# Date: 2025-11-07 +# Version: 1.0.0 +# Category: prax/handlers/registry +# +# CHANGELOG: +# - v1.0.0 (2025-11-07): Extracted from prax_registry.py - registry statistics +# ============================================= + +""" +Registry Statistics Handler + +Extracts and returns statistics about the Prax module registry. +Returns total module count and other registry metadata. + +Features: +- Reads prax_registry.json statistics section +- Returns total_modules count +- Includes registry_exists flag +- Graceful error handling + +Usage: + from aipass.prax.apps.handlers.registry.statistics import get_registry_statistics + + stats = get_registry_statistics() + print(f"Total modules: {stats['total_modules']}") +""" + +import json +from pathlib import Path +from typing import Dict, Any + +from aipass.prax.apps.handlers.config.load import PRAX_ROOT + +# ============================================= +# CONFIGURATION +# ============================================= + +MODULE_NAME = "statistics" +PRAX_JSON_DIR = PRAX_ROOT / "prax_json" +REGISTRY_FILE = PRAX_JSON_DIR / "prax_registry.json" + +# ============================================= +# HANDLER FUNCTION +# ============================================= + +def get_registry_statistics() -> Dict[str, Any]: + """Get statistics about the module registry + + Returns: + Dict containing registry statistics: + { + "total_modules": 42, + "registry_exists": True, + "last_updated": "2025-11-07T...", + "scan_location": "/home/aipass/aipass_core" + } + + If registry doesn't exist or error occurs: + { + "total_modules": 0, + "registry_exists": False + } + + Example: + >>> stats = get_registry_statistics() + >>> if stats["registry_exists"]: + >>> print(f"Registry has {stats['total_modules']} modules") + """ + if not REGISTRY_FILE.exists(): + return { + "total_modules": 0, + "registry_exists": False + } + + try: + with open(REGISTRY_FILE, 'r', encoding='utf-8') as f: + data = json.load(f) + + # Extract statistics section if present + stats = data.get('statistics', {}) + + # Add registry_exists flag + stats['registry_exists'] = True + + # Ensure total_modules is present (calculate if missing) + if 'total_modules' not in stats: + stats['total_modules'] = len(data.get('modules', {})) + + return stats + + except Exception as e: + # Silently return error info - logging not available at this level + return { + "total_modules": 0, + "registry_exists": False, + "error": str(e) + } diff --git a/src/aipass/prax/apps/handlers/watcher/__init__.py b/src/aipass/prax/apps/handlers/watcher/__init__.py new file mode 100755 index 00000000..766dcc5b --- /dev/null +++ b/src/aipass/prax/apps/handlers/watcher/__init__.py @@ -0,0 +1 @@ +"""Branch watcher handlers - file monitoring and reporting""" diff --git a/src/aipass/prax/apps/handlers/watcher/monitor.py b/src/aipass/prax/apps/handlers/watcher/monitor.py new file mode 100755 index 00000000..daf0dd0e --- /dev/null +++ b/src/aipass/prax/apps/handlers/watcher/monitor.py @@ -0,0 +1,185 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: monitor.py - File System Monitor Handler +# Date: 2025-11-15 +# Version: 0.1.0 +# Category: aipass/handlers/watcher +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2025-11-15): Initial version - file system monitoring +# +# CODE STANDARDS: +# - Error handling: Return None on errors, log failures +# - Logging: Use Prax logger +# ============================================= + +""" +File System Monitor Handler + +Watches directories for file changes using watchdog library. +Monitors all files (including __pycache__, .pyc, etc.) to provide +complete visibility into branch modifications. +""" + +from pathlib import Path +from typing import List, Callable, Optional, TYPE_CHECKING, Any + +# ============================================================================= +# WATCHDOG IMPORT (external dependency) +# ============================================================================= + +try: + from watchdog.observers import Observer # type: ignore + from watchdog.events import FileSystemEventHandler # type: ignore + from watchdog.events import FileSystemEvent # type: ignore + WATCHDOG_AVAILABLE = True +except ImportError: + WATCHDOG_AVAILABLE = False + # Create placeholder classes for when watchdog not available + class Observer: # type: ignore + def schedule(self, *args, **kwargs): pass + def start(self): pass + def stop(self): pass + def join(self): pass + class FileSystemEventHandler: # type: ignore + pass + class FileSystemEvent: # type: ignore + pass + +# ============================================================================= +# EVENT HANDLER +# ============================================================================= + +class BranchFileHandler(FileSystemEventHandler): + """ + Custom file system event handler for branch monitoring + + Captures all file events and passes them to callback function. + Ignores log files to prevent infinite loops. + """ + + def __init__(self, branch_name: str, callback: Callable): + """ + Initialize handler + + Args: + branch_name: Name of the branch being monitored + callback: Function to call with (branch_name, event_type, file_path) + """ + super().__init__() + self.branch_name = branch_name + self.callback = callback + + def _should_ignore(self, path: str) -> bool: + """ + Check if file should be ignored + + Args: + path: File path to check + + Returns: + True if file should be ignored, False otherwise + """ + # Ignore log files (prevents infinite loop) + if path.endswith('.log'): + return True + + # Ignore temporary and backup files + if '.tmp.' in path or path.endswith('.tmp'): + return True + if path.endswith('.backup') or path.endswith('.bak'): + return True + if path.endswith('~'): # Editor backup files + return True + if path.endswith('.swp') or path.endswith('.swo'): # Vim swap files + return True + + # Ignore log directories + if '/logs/' in path or '/system_logs/' in path: + return True + + # Ignore system/config directories (prevents watching non-branch files) + ignore_dirs = [ + '/.claude/', + '/.local/', + '/.cache/', + '/.config/', + '/.vscode/', + '/.git/', + '/__pycache__/', + '/.pytest_cache/', + '/node_modules/', + '/.venv/', + '/venv/' + ] + + for ignore_dir in ignore_dirs: + if ignore_dir in path: + return True + + return False + + def on_created(self, event) -> None: + """File or directory created""" + if not event.is_directory and not self._should_ignore(event.src_path): + self.callback(self.branch_name, 'CREATED', event.src_path) + + def on_modified(self, event) -> None: + """File or directory modified""" + if not event.is_directory and not self._should_ignore(event.src_path): + self.callback(self.branch_name, 'MODIFIED', event.src_path) + + def on_deleted(self, event) -> None: + """File or directory deleted""" + if not event.is_directory and not self._should_ignore(event.src_path): + self.callback(self.branch_name, 'DELETED', event.src_path) + + def on_moved(self, event) -> None: + """File or directory moved/renamed""" + if not event.is_directory and not self._should_ignore(event.src_path): + self.callback(self.branch_name, 'MOVED', f"{event.src_path} → {event.dest_path}") + +# ============================================================================= +# MONITOR FUNCTIONS +# ============================================================================= + +def start_monitoring(branch_paths: List[tuple], callback: Callable) -> Any: + """ + Start monitoring multiple branch directories + + Args: + branch_paths: List of (branch_name, path) tuples + callback: Function to call with (branch_name, event_type, file_path) + + Returns: + Observer instance, or None if watchdog not available + """ + if not WATCHDOG_AVAILABLE: + return None + + observer = Observer() + + for branch_name, path in branch_paths: + if not path.exists(): + continue + + event_handler = BranchFileHandler(branch_name, callback) + observer.schedule(event_handler, str(path), recursive=True) + + observer.start() + + return observer + + +def stop_monitoring(observer: Any) -> None: + """ + Stop monitoring + + Args: + observer: Observer instance to stop + """ + if observer: + observer.stop() + observer.join() diff --git a/src/aipass/prax/apps/handlers/watcher/reporter.py b/src/aipass/prax/apps/handlers/watcher/reporter.py new file mode 100755 index 00000000..a97d038b --- /dev/null +++ b/src/aipass/prax/apps/handlers/watcher/reporter.py @@ -0,0 +1,125 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: reporter.py - File Change Reporter Handler +# Date: 2025-11-15 +# Version: 0.2.0 +# Category: aipass/handlers/watcher +# +# CHANGELOG (Max 5 entries): +# - v0.2.0 (2025-11-15): Updated to use CLI service provider for Rich formatting +# - v0.1.0 (2025-11-15): Initial version - format and display file changes +# +# CODE STANDARDS: +# - Error handling: Return None on errors, log failures +# - Logging: Use Prax logger +# - CLI: Use CLI service provider for all display +# ============================================= + +""" +File Change Reporter Handler + +Formats and displays file change events in real-time using CLI service provider. +Shows branch name, action type, and file path with Rich formatting. +""" + +from pathlib import Path +from datetime import datetime + +from aipass.cli.apps.modules import console, header + +# ============================================================================= +# FORMATTING +# ============================================================================= + +# Action symbols and colors (Rich markup) +ACTION_FORMAT = { + 'CREATED': {'symbol': '+', 'color': 'green'}, + 'MODIFIED': {'symbol': '~', 'color': 'yellow'}, + 'DELETED': {'symbol': '-', 'color': 'red'}, + 'MOVED': {'symbol': '→', 'color': 'blue'} +} + +# ============================================================================= +# REPORTER FUNCTIONS +# ============================================================================= + +def format_change(branch_name: str, action: str, file_path: str) -> str: + """ + Format a file change event for display with Rich markup + + Args: + branch_name: Name of the branch + action: Type of action (CREATED, MODIFIED, DELETED, MOVED) + file_path: Path to the file + + Returns: + Formatted string with Rich markup + """ + timestamp = datetime.now().strftime('%H:%M:%S') + fmt = ACTION_FORMAT.get(action, {'symbol': '?', 'color': 'white'}) + symbol = fmt['symbol'] + color = fmt['color'] + + # Make path relative to branch for cleaner output + try: + path_obj = Path(file_path) + relative_path = file_path + for part in path_obj.parts: + if part in ['aipass_core', 'aipass']: + idx = path_obj.parts.index(part) + if idx + 1 < len(path_obj.parts): + relative_path = str(Path(*path_obj.parts[idx+1:])) + break + except Exception: + relative_path = file_path + + # Rich markup format + return f"[dim]\\[{timestamp}][/dim] [{color}]\\[{branch_name.upper():12}][/{color}] [{color}]{symbol}[/{color}] {relative_path}" + + +def report_change(branch_name: str, action: str, file_path: str) -> None: + """ + Report a file change to terminal and log using CLI service + + Args: + branch_name: Name of the branch + action: Type of action (CREATED, MODIFIED, DELETED, MOVED) + file_path: Path to the file + """ + formatted = format_change(branch_name, action, file_path) + console.print(formatted) + + +def print_header() -> None: + """Print monitoring header using CLI service""" + console.print() + console.print("─" * 80) + console.print("[bold cyan]🔍 BRANCH WATCHER[/bold cyan] [dim]- Real-time File Monitoring[/dim]") + console.print("─" * 80) + console.print() + console.print("[cyan]Legend:[/cyan]") + console.print(" [green]+[/green] CREATED New file") + console.print(" [yellow]~[/yellow] MODIFIED File changed") + console.print(" [red]-[/red] DELETED File removed") + console.print(" [blue]→[/blue] MOVED File renamed/moved") + console.print() + console.print("[dim]Press Ctrl+C to stop monitoring[/dim]") + console.print() + console.print("─" * 80) + console.print() + + +def print_footer(total_changes: int) -> None: + """ + Print monitoring summary using CLI service + + Args: + total_changes: Total number of changes detected + """ + console.print() + console.print("─" * 80) + console.print(f"[cyan]Monitoring stopped.[/cyan] Total changes: [bold]{total_changes}[/bold]") + console.print("─" * 80) + console.print() diff --git a/src/aipass/prax/apps/json_templates/__init__.py b/src/aipass/prax/apps/json_templates/__init__.py new file mode 100755 index 00000000..5d00b535 --- /dev/null +++ b/src/aipass/prax/apps/json_templates/__init__.py @@ -0,0 +1 @@ +# JSON Templates package - Default JSON file templates diff --git a/src/aipass/prax/apps/json_templates/default/config.json b/src/aipass/prax/apps/json_templates/default/config.json new file mode 100644 index 00000000..d29d029f --- /dev/null +++ b/src/aipass/prax/apps/json_templates/default/config.json @@ -0,0 +1,9 @@ +{ + "module_name": "{{MODULE_NAME}}", + "version": "1.0.0", + "timestamp": "2025-11-13", + "config": { + "auto_save": true, + "enabled": true + } +} diff --git a/src/aipass/prax/apps/json_templates/default/data.json b/src/aipass/prax/apps/json_templates/default/data.json new file mode 100644 index 00000000..82912a72 --- /dev/null +++ b/src/aipass/prax/apps/json_templates/default/data.json @@ -0,0 +1,8 @@ +{ + "module_name": "{{MODULE_NAME}}", + "created": "2025-11-13", + "last_updated": "2025-11-13", + "operations_total": 0, + "operations_successful": 0, + "operations_failed": 0 +} diff --git a/src/aipass/prax/apps/json_templates/default/log.json b/src/aipass/prax/apps/json_templates/default/log.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/aipass/prax/apps/json_templates/default/log.json @@ -0,0 +1 @@ +[] diff --git a/src/aipass/prax/apps/modules/__init__.py b/src/aipass/prax/apps/modules/__init__.py old mode 100644 new mode 100755 index f6369e0e..3709d413 --- a/src/aipass/prax/apps/modules/__init__.py +++ b/src/aipass/prax/apps/modules/__init__.py @@ -1,2 +1,17 @@ -"""Prax modules - logging services.""" -from aipass.prax.apps.modules.logger import system_logger +""" +PRAX Modules - Public API + +Modules in this directory are SERVICES that PRAX provides to other branches. +Other branches import from here to use PRAX services. + +Available modules: +- logger: System-wide logging service + Import: from aipass.prax.apps.modules.logger import system_logger + + Usage: + system_logger.info("Your message") + system_logger.warning("Warning message") + system_logger.error("Error message") + + Logs auto-route to: /home/aipass/system_logs/.log +""" diff --git a/src/aipass/prax/apps/modules/agent_status_module.py b/src/aipass/prax/apps/modules/agent_status_module.py new file mode 100644 index 00000000..e73fc3fe --- /dev/null +++ b/src/aipass/prax/apps/modules/agent_status_module.py @@ -0,0 +1,134 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: agent_status_module.py - PRAX Agent Status Push Command +# Date: 2026-02-25 +# Version: 0.1.0 +# Category: prax/modules +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2026-02-25): FPLAN-0374 Phase 3 - agent-status-push command +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Implements handle_command(command: str, args: List[str]) -> bool interface +# - Uses Prax logger for system-wide logging +# ============================================= + +""" +PRAX Agent Status Module + +Implements the 'agent-status-push' command using handle_command interface. +Pushes agent_status section to all branch dashboards showing active/stale agents. +""" + +import sys +from typing import List + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console + + +def print_introspection(): + """Display module introspection - shows connected handlers""" + console.print() + console.print("[bold cyan]PRAX Agent Status Module[/bold cyan]") + console.print() + console.print("[yellow]Purpose:[/yellow]") + console.print(" Push agent_status section to all branch dashboards") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + console.print(" [cyan]prax/handlers/dashboard/[/cyan]") + console.print(" [dim]- agent_status_writer.py (push_agent_status_dashboard, build_agent_status_section)[/dim]") + console.print() + + console.print("[dim]Run 'drone @prax agent-status-push' to execute[/dim]") + console.print() + + +def print_help(): + """Drone-compliant help output""" + console.print() + console.print("[bold cyan]PRAX Agent Status Push[/bold cyan]") + console.print() + + console.print("[yellow]Purpose:[/yellow]") + console.print(" Scan for active dispatch agents and push status to all dashboards") + console.print() + + console.print("[yellow]Usage:[/yellow]") + console.print() + console.print(" [dim]# Push agent status to all branch dashboards[/dim]") + console.print(" $ drone @prax agent-status-push") + console.print() + console.print(" [dim]# Preview section data without pushing[/dim]") + console.print(" $ drone @prax agent-status-push --dry-run") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle agent-status-push command + + Args: + command: Command name + args: Command arguments + + Returns: + True if command was handled + """ + if command != 'agent-status-push': + return False + + from aipass.prax.apps.handlers.dashboard.agent_status_writer import ( + build_agent_status_section, + push_agent_status_dashboard, + ) + + if '--help' in args: + print_help() + return True + + if '--dry-run' in args: + import json + section = build_agent_status_section() + console.print("\n[bold cyan]Agent Status Section (dry-run)[/bold cyan]") + console.print(json.dumps(section, indent=2)) + return True + + section = build_agent_status_section() + active = section["agent_count"] + stale = len(section["stale_agents"]) + + console.print(f"\n[bold cyan]Agent Status Push[/bold cyan]") + console.print(f" Active agents: {active}") + console.print(f" Stale agents: {stale}") + + result = push_agent_status_dashboard() + + if result: + console.print("[green]✅ Pushed to all branch dashboards[/green]\n") + else: + console.print("[red]❌ Push failed — check logs[/red]\n") + + return True + + +if __name__ == "__main__": + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + if '--help' in sys.argv: + print_help() + sys.exit(0) + + if '--introspect' in sys.argv: + print_introspection() + sys.exit(0) + + args = [arg for arg in sys.argv[1:] if not arg.startswith('--')] + handle_command('agent-status-push', args + [a for a in sys.argv[1:] if a.startswith('--')]) diff --git a/src/aipass/prax/apps/modules/discover_module.py b/src/aipass/prax/apps/modules/discover_module.py new file mode 100755 index 00000000..84bde1a5 --- /dev/null +++ b/src/aipass/prax/apps/modules/discover_module.py @@ -0,0 +1,77 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: discover_module.py - PRAX Discover Command +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: prax/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Created with handle_command interface +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Implements handle_command(command: str, args: List[str]) -> bool interface +# - Uses Prax logger for system-wide logging +# ============================================= + +""" +PRAX Discover Module + +Implements the 'discover' command using handle_command interface. +""" + +import sys +from typing import List + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console, header, success, error +from aipass.prax.apps.handlers.discovery.scanner import discover_python_modules + + +def print_help(): + """Display module help and connected handlers""" + console.print() + console.print("[bold cyan]PRAX Discover Module[/bold cyan]") + console.print() + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + console.print(" [cyan]prax/modules/[/cyan]") + console.print(" [dim]- logger.py[/dim] (system_logger)") + console.print() + + console.print(" [cyan]prax/handlers/discovery/[/cyan]") + console.print(" [dim]- scanner.py[/dim] (discover_python_modules)") + console.print() + + console.print("[dim]Run 'python3 discover_module.py --help' for usage[/dim]") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle discover command + + Args: + command: Command name + args: Command arguments + + Returns: + True if command was handled + """ + if command != 'discover': + return False + + console.print("🔍 Discovering Python modules...") + modules = discover_python_modules() + console.print(f"✅ Discovered {len(modules)} modules") + return True + + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_help() + sys.exit(0) diff --git a/src/aipass/prax/apps/modules/init_module.py b/src/aipass/prax/apps/modules/init_module.py new file mode 100755 index 00000000..cbc74908 --- /dev/null +++ b/src/aipass/prax/apps/modules/init_module.py @@ -0,0 +1,112 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: init_module.py - PRAX Init Command +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: prax/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Created with handle_command interface +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Implements handle_command(command: str, args: List[str]) -> bool interface +# - Uses Prax logger for system-wide logging +# ============================================= + +""" +PRAX Init Module + +Implements the 'init' command using handle_command interface. +""" + +import sys +from typing import List + +from aipass.prax.apps.modules.logger import initialize_logging_system, system_logger as logger +from aipass.cli.apps.modules import console + + +def print_introspection(): + """Display module introspection - shows connected handlers""" + console.print() + console.print("[bold cyan]PRAX Init Module[/bold cyan]") + console.print() + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + console.print(" [cyan]prax/modules/[/cyan]") + console.print(" [dim]- logger.py[/dim] (initialize_logging_system, system_logger)") + console.print() + + console.print("[dim]Run 'python3 init_module.py --help' for usage[/dim]") + console.print() + + +def print_help(): + """Drone-compliant help output - command syntax and examples""" + console.print() + console.print("[bold cyan]PRAX Init - Initialize Logging System[/bold cyan]") + console.print() + + console.print("[yellow]Purpose:[/yellow]") + console.print(" Initialize the PRAX logging system for monitoring AIPass operations") + console.print() + + console.print("[yellow]Usage Examples:[/yellow]") + console.print() + console.print(" [dim]# Initialize logging system[/dim]") + console.print(" $ prax init") + console.print() + console.print(" [dim]# Standalone execution[/dim]") + console.print(" $ python3 init_module.py") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle init command + + Args: + command: Command name + args: Command arguments + + Returns: + True if command was handled + """ + if command != 'init': + return False + + try: + console.print("🚀 Initializing PRAX logging system...") + initialize_logging_system() + console.print("✅ PRAX logging system initialized") + return True + + except Exception as e: + logger.error(f"Error in init command: {e}") + console.print(f"[red]❌ ERROR: {e}[/red]") + return True + + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + # Handle --help flag + if '--help' in sys.argv: + print_help() + sys.exit(0) + + # Handle --introspect flag + if '--introspect' in sys.argv: + print_introspection() + sys.exit(0) + + # Execute init command + handled = handle_command('init', []) + sys.exit(0 if handled else 1) diff --git a/src/aipass/prax/apps/modules/log_audit_module.py b/src/aipass/prax/apps/modules/log_audit_module.py new file mode 100644 index 00000000..733e8052 --- /dev/null +++ b/src/aipass/prax/apps/modules/log_audit_module.py @@ -0,0 +1,158 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: log_audit_module.py - PRAX Log Audit Command +# Date: 2026-02-26 +# Version: 0.1.0 +# Category: prax/modules +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2026-02-26): DPLAN-037 Phase 4 - log audit and enforcement command +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Implements handle_command(command: str, args: List[str]) -> bool interface +# - Uses Prax logger for system-wide logging +# ============================================= + +""" +PRAX Log Audit Module + +Implements the 'log-audit' command for system log health monitoring. +Scans /home/aipass/system_logs/ for oversized files, reports status, +and optionally enforces size limits by truncating bloated logs. +""" + +import sys +from typing import List + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.cli.apps.modules import console + + +def print_introspection(): + """Display module introspection - shows connected handlers""" + console.print() + console.print("[bold cyan]PRAX Log Audit Module[/bold cyan]") + console.print() + console.print("[yellow]Purpose:[/yellow]") + console.print(" Monitor and enforce system log size limits") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + console.print(" [cyan]prax/handlers/logging/[/cyan]") + console.print(" [dim]- log_watchdog.py (scan_log_files, enforce_log_limits, log_health_summary)[/dim]") + console.print() + + console.print("[dim]Run 'drone @prax log-audit' for status, 'drone @prax log-audit enforce' to truncate[/dim]") + console.print() + + +def print_help(): + """Drone-compliant help output""" + console.print() + console.print("[bold cyan]PRAX Log Audit[/bold cyan]") + console.print() + + console.print("[yellow]Purpose:[/yellow]") + console.print(" Scan system_logs/ for oversized files and enforce rotation limits") + console.print() + + console.print("[yellow]Usage:[/yellow]") + console.print() + console.print(" [dim]# Show log health summary + any oversized files[/dim]") + console.print(" $ drone @prax log-audit") + console.print() + console.print(" [dim]# Truncate all oversized files to 1000 lines[/dim]") + console.print(" $ drone @prax log-audit enforce") + console.print() + + +def _display_audit(files: list, summary: dict) -> None: + """Display audit results.""" + console.print() + console.print("[bold cyan]System Log Audit[/bold cyan]") + console.print(f" Total files: {summary['total_files']}") + console.print(f" Total lines: {summary['total_lines']:,}") + console.print(f" Largest: {summary['largest_file']} ({summary['largest_lines']:,} lines)") + + if summary['healthy']: + console.print("[green] Status: HEALTHY — all logs within limits[/green]") + else: + console.print(f"[red] Status: {summary['oversized_count']} oversized, {summary['critical_count']} critical[/red]") + + # Show oversized files + oversized = [f for f in files if f["status"] != "ok"] + if oversized: + console.print() + console.print("[yellow]Oversized files:[/yellow]") + for f in oversized: + status_color = "red" if f["status"] == "critical" else "yellow" + console.print( + f" [{status_color}]{f['status'].upper()}[/{status_color}] " + f"{f['name']}: {f['lines']:,} lines ({f['size_kb']} KB)" + ) + console.print() + console.print("[dim]Run 'drone @prax log-audit enforce' to truncate oversized files[/dim]") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle log-audit command + + Args: + command: Command name + args: Command arguments ('enforce' to truncate) + + Returns: + True if command was handled + """ + if command != 'log-audit': + return False + + from aipass.prax.apps.handlers.logging.log_watchdog import ( + scan_log_files, + enforce_log_limits, + log_health_summary, + ) + + if args and args[0] == 'enforce': + console.print("\n[bold cyan]Enforcing log limits...[/bold cyan]") + actions = enforce_log_limits() + + if not actions: + console.print("[green]All logs within limits — nothing to truncate[/green]\n") + else: + for action in actions: + if action["truncated"]: + console.print( + f" [yellow]TRUNCATED[/yellow] {action['name']}: " + f"{action['original_lines']:,} → {action['new_lines']:,} lines" + ) + else: + console.print(f" [green]OK[/green] {action['name']}: within limits") + console.print() + logger.info("[log-audit] Enforced limits on %d files", len(actions)) + return True + + # Default: audit mode + files = scan_log_files() + summary = log_health_summary() + _display_audit(files, summary) + return True + + +if __name__ == "__main__": + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + if '--help' in sys.argv: + print_help() + sys.exit(0) + + args = [arg for arg in sys.argv[1:] if not arg.startswith('--')] + handle_command('log-audit', args) diff --git a/src/aipass/prax/apps/modules/logger.py b/src/aipass/prax/apps/modules/logger.py old mode 100644 new mode 100755 index 2c79e32b..262c4428 --- a/src/aipass/prax/apps/modules/logger.py +++ b/src/aipass/prax/apps/modules/logger.py @@ -1,23 +1,268 @@ -"""Prax Logger - Minimal stub for AIPass public repo.""" -import logging +#!/home/aipass/.venv/bin/python3 -_logger = logging.getLogger("aipass") -_handler = logging.StreamHandler() -_handler.setFormatter(logging.Formatter("%(levelname)s: %(message)s")) -_logger.addHandler(_handler) -_logger.setLevel(logging.INFO) +# ===================AIPASS==================== +# META DATA HEADER +# Name: logger.py - PRAX Public API +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: prax/modules +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2026-02-27): Added direct logger exports (FPLAN-0382 Phase 2) +# - v1.0.0 (2025-11-15): Updated logger module with complete public API +# - v0.1.0 (2025-11-10): Created modular public API from archive.temp +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Public API for system-wide logging +# - Auto-routing logger for all modules in ecosystem +# - Provides lifecycle functions: initialize, shutdown, status +# ============================================= +""" +PRAX Logger - Public API + +This is the main entry point for PRAX logging system. +Other branches import from here: + from aipass.prax.apps.modules.prax_logger import system_logger + +Provides: +- system_logger: Auto-routing logger for all modules +- get_direct_logger / direct_log: Event-pipeline-bypass logging for infrastructure +- Lifecycle functions: initialize, shutdown +- Status and control functions +""" + +import sys +from typing import Dict, Any + +# NOTE: Cannot import CLI here - creates circular dependency +# CLI imports prax logger, so prax logger must not import CLI + +# Import from handlers - internal implementation +from aipass.prax.apps.handlers.logging.setup import ( + setup_system_logger, + setup_individual_logger, + get_captured_loggers_count, + enable_terminal_output as _enable_terminal, + disable_terminal_output as _disable_terminal, + is_terminal_output_enabled +) +from aipass.prax.apps.handlers.logging.introspection import get_calling_module +from aipass.prax.apps.handlers.logging.override import ( + install_logger_override, + restore_original_logger, + is_override_active +) +from aipass.prax.apps.handlers.logging.operations import ( + log_operation, + create_config_file +) +from aipass.prax.apps.handlers.discovery.scanner import discover_python_modules +from aipass.prax.apps.handlers.discovery.watcher import ( + start_file_watcher, + stop_file_watcher, + is_file_watcher_active +) +from aipass.prax.apps.handlers.registry.save import save_module_registry +from aipass.prax.apps.handlers.registry.load import load_module_registry +from aipass.prax.apps.handlers.config.load import SYSTEM_LOGS_DIR, PRAX_JSON_DIR +from aipass.prax.apps.handlers.logging.direct import ( + get_direct_logger, + direct_log, + DirectLogger +) + +# Module constants +MODULE_NAME = "prax_logger" +DATA_FILE = PRAX_JSON_DIR / f"{MODULE_NAME}_data.json" + +# ============================================= +# SYSTEM LOGGER - THE MAIN EXPORT +# ============================================= + +def get_system_logger(): + """Get logger that automatically routes to correct module log file""" + module_name = get_calling_module() + return setup_individual_logger(module_name) class SystemLogger: - """Minimal system logger matching Dev-Pass interface.""" - def info(self, msg, *args, **kwargs): - _logger.info(msg, *args, **kwargs) - def warning(self, msg, *args, **kwargs): - _logger.warning(msg, *args, **kwargs) - def error(self, msg, *args, **kwargs): - _logger.error(msg, *args, **kwargs) - def debug(self, msg, *args, **kwargs): - _logger.debug(msg, *args, **kwargs) + """Auto-routing logger that writes to calling module's log file""" + _watcher_started = False + def _ensure_watcher(self): + """Lazy-start file watchers on first logger use""" + if not SystemLogger._watcher_started: + # Set flag FIRST to prevent recursion: trigger.fire() uses logger + # internally, which would re-enter _ensure_watcher() before we return + SystemLogger._watcher_started = True + # Start prax watcher (Python file discovery) + # Wrapped in try/except - inotify may be maxed by VS Code + if not is_file_watcher_active(): + try: + start_file_watcher() + except OSError: + pass # inotify limit reached, continue without watcher + # Fire startup event (trigger auto-initializes handlers) + try: + from aipass.trigger.apps.modules.core import trigger + trigger.fire('startup') + except (ImportError, OSError): + pass # Trigger not available or inotify full, silent fallback + + def info(self, message, *args, **kwargs): + """Log info message to calling module's log file""" + self._ensure_watcher() + logger = get_system_logger() + logger.info(message, *args, **kwargs) + + def warning(self, message, *args, **kwargs): + """Log warning message to calling module's log file""" + self._ensure_watcher() + logger = get_system_logger() + logger.warning(message, *args, **kwargs) + + def error(self, message, *args, **kwargs): + """Log error message to calling module's log file""" + self._ensure_watcher() + logger = get_system_logger() + logger.error(message, *args, **kwargs) + +# Export the logger object - this is what other branches import system_logger = SystemLogger() + +# ============================================= +# LIFECYCLE FUNCTIONS +# ============================================= + +def initialize_logging_system(): + """Initialize the complete logging system + + Steps: + 1. Create config file if missing + 2. Discover all Python modules + 3. Save module registry + 4. Setup system logger + 5. Install logger override + 6. Start file watcher + """ + print(f"[{MODULE_NAME}] Initializing system-wide logging...") + + # Create config file if missing + create_config_file() + + # Discover all modules + modules = discover_python_modules() + + # Save registry + save_module_registry(modules) + + # Setup prax_logger's own logging + system_logger_instance = setup_system_logger() + + # Log system startup + system_logger_instance.info("Prax logging system initialized") + system_logger_instance.info(f"System logs directory: {SYSTEM_LOGS_DIR}") + system_logger_instance.info(f"Found {len(modules)} modules for logging setup") + + # Install logger override + install_logger_override() + system_logger_instance.info("Logger override system installed") + + # Start file watcher + start_file_watcher() + + log_operation("Logging system initialized", { + "modules_discovered": len(modules), + "consolidated_logger": True + }) + + print(f"[{MODULE_NAME}] System initialized - {len(modules)} modules, individual logging") + +def shutdown_logging_system(): + """Shutdown logging system cleanly + + Steps: + 1. Stop file watcher + 2. Restore original logger + 3. Log shutdown operation + """ + print(f"[{MODULE_NAME}] Shutting down logging system...") + + # Stop file watcher + stop_file_watcher() + + # Restore original logger + restore_original_logger() + + log_operation("Logging system shutdown", {}) + print(f"[{MODULE_NAME}] Shutdown complete") + +def start_continuous_logging(): + """Start continuous logging in background mode with live terminal output + + Enables terminal output and runs until Ctrl+C. + Displays status updates every 5 minutes. + + MODULE orchestration pattern: Thin wrapper that delegates to handler. + """ + from aipass.prax.apps.handlers.logging.monitoring import run_monitoring_loop + + print(f"[{MODULE_NAME}] Starting continuous logging mode with terminal output...") + sys.stdout.flush() + + # Enable terminal output for live debugging + enable_terminal_output() + + # Initialize the logging system + initialize_logging_system() + + # Delegate to handler for monitoring loop + try: + run_monitoring_loop( + status_callback=get_system_status, + interval=5, + status_interval=300 + ) + except KeyboardInterrupt: + # Handler re-raises KeyboardInterrupt, we handle cleanup here + disable_terminal_output() + shutdown_logging_system() + print(f"[{MODULE_NAME}] Logger capture stopped.") + sys.stdout.flush() + +# ============================================= +# STATUS AND CONTROL +# ============================================= + +def get_system_status() -> Dict[str, Any]: + """Get current logging system status + + Returns: + Dict with system status information: + - total_modules: Number of discovered modules + - individual_loggers: Number of active loggers + - system_logs_dir: Path to system logs + - registry_file: Path to module registry + - file_watcher_active: Watcher status + - logger_override_active: Override status + """ + modules = load_module_registry() + + return { + "total_modules": len(modules), + "individual_loggers": get_captured_loggers_count(), + "system_logs_dir": str(SYSTEM_LOGS_DIR), + "registry_file": str(DATA_FILE), + "file_watcher_active": is_file_watcher_active(), + "logger_override_active": is_override_active() + } + +def enable_terminal_output(): + """Enable terminal output for all future loggers""" + _enable_terminal() + +def disable_terminal_output(): + """Disable terminal output""" + _disable_terminal() diff --git a/src/aipass/prax/apps/modules/monitor_module.py b/src/aipass/prax/apps/modules/monitor_module.py new file mode 100755 index 00000000..00c1b3a7 --- /dev/null +++ b/src/aipass/prax/apps/modules/monitor_module.py @@ -0,0 +1,836 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: monitor_module.py - Unified Monitoring Module +# Date: 2025-11-23 +# Version: 0.2.0 +# Category: prax/modules +# +# CHANGELOG (Max 5 entries): +# - v0.2.0 (2025-11-23): Implemented threading-based monitoring loop +# * Added display thread (pulls from event queue) +# * Added file watcher thread (monitors filesystem changes) +# * Added log watcher thread (monitors log files) +# * Implemented interactive command handling (watch, filter, status, help, quit) +# * Starts in quiet mode - no output until user specifies what to watch +# - v0.1.0 (2025-11-23): Initial version - Mission Control orchestrator +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Implements handle_command(command: str, args: List[str]) -> bool interface +# - Uses Prax logger for system-wide logging +# - Orchestration only - delegates to handlers +# ============================================= + +""" +PRAX Monitor Module - Mission Control for Autonomous Branches + +Unified monitoring orchestrator that provides real-time visibility into: +- File changes across all branches (file watcher) +- Log events from all modules (log monitoring) +- Branch activity and state changes +- Module execution tracking +- System health and status + +Purpose: + Single command interface for monitoring all autonomous branch activity. + Replaces fragmented monitoring with unified Mission Control console. + Enables multi-agent workflow visibility and coordination. + +Usage: + prax monitor # Monitor all branches (quiet mode) + prax monitor all # Explicit all-branches monitoring + prax monitor seed,cli # Monitor specific branches + +Interactive Commands: + help # Show available commands + status # Display current monitoring state + filter [branches] # Adjust branch filter + quit/exit # Stop monitoring + +Architecture: + This module is thin orchestration layer only. All implementation + delegated to specialized handlers in apps/handlers/monitoring/: + + - unified_stream.py → Terminal output formatting + - branch_detector.py → Path-to-branch mapping + - interactive_filter.py → Runtime filter adjustment + - monitoring_filters.py → Event filtering logic (TODO) + - event_queue.py → Event buffering and deduplication + - module_tracker.py → Module execution tracking + - (file watcher) → Real-time file change detection (TODO) + - (log monitor) → Log stream processing (TODO) +""" + +import sys +import argparse +import threading +import time +from pathlib import Path +from typing import List, Optional + +# Prax logger (system-wide, always first) +from aipass.prax.apps.modules.logger import system_logger as logger + +# CLI services (display/output formatting) +from aipass.cli.apps.modules import console, header, success, error, warning + +# Monitoring handlers (connected subsystems) +from aipass.prax.apps.handlers.monitoring import ( + print_event, # unified_stream.py + print_command_separator, # unified_stream.py - command headers + detect_branch_from_path, # branch_detector.py + FilterState, # interactive_filter.py + parse_command, # interactive_filter.py + should_monitor, # monitoring_filters.py + get_priority, # monitoring_filters.py + MonitoringEvent, # event_queue.py + MonitoringQueue, # event_queue.py + ModuleTracker, # module_tracker.py +) + +# Telegram relay (optional - graceful if unavailable) +try: + from aipass.prax.apps.handlers.monitoring.telegram_relay import ( + telegram_start, telegram_stop, telegram_queue_event + ) + _telegram_available = True +except ImportError: + _telegram_available = False + +# ============================================================================= +# UTILITY FUNCTIONS +# ============================================================================= + +def normalize_branch_arg(arg: str) -> str: + """ + Convert path or name to branch name. + + DRONE now resolves @branch arguments to full paths before passing to modules. + This function normalizes both formats to branch names. + + Args: + arg: Branch name (e.g., "flow") or full path (e.g., "/home/aipass/aipass_core/flow") + + Returns: + Uppercase branch name (e.g., "FLOW") + + Examples: + >>> normalize_branch_arg("flow") + "FLOW" + >>> normalize_branch_arg("/home/aipass/aipass_core/flow") + "FLOW" + >>> normalize_branch_arg("/home/aipass/seed") + "SEED" + """ + if arg.startswith('/'): + from pathlib import Path + parts = Path(arg).parts + # Check if path contains aipass_core - extract branch name after it + if 'aipass_core' in parts: + idx = parts.index('aipass_core') + if idx + 1 < len(parts): + return parts[idx + 1].upper() + # Otherwise, use last part of path + return Path(arg).name.upper() + return arg.upper() + +# ============================================================================= +# PID CACHE - Maps branch names to active agent PIDs from dispatch lock files +# ============================================================================= + +import json as _json + +_pid_cache: dict[str, int] = {} +_pid_cache_lock = threading.Lock() +_pid_cache_last_refresh: float = 0.0 +_PID_CACHE_TTL = 30.0 # Refresh every 30 seconds + + +def _refresh_pid_cache() -> None: + """Scan dispatch lock files to build branch→PID mapping.""" + global _pid_cache_last_refresh + import time as _time + now = _time.time() + if now - _pid_cache_last_refresh < _PID_CACHE_TTL: + return + _pid_cache_last_refresh = now + + try: + registry_path = Path.home() / "BRANCH_REGISTRY.json" + if not registry_path.exists(): + return + data = _json.loads(registry_path.read_text(encoding="utf-8")) + new_cache: dict[str, int] = {} + for branch in data.get("branches", []): + branch_path = Path(branch.get("path", "")) + lock_path = branch_path / "ai_mail.local" / ".dispatch.lock" + if not lock_path.exists(): + continue + try: + lock_data = _json.loads(lock_path.read_text(encoding="utf-8")) + pid = lock_data.get("pid", 0) + if pid and Path(f"/proc/{pid}").exists(): + name = branch.get("name", "").upper() + if name: + new_cache[name] = pid + except (ValueError, OSError): + continue + with _pid_cache_lock: + _pid_cache.clear() + _pid_cache.update(new_cache) + except Exception as e: + logger.info(f"[monitor] PID cache refresh failed: {e}") + + +def _get_pid_for_branch(branch: str) -> Optional[int]: + """Look up PID for a branch from the cache.""" + _refresh_pid_cache() + base = branch.upper() + if base.endswith(' AGENT'): + base = base[:-6] + with _pid_cache_lock: + return _pid_cache.get(base) + + +# ============================================================================= +# MODULE STATE +# ============================================================================= + +# Global monitoring state +_monitoring_active = False +_filter_state: Optional[FilterState] = None +_event_queue: Optional[MonitoringQueue] = None +_module_tracker: Optional[ModuleTracker] = None +_display_thread: Optional[threading.Thread] = None +_file_watcher_thread: Optional[threading.Thread] = None +_log_watcher_thread: Optional[threading.Thread] = None + + +# ============================================================================= +# CORE COMMAND HANDLER (Required for auto-discovery) +# ============================================================================= + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle monitor command - required for auto-discovery by prax.py + + Args: + command: Command name from prax.py dispatcher + args: Command arguments (branch filters, flags, etc.) + + Returns: + True if command was handled (command == "monitor") + False if not our command (pass to next handler) + """ + if command != 'monitor': + return False + + global _monitoring_active, _filter_state, _event_queue, _module_tracker + global _display_thread, _file_watcher_thread, _log_watcher_thread + + logger.info(f"Starting unified monitoring (args: {args})") + + # Initialize monitoring subsystems + _filter_state = FilterState() + _event_queue = MonitoringQueue() + _module_tracker = ModuleTracker() + _monitoring_active = True + + # Parse args for initial branch filters + if args: + if args[0] == 'all': + # Watch all branches + _filter_state.show_all = True + _filter_state.show_info = True + else: + # Watch specific branches + initial_branches = args[0].split(',') + _filter_state.watched_branches = set(normalize_branch_arg(b.strip()) for b in initial_branches) + _filter_state.show_info = True + + # Display header + console.print() + header("PRAX Mission Control - Unified Monitoring") + console.print() + console.print("[green]Monitoring system starting...[/green]") + console.print("[yellow]Quiet mode active - type 'help' for commands[/yellow]") + console.print() + + # Start monitoring threads + Telegram relay + _start_threads() + if _telegram_available: + telegram_start() + + # Enter interactive mode + _interactive_loop() + + # Cleanup on exit + if _telegram_available: + telegram_stop() + _stop_threads() + + return True + + +def _start_threads(): + """Start all monitoring threads""" + global _display_thread, _file_watcher_thread, _log_watcher_thread + + # Display thread - pulls from event queue and displays + _display_thread = threading.Thread(target=_display_worker, daemon=True) + _display_thread.start() + + # File watcher thread - watches filesystem changes + _file_watcher_thread = threading.Thread(target=_file_watcher_worker, daemon=True) + _file_watcher_thread.start() + + # Log watcher thread - watches log files + _log_watcher_thread = threading.Thread(target=_log_watcher_worker, daemon=True) + _log_watcher_thread.start() + + logger.info("All monitoring threads started") + + +def _stop_threads(): + """Stop all monitoring threads""" + global _monitoring_active, _event_queue + + _monitoring_active = False + + if _event_queue: + _event_queue.stop() + + # Give threads time to finish + time.sleep(0.5) + + logger.info("All monitoring threads stopped") + + +def _display_worker(): + """Display thread - pulls events from queue and displays them""" + global _monitoring_active, _event_queue, _filter_state + + while _monitoring_active: + if not _event_queue: + time.sleep(0.1) + continue + + # Get next event from queue + event = _event_queue.dequeue(timeout=0.1) + + if event: + # Check if event should be displayed based on filters + from aipass.prax.apps.handlers.monitoring.interactive_filter import should_display_event + + if _filter_state and should_display_event(event.event_type, event.branch, event.level, _filter_state, event.message): + # Resolve PID for this branch + branch_pid = _get_pid_for_branch(event.branch) + + # Display the event - use separator for commands, regular format for others + if event.event_type == 'command': + # Pass caller and target for attribution + caller = getattr(event, 'caller', None) + # Target encoded in action field as "executed:TARGET" + target = None + if hasattr(event, 'action') and event.action and ':' in event.action: + parts = event.action.split(':', 1) + if len(parts) == 2 and parts[1]: + target = parts[1] + print_command_separator(event.branch, event.message, caller, target) + # Telegram relay + if _telegram_available: + telegram_queue_event('command', event.branch, event.message, caller, target) + else: + print_event(event.event_type, event.branch, event.message, event.level, pid=branch_pid) + # Telegram relay + if _telegram_available: + telegram_queue_event(event.event_type, event.branch, event.message) + + +def _file_watcher_worker(): + """File watcher thread - watches filesystem changes and pushes to queue""" + global _monitoring_active, _event_queue + + # Use the existing file watcher from discovery + from aipass.prax.apps.handlers.discovery.watcher import start_file_watcher, stop_file_watcher + from watchdog.observers import Observer + from watchdog.events import FileSystemEventHandler + from aipass.prax.apps.handlers.config.load import ECOSYSTEM_ROOT + + # Files whose modification indicates a command is running (python3 direct calls) + # Maps filename -> command description. Used to emit command separators from file events. + COMMAND_INDICATOR_FILES = { + 'standards_audit_log.json': 'seed audit', + 'standards_checklist_log.json': 'seed checklist', + } + # Track last command emitted per file to avoid duplicate separators + last_file_command = {} + # Track JSONL file positions for incremental reading + jsonl_positions = {} + # Track last agent action per session to avoid duplicate displays + last_agent_action = {} + + class MonitoringFileHandler(FileSystemEventHandler): + """File system event handler that pushes to event queue""" + + def on_created(self, event): + if not event.is_directory: + self._handle_event('created', event.src_path) + + def on_modified(self, event): + if not event.is_directory: + self._handle_event('modified', event.src_path) + + def on_deleted(self, event): + if not event.is_directory: + self._handle_event('deleted', event.src_path) + + def on_moved(self, event): + if not event.is_directory: + # dest_path can be bytes or str, normalize to str for comparison + dest_path_str = event.dest_path.decode() if isinstance(event.dest_path, bytes) else event.dest_path + src_path_str = event.src_path.decode() if isinstance(event.src_path, bytes) else event.src_path + if 'Trash' in dest_path_str or '.local/share/Trash' in dest_path_str: + # Moved to Trash = deletion + self._handle_event('deleted', src_path_str) + elif '.tmp.' in src_path_str or src_path_str.endswith('.tmp'): + # Atomic write: tmp file moved to real file = modification + self._handle_event('modified', dest_path_str) + else: + self._handle_event('moved', dest_path_str) + + def _parse_agent_activity(self, file_path, branch): + """Parse Claude Code session JSONL to show agent actions. + + Returns True if an event was emitted (or deduped), False on failure. + """ + import json as _json + try: + path_key = str(file_path) + current_size = file_path.stat().st_size + last_pos = jsonl_positions.get(path_key, 0) + + # File shrunk or new - reset + if current_size < last_pos: + last_pos = 0 + + if current_size <= last_pos: + return True # No new data, but not an error + + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + f.seek(last_pos) + new_data = f.read() + jsonl_positions[path_key] = f.tell() + + # Parse last meaningful line + lines = [l for l in new_data.strip().split('\n') if l.strip()] + if not lines: + return True # Empty, not an error + + for line in reversed(lines): + try: + entry = _json.loads(line) + except _json.JSONDecodeError: + continue + + entry_type = entry.get('type', '') + msg = entry.get('message', {}) if isinstance(entry.get('message'), dict) else {} + content = msg.get('content', []) + + # Skip noise entries - look for next meaningful one + if entry_type in ('progress', 'system', 'file-history-snapshot', 'queue-operation'): + continue + + action_text = None + + if entry_type == 'assistant' and isinstance(content, list): + for item in content: + if not isinstance(item, dict): + continue + item_type = item.get('type', '') + + if item_type == 'thinking': + action_text = '💭 Thinking' + break + elif item_type == 'tool_use': + tool_name = item.get('name', '') + inp = item.get('input', {}) + if tool_name in ('Read', 'Edit', 'Write'): + fp = inp.get('file_path', '') + short = fp.split('/')[-1] if '/' in fp else fp + action_text = f"🔧 {tool_name}: {short}" + elif tool_name == 'Bash': + desc = inp.get('description', '') + if not desc: + cmd = inp.get('command', '')[:120] + desc = cmd + action_text = f"⚡ Bash: {desc[:120]}" + elif tool_name in ('Grep', 'Glob'): + pat = inp.get('pattern', '')[:80] + action_text = f"🔍 {tool_name}: {pat}" + elif tool_name == 'Task': + desc = inp.get('description', '')[:80] + action_text = f"🚀 Agent: {desc}" + else: + action_text = f"🔧 {tool_name}" + break + elif item_type == 'text': + text = item.get('text', '').strip()[:200] + if text: + action_text = f"💬 {text}" + break + + elif entry_type == 'user': + # Skip tool_result entries (noise - every tool call produces one) + # Only show actual user messages (new prompts) + is_tool_result = False + if isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get('type') == 'tool_result': + is_tool_result = True + break + if not is_tool_result: + action_text = '📩 User message' + + if action_text: + # Dedup: skip if same action for same session + if last_agent_action.get(path_key) == action_text: + return True # Deduped, not an error + last_agent_action[path_key] = action_text + + event = MonitoringEvent( + priority=1, + event_type='agent', + branch=branch, + action='activity', + message=action_text, + level='info' + ) + if _event_queue: + _event_queue.enqueue(event) + return True + + # All lines were progress/system - that's fine + return True + + except Exception as e: + logger.info(f"[monitor] JSONL parse error for {file_path.name}: {e}") + return False + + def _handle_event(self, action, path_str): + """Process file event and push to queue.""" + try: + file_path = Path(path_str) + + # Check if should monitor this path + if not should_monitor(file_path): + return + + # Detect branch from path + branch = detect_branch_from_path(str(file_path)) + + # Claude Code JSONL files: parse agent activity instead of raw modification + if file_path.suffix == '.jsonl' and '.claude/projects/' in path_str: + # Distinguish subagents from main sessions + # Main: ~/.claude/projects/{hash}/{uuid}.jsonl + # Sub: ~/.claude/projects/{hash}/{uuid}/subagents/agent-{id}.jsonl + if '/subagents/' in path_str: + branch = branch + ' agent' + if self._parse_agent_activity(file_path, branch): + return # Parsed successfully, don't show raw event + # Parsing failed - fall through to show raw file event + + # Check if this file indicates a command (python3 direct calls) + if action == 'modified' and file_path.name in COMMAND_INDICATOR_FILES: + cmd = COMMAND_INDICATOR_FILES[file_path.name] + dedup_key = f"{branch}:{cmd}" + if last_file_command.get(file_path.name) != dedup_key: + last_file_command[file_path.name] = dedup_key + cmd_event = MonitoringEvent( + priority=2, + event_type='command', + branch=branch, + action='executed', + message=cmd, + level='info' + ) + if _event_queue: + _event_queue.enqueue(cmd_event) + # Still show the file event too (don't return) + + # Get priority + priority_level = get_priority(file_path, action) + + # Build display name with context (branch-relative path or short path) + display_name = file_path.name + # Show parent dir for context when file is deep in a branch + parts = file_path.parts + # Find branch root and show relative path from there + for i, part in enumerate(parts): + if part in ('apps', 'handlers', 'modules', 'docs', 'templates'): + display_name = '/'.join(parts[i:]) + break + + # Create event + event = MonitoringEvent( + priority=0, # Will be set based on level + event_type='file', + branch=branch, + action=action, + message=f"{action.upper()}: {display_name}", + level=priority_level if priority_level in ['error', 'warning', 'info'] else 'info' + ) + + # Push to queue + if _event_queue: + _event_queue.enqueue(event) + except Exception as e: + # Log error but don't crash the watcher + logger.error(f"[monitor] Error handling {action} event for {path_str}: {e}") + + # Create observer and start watching + # Watch entire /home/aipass directory (covers aipass_core, aipass_os, speakeasy, etc.) + observer = Observer() + handler = MonitoringFileHandler() + home_dir = Path.home() + observer.schedule(handler, str(home_dir), recursive=True) + observer.start() + + try: + while _monitoring_active: + time.sleep(0.1) + finally: + observer.stop() + observer.join() + + +def _log_watcher_worker(): + """Log watcher thread - uses proper log_watcher.py with all improvements""" + global _monitoring_active, _event_queue + + from aipass.prax.apps.handlers.monitoring.log_watcher import start_log_watcher, stop_log_watcher + + # Start the proper log watcher (has command detection, branch detection, message parsing) + # Guard against None - should never happen since we initialize before starting threads + if _event_queue is None: + logger.error("[monitor] Event queue not initialized for log watcher") + return + observer = start_log_watcher(_event_queue) + + try: + while _monitoring_active: + time.sleep(0.1) + finally: + stop_log_watcher() + + +def _interactive_loop(): + """Interactive command loop - handles user input""" + global _monitoring_active, _filter_state, _event_queue + + from aipass.prax.apps.handlers.monitoring.interactive_filter import ( + parse_command, + apply_filter, + get_help_text + ) + + while _monitoring_active: + try: + # Get user input + user_input = input().strip() + + if not user_input: + continue + + # Parse command + cmd, cmd_args = parse_command(user_input) + + if not cmd: + continue + + # Handle commands + if cmd in ['quit', 'exit', 'q']: + console.print("[yellow]Stopping monitoring...[/yellow]") + break + + elif cmd == 'help': + console.print(get_help_text()) + + elif cmd == 'status': + _print_status() + + elif cmd in ['watch', 'monitor', 'filter', 'verbosity', 'clear']: + # Update filter state + if _filter_state: + apply_filter(_filter_state, cmd, cmd_args) + console.print(f"[green]Filter updated: {cmd} {' '.join(cmd_args)}[/green]") + + else: + console.print(f"[red]Unknown command: {cmd}[/red]") + console.print("[dim]Type 'help' for available commands[/dim]") + + except KeyboardInterrupt: + console.print("\n[yellow]Stopping monitoring...[/yellow]") + break + except EOFError: + break + + +def _print_status(): + """Display current monitoring status""" + global _filter_state, _event_queue + + console.print() + console.print("[bold cyan]Monitoring Status:[/bold cyan]") + console.print() + + if _filter_state: + if _filter_state.show_all: + console.print(" [yellow]Watching:[/yellow] All branches") + elif _filter_state.watched_branches: + console.print(f" [yellow]Watching:[/yellow] {', '.join(sorted(_filter_state.watched_branches))}") + else: + console.print(" [yellow]Watching:[/yellow] None (quiet mode)") + + console.print(f" [yellow]Show errors:[/yellow] {_filter_state.show_errors}") + console.print(f" [yellow]Show warnings:[/yellow] {_filter_state.show_warnings}") + console.print(f" [yellow]Show info:[/yellow] {_filter_state.show_info}") + console.print(f" [yellow]Verbosity:[/yellow] {_filter_state.verbosity}") + + if _event_queue: + console.print(f" [yellow]Queue size:[/yellow] {_event_queue.size()}") + + console.print() + + +# ============================================================================= +# INTROSPECTION (Module metadata and handler connections) +# ============================================================================= + +def print_introspection(): + """Display module introspection - shows connected handlers and architecture""" + console.print() + console.print("[bold cyan]PRAX Monitor Module[/bold cyan]") + console.print() + console.print("[yellow]Purpose:[/yellow]") + console.print(" Mission Control for autonomous branch monitoring") + console.print(" Unified console for file changes, logs, and module activity") + console.print() + + console.print("[yellow]Connected Handlers (apps/handlers/monitoring/):[/yellow]") + console.print() + console.print(" [cyan]1. unified_stream.py[/cyan]") + console.print(" [dim]→ print_event() - Terminal output formatting[/dim]") + console.print() + console.print(" [cyan]2. branch_detector.py[/cyan]") + console.print(" [dim]→ detect_branch_from_path() - Path-to-branch mapping[/dim]") + console.print() + console.print(" [cyan]3. interactive_filter.py[/cyan]") + console.print(" [dim]→ FilterState, parse_command() - Runtime filtering[/dim]") + console.print() + console.print(" [cyan]4. monitoring_filters.py[/cyan]") + console.print(" [dim]→ should_monitor(), get_priority() - Event filtering[/dim]") + console.print() + console.print(" [cyan]5. event_queue.py[/cyan]") + console.print(" [dim]→ MonitoringEvent, MonitoringQueue - Event buffering[/dim]") + console.print() + console.print(" [cyan]6. module_tracker.py[/cyan]") + console.print(" [dim]→ ModuleTracker - Module execution tracking[/dim]") + console.print() + console.print(" [cyan]7. file watcher (threaded)[/cyan]") + console.print(" [dim]→ Real-time file change detection using watchdog[/dim]") + console.print(" [green]STATUS: Active - monitors ECOSYSTEM_ROOT recursively[/green]") + console.print() + console.print(" [cyan]8. log monitor (threaded)[/cyan]") + console.print(" [dim]→ Log stream processing from SYSTEM_LOGS_DIR[/dim]") + console.print(" [green]STATUS: Active - watches *.log files for new entries[/green]") + console.print() + + console.print("[dim]Run 'python3 monitor_module.py --help' for usage[/dim]") + console.print() + + +# ============================================================================= +# HELP OUTPUT (Drone-compliant command documentation) +# ============================================================================= + +def print_help(): + """Drone-compliant help output - command syntax and examples""" + console.print() + console.print("[bold cyan]PRAX Monitor - Unified Branch Monitoring[/bold cyan]") + console.print() + + console.print("[yellow]Commands:[/yellow]") + console.print() + console.print(" [cyan]monitor[/cyan]") + console.print(" Start monitoring all branches (quiet mode)") + console.print() + console.print(" [cyan]monitor all[/cyan]") + console.print(" Explicit all-branches monitoring") + console.print() + console.print(" [cyan]monitor [branches][/cyan]") + console.print(" Monitor specific branches (comma-separated)") + console.print(" Example: monitor seed,cli,flow") + console.print() + + console.print("[yellow]Interactive Mode Commands:[/yellow]") + console.print() + console.print(" [cyan]help[/cyan] Show available commands") + console.print(" [cyan]status[/cyan] Display current monitoring state") + console.print(" [cyan]filter [branches][/cyan] Adjust branch filter") + console.print(" [cyan]quit/exit[/cyan] Stop monitoring") + console.print() + + console.print("[yellow]Examples:[/yellow]") + console.print() + console.print(" [dim]# Monitor all branches[/dim]") + console.print(" $ prax monitor") + console.print() + console.print(" [dim]# Monitor specific branches[/dim]") + console.print(" $ prax monitor seed,cli,flow") + console.print() + console.print(" [dim]# Standalone execution[/dim]") + console.print(" $ python3 monitor_module.py") + console.print() + + +# ============================================================================= +# MAIN BLOCK (Standalone execution support) +# ============================================================================= + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + # Parse command line arguments + parser = argparse.ArgumentParser( + description="PRAX Unified Monitoring - Mission Control", + add_help=False + ) + parser.add_argument('--help', action='store_true', help='Show help message') + parser.add_argument('--introspect', action='store_true', help='Show module introspection') + parser.add_argument('branches', nargs='?', help='Branches to monitor (comma-separated)') + + args = parser.parse_args() + + # Handle flags + if args.help: + print_help() + sys.exit(0) + + if args.introspect: + print_introspection() + sys.exit(0) + + # Prepare arguments for handle_command + cmd_args = [] + if args.branches: + cmd_args = [args.branches] + + # Execute monitor command + handled = handle_command('monitor', cmd_args) + sys.exit(0 if handled else 1) diff --git a/src/aipass/prax/apps/modules/run_module.py b/src/aipass/prax/apps/modules/run_module.py new file mode 100755 index 00000000..6685c7ff --- /dev/null +++ b/src/aipass/prax/apps/modules/run_module.py @@ -0,0 +1,72 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: run_module.py - PRAX Run Command +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: prax/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Created with handle_command interface +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Implements handle_command(command: str, args: List[str]) -> bool interface +# - Uses Prax logger for system-wide logging +# ============================================= + +""" +PRAX Run Module + +Implements the 'run' command using handle_command interface. +""" + +import sys +from pathlib import Path +from typing import List + +from aipass.prax.apps.modules.logger import start_continuous_logging, system_logger as logger +from aipass.cli.apps.modules import console, header, success, error + + +def print_help(): + """Display module help and connected handlers""" + console.print() + console.print("[bold cyan]PRAX Run Module[/bold cyan]") + console.print() + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + console.print(" [cyan]prax/modules/[/cyan]") + console.print(" [dim]- logger.py[/dim] (start_continuous_logging, system_logger)") + console.print() + + console.print("[dim]Run 'python3 run_module.py --help' for usage[/dim]") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle run command + + Args: + command: Command name + args: Command arguments + + Returns: + True if command was handled + """ + if command != 'run': + return False + + console.print("🚀 Starting PRAX continuous logging mode...") + start_continuous_logging() + return True + + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_help() + sys.exit(0) diff --git a/src/aipass/prax/apps/modules/shutdown_module.py b/src/aipass/prax/apps/modules/shutdown_module.py new file mode 100755 index 00000000..60ac9ff4 --- /dev/null +++ b/src/aipass/prax/apps/modules/shutdown_module.py @@ -0,0 +1,73 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: shutdown_module.py - PRAX Shutdown Command +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: prax/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Created with handle_command interface +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Implements handle_command(command: str, args: List[str]) -> bool interface +# - Uses Prax logger for system-wide logging +# ============================================= + +""" +PRAX Shutdown Module + +Implements the 'shutdown' command using handle_command interface. +""" + +import sys +from pathlib import Path +from typing import List + +from aipass.prax.apps.modules.logger import shutdown_logging_system, system_logger as logger +from aipass.cli.apps.modules import console, header, success, error + + +def print_help(): + """Display module help and connected handlers""" + console.print() + console.print("[bold cyan]PRAX Shutdown Module[/bold cyan]") + console.print() + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + console.print(" [cyan]prax/modules/[/cyan]") + console.print(" [dim]- logger.py[/dim] (shutdown_logging_system, system_logger)") + console.print() + + console.print("[dim]Run 'python3 shutdown_module.py --help' for usage[/dim]") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle shutdown command + + Args: + command: Command name + args: Command arguments + + Returns: + True if command was handled + """ + if command != 'shutdown': + return False + + console.print("🛑 Shutting down PRAX logging system...") + shutdown_logging_system() + console.print("✅ PRAX logging system shutdown complete") + return True + + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_help() + sys.exit(0) diff --git a/src/aipass/prax/apps/modules/status_module.py b/src/aipass/prax/apps/modules/status_module.py new file mode 100755 index 00000000..95e0ccbd --- /dev/null +++ b/src/aipass/prax/apps/modules/status_module.py @@ -0,0 +1,81 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: status_module.py - PRAX Status Command +# Date: 2025-11-15 +# Version: 1.0.0 +# Category: prax/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-15): Created with handle_command interface +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Implements handle_command(command: str, args: List[str]) -> bool interface +# - Uses Prax logger for system-wide logging +# ============================================= + +""" +PRAX Status Module + +Implements the 'status' command using handle_command interface. +""" + +import sys +from pathlib import Path +from typing import List + +from aipass.prax.apps.modules.logger import get_system_status, system_logger as logger +from aipass.cli.apps.modules import console, header, success, error + + +def print_help(): + """Display module help and connected handlers""" + console.print() + console.print("[bold cyan]PRAX Status Module[/bold cyan]") + console.print() + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + console.print(" [cyan]prax/modules/[/cyan]") + console.print(" [dim]- logger.py[/dim] (get_system_status, system_logger)") + console.print() + + console.print("[dim]Run 'python3 status_module.py --help' for usage[/dim]") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle status command + + Args: + command: Command name + args: Command arguments + + Returns: + True if command was handled + """ + if command != 'status': + return False + + status = get_system_status() + + console.print("\n📊 PRAX System Status") + console.print("=" * 60) + console.print(f"Total Modules: {status['total_modules']}") + console.print(f"Active Loggers: {status['individual_loggers']}") + console.print(f"System Logs Dir: {status['system_logs_dir']}") + console.print(f"Registry File: {status['registry_file']}") + console.print(f"File Watcher: {'🟢 Active' if status['file_watcher_active'] else '🔴 Inactive'}") + console.print(f"Logger Override: {'🟢 Active' if status['logger_override_active'] else '🔴 Inactive'}") + console.print("=" * 60 + "\n") + return True + + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_help() + sys.exit(0) diff --git a/src/aipass/prax/apps/modules/terminal_module.py b/src/aipass/prax/apps/modules/terminal_module.py new file mode 100755 index 00000000..558ff21f --- /dev/null +++ b/src/aipass/prax/apps/modules/terminal_module.py @@ -0,0 +1,142 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: terminal_module.py - PRAX Terminal Command +# Date: 2025-11-15 +# Version: 1.1.0 +# Category: prax/modules +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2025-11-29): UX improvements - added help output for invalid/missing args +# * Added print_help() function with drone-compliant format +# * Updated handle_command() to show help instead of bare error message +# * Added --help and --introspect flag support in main block +# * Added error handling with try/except and logger.error +# - v1.0.0 (2025-11-15): Created with handle_command interface +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Implements handle_command(command: str, args: List[str]) -> bool interface +# - Uses Prax logger for system-wide logging +# ============================================= + +""" +PRAX Terminal Module + +Implements the 'terminal' command using handle_command interface. +""" + +import sys +from pathlib import Path +from typing import List + +from aipass.prax.apps.modules.logger import enable_terminal_output, disable_terminal_output, system_logger as logger +from aipass.cli.apps.modules import console + + +def print_introspection(): + """Display module introspection - shows connected handlers""" + console.print() + console.print("[bold cyan]PRAX Terminal Module[/bold cyan]") + console.print() + console.print("[yellow]Purpose:[/yellow]") + console.print(" Enable or disable terminal output for PRAX logging system") + console.print() + + console.print("[yellow]Connected Handlers:[/yellow]") + console.print() + + console.print(" [cyan]prax/modules/[/cyan]") + console.print(" [dim]- logger.py[/dim] (enable_terminal_output, disable_terminal_output, system_logger)") + console.print() + + console.print("[dim]Run 'python3 terminal_module.py --help' for usage[/dim]") + console.print() + + +def print_help(): + """Drone-compliant help output - command syntax and examples""" + console.print() + console.print("[bold cyan]PRAX Terminal - Logger Output Control[/bold cyan]") + console.print() + + console.print("[yellow]Available Subcommands:[/yellow]") + console.print() + console.print(" [cyan]enable[/cyan]") + console.print(" Enable terminal output for PRAX logging") + console.print() + console.print(" [cyan]disable[/cyan]") + console.print(" Disable terminal output for PRAX logging") + console.print() + + console.print("[yellow]Usage Examples:[/yellow]") + console.print() + console.print(" [dim]# Enable terminal output[/dim]") + console.print(" $ prax terminal enable") + console.print() + console.print(" [dim]# Disable terminal output[/dim]") + console.print(" $ prax terminal disable") + console.print() + console.print(" [dim]# Standalone execution[/dim]") + console.print(" $ python3 terminal_module.py enable") + console.print() + + console.print("[dim]Commands: enable, disable[/dim]") + console.print() + + +def handle_command(command: str, args: List[str]) -> bool: + """ + Handle terminal command + + Args: + command: Command name + args: Command arguments (expects 'enable' or 'disable') + + Returns: + True if command was handled + """ + if command != 'terminal': + return False + + try: + if not args or args[0] not in ['enable', 'disable']: + print_help() + return True # Command was handled, even if validation failed + + if args[0] == 'enable': + enable_terminal_output() + console.print("✅ Terminal output enabled") + else: + disable_terminal_output() + console.print("✅ Terminal output disabled") + + return True + + except Exception as e: + logger.error(f"Error in terminal command: {e}") + console.print(f"[red]❌ ERROR: {e}[/red]") + return True + + +if __name__ == "__main__": + # Show introspection when run without arguments + if len(sys.argv) == 1: + print_introspection() + sys.exit(0) + + # Handle --help flag + if '--help' in sys.argv: + print_help() + sys.exit(0) + + # Handle --introspect flag + if '--introspect' in sys.argv: + print_introspection() + sys.exit(0) + + # Execute terminal command with remaining args + args = [arg for arg in sys.argv[1:] if not arg.startswith('--')] + handled = handle_command('terminal', args) + sys.exit(0 if handled else 1) diff --git a/src/aipass/prax/apps/plugins/__init__.py b/src/aipass/prax/apps/plugins/__init__.py old mode 100644 new mode 100755 index e69de29b..69b056dd --- a/src/aipass/prax/apps/plugins/__init__.py +++ b/src/aipass/prax/apps/plugins/__init__.py @@ -0,0 +1 @@ +# Plugins package - Pluggable components for branch capabilities diff --git a/src/aipass/prax/apps/prax.py b/src/aipass/prax/apps/prax.py new file mode 100755 index 00000000..322b916d --- /dev/null +++ b/src/aipass/prax/apps/prax.py @@ -0,0 +1,202 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: prax.py - PRAX Branch Orchestrator +# Date: 2025-11-15 +# Version: 2.0.0 +# Category: prax +# +# CHANGELOG (Max 5 entries): +# - v2.0.0 (2025-11-15): Refactored to use auto-discovery pattern from Seed +# - v1.0.0 (2025-11-08): Initial version - modular architecture +# +# CODE STANDARDS: +# - Follows AIPass Prax standards +# - Uses auto-discovery pattern for command modules +# - Minimal entry point - all logic in modules +# ============================================= + +""" +PRAX Branch - Main Orchestrator + +Auto-discovers command modules and routes commands to them. +Entry point contains no business logic - modules implement functionality. +""" + +import sys +from pathlib import Path + +# Standard library imports +import argparse +import importlib +from typing import List, Callable + +# Prax logger +from aipass.prax.apps.modules.logger import system_logger as logger + +# CLI services +from aipass.cli.apps.modules import console, header, success, error + +# ============================================================================= +# MODULE DISCOVERY +# ============================================================================= + +def discover_command_modules() -> List[Callable]: + """ + Auto-discover command modules from modules/ directory + + Each module must implement: + handle_command(command: str, args: List[str]) -> bool + + Returns: + List of handle_command functions from discovered modules + """ + command_handlers = [] + modules_dir = Path(__file__).parent / "modules" + + if not modules_dir.exists(): + return command_handlers + + # Scan for Python files in modules directory + for module_file in modules_dir.glob("*.py"): + # Skip __init__.py and non-command modules + if module_file.name.startswith('_') or module_file.name == 'logger.py': + continue + + try: + # Import module dynamically + module_name = f"prax.apps.modules.{module_file.stem}" + module = importlib.import_module(module_name) + + # Check for handle_command interface + if hasattr(module, 'handle_command'): + command_handlers.append(module.handle_command) + + except Exception as e: + console.print(f"Warning: Failed to load module {module_file.name}: {e}") + + return command_handlers + +# ============================================================================= +# INTROSPECTION DISPLAY +# ============================================================================= + +def print_introspection(): + """Display discovered modules (main entry point - modules only, no handlers)""" + console.print() + console.print("[bold cyan]PRAX - System-Wide Logging Infrastructure[/bold cyan]") + console.print() + console.print("[dim]Unified logging system for AIPass ecosystem[/dim]") + console.print() + + # Discover modules + modules_dir = Path(__file__).parent / "modules" + discovered_modules = [] + + if modules_dir.exists(): + for module_file in modules_dir.glob("*.py"): + if module_file.name.startswith('_') or module_file.name == 'logger.py': + continue + discovered_modules.append(module_file.stem) + + console.print(f"[yellow]Discovered Modules:[/yellow] {len(discovered_modules)}") + console.print() + + for module_name in sorted(discovered_modules): + console.print(f" [cyan]•[/cyan] {module_name}") + + console.print() + console.print("[dim]Run 'prax --help' for usage information[/dim]") + console.print() + + +# ============================================================================= +# COMMAND ROUTING +# ============================================================================= + +def route_command(command: str, args: List[str], handlers: List[Callable]) -> bool: + """ + Route command to appropriate module handler + + Args: + command: Command name + args: Command arguments + handlers: List of command handler functions + + Returns: + True if command was handled, False otherwise + """ + for handler in handlers: + try: + if handler(command, args): + return True + except Exception as e: + console.print(f"❌ ERROR: Handler failed: {e}") + return False + + return False + +# ============================================================================= +# MAIN +# ============================================================================= + +def main(): + """Main entry point""" + parser = argparse.ArgumentParser( + description='PRAX - System-Wide Logging Infrastructure', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Available Commands: + monitor Mission Control - unified real-time monitoring + init Initialize PRAX logging system + status Show PRAX system status + run Start continuous logging mode + shutdown Shutdown PRAX logging system + discover Discover Python modules in ecosystem + terminal Enable/disable terminal output (requires: enable|disable) + +Examples: + prax monitor + prax init + prax status + prax terminal enable + """ + ) + + # Add command argument (optional) + parser.add_argument('command', + nargs='?', + help='Command to execute') + + # Add remaining arguments for command handlers + parser.add_argument('args', + nargs='*', + help='Arguments for the command') + + parser.add_argument('--version', '-V', action='version', version='PRAX v2.0.0') + parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output') + + parsed_args = parser.parse_args() + + # If no command provided, show introspection display + if not parsed_args.command: + print_introspection() + return 0 + + # Discover command modules + handlers = discover_command_modules() + + if not handlers: + console.print("❌ ERROR: No command modules discovered") + return 1 + + # Route command to appropriate handler + if route_command(parsed_args.command, parsed_args.args, handlers): + return 0 + else: + console.print(f"❌ ERROR: Unknown command: {parsed_args.command}") + return 1 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/aipass/seedgo/drone_adapter.py b/src/aipass/seedgo/drone_adapter.py new file mode 100644 index 00000000..382519cc --- /dev/null +++ b/src/aipass/seedgo/drone_adapter.py @@ -0,0 +1,92 @@ +""" +Seedgo drone adapter — bridges drone routing to seedgo commands. + +Drone discovers this module via aipass.drone.modules._MODULE_REGISTRY +and routes `drone @seedgo [args]` here. +""" + +import sys +from io import StringIO + +DRONE_MODULE = { + "name": "seedgo", + "version": "1.0.0", + "description": "Standards compliance through pluggable standard packs", +} + + +def handle_command(command: str, args: list[str] | None = None) -> dict: + """Route a drone command to seedgo's entry point. + + Captures stdout/stderr and returns as dict for drone CLI to print. + """ + if args is None: + args = [] + + # Build argv as if `seedgo [args]` was called + original_argv = sys.argv + old_stdout = sys.stdout + old_stderr = sys.stderr + captured_out = StringIO() + captured_err = StringIO() + + try: + sys.argv = ["seedgo", command] + args + sys.stdout = captured_out + sys.stderr = captured_err + + # Import here to avoid circular imports at module level + from aipass.seedgo.apps.seedgo import main + exit_code = main() + except SystemExit as e: + exit_code = e.code if e.code is not None else 0 + except Exception as e: + captured_err.write(str(e)) + exit_code = 1 + finally: + sys.argv = original_argv + sys.stdout = old_stdout + sys.stderr = old_stderr + + return { + "stdout": captured_out.getvalue(), + "stderr": captured_err.getvalue(), + "exit_code": exit_code if isinstance(exit_code, int) else 1, + } + + +def get_help(command: str | None = None) -> str: + """Return help text for seedgo.""" + if command: + result = handle_command(command, ["--help"]) + return result.get("stdout", "") or result.get("stderr", "") + + return ( + "seedgo — Standards compliance platform\n" + "\n" + "Commands:\n" + " audit Run pack audit against current project\n" + " checklist Check single file against pack standards\n" + " list Show installed standard packs\n" + " verify Self-check seedgo installation\n" + "\n" + "Usage via drone:\n" + " drone @seedgo audit aipass\n" + " drone @seedgo list\n" + " drone @seedgo verify\n" + ) + + +def get_introspective() -> str: + """Discovery mode: show what seedgo has connected.""" + try: + from aipass.seedgo.apps.seedgo import discover_packs + packs = discover_packs() + pack_names = ", ".join(packs.keys()) if packs else "none" + return ( + f"@seedgo — Standards compliance platform\n" + f" Installed packs: {pack_names}\n" + f" Run 'drone @seedgo --help' for usage\n" + ) + except Exception: + return "@seedgo — Standards compliance platform (run 'drone @seedgo --help' for usage)\n" diff --git a/src/aipass/trigger/apps/__init__.py b/src/aipass/trigger/apps/__init__.py index dcf3b744..73ab12a7 100644 --- a/src/aipass/trigger/apps/__init__.py +++ b/src/aipass/trigger/apps/__init__.py @@ -1 +1 @@ -# TRIGGER apps package +# Apps package - Branch application modules and handlers diff --git a/src/aipass/trigger/apps/branch.py b/src/aipass/trigger/apps/branch.py deleted file mode 100644 index fb303397..00000000 --- a/src/aipass/trigger/apps/branch.py +++ /dev/null @@ -1,86 +0,0 @@ -""" -TRIGGER Branch - Main Orchestrator - -Auto-discovery architecture: -- Scans modules/ directory for .py files with handle_command() -- Routes commands to discovered modules automatically -- No manual imports or routing needed -""" - -import sys -import importlib -from pathlib import Path -from typing import List, Any - -from aipass.prax import logger - -# ============================================================================= -# MODULE DISCOVERY -# ============================================================================= - -MODULES_DIR = Path(__file__).parent / "modules" - - -def discover_modules() -> List[Any]: - """Auto-discover modules in modules/ directory.""" - modules = [] - - if not MODULES_DIR.exists(): - return modules - - for file_path in MODULES_DIR.glob("*.py"): - if file_path.name.startswith("_"): - continue - - module_name = f"apps.modules.{file_path.stem}" - - try: - module = importlib.import_module(module_name) - if hasattr(module, "handle_command"): - modules.append(module) - except Exception as e: - logger.error(f"[TRIGGER] Failed to load module {module_name}: {e}") - - return modules - - -def route_command(command: str, args: List[str], modules: List[Any]) -> bool: - """Route command to appropriate module.""" - for module in modules: - try: - if module.handle_command(command, args): - return True - except Exception as e: - logger.error(f"[TRIGGER] Module {module.__name__} error: {e}") - return False - - -# ============================================================================= -# MAIN ENTRY POINT -# ============================================================================= - -def main(): - """Main entry point - routes commands or shows help.""" - modules = discover_modules() - args = sys.argv[1:] - - if len(args) == 0 or args[0] in ["--help", "-h", "help"]: - print(f"TRIGGER - {len(modules)} modules discovered") - for module in modules: - name = module.__name__.split(".")[-1] - desc = (module.__doc__ or "").strip().split("\n")[0] if module.__doc__ else "No description" - print(f" {name:20} {desc}") - return 0 - - command = args[0] - remaining = args[1:] if len(args) > 1 else [] - - if route_command(command, remaining, modules): - return 0 - - print(f"Unknown command: {command}") - return 1 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/src/aipass/trigger/apps/config.py b/src/aipass/trigger/apps/config.py new file mode 100644 index 00000000..27b72c54 --- /dev/null +++ b/src/aipass/trigger/apps/config.py @@ -0,0 +1,14 @@ +""" +Trigger package path configuration. + +Provides package-relative paths for trigger data directories. +Works in both pip-installed and development environments. +""" + +from pathlib import Path + +# Trigger package root: .../aipass/trigger/ +TRIGGER_ROOT = Path(__file__).resolve().parents[1] + +# AIPass package root: .../aipass/ +AIPASS_PKG_ROOT = TRIGGER_ROOT.parent diff --git a/src/aipass/trigger/apps/extensions/__init__.py b/src/aipass/trigger/apps/extensions/__init__.py new file mode 100644 index 00000000..95322c94 --- /dev/null +++ b/src/aipass/trigger/apps/extensions/__init__.py @@ -0,0 +1 @@ +# Extensions package - Drop-in extensions for branch functionality diff --git a/src/aipass/trigger/apps/handlers/__init__.py b/src/aipass/trigger/apps/handlers/__init__.py index e69de29b..bdde4f1b 100644 --- a/src/aipass/trigger/apps/handlers/__init__.py +++ b/src/aipass/trigger/apps/handlers/__init__.py @@ -0,0 +1,90 @@ +"""TRIGGER handlers package - Security protected.""" + +import inspect +from pathlib import Path + +MY_BRANCH = "trigger" + + +def _find_real_caller(): + """Walk the stack to find the actual file that triggered this import.""" + stack = inspect.stack() + this_file = str(Path(__file__).resolve()) + + for frame_info in stack: + filename = frame_info.filename + if this_file in str(Path(filename).resolve()): + continue + if filename.startswith("<") or "importlib" in filename: + continue + import_line = None + if frame_info.code_context: + import_line = frame_info.code_context[0].strip() + return str(Path(filename).resolve()), import_line + return None, None + + +def _extract_branch_name(filepath: str) -> str: + """Extract branch name from a file path.""" + parts = filepath.split("/") + for i, part in enumerate(parts): + if part in ("aipass_core", "MEMORY_BANK", "seed", ".vscode"): + if i + 1 < len(parts): + return parts[i + 1] + if part in ("aipass",) and i + 1 < len(parts) and parts[i + 1] == "apps": + return "aipass" + return "unknown" + + +def _guard_branch_access(): + """Block cross-branch handler imports.""" + caller_file, import_line = _find_real_caller() + + import os + if os.environ.get("AIPASS_DEBUG_GUARD"): + import sys + print(f"[GUARD DEBUG] caller_file = {caller_file}", file=sys.stderr) + print(f"[GUARD DEBUG] import_line = {import_line}", file=sys.stderr) + + if caller_file is None: + stack = inspect.stack() + for frame in stack: + if frame.filename in ("", ""): + target_line = "unknown" + if frame.code_context: + target_line = frame.code_context[0].strip() + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller: interactive/script\n" + f" Blocked: {target_line}\n\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from aipass.trigger.apps.modules. import \n" + f"{'='*60}" + ) + return + + if f"/trigger/" in caller_file: + return + + caller_branch = _extract_branch_name(caller_file) + caller_filename = Path(caller_file).name + blocked_import = import_line if import_line else "unknown" + + raise ImportError( + f"\n{'='*60}\n" + f"ACCESS DENIED: Cross-branch handler import blocked\n" + f"{'='*60}\n" + f" Caller branch: {caller_branch}\n" + f" Caller file: {caller_filename}\n" + f" Blocked: {blocked_import}\n\n" + f" Handlers are internal to their branch.\n" + f" Use the module API instead:\n" + f" from aipass.trigger.apps.modules. import \n" + f"{'='*60}" + ) + + +_guard_branch_access() diff --git a/src/aipass/trigger/apps/handlers/error_registry.py b/src/aipass/trigger/apps/handlers/error_registry.py new file mode 100644 index 00000000..a5b9aaa2 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/error_registry.py @@ -0,0 +1,877 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: error_registry.py - Error Registry Handler +# Date: 2026-02-25 +# Version: 2.3.0 +# Category: trigger/handlers +# +# CHANGELOG (Max 5 entries): +# - v2.4.0 (2026-02-27): FPLAN-0382 Phase 3 - Migrate stdlib logging to Prax direct_log() (no-event pipeline) +# - v2.3.0 (2026-02-25): FPLAN-0371 Phase 5 - Persist circuit breaker state to trigger_config.json, add logging to silent failure paths +# - v2.2.0 (2026-02-25): FPLAN-0371 Phase 2 - User-error rejection layer in report() (auto-suppress unknown commands, invalid args) +# - v2.1.0 (2026-02-13): Phase 5 - Source fix pipeline (update_source_fix_status) +# - v2.0.0 (2026-02-13): Phase 2 - Circuit breaker + per-fingerprint rate limiting +# - v1.0.0 (2026-02-13): Created - Medic v2 Phase 1 error registry foundation +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - NO console.print() - handlers return data to modules +# - Uses Prax direct_log() (no event pipeline) to avoid recursion +# - Self-contained - no imports from other trigger modules +# - JSON file-based storage for error registry +# ============================================= + +""" +Error Registry Handler - Structured error tracking for Medic v2 + +Replaces the simple MD5 hash dedup from Medic v1 with a structured +error registry. Errors become first-class objects with fingerprinting, +deduplication, status tracking, and metadata. + +Phase 2 adds: + - Circuit breaker: Pauses all dispatch when error rate exceeds threshold. + Three states (closed/open/half_open) with exponential cooldown backoff. + - Per-fingerprint rate limiting: Exponential backoff per error fingerprint + to prevent repeated dispatch of the same known error. + +Architecture: + Module (medic.py) orchestrates, this handler manages error records. + Errors are normalized, fingerprinted (SHA1), and stored as structured + entries in error_registry.json. Circuit breaker and rate limiting state + is held in-memory (resets on process restart). + +Storage: + /home/aipass/aipass_core/trigger/trigger_json/error_registry.json + Format: { + "errors": {fingerprint: ErrorEvent_as_dict, ...}, + "metadata": {"version": "2.0.0", "last_updated": "..."} + } +""" + +import hashlib +import json +import re +import time +import uuid +from dataclasses import asdict, dataclass, field +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, List, Optional + + +from aipass.prax.apps.modules.logger import get_direct_logger +from aipass.trigger.apps.config import TRIGGER_ROOT + +logger = get_direct_logger() +REGISTRY_FILE = TRIGGER_ROOT / "trigger_json" / "error_registry.json" +TRIGGER_CONFIG_FILE = TRIGGER_ROOT / "trigger_json" / "trigger_config.json" + +VALID_STATUSES = ('new', 'investigating', 'suppressed', 'resolved') +VALID_SEVERITIES = ('low', 'medium', 'high', 'critical') +VALID_FIX_STATUSES = ('none', 'pending_fix', 'fix_requested', 'fix_confirmed') + +# Patterns that indicate user errors (bad commands, wrong args) rather +# than system failures. Matched case-insensitively against the raw message. +_USER_ERROR_PATTERNS: re.Pattern = re.compile( + r'unknown command|invalid argument|unrecognized|' + r'missing required argument|usage:|no such command|' + r'not a valid command|unrecognized option|' + r'unknown option|too few arguments|too many arguments', + re.IGNORECASE, +) + + +@dataclass +class ErrorEvent: + """Structured error entry for the registry. + + Attributes: + id: Short UUID4 identifier (first 8 chars) + fingerprint: SHA1 of normalized error_type + message + component + error_type: Error class name (e.g., 'ImportError', 'ConnectionError') + message: Original error message text + normalized_message: Message stripped of variable data + component: Branch/module that generated the error + severity: Error severity level (low, medium, high, critical) + count: Number of times this fingerprint was seen + status: Current tracking status (new, investigating, suppressed, resolved) + first_seen: ISO timestamp of first occurrence + last_seen: ISO timestamp of most recent occurrence + log_path: Source log file path + suppress_reason: Why this error was suppressed (optional) + source_fix_status: Fix tracking (none, pending_fix, fix_requested, fix_confirmed) + """ + id: str = field(default_factory=lambda: uuid.uuid4().hex[:8]) + fingerprint: str = "" + error_type: str = "" + message: str = "" + normalized_message: str = "" + component: str = "" + severity: str = "medium" + count: int = 1 + status: str = "new" + first_seen: str = field(default_factory=lambda: datetime.now().isoformat()) + last_seen: str = field(default_factory=lambda: datetime.now().isoformat()) + log_path: str = "" + suppress_reason: str = "" + source_fix_status: str = "none" + + +@dataclass +class CircuitBreakerState: + """Circuit breaker state tracking. + + Three states control dispatch flow: + - closed: Normal operation, all dispatches allowed + - open: Paused, no dispatches until cooldown expires + - half_open: Testing, allows one dispatch to probe recovery + """ + state: str = "closed" # closed, open, half_open + opened_at: float = 0.0 # time.time() when breaker opened + cooldown_seconds: int = 300 # Current cooldown (5 min default) + base_cooldown: int = 300 # Base cooldown for reset + max_cooldown: int = 3600 # Max cooldown (1 hour) + recent_errors: list = field(default_factory=list) # timestamps of recent new errors + trip_threshold: int = 10 # Errors per window to trip + trip_window_seconds: int = 60 # Window to count errors in + summary_sent: bool = False # Whether summary notification sent for current open state + half_open_allow: bool = True # Whether the half_open probe dispatch is still available + + +# --------------------------------------------------------------------------- +# Circuit Breaker Persistence +# --------------------------------------------------------------------------- + +def _save_circuit_breaker_state() -> None: + """Persist circuit breaker state to trigger_config.json. + + Saves the current breaker state, opened_at timestamp, and cooldown + under the 'circuit_breaker' key so the state survives process restarts. + """ + try: + data: Dict[str, Any] = {} + if TRIGGER_CONFIG_FILE.exists(): + data = json.loads(TRIGGER_CONFIG_FILE.read_text(encoding='utf-8')) + data['circuit_breaker'] = { + 'state': _circuit_breaker.state, + 'opened_at': _circuit_breaker.opened_at, + 'cooldown_seconds': _circuit_breaker.cooldown_seconds, + } + TRIGGER_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + TRIGGER_CONFIG_FILE.write_text( + json.dumps(data, indent=2), encoding='utf-8' + ) + except Exception as exc: + logger.warning("Failed to save circuit breaker state: %s", exc) + + +def _load_circuit_breaker_state() -> CircuitBreakerState: + """Load persisted circuit breaker state from trigger_config.json. + + If a valid persisted state exists, restores it into a new + CircuitBreakerState. Otherwise returns a fresh default instance. + + Returns: + CircuitBreakerState populated from disk or defaults. + """ + try: + if TRIGGER_CONFIG_FILE.exists(): + data = json.loads(TRIGGER_CONFIG_FILE.read_text(encoding='utf-8')) + cb_data = data.get('circuit_breaker') + if isinstance(cb_data, dict) and cb_data.get('state') in ('closed', 'open', 'half_open'): + breaker = CircuitBreakerState() + breaker.state = cb_data['state'] + breaker.opened_at = float(cb_data.get('opened_at', 0.0)) + breaker.cooldown_seconds = int(cb_data.get('cooldown_seconds', breaker.base_cooldown)) + return breaker + except Exception as exc: + logger.warning("Failed to load circuit breaker state: %s", exc) + return CircuitBreakerState() + + +def _clear_circuit_breaker_state() -> None: + """Remove persisted circuit breaker state from trigger_config.json. + + Deletes the 'circuit_breaker' key so restarts start with a clean slate. + """ + try: + if TRIGGER_CONFIG_FILE.exists(): + data = json.loads(TRIGGER_CONFIG_FILE.read_text(encoding='utf-8')) + if 'circuit_breaker' in data: + del data['circuit_breaker'] + TRIGGER_CONFIG_FILE.write_text( + json.dumps(data, indent=2), encoding='utf-8' + ) + except Exception as exc: + logger.warning("Failed to clear circuit breaker state: %s", exc) + + +# Module-level circuit breaker state (restored from disk if available) +_circuit_breaker = _load_circuit_breaker_state() + +# Module-level dicts for per-fingerprint dispatch tracking +_fingerprint_dispatch_times: Dict[str, List[float]] = {} # fingerprint -> [dispatch_timestamps] +_fingerprint_dispatch_count: Dict[str, int] = {} # fingerprint -> total dispatch count + + +# --------------------------------------------------------------------------- +# Circuit Breaker +# --------------------------------------------------------------------------- + +def circuit_breaker_allows() -> bool: + """Check if the circuit breaker allows dispatch. + + Three states: + - Closed (normal): All dispatches allowed. Records are checked against + trip_threshold to determine if breaker should open. + - Open (paused): No dispatches. Transitions to half_open after cooldown + period expires. + - Half-Open (testing): Allow ONE dispatch to test recovery. If it + resolves, caller should reset to Closed. If another error comes, + circuit_breaker_record_error() will re-open with doubled cooldown. + + Returns: + True if dispatch is allowed, False if breaker is blocking + """ + global _circuit_breaker + now = time.time() + + if _circuit_breaker.state == "closed": + return True + + if _circuit_breaker.state == "open": + elapsed = now - _circuit_breaker.opened_at + if elapsed >= _circuit_breaker.cooldown_seconds: + # Cooldown expired - transition to half_open + # This call IS the probe dispatch, so mark probe as used + _circuit_breaker.state = "half_open" + _circuit_breaker.half_open_allow = False + _circuit_breaker.summary_sent = False + return True + return False + + if _circuit_breaker.state == "half_open": + if _circuit_breaker.half_open_allow: + _circuit_breaker.half_open_allow = False + return True + return False + + return False + + +def circuit_breaker_record_error() -> None: + """Record a new error occurrence for circuit breaker tracking. + + Adds the current timestamp to recent_errors. Prunes errors outside + the trip_window_seconds window. If the count of recent errors exceeds + trip_threshold, trips the breaker to open state. + + In half_open state, any error re-opens the breaker with doubled + cooldown (up to max_cooldown). + """ + global _circuit_breaker + now = time.time() + + if _circuit_breaker.state == "half_open": + # Error during probe - re-open with doubled cooldown + new_cooldown = min( + _circuit_breaker.cooldown_seconds * 2, + _circuit_breaker.max_cooldown + ) + _circuit_breaker.state = "open" + _circuit_breaker.opened_at = now + _circuit_breaker.cooldown_seconds = new_cooldown + _circuit_breaker.summary_sent = False + _circuit_breaker.recent_errors = [now] + _save_circuit_breaker_state() + return + + # Add timestamp and prune old entries + _circuit_breaker.recent_errors.append(now) + cutoff = now - _circuit_breaker.trip_window_seconds + _circuit_breaker.recent_errors = [ + t for t in _circuit_breaker.recent_errors if t >= cutoff + ] + + # Check if threshold exceeded -> trip + if len(_circuit_breaker.recent_errors) >= _circuit_breaker.trip_threshold: + circuit_breaker_trip(reason="threshold_exceeded") + + +def circuit_breaker_trip(reason: str = "") -> None: + """Manually trip the circuit breaker to open state. + + Sets state to open with the current cooldown_seconds. Resets + summary_sent flag so a new summary can be generated. + Persists state to trigger_config.json for restart survival. + + Args: + reason: Optional reason string for logging/diagnostics + """ + global _circuit_breaker + _circuit_breaker.state = "open" + _circuit_breaker.opened_at = time.time() + _circuit_breaker.summary_sent = False + _save_circuit_breaker_state() + + +def circuit_breaker_reset() -> None: + """Reset circuit breaker to closed state. + + Restores all state to defaults: closed state, base cooldown, + empty recent_errors list, and cleared flags. + Clears persisted state from trigger_config.json. + """ + global _circuit_breaker + _circuit_breaker.state = "closed" + _circuit_breaker.opened_at = 0.0 + _circuit_breaker.cooldown_seconds = _circuit_breaker.base_cooldown + _circuit_breaker.recent_errors = [] + _circuit_breaker.summary_sent = False + _circuit_breaker.half_open_allow = True + _clear_circuit_breaker_state() + + +def get_circuit_breaker_status() -> dict: + """Get current circuit breaker state as a dictionary. + + Returns: + Dict with keys: state, opened_at, cooldown_seconds, + recent_error_count, summary_sent + """ + return { + "state": _circuit_breaker.state, + "opened_at": _circuit_breaker.opened_at, + "cooldown_seconds": _circuit_breaker.cooldown_seconds, + "recent_error_count": len(_circuit_breaker.recent_errors), + "summary_sent": _circuit_breaker.summary_sent, + } + + +# --------------------------------------------------------------------------- +# Per-Fingerprint Rate Limiting (Exponential Backoff) +# --------------------------------------------------------------------------- + +def get_backoff_seconds(dispatch_count: int) -> int: + """Calculate backoff duration based on dispatch count. + + Backoff schedule: + - 0 previous dispatches: 0 seconds (dispatch immediately) + - 1 previous dispatch: 300 seconds (5 minutes) + - 2 previous dispatches: 900 seconds (15 minutes) + - 3 previous dispatches: 2700 seconds (45 minutes) + - 4+ previous dispatches: 7200 seconds (2 hours) + + Args: + dispatch_count: How many times this fingerprint has been dispatched + + Returns: + Seconds to wait before next dispatch + """ + if dispatch_count <= 0: + return 0 + if dispatch_count == 1: + return 300 + if dispatch_count == 2: + return 900 + if dispatch_count == 3: + return 2700 + return 7200 + + +def should_dispatch(fingerprint: str) -> bool: + """Check if this fingerprint should be dispatched based on exponential backoff. + + Uses the dispatch history for this fingerprint to determine if enough + time has elapsed since the last dispatch according to the backoff + schedule. + + Args: + fingerprint: Error fingerprint to check + + Returns: + True if enough time has passed since last dispatch for this fingerprint + """ + count = _fingerprint_dispatch_count.get(fingerprint, 0) + if count == 0: + return True + + times = _fingerprint_dispatch_times.get(fingerprint, []) + if not times: + return True + + last_dispatch = max(times) + backoff = get_backoff_seconds(count) + elapsed = time.time() - last_dispatch + + return elapsed >= backoff + + +def record_dispatch(fingerprint: str) -> None: + """Record that a dispatch was sent for this fingerprint. + + Appends the current timestamp to the dispatch history and increments + the total dispatch count for this fingerprint. + + Args: + fingerprint: Error fingerprint that was dispatched + """ + now = time.time() + if fingerprint not in _fingerprint_dispatch_times: + _fingerprint_dispatch_times[fingerprint] = [] + _fingerprint_dispatch_times[fingerprint].append(now) + _fingerprint_dispatch_count[fingerprint] = \ + _fingerprint_dispatch_count.get(fingerprint, 0) + 1 + + +# --------------------------------------------------------------------------- +# Fingerprinting +# --------------------------------------------------------------------------- + +def normalize_message(message: str) -> str: + """Normalize an error message by stripping variable data. + + Strips line numbers, timestamps, absolute paths, UUIDs, hex hashes, + port numbers, and numeric IDs to produce a stable message for + fingerprinting. + + Args: + message: Raw error message text + + Returns: + Normalized message with variable data replaced by placeholders + """ + normalized = message + + # Strip ISO timestamps (2026-02-13T10:30:45.123456, 2026-02-13 10:30:45) + normalized = re.sub( + r'\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:[.,]\d+)?(?:[+-]\d{2}:?\d{2}|Z)?', + '', + normalized + ) + + # Strip date-only patterns (2026-02-13) + normalized = re.sub(r'\d{4}-\d{2}-\d{2}', '', normalized) + + # Strip absolute paths (/home/aipass/... or any /path/to/something) + normalized = re.sub(r'/[\w./-]+', '', normalized) + + # Strip line numbers ("line 42" -> "line N") + normalized = re.sub(r'\bline \d+\b', 'line N', normalized, flags=re.IGNORECASE) + + # Strip UUIDs (8-4-4-4-12 hex format) + normalized = re.sub( + r'[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}', + '', + normalized + ) + + # Strip hex hashes (8+ hex chars that look like hashes) + normalized = re.sub(r'\b[0-9a-fA-F]{8,}\b', '', normalized) + + # Strip port numbers (:8080, :3000, :443) + normalized = re.sub(r':\d{2,5}\b', ':', normalized) + + # Strip standalone numeric IDs (pure numbers 3+ digits) + normalized = re.sub(r'\b\d{3,}\b', '', normalized) + + # Collapse multiple spaces + normalized = re.sub(r'\s+', ' ', normalized).strip() + + return normalized + + +def compute_fingerprint(error_type: str, normalized_msg: str, component: str) -> str: + """Compute a SHA1 fingerprint for error deduplication. + + Creates a deterministic hash from the combination of error type, + normalized message, and component. This allows the same logical + error from the same component to be recognized across occurrences. + + Args: + error_type: Error class name (e.g., 'ImportError') + normalized_msg: Message after normalize_message() processing + component: Branch/module identifier (e.g., 'FLOW') + + Returns: + Full SHA1 hex digest (40 chars). Use [:12] for display. + """ + content = f"{error_type}:{normalized_msg}:{component}" + return hashlib.sha1(content.encode('utf-8')).hexdigest() + + +# --------------------------------------------------------------------------- +# Registry I/O +# --------------------------------------------------------------------------- + +def _load_registry() -> dict: + """Load the error registry from disk. + + Returns: + Registry dict with 'errors' and 'metadata' keys. + Returns empty registry structure on read failure. + """ + try: + if REGISTRY_FILE.exists(): + data = json.loads(REGISTRY_FILE.read_text(encoding='utf-8')) + if isinstance(data, dict) and 'errors' in data: + return data + except Exception: + pass + return { + "errors": {}, + "metadata": { + "version": "1.0.0", + "last_updated": datetime.now().isoformat() + } + } + + +def _save_registry(data: dict) -> bool: + """Save the error registry to disk. + + Args: + data: Full registry dict to persist + + Returns: + True on success, False on failure + """ + try: + data["metadata"]["last_updated"] = datetime.now().isoformat() + REGISTRY_FILE.parent.mkdir(parents=True, exist_ok=True) + REGISTRY_FILE.write_text( + json.dumps(data, indent=2), encoding='utf-8' + ) + return True + except Exception as exc: + logger.warning("Failed to save error registry to %s: %s", REGISTRY_FILE, exc) + return False + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + +def report( + error_type: str, + message: str, + component: str, + log_path: str = "", + severity: str = "medium" +) -> dict: + """Report an error to the registry. + + Normalizes the message, computes a fingerprint, and either creates + a new entry or increments the count on an existing one. + + Args: + error_type: Error class name (e.g., 'ImportError', 'ConnectionError') + message: Original error message text + component: Branch/module that generated the error (e.g., 'FLOW') + log_path: Path to source log file (optional) + severity: Error severity - low, medium, high, critical (default: medium) + + Returns: + Dict of the error entry with an additional 'is_new' bool flag. + Returns minimal error dict on failure. + """ + try: + # Validate severity + if severity not in VALID_SEVERITIES: + severity = "medium" + + normalized = normalize_message(message) + fingerprint = compute_fingerprint(error_type, normalized, component) + + # User-error rejection: errors that look like bad commands or + # invalid arguments are auto-suppressed to avoid noisy dispatch. + is_user_error = bool(_USER_ERROR_PATTERNS.search(message)) + + registry = _load_registry() + now = datetime.now().isoformat() + + if fingerprint in registry["errors"]: + # Existing error - increment count and update last_seen + entry = registry["errors"][fingerprint] + entry["count"] = entry.get("count", 1) + 1 + entry["last_seen"] = now + # Update log_path if provided (might be from a different log file) + if log_path: + entry["log_path"] = log_path + # Auto-suppress user errors that were previously unsuppressed + if is_user_error and entry.get("status") != "suppressed": + entry["status"] = "suppressed" + entry["suppress_reason"] = "user_error" + _save_registry(registry) + result = dict(entry) + result["is_new"] = False + return result + else: + # New error - create entry + initial_status = "suppressed" if is_user_error else "new" + initial_suppress_reason = "user_error" if is_user_error else "" + + event = ErrorEvent( + fingerprint=fingerprint, + error_type=error_type, + message=message, + normalized_message=normalized, + component=component, + severity=severity, + count=1, + status=initial_status, + first_seen=now, + last_seen=now, + log_path=log_path, + suppress_reason=initial_suppress_reason, + source_fix_status="none" + ) + entry_dict = asdict(event) + registry["errors"][fingerprint] = entry_dict + _save_registry(registry) + result = dict(entry_dict) + result["is_new"] = True + return result + + except Exception: + return { + "error_type": error_type, + "message": message, + "component": component, + "is_new": True, + "status": "new", + "count": 1 + } + + +def query( + status: Optional[str] = None, + component: Optional[str] = None, + severity: Optional[str] = None, + limit: int = 50 +) -> list: + """Query the error registry with optional filters. + + Filters errors by any combination of status, component, and severity. + Results are sorted by last_seen descending (most recent first). + + Args: + status: Filter by status (new, investigating, suppressed, resolved) + component: Filter by component/branch name + severity: Filter by severity (low, medium, high, critical) + limit: Maximum number of results to return (default: 50) + + Returns: + List of matching error entry dicts, sorted by last_seen descending + """ + try: + registry = _load_registry() + entries = list(registry["errors"].values()) + + # Apply filters + if status is not None: + entries = [e for e in entries if e.get("status") == status] + if component is not None: + entries = [e for e in entries + if e.get("component", "").upper() == component.upper()] + if severity is not None: + entries = [e for e in entries if e.get("severity") == severity] + + # Sort by last_seen descending + entries.sort(key=lambda e: e.get("last_seen", ""), reverse=True) + + return entries[:limit] + + except Exception: + return [] + + +def update_status(fingerprint: str, new_status: str, reason: str = "") -> bool: + """Update the status of an error entry. + + Supports the lifecycle: new -> investigating -> resolved/suppressed. + If suppressing, stores the reason in suppress_reason. + + Args: + fingerprint: Full or prefix fingerprint to match + new_status: Target status (new, investigating, suppressed, resolved) + reason: Reason for status change (stored in suppress_reason if suppressing) + + Returns: + True on success, False if fingerprint not found or invalid status + """ + try: + if new_status not in VALID_STATUSES: + return False + + registry = _load_registry() + entry = _find_entry(registry, fingerprint) + + if entry is None: + return False + + entry["status"] = new_status + if new_status == "suppressed" and reason: + entry["suppress_reason"] = reason + elif reason: + entry["suppress_reason"] = reason + + return _save_registry(registry) + + except Exception: + return False + + +def get_entry(fingerprint: str) -> Optional[dict]: + """Get a single error entry by fingerprint or prefix. + + Supports prefix matching - if the provided string is shorter than + 40 chars, matches against the beginning of stored fingerprints. + + Args: + fingerprint: Full fingerprint (40 chars) or prefix to match + + Returns: + Error entry dict, or None if not found + """ + try: + registry = _load_registry() + return _find_entry(registry, fingerprint) + except Exception: + return None + + +def clear_resolved(days: int = 7) -> int: + """Remove resolved entries older than N days. + + Cleans up the registry by removing entries with status='resolved' + whose last_seen timestamp is older than the specified number of days. + + Args: + days: Age threshold in days (default: 7) + + Returns: + Count of removed entries + """ + try: + registry = _load_registry() + cutoff = (datetime.now() - timedelta(days=days)).isoformat() + removed = 0 + + fingerprints_to_remove = [] + for fp, entry in registry["errors"].items(): + if entry.get("status") == "resolved": + last_seen = entry.get("last_seen", "") + if last_seen and last_seen < cutoff: + fingerprints_to_remove.append(fp) + + for fp in fingerprints_to_remove: + del registry["errors"][fp] + removed += 1 + + if removed > 0: + _save_registry(registry) + + return removed + + except Exception: + return 0 + + +def get_stats() -> dict: + """Get summary statistics from the error registry. + + Returns: + Dict with: + - total: Total number of tracked errors + - by_status: Count per status (new, investigating, etc.) + - by_component: Count per component/branch + - by_severity: Count per severity level + """ + try: + registry = _load_registry() + entries = list(registry["errors"].values()) + + by_status: Dict[str, int] = {} + by_component: Dict[str, int] = {} + by_severity: Dict[str, int] = {} + + for entry in entries: + status = entry.get("status", "unknown") + by_status[status] = by_status.get(status, 0) + 1 + + component = entry.get("component", "unknown") + by_component[component] = by_component.get(component, 0) + 1 + + severity = entry.get("severity", "unknown") + by_severity[severity] = by_severity.get(severity, 0) + 1 + + return { + "total": len(entries), + "by_status": by_status, + "by_component": by_component, + "by_severity": by_severity + } + + except Exception: + return { + "total": 0, + "by_status": {}, + "by_component": {}, + "by_severity": {} + } + + +def update_source_fix_status(fingerprint: str, fix_status: str) -> bool: + """Update the source fix status of an error entry. + + Tracks whether the source branch has been notified and fixed + the underlying issue. + + Args: + fingerprint: Full or prefix fingerprint to match + fix_status: One of: none, pending_fix, fix_requested, fix_confirmed + + Returns: + True on success + """ + if fix_status not in VALID_FIX_STATUSES: + return False + try: + registry = _load_registry() + entry = _find_entry(registry, fingerprint) + if entry is None: + return False + entry["source_fix_status"] = fix_status + return _save_registry(registry) + except Exception: + return False + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +def _find_entry(registry: dict, fingerprint: str) -> Optional[dict]: + """Find an entry by exact fingerprint or prefix match. + + Args: + registry: Loaded registry dict + fingerprint: Full fingerprint or prefix + + Returns: + Reference to the entry dict within the registry, or None + """ + # Exact match first + if fingerprint in registry["errors"]: + return registry["errors"][fingerprint] + + # Prefix match (for short fingerprint lookups like [:12]) + if len(fingerprint) < 40: + for fp, entry in registry["errors"].items(): + if fp.startswith(fingerprint): + return entry + + return None diff --git a/src/aipass/trigger/apps/handlers/events/__init__.py b/src/aipass/trigger/apps/handlers/events/__init__.py new file mode 100644 index 00000000..dc7f278e --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/__init__.py @@ -0,0 +1 @@ +"""Event handlers package - Empty init file""" diff --git a/src/aipass/trigger/apps/handlers/events/bulletin_created.py b/src/aipass/trigger/apps/handlers/events/bulletin_created.py new file mode 100644 index 00000000..d418211a --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/bulletin_created.py @@ -0,0 +1,236 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: bulletin_created.py - Bulletin Created Event Handler +# Date: 2026-01-31 +# Version: 1.0.0 +# Category: trigger/handlers/events +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-01-31): Created - Phase 3 migration (FPLAN-0280) +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - NO logger imports (causes infinite recursion in event handlers) +# - NO print statements (handlers must be silent) +# - Silent failure - catch all exceptions, pass +# ============================================= + +""" +Bulletin Created Event Handler + +Handles bulletin_created events fired when a new bulletin is created. +Propagates the bulletin to all branch dashboards. + +Event data expected: + - bulletin_id: ID of the created bulletin + - title: Bulletin title + - message: Bulletin content + - priority: Bulletin priority level + - created_by: Who created it + - timestamp: When created +""" + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List + +AIPASS_HOME = Path.home() + +# Paths +BRANCH_REGISTRY = AIPASS_HOME / "BRANCH_REGISTRY.json" +BULLETINS_PATH = AIPASS_HOME / "aipass_os" / "AI_CENTRAL" / "BULLETINS.central.json" + + +def _load_branch_registry() -> List[Dict]: + """ + Load branch registry silently. + + Returns: + List of branch dicts with name, path, status. + Empty list on any error. + """ + try: + if not BRANCH_REGISTRY.exists(): + return [] + data = json.loads(BRANCH_REGISTRY.read_text()) + return data.get("branches", []) + except Exception: + return [] + + +def _load_bulletins() -> List[Dict]: + """ + Load bulletins from central storage. + + Returns: + List of bulletin dicts. Empty list on error. + """ + try: + if not BULLETINS_PATH.exists(): + return [] + data = json.loads(BULLETINS_PATH.read_text()) + return data.get("bulletins", []) + except Exception: + return [] + + +def _filter_active_bulletins(bulletins: List[Dict]) -> List[Dict]: + """ + Filter bulletins to only active ones. + + Args: + bulletins: List of all bulletins + + Returns: + List of active bulletins only + """ + return [b for b in bulletins if b.get("active", False)] + + +def _load_dashboard(branch_path: Path) -> Dict: + """ + Load existing dashboard or create minimal structure. + + Args: + branch_path: Path to branch root + + Returns: + Dashboard data dict (existing or minimal structure) + """ + dashboard_path = branch_path / "DASHBOARD.local.json" + + if dashboard_path.exists(): + try: + data = json.loads(dashboard_path.read_text()) + if "sections" not in data: + data["sections"] = {} + if "bulletin_board" not in data["sections"]: + data["sections"]["bulletin_board"] = { + "managed_by": "aipass", + "active_bulletins": [], + "pending_ack": [] + } + return data + except Exception: + pass + + # Create minimal dashboard structure + return { + "last_refreshed": datetime.now().strftime("%Y-%m-%d %H:%M:%S"), + "sections": { + "bulletin_board": { + "managed_by": "aipass", + "active_bulletins": [], + "pending_ack": [] + } + } + } + + +def _save_dashboard(branch_path: Path, dashboard: Dict) -> bool: + """ + Save dashboard to branch. + + Args: + branch_path: Path to branch root + dashboard: Dashboard data to save + + Returns: + True if saved, False on error + """ + try: + dashboard_path = branch_path / "DASHBOARD.local.json" + dashboard["last_refreshed"] = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + dashboard_path.write_text(json.dumps(dashboard, indent=2)) + return True + except Exception: + return False + + +def _propagate_bulletins_to_branches() -> None: + """ + Propagate active bulletins to all branch dashboards. + + Loads active bulletins and updates each branch's dashboard + with the bulletin_board section. + + Silent failure - catches all exceptions. + """ + try: + # Load branch registry + branches = _load_branch_registry() + if not branches: + return + + # Load and filter active bulletins + all_bulletins = _load_bulletins() + active_bulletins = _filter_active_bulletins(all_bulletins) + + # Update each branch dashboard + for branch in branches: + branch_path_str = branch.get("path") + if not branch_path_str: + continue + + branch_path = Path(branch_path_str) + if not branch_path.exists(): + continue + + try: + # Load dashboard + dashboard = _load_dashboard(branch_path) + + # Update bulletin_board section ONLY + if "sections" not in dashboard: + dashboard["sections"] = {} + + dashboard["sections"]["bulletin_board"] = { + "managed_by": "aipass", + "active_bulletins": active_bulletins, + "pending_ack": [] + } + + # Save dashboard + _save_dashboard(branch_path, dashboard) + except Exception: + continue + + except Exception: + pass + + +def handle_bulletin_created( + _bulletin_id: str | None = None, + _title: str | None = None, + _message: str | None = None, + _priority: str | None = None, + _created_by: str | None = None, + _timestamp: str | None = None, + **_kwargs: Any +) -> None: + """ + Handle bulletin_created event - propagate bulletin to all branch dashboards. + + Event parameters are received but not used directly - we reload from + central storage to ensure consistency with any concurrent updates. + + Args: + _bulletin_id: ID of the created bulletin (unused - reload from storage) + _title: Bulletin title (unused - reload from storage) + _message: Bulletin content (unused - reload from storage) + _priority: Bulletin priority level (unused - reload from storage) + _created_by: Who created it (unused - reload from storage) + _timestamp: When created (unused - reload from storage) + **_kwargs: Additional event data (ignored) + """ + try: + # Propagate bulletins to all branches + # We reload from central storage to ensure consistency + # (the newly created bulletin should already be saved there) + _propagate_bulletins_to_branches() + + except Exception: + pass diff --git a/src/aipass/trigger/apps/handlers/events/cli.py b/src/aipass/trigger/apps/handlers/events/cli.py new file mode 100644 index 00000000..64058726 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/cli.py @@ -0,0 +1,25 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: cli.py - CLI Event Handler +# Date: 2025-12-04 +# Version: 0.1.0 +# Category: trigger/handlers/events +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2025-12-04): Created CLI event handler +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - Handlers must not import Prax logger +# ============================================= + +"""CLI Event Handler - Handle CLI display events""" + + +def handle_cli_header_displayed(**kwargs): + """Handle cli_header_displayed event - logs when CLI displays headers""" + # Handlers cannot use logger or print - event is already logged by core.py + # This handler exists to demonstrate event registration works + pass diff --git a/src/aipass/trigger/apps/handlers/events/error_detected.py b/src/aipass/trigger/apps/handlers/events/error_detected.py new file mode 100644 index 00000000..8c951131 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/error_detected.py @@ -0,0 +1,554 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: error_detected.py - Error Detected Event Handler +# Date: 2026-02-14 +# Version: 2.1.0 +# Category: trigger/handlers/events +# +# CHANGELOG (Max 5 entries): +# - v2.1.0 (2026-02-14): Dispatch threshold - count >= 2 required (skip first occurrence) +# - v2.0.0 (2026-02-13): Medic v2 Phase 3 - Circuit breaker + per-fingerprint backoff dispatch +# - v1.3.0 (2026-02-12): Medic Phase 2 - per-branch mute/unmute check +# - v1.2.0 (2026-02-12): Medic toggle - check medic_enabled before dispatching +# - v1.1.1 (2026-02-10): Add Seed standards reminder to error dispatch template +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - NO console.print() - handlers return data to modules +# - NO logger calls in handler (causes recursion with trigger) +# - Silent failure pattern - catch all exceptions +# - Responds to error_detected events from Trigger's log_watcher +# - Dispatch threshold: count >= 2 required (first occurrence is silent) +# - Dispatch gating: circuit breaker (global) + per-fingerprint backoff (Medic v2) +# ============================================= + +""" +Error Detected Event Consumer + +Handles error_detected events fired by Trigger's log_watcher. +Delivers notifications to affected branches via AI_MAIL. + +Event data from log_watcher.py (Medic v2): + - branch: Target branch name (e.g., 'FLOW') + - module: Module that logged the error + - message: Error message text + - log_path: Path to log file + - error_hash: Short ID from registry (or legacy 8-char MD5) + - timestamp: When error occurred + - fingerprint: SHA1 fingerprint from error_registry (Medic v2) + - registry_id: Short UUID from error_registry (Medic v2) + - first_seen: ISO timestamp of first occurrence (Medic v2) + - last_seen: ISO timestamp of most recent occurrence (Medic v2) + +Architecture (Medic v2): + 1. Trigger's log_watcher detects ERROR in branch logs + 2. log_watcher reports to error_registry, fires error_detected if new + 3. This handler checks circuit_breaker_allows() (global gate) + 4. This handler checks should_dispatch(fingerprint) (per-error backoff) + 5. If allowed, delivers email to affected branch (auto_execute=True) + 6. Records dispatch via record_dispatch() and circuit_breaker_record_error() +""" + +import json +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional +from aipass.trigger.apps.config import TRIGGER_ROOT + +AIPASS_HOME = Path.home() + +BRANCH_REGISTRY_FILE = AIPASS_HOME / "BRANCH_REGISTRY.json" +TRIGGER_CONFIG_FILE = TRIGGER_ROOT / "trigger_json" / "trigger_config.json" + +# Email send callback (set by module layer, avoids handler importing from modules) +_send_email: Optional[Callable[..., bool]] = None + +# Try to import error_registry for Medic v2 circuit breaker + per-fingerprint backoff +try: + from aipass.trigger.apps.handlers.error_registry import ( + circuit_breaker_allows, + circuit_breaker_record_error, + should_dispatch as registry_should_dispatch, + record_dispatch as registry_record_dispatch, + ) + _REGISTRY_DISPATCH_AVAILABLE = True +except ImportError: + _REGISTRY_DISPATCH_AVAILABLE = False + +# Legacy rate limiting (kept for backward compat when registry unavailable) +_dispatch_timestamps: Dict[str, List[float]] = {} +MAX_DISPATCHES_PER_WINDOW = 3 +RATE_LIMIT_WINDOW_SECONDS = 600 # 10 minutes + + +def _is_medic_enabled() -> bool: + """ + Check if medic (auto-healing dispatch) is enabled. + + Reads medic_enabled from trigger_config.json. + Defaults to True if config is missing or unreadable. + + Returns: + True if medic dispatch is enabled + """ + try: + if TRIGGER_CONFIG_FILE.exists(): + data = json.loads(TRIGGER_CONFIG_FILE.read_text(encoding='utf-8')) + return bool(data.get('config', {}).get('medic_enabled', True)) + except Exception: + return True # Default to enabled on read failure + return True + + +def _is_branch_muted(branch_name: str) -> bool: + """ + Check if a specific branch is muted for medic dispatch. + + Reads muted_branches list from trigger_config.json. + Muted branches have errors detected but NOT dispatched. + + Args: + branch_name: Branch name (case-insensitive) + + Returns: + True if branch is in the muted list + """ + try: + if TRIGGER_CONFIG_FILE.exists(): + data = json.loads(TRIGGER_CONFIG_FILE.read_text(encoding='utf-8')) + muted = data.get('config', {}).get('muted_branches', []) + return branch_name.lower() in [b.lower() for b in muted] + except Exception: + return False + return False + + +def set_send_email_callback(callback: Callable[..., bool]) -> None: + """ + Set the callback function for sending emails. + + Must be called by the module/registry layer before events fire. + This avoids handler importing from modules (maintains independence). + + Args: + callback: Function matching send_email_direct signature + """ + global _send_email + _send_email = callback + + +def _get_registered_emails() -> set: + """ + Read registered branch emails from BRANCH_REGISTRY.json. + + Returns: + Set of registered email addresses (e.g., {'@flow', '@drone'}) + """ + try: + if BRANCH_REGISTRY_FILE.exists(): + data = json.loads(BRANCH_REGISTRY_FILE.read_text(encoding='utf-8')) + return {b["email"] for b in data.get("branches", [])} + except Exception: + return set() + return set() + + +def _is_rate_limited(branch_email: str) -> bool: + """ + Check if a branch has exceeded the dispatch rate limit. + + BACKWARD COMPAT: Legacy Medic v1 per-branch rate limiting. + Primary dispatch gating is now circuit_breaker_allows() + + should_dispatch(fingerprint) from error_registry (Medic v2). + + Args: + branch_email: Target branch email (e.g., '@flow') + + Returns: + True if branch has hit the limit (3 dispatches in 10 minutes) + """ + now = time.time() + cutoff = now - RATE_LIMIT_WINDOW_SECONDS + + if branch_email not in _dispatch_timestamps: + _dispatch_timestamps[branch_email] = [] + + # Prune old timestamps + _dispatch_timestamps[branch_email] = [ + ts for ts in _dispatch_timestamps[branch_email] if ts > cutoff + ] + + return len(_dispatch_timestamps[branch_email]) >= MAX_DISPATCHES_PER_WINDOW + + +def _record_dispatch(branch_email: str) -> None: + """ + Record a dispatch timestamp for rate limiting. + + BACKWARD COMPAT: Legacy Medic v1 per-branch dispatch tracking. + Primary tracking is now record_dispatch(fingerprint) from error_registry (Medic v2). + + Args: + branch_email: Target branch email (e.g., '@flow') + """ + if branch_email not in _dispatch_timestamps: + _dispatch_timestamps[branch_email] = [] + _dispatch_timestamps[branch_email].append(time.time()) + + +def _read_log_context(log_path: str, error_message: str, context_lines: int = 2) -> str: + """ + Read context lines around an error in the log file. + + Args: + log_path: Path to the log file + error_message: Error message to find in the log + context_lines: Number of lines before and after to include + + Returns: + Formatted context string, or empty string if unavailable + """ + try: + path = Path(log_path) + if not path.exists(): + return "" + lines = path.read_text(encoding='utf-8', errors='ignore').splitlines() + # Find last occurrence of the error message + target_idx = -1 + for i in range(len(lines) - 1, -1, -1): + if error_message in lines[i]: + target_idx = i + break + if target_idx < 0: + return "" + start = max(0, target_idx - context_lines) + end = min(len(lines), target_idx + context_lines + 1) + context = lines[start:end] + return "\n".join(context) + except Exception: + return "" + + +def _build_notification_message( + error_hash: str, + module: str, + message: str, + timestamp: str, + log_path: str, + occurrences: int = 1, + first_seen: str = "", + last_seen: str = "", + log_context: str = "", + fingerprint: str = "", + registry_id: str = "" +) -> str: + """ + Build error notification message with investigation instructions. + + Args: + error_hash: Unique error identifier (8-char legacy or registry ID) + module: Module that logged the error + message: Error message text + timestamp: When error occurred + log_path: Path to source log file + occurrences: Number of times this error was seen + first_seen: Timestamp of first occurrence + last_seen: Timestamp of most recent occurrence + log_context: Lines surrounding the error from the log file + fingerprint: SHA1 fingerprint from error_registry (Medic v2) + registry_id: Short UUID from error_registry (Medic v2) + + Returns: + Formatted message string with investigation instructions + """ + context_block = "" + if log_context: + context_block = f""" +Log context (surrounding lines): +{log_context} +""" + + # Registry tracking info (Medic v2) + registry_block = "" + if fingerprint or registry_id: + display_fp = fingerprint[:12] if fingerprint else "n/a" + display_id = registry_id if registry_id else "n/a" + registry_block = f""" +Registry tracking: + Fingerprint: {display_fp} + Registry ID: {display_id} +""" + + return f"""Error detected - investigate and respond. + +Error ID: {error_hash} +Module: {module} +Timestamp: {timestamp} +Log file: {log_path} +Occurrences: {occurrences} +First seen: {first_seen or timestamp} +Last seen: {last_seen or timestamp} +{registry_block} +Error message: +{message} +{context_block} +--- +INVESTIGATION STEPS: +1. Check the log file for context around this error +2. Identify root cause + +DECISION TREE: +- SIMPLE FIX (typo, missing import, config issue): + -> Fix it yourself, then report what you did to @dev_central +- COMPLEX/UNCLEAR (needs research, affects multiple files): + -> Report findings only to @dev_central, recommend action, don't fix +- CRITICAL (data loss risk, security, system stability): + -> STOP immediately, escalate to @dev_central with full context + +SEED STANDARDS REMINDER: +- Any code changes made during this investigation MUST follow Seed standards +- After fixing, run: drone @seed checklist +- Fixes scoring below 80% on Seed audit should NOT be shipped - clean up first + +REPORT TO @dev_central: + ai_mail send @dev_central "ERROR {error_hash} - [STATUS]" "Findings..." + + Include: Error ID, severity (low/medium/high/critical), what you found, action taken or recommended. +""" + + +def handle_error_detected( + branch: str | None = None, + module: str | None = None, + message: str | None = None, + log_path: str | None = None, + error_hash: str | None = None, + timestamp: str | None = None, + fingerprint: str = "", + registry_id: str = "", + first_seen: str = "", + last_seen: str = "", + count: int = 1, + **kwargs: Any +) -> None: + """ + Handle error_detected event - deliver notification to affected branch. + + Called by Trigger when log_watcher detects an ERROR in branch logs. + Sends email to affected branch with auto_execute=True so an + investigation agent spawns automatically. + + Dispatch threshold: count >= 2 required before dispatching. First + occurrence (count == 1) is registered but NOT dispatched - could be + transient. Second occurrence (count >= 2) confirms a pattern and + triggers investigation dispatch. + + Medic v2 dispatch gating (when error_registry available): + 1. count >= 2 threshold (skip first occurrence) + 2. circuit_breaker_allows() - global gate, pauses all dispatch on error storm + 3. should_dispatch(fingerprint) - per-error exponential backoff + Fallback: legacy per-branch rate limiting (3 per 10 min) if registry unavailable. + + Args: + branch: Target branch name (e.g., 'FLOW') - REQUIRED + module: Module that logged the error - REQUIRED + message: Error message text - REQUIRED + log_path: Path to source log file + error_hash: Short ID from registry or legacy 8-char hash - REQUIRED + timestamp: When error occurred (defaults to now) + fingerprint: SHA1 fingerprint from error_registry (Medic v2) + registry_id: Short UUID from error_registry (Medic v2) + first_seen: ISO timestamp of first occurrence (Medic v2) + last_seen: ISO timestamp of most recent occurrence (Medic v2) + count: Registry occurrence count (default: 1). Dispatch requires >= 2. + **kwargs: Additional event data (ignored) + + Returns: + None - handlers must not return values + + Note: + Handler follows silent failure pattern - all exceptions caught. + NO logger imports (causes infinite recursion with trigger events). + NO console.print() (handlers must be silent). + """ + try: + # Validate required fields + if not branch or not module or not message or not error_hash: + return + + # Medic toggle - if disabled, log but do NOT dispatch + if not _is_medic_enabled(): + try: + suppressed_log = TRIGGER_ROOT / "logs" / "medic_suppressed.log" + suppressed_log.parent.mkdir(parents=True, exist_ok=True) + with open(suppressed_log, 'a') as f: + f.write( + f"{datetime.now().isoformat()} | " + f"Medic OFF - suppressed dispatch for {branch}: " + f"{module} - {message[:100]}\n" + ) + except Exception: + return # Can't log suppression, but still skip dispatch + return + + # Per-branch mute check - muted branches have errors logged but NOT dispatched + if _is_branch_muted(branch): + try: + suppressed_log = TRIGGER_ROOT / "logs" / "medic_suppressed.log" + suppressed_log.parent.mkdir(parents=True, exist_ok=True) + with open(suppressed_log, 'a') as f: + f.write( + f"{datetime.now().isoformat()} | " + f"Branch muted - suppressed dispatch for {branch}: " + f"{module} - {message[:100]}\n" + ) + except Exception: + return # Can't log suppression, but still skip dispatch + return + + # Dispatch threshold: count >= 2 required. First occurrence could + # be transient - only dispatch when the error recurs. + if count < 2: + try: + suppressed_log = TRIGGER_ROOT / "logs" / "medic_suppressed.log" + suppressed_log.parent.mkdir(parents=True, exist_ok=True) + with open(suppressed_log, 'a') as f: + f.write( + f"{datetime.now().isoformat()} | " + f"First occurrence (count={count}) - waiting for pattern: " + f"{branch}: {module} - {message[:100]}\n" + ) + except Exception: + return # Can't log, but still skip dispatch + return + + # Callback must be set by module layer before events fire + if _send_email is None: + return + + # Convert branch name to email format (FLOW -> @flow) + recipient = f"@{branch.lower()}" + + # HARD RULE: DEV_CENTRAL is NEVER auto-triggered + if recipient == '@dev_central': + return + + # Validate target branch exists in registry before attempting delivery + registered_emails = _get_registered_emails() + if recipient not in registered_emails: + # Unknown branch - log and skip (do NOT route to dev_central) + try: + suppressed_log = TRIGGER_ROOT / "logs" / "medic_suppressed.log" + suppressed_log.parent.mkdir(parents=True, exist_ok=True) + with open(suppressed_log, 'a') as f: + f.write( + f"{datetime.now().isoformat()} | " + f"Unknown branch skipped: {recipient} - " + f"{module}: {message[:100]}\n" + ) + except Exception: + return # Can't log skip, still don't dispatch + return + + # --- Dispatch gating --- + if _REGISTRY_DISPATCH_AVAILABLE and fingerprint: + # Medic v2: Circuit breaker (global) + per-fingerprint backoff + if not circuit_breaker_allows(): + try: + suppressed_log = TRIGGER_ROOT / "logs" / "medic_suppressed.log" + suppressed_log.parent.mkdir(parents=True, exist_ok=True) + with open(suppressed_log, 'a') as f: + f.write( + f"{datetime.now().isoformat()} | " + f"Circuit breaker OPEN - suppressed dispatch for {branch}: " + f"{module} - {message[:100]}\n" + ) + except Exception: + pass + return + + if not registry_should_dispatch(fingerprint): + try: + rate_log = TRIGGER_ROOT / "logs" / "rate_limited.log" + rate_log.parent.mkdir(parents=True, exist_ok=True) + with open(rate_log, 'a') as f: + f.write( + f"{datetime.now().isoformat()} | " + f"Backoff active for fingerprint {fingerprint[:12]}: " + f"{recipient} - {module}, skipping\n" + ) + except Exception: + pass + return + else: + # Legacy fallback: per-branch rate limiting (Medic v1) + recent_count = len([ + ts for ts in _dispatch_timestamps.get(recipient, []) + if ts > time.time() - RATE_LIMIT_WINDOW_SECONDS + ]) + if _is_rate_limited(recipient): + try: + rate_log = TRIGGER_ROOT / "logs" / "rate_limited.log" + rate_log.parent.mkdir(parents=True, exist_ok=True) + with open(rate_log, 'a') as f: + f.write( + f"{datetime.now().isoformat()} | " + f"Rate limited: {recipient} has {recent_count} " + f"recent dispatches, skipping\n" + ) + except Exception: + pass + return + + # Default timestamp to now if not provided + if not timestamp: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + # Default log_path if not provided + effective_log_path = log_path if log_path else "unknown" + + # Read log context (2 lines before and after) + error_log_context = _read_log_context(effective_log_path, message) + + # Build subject line + email_subject = f"[ERROR] {module} - detected in logs" + + # Build notification message with structured context + notification_message = _build_notification_message( + error_hash=error_hash, + module=module, + message=message, + timestamp=timestamp, + log_path=effective_log_path, + occurrences=1, + first_seen=first_seen or timestamp, + last_seen=last_seen or timestamp, + log_context=error_log_context, + fingerprint=fingerprint, + registry_id=registry_id + ) + + # Send via callback (set by module layer, trigger isn't a branch so PWD detection fails) + _send_email( + to_branch=recipient, + subject=email_subject, + message=notification_message, + auto_execute=True, + reply_to='@trigger', + from_branch='@trigger' + ) + + # Record dispatch for tracking + if _REGISTRY_DISPATCH_AVAILABLE and fingerprint: + # Medic v2: per-fingerprint dispatch tracking + circuit breaker + registry_record_dispatch(fingerprint) + circuit_breaker_record_error() + else: + # Legacy: per-branch rate limiting + _record_dispatch(recipient) + + except Exception: + return # Silent failure - handler must not raise diff --git a/src/aipass/trigger/apps/handlers/events/error_logged.py b/src/aipass/trigger/apps/handlers/events/error_logged.py new file mode 100644 index 00000000..ed6673f8 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/error_logged.py @@ -0,0 +1,325 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: error_logged.py - Error Logged Event Handler (DEPRECATED) +# Date: 2026-01-31 +# Version: 2.0.0 +# Category: trigger/handlers/events +# +# CHANGELOG (Max 5 entries): +# - v2.0.0 (2026-02-25): FPLAN-0371 Phase 1 - Add medic gating, DEV_CENTRAL protection, rate limiting +# - v1.0.2 (2026-02-06): Validate target branch exists before delivery (fixes @telegram spam) +# - v1.0.1 (2026-02-03): Fixed cross-branch import - uses module API instead of handler +# - v1.0.0 (2026-01-31): Created - Phase 2 migration (FPLAN-0279) +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - NO logger imports (causes infinite recursion in event handlers) +# - NO print statements (handlers must be silent) +# - Silent failure - catch all exceptions, pass +# - DEPRECATED: error_detected.py (Medic v2) is the primary error dispatch handler +# - This handler remains for backward compat with code that fires error_logged events +# ============================================= + +""" +Error Logged Event Handler (DEPRECATED) + +Legacy handler for error_logged events. The primary error dispatch pipeline +is now error_detected.py (Medic v2) which provides circuit breaker, per-fingerprint +backoff, and registry-based deduplication. + +This handler remains for backward compatibility with code that fires error_logged +events directly. It now includes full medic gating (medic_enabled, branch_muted, +rate limiting, DEV_CENTRAL protection) to prevent bypass. + +Event data expected: + - branch: Branch where error occurred (e.g., FLOW) + - message: Error message text + - error_hash: Unique hash for deduplication + - timestamp: When the error occurred + - log_file: Path to log file + - source_module: Module that logged the error + - level: Log level (always 'error' for this handler) +""" + +import json +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List +from aipass.trigger.apps.config import TRIGGER_ROOT + +AIPASS_HOME = Path.home() + +TRIGGER_CONFIG_FILE = TRIGGER_ROOT / "trigger_json" / "trigger_config.json" +BRANCH_REGISTRY_FILE = AIPASS_HOME / "BRANCH_REGISTRY.json" +SUPPRESSED_LOG = TRIGGER_ROOT / "logs" / "medic_suppressed.log" + +# Legacy rate limiting +_dispatch_timestamps: Dict[str, List[float]] = {} +MAX_DISPATCHES_PER_WINDOW = 3 +RATE_LIMIT_WINDOW_SECONDS = 600 # 10 minutes + + +def _is_medic_enabled() -> bool: + """Check if medic dispatch is enabled globally. + + Reads medic_enabled from trigger_config.json. + Defaults to True if config is missing or unreadable. + + Returns: + True if medic dispatch is enabled + """ + try: + if TRIGGER_CONFIG_FILE.exists(): + data = json.loads(TRIGGER_CONFIG_FILE.read_text(encoding='utf-8')) + return bool(data.get('config', {}).get('medic_enabled', True)) + except Exception: + return True + return True + + +def _is_branch_muted(branch_name: str) -> bool: + """Check if a specific branch is muted for medic dispatch. + + Reads muted_branches list from trigger_config.json. + + Args: + branch_name: Branch name (case-insensitive) + + Returns: + True if branch is in the muted list + """ + try: + if TRIGGER_CONFIG_FILE.exists(): + data = json.loads(TRIGGER_CONFIG_FILE.read_text(encoding='utf-8')) + muted = data.get('config', {}).get('muted_branches', []) + return branch_name.lower() in [b.lower() for b in muted] + except Exception: + return False + return False + + +def _get_registered_emails() -> set: + """Read registered branch emails from BRANCH_REGISTRY.json. + + Returns: + Set of registered email addresses (e.g., {'@flow', '@drone'}) + """ + try: + if BRANCH_REGISTRY_FILE.exists(): + data = json.loads(BRANCH_REGISTRY_FILE.read_text(encoding='utf-8')) + return {b["email"] for b in data.get("branches", [])} + except Exception: + return set() + return set() + + +def _is_rate_limited(branch_email: str) -> bool: + """Check if a branch has exceeded the dispatch rate limit. + + Args: + branch_email: Target branch email (e.g., '@flow') + + Returns: + True if branch has hit the limit (3 dispatches in 10 minutes) + """ + now = time.time() + cutoff = now - RATE_LIMIT_WINDOW_SECONDS + + if branch_email not in _dispatch_timestamps: + _dispatch_timestamps[branch_email] = [] + + _dispatch_timestamps[branch_email] = [ + ts for ts in _dispatch_timestamps[branch_email] if ts > cutoff + ] + + return len(_dispatch_timestamps[branch_email]) >= MAX_DISPATCHES_PER_WINDOW + + +def _record_dispatch(branch_email: str) -> None: + """Record a dispatch timestamp for rate limiting. + + Args: + branch_email: Target branch email (e.g., '@flow') + """ + if branch_email not in _dispatch_timestamps: + _dispatch_timestamps[branch_email] = [] + _dispatch_timestamps[branch_email].append(time.time()) + + +def _log_suppression(reason: str, branch: str, source_module: str, message: str) -> None: + """Log a suppressed dispatch to medic_suppressed.log. + + Args: + reason: Why dispatch was suppressed + branch: Target branch name + source_module: Module that logged the error + message: Error message (truncated to 100 chars) + """ + try: + SUPPRESSED_LOG.parent.mkdir(parents=True, exist_ok=True) + with open(SUPPRESSED_LOG, 'a') as f: + f.write( + f"{datetime.now().isoformat()} | " + f"{reason} - suppressed dispatch for {branch}: " + f"{source_module} - {message[:100]}\n" + ) + except Exception: + return + + +def _build_notification_message( + error_hash: str, + source_module: str, + message: str, + timestamp: str, + log_file: str +) -> str: + """Build error notification message with investigation instructions. + + Args: + error_hash: Unique error identifier + source_module: Module that logged the error + message: Error message text + timestamp: When error occurred + log_file: Path to source log file + + Returns: + Formatted message string + """ + return f"""Error detected - investigate and respond. + +Error ID: {error_hash} +Module: {source_module} +Timestamp: {timestamp} +Log file: {log_file} + +Error message: +{message} + +--- +INVESTIGATION STEPS: +1. Check the log file for context around this error +2. Identify root cause + +DECISION TREE: +- SIMPLE FIX (typo, missing import, config issue): + -> Fix it yourself, then report what you did to @dev_central +- COMPLEX/UNCLEAR (needs research, affects multiple files): + -> Report findings only to @dev_central, recommend action, don't fix +- CRITICAL (data loss risk, security, system stability): + -> STOP immediately, escalate to @dev_central with full context + +REPORT TO @dev_central: + ai_mail send @dev_central "ERROR {error_hash[:8]} - [STATUS]" "Findings..." +""" + + +def handle_error_logged( + branch: str | None = None, + message: str | None = None, + error_hash: str | None = None, + timestamp: str | None = None, + log_file: str | None = None, + source_module: str | None = None, + module_name: str | None = None, + level: str | None = None, # noqa: ARG001 + **kwargs: Any # noqa: ARG001 +) -> None: + """Handle error_logged event with full medic gating. + + DEPRECATED: This is the legacy error notification handler. The primary + pipeline is error_detected.py (Medic v2). This handler remains for + backward compatibility with code that fires error_logged events. + + Gating (matches error_detected.py): + 1. medic_enabled check (global toggle) + 2. branch_muted check (per-branch suppression) + 3. DEV_CENTRAL protection (HARD RULE: never auto-trigger) + 4. Branch validation (unknown branches logged + skipped) + 5. Rate limiting (3 per 10 minutes per branch) + + Args: + branch: Branch where error occurred - REQUIRED + message: Error message text - REQUIRED + error_hash: Unique error identifier - REQUIRED + timestamp: When error occurred (defaults to now) + log_file: Path to source log file + source_module: Module that logged the error + module_name: Deprecated alias for source_module + level: Log level (for reference, unused) + **kwargs: Additional event data (ignored) + """ + try: + if not branch or not message or not error_hash: + return + + # Resolve source_module from either parameter name + effective_module = source_module or module_name or "unknown" + + # --- Medic gating (FPLAN-0371 Phase 1) --- + + # Gate 1: Global medic toggle + if not _is_medic_enabled(): + _log_suppression("Medic OFF", branch, effective_module, message) + return + + # Gate 2: Per-branch mute + if _is_branch_muted(branch): + _log_suppression("Branch muted", branch, effective_module, message) + return + + # Gate 3: Convert branch name to email format + recipient = f"@{branch.lower()}" + + # HARD RULE: DEV_CENTRAL is NEVER auto-triggered + if recipient == '@dev_central': + return + + # Gate 4: Validate target branch exists in registry + registered_emails = _get_registered_emails() + if recipient not in registered_emails: + _log_suppression("Unknown branch skipped", branch, effective_module, message) + return + + # Gate 5: Rate limiting (3 dispatches per 10 minutes per branch) + if _is_rate_limited(recipient): + _log_suppression("Rate limited", branch, effective_module, message) + return + + # --- Dispatch --- + + try: + from aipass.ai_mail.apps.modules.email import send_email_direct + except ImportError: + return + + effective_timestamp = timestamp or datetime.now().strftime("%Y-%m-%d %H:%M:%S") + effective_log_file = log_file or "unknown" + + email_subject = f"[ERROR] {effective_module} - investigation needed" + + notification_message = _build_notification_message( + error_hash=error_hash, + source_module=effective_module, + message=message, + timestamp=effective_timestamp, + log_file=effective_log_file + ) + + send_email_direct( + to_branch=recipient, + subject=email_subject, + message=notification_message, + auto_execute=True, + reply_to='@trigger', + from_branch='@trigger' + ) + + # Record dispatch for rate limiting + _record_dispatch(recipient) + + except Exception: + return diff --git a/src/aipass/trigger/apps/handlers/events/memory.py b/src/aipass/trigger/apps/handlers/events/memory.py new file mode 100644 index 00000000..fcf19b2c --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/memory.py @@ -0,0 +1,39 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: memory.py - Memory Event Handler +# Date: 2025-12-04 +# Version: 0.1.0 +# Category: trigger/handlers/events +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2025-12-04): Created memory event handler placeholder +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - Placeholder for future memory event handling +# - Handlers must not import Prax logger +# ============================================= + +"""Memory Event Handler - Handle memory-related events + +Placeholder for future memory event handling. +""" + +from pathlib import Path + + + +def handle_memory_saved(**kwargs): + """Handle memory save events - placeholder for future + + Will check line count and trigger rollover if needed. + + Args: + **kwargs: Event data (branch, lines, file_path, etc.) + """ + # Future: Check line count and trigger rollover + # if lines > 600: + # trigger_rollover(branch) + pass diff --git a/src/aipass/trigger/apps/handlers/events/memory_template_updated.py b/src/aipass/trigger/apps/handlers/events/memory_template_updated.py new file mode 100644 index 00000000..a6c26382 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/memory_template_updated.py @@ -0,0 +1,54 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: memory_template_updated.py - Memory Template Updated Event Handler +# Date: 2026-02-14 +# Version: 1.0.0 +# Category: trigger/handlers/events +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-14): Created - Phase 3 of FPLAN-0340 +# * Handles memory_template_updated event +# * Pushes living template updates to all branches +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - NO logger imports (causes infinite recursion in event handlers) +# - NO print statements (handlers must be silent) +# - Silent failure - catch all exceptions, pass +# ============================================= + +""" +Memory Template Updated Event Handler + +Handles memory_template_updated events fired when a living template +is modified. Pushes structural updates to all registered branches. + +Event data expected: + - template_name: Name of the updated template (optional) + - updated_by: Who triggered the update (optional) + - timestamp: When the update occurred (optional) +""" + +from pathlib import Path +from typing import Any + +AIPASS_HOME = Path.home() + + +def handle_memory_template_updated(**kwargs: Any) -> None: + """ + Handle memory_template_updated event - push templates to all branches. + + Imports and calls push_templates() from Memory Bank's pusher handler. + All operations are wrapped in try/except for silent failure. + + Args: + **kwargs: Event data (template_name, updated_by, timestamp, etc.) + """ + try: + from aipass.memory_bank.apps.handlers.templates.pusher import push_templates + push_templates(dry_run=False) + except Exception: + pass diff --git a/src/aipass/trigger/apps/handlers/events/memory_threshold_exceeded.py b/src/aipass/trigger/apps/handlers/events/memory_threshold_exceeded.py new file mode 100644 index 00000000..d67f4788 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/memory_threshold_exceeded.py @@ -0,0 +1,170 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: memory_threshold_exceeded.py - Memory Threshold Exceeded Event Handler +# Date: 2026-01-31 +# Version: 1.0.0 +# Category: trigger/handlers/events +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-01-31): Created - Phase 3 migration (FPLAN-0280) +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - NO logger imports (causes infinite recursion in event handlers) +# - NO print statements (handlers must be silent) +# - Silent failure - catch all exceptions, pass +# ============================================= + +""" +Memory Threshold Exceeded Event Handler + +Handles memory_threshold_exceeded events fired when a branch's memory file +exceeds the configured threshold (600 lines by default). + +Sends compression notification to the affected branch via AI_Mail. + +Event data expected: + - branch: Branch name where threshold exceeded + - branch_path: Path to branch root + - file_name: Memory file name (e.g., local.json, observations.json) + - file_path: Full path to the memory file + - line_count: Current line count + - threshold: Threshold that was exceeded + - timestamp: When detected +""" + +from datetime import datetime +from pathlib import Path +from typing import Any + +AIPASS_HOME = Path.home() + + +def _build_compression_message( + branch: str, + file_name: str, + line_count: int, + threshold: int +) -> str: + """ + Build compression notification message. + + Args: + branch: Branch name + file_name: Memory file that exceeded threshold + line_count: Current line count + threshold: Threshold that was exceeded + + Returns: + Formatted message string with compression instructions + """ + return f"""Memory file threshold exceeded - compression needed. + +Branch: {branch} +File: {file_name} +Current lines: {line_count} +Threshold: {threshold} + +--- +COMPRESSION INSTRUCTIONS: + +Target: Reduce to ~400 lines while preserving critical information. + +Priority order (what to keep): +1. Top 25% (most recent): Keep mostly intact +2. Next 25%: Reduce slightly (combine related entries) +3. Next 25%: Reduce more (summary format) +4. Last 25% (oldest): Delete if needed for space + +Always preserve: +- Session headers and dates +- Key achievements and milestones +- Critical errors and resolutions +- Important patterns and learnings + +Safe to remove: +- Routine status updates +- Redundant information +- Low-value details +- Completed temporary tasks + +Maintain chronological order (newest first). + +--- +After compression, verify the file still loads correctly. +""" + + +def handle_memory_threshold_exceeded( + branch: str | None = None, + branch_path: str | None = None, + file_name: str | None = None, + file_path: str | None = None, + line_count: int | None = None, + threshold: int | None = None, + timestamp: str | None = None, + **_kwargs: Any +) -> None: + """ + Handle memory_threshold_exceeded event - send compression notification. + + Sends AI_Mail to affected branch with compression instructions when + their memory file exceeds the configured threshold. + + Args: + branch: Branch name where threshold exceeded - REQUIRED + branch_path: Path to branch root + file_name: Memory file name - REQUIRED + file_path: Full path to the memory file + line_count: Current line count - REQUIRED + threshold: Threshold that was exceeded (defaults to 600) + timestamp: When detected (defaults to now) + **_kwargs: Additional event data (ignored) + """ + try: + # Validate required fields + if not branch or not file_name or line_count is None: + return + + # Import AI_Mail delivery + try: + from aipass.ai_mail.apps.handlers.email.delivery import deliver_email_to_branch + except ImportError: + return + + # Set defaults + if not timestamp: + timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S") + + if not threshold: + threshold = 600 + + # Build target and message + target_branch = f"@{branch.lower()}" + subject = f"[MEMORY] {file_name} exceeded {threshold} lines - compress needed" + + notification_message = _build_compression_message( + branch=branch, + file_name=file_name, + line_count=line_count, + threshold=threshold + ) + + # Build and deliver email + email_data = { + 'from': '@trigger', + 'from_name': 'Trigger', + 'to': target_branch, + 'subject': subject, + 'message': notification_message, + 'timestamp': timestamp, + 'auto_execute': False, + 'priority': 'normal' + } + + deliver_email_to_branch(target_branch, email_data) + + except Exception: + pass diff --git a/src/aipass/trigger/apps/handlers/events/plan_file.py b/src/aipass/trigger/apps/handlers/events/plan_file.py new file mode 100644 index 00000000..3cae558c --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/plan_file.py @@ -0,0 +1,210 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: plan_file.py - PLAN file event handlers +# Date: 2026-01-20 +# Version: 1.0.0 +# Category: trigger/handlers/events +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-01-20): Migrated from Flow's registry_monitor.py +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - Handles plan_file_created, plan_file_deleted, plan_file_moved events +# - Updates Flow's registry when PLAN files change +# - No Prax logger imports (handler independence) +# ============================================= + +""" +PLAN File Event Handlers + +Handles filesystem events for PLAN files and updates Flow's registry. + +Events handled: +- plan_file_created: New PLAN file detected +- plan_file_deleted: PLAN file removed +- plan_file_moved: PLAN file moved/renamed + +Architecture: +- Flow fires these events via trigger.fire() +- Trigger handlers update Flow's registry +- Decoupled: Flow doesn't know what happens after firing +""" + +import re +from datetime import datetime, timezone +from pathlib import Path +from typing import Optional +from aipass.trigger.apps.config import TRIGGER_ROOT, AIPASS_PKG_ROOT + +ECOSYSTEM_ROOT = Path("/home/aipass") + +# Registry JSON file path (direct file access, no handler imports) +FLOW_JSON_DIR = AIPASS_PKG_ROOT / "flow" / "flow_json" +REGISTRY_FILE = FLOW_JSON_DIR / "PLAN_REGISTRY.json" + +# Log file for handler errors (no Prax imports in handlers - causes recursion) +HANDLER_LOG = TRIGGER_ROOT / "logs" / "plan_file_handler.log" + +MODULE_NAME = "trigger.plan_file" + + +def _log_error(message: str) -> None: + """Log error to file (handlers cannot import Prax logger - causes recursion)""" + try: + HANDLER_LOG.parent.mkdir(parents=True, exist_ok=True) + timestamp = datetime.now(timezone.utc).isoformat() + with open(HANDLER_LOG, 'a', encoding='utf-8') as f: + f.write(f"[{timestamp}] [{MODULE_NAME}] {message}\n") + except Exception: + pass # Last resort - cannot fail on logging failure + + +def _load_registry() -> dict: + """Load registry from JSON file""" + import json + if REGISTRY_FILE.exists(): + with open(REGISTRY_FILE, 'r', encoding='utf-8') as f: + return json.load(f) + return {"plans": {}, "next_number": 1} + + +def _save_registry(registry: dict) -> None: + """Save registry to JSON file""" + import json + FLOW_JSON_DIR.mkdir(parents=True, exist_ok=True) + with open(REGISTRY_FILE, 'w', encoding='utf-8') as f: + json.dump(registry, f, indent=2) + + +def _get_plan_number(file_path: Path) -> Optional[str]: + """Extract plan number from filename (e.g., FPLAN-0001.md -> 0001)""" + match = re.search(r'FPLAN-(\d{4})\.md$', file_path.name) + return match.group(1) if match else None + + +def handle_plan_file_created(path: str, **kwargs): + """ + Handle new PLAN file creation + + Args: + path: Absolute path to the new PLAN file + """ + file_path = Path(path) + plan_number = _get_plan_number(file_path) + + if not plan_number: + return + + try: + registry = _load_registry() + + # Check if already exists + if plan_number in registry.get("plans", {}): + existing_plan = registry["plans"][plan_number] + + # If plan is closed, preserve closed status and just update location + if existing_plan.get("status") == "closed": + existing_plan["location"] = str(file_path.parent) + existing_plan["relative_path"] = str(file_path.parent.relative_to(ECOSYSTEM_ROOT)) + existing_plan["file_path"] = str(file_path) + existing_plan["last_updated"] = datetime.now(timezone.utc).isoformat() + _save_registry(registry) + return + else: + return + + # Add to registry + relative_path = str(file_path.parent.relative_to(ECOSYSTEM_ROOT)) + registry.setdefault("plans", {})[plan_number] = { + "location": str(file_path.parent), + "relative_path": relative_path, + "created": datetime.now(timezone.utc).isoformat(), + "subject": "Auto-detected PLAN", + "status": "open", + "file_path": str(file_path), + "last_updated": datetime.now(timezone.utc).isoformat() + } + + # Update next_number if needed + current_next = registry.get("next_number", 1) + plan_num_int = int(plan_number) + if plan_num_int >= current_next: + registry["next_number"] = plan_num_int + 1 + + _save_registry(registry) + + except Exception as e: + _log_error(f"handle_plan_file_created failed for {path}: {e}") + + +def handle_plan_file_deleted(path: str, **kwargs): + """ + Handle PLAN file deletion + + Args: + path: Absolute path to the deleted PLAN file + """ + file_path = Path(path) + plan_number = _get_plan_number(file_path) + + if not plan_number: + return + + try: + registry = _load_registry() + plans = registry.get("plans", {}) + + if plan_number in plans: + plan_info = plans[plan_number] + + # If plan is closed/processed, preserve it in registry but mark as archived + if plan_info.get("status") == "closed" or plan_info.get("processed"): + plan_info["archived"] = True + plan_info["archived_date"] = datetime.now(timezone.utc).isoformat() + plan_info["last_updated"] = datetime.now(timezone.utc).isoformat() + _save_registry(registry) + else: + # Plan is open but file deleted - remove from registry completely + del plans[plan_number] + registry["plans"] = plans + _save_registry(registry) + + except Exception as e: + _log_error(f"handle_plan_file_deleted failed for {path}: {e}") + + +def handle_plan_file_moved(src_path: str, dest_path: str, **kwargs): + """ + Handle PLAN file move/rename + + Args: + src_path: Original path of the PLAN file + dest_path: New path of the PLAN file + """ + dest_file = Path(dest_path) + plan_number = _get_plan_number(dest_file) + + if not plan_number: + return + + try: + registry = _load_registry() + plans = registry.get("plans", {}) + + if plan_number in plans: + relative_path = str(dest_file.parent.relative_to(ECOSYSTEM_ROOT)) + + # CRITICAL: Only update location fields, preserve ALL other metadata + # (status, closed, closed_reason, memory_created, memory_created_date, etc.) + plans[plan_number]["location"] = str(dest_file.parent) + plans[plan_number]["relative_path"] = relative_path + plans[plan_number]["file_path"] = str(dest_file) + plans[plan_number]["last_updated"] = datetime.now(timezone.utc).isoformat() + + _save_registry(registry) + + except Exception as e: + _log_error(f"handle_plan_file_moved failed for {src_path} -> {dest_path}: {e}") diff --git a/src/aipass/trigger/apps/handlers/events/registry.py b/src/aipass/trigger/apps/handlers/events/registry.py new file mode 100644 index 00000000..e963ba60 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/registry.py @@ -0,0 +1,61 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: registry.py - Event Handler Registry +# Date: 2025-12-04 +# Version: 0.1.0 +# Category: trigger/handlers/events +# +# CHANGELOG (Max 5 entries): +# - v0.1.0 (2025-12-04): Created event handler registry +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - Registers all event handlers on startup +# - Event-based trigger system +# ============================================= + +"""Event Handler Registry - Setup all event handlers on startup""" + +from pathlib import Path + + + +def setup_handlers(): + """Register all event handlers on startup""" + from aipass.trigger.apps.modules.core import trigger + from .startup import handle_startup + from .memory import handle_memory_saved + from .cli import handle_cli_header_displayed + from .plan_file import ( + handle_plan_file_created, + handle_plan_file_deleted, + handle_plan_file_moved + ) + from .error_detected import handle_error_detected, set_send_email_callback + from .error_logged import handle_error_logged + + # Wire up email send callback for error_detected handler (avoids handler importing from modules) + try: + from aipass.ai_mail.apps.modules.email import send_email_direct + set_send_email_callback(send_email_direct) + except ImportError: + pass # ai_mail not available - error notifications won't send + from .warning_logged import handle_warning_logged + from .bulletin_created import handle_bulletin_created + from .memory_threshold_exceeded import handle_memory_threshold_exceeded + from .memory_template_updated import handle_memory_template_updated + + trigger.on('startup', handle_startup) + trigger.on('memory_saved', handle_memory_saved) + trigger.on('cli_header_displayed', handle_cli_header_displayed) + trigger.on('plan_file_created', handle_plan_file_created) + trigger.on('plan_file_deleted', handle_plan_file_deleted) + trigger.on('plan_file_moved', handle_plan_file_moved) + trigger.on('error_detected', handle_error_detected) + trigger.on('error_logged', handle_error_logged) + trigger.on('warning_logged', handle_warning_logged) + trigger.on('bulletin_created', handle_bulletin_created) + trigger.on('memory_threshold_exceeded', handle_memory_threshold_exceeded) + trigger.on('memory_template_updated', handle_memory_template_updated) diff --git a/src/aipass/trigger/apps/handlers/events/startup.py b/src/aipass/trigger/apps/handlers/events/startup.py new file mode 100644 index 00000000..6aa4833d --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/startup.py @@ -0,0 +1,382 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: startup.py - Startup Event Handler +# Date: 2026-02-03 +# Version: 1.0.0 +# Category: trigger/handlers/events +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-26): DPLAN-037 - Add MAX_ERRORS, MAX_FILE_SIZE, time budget to error catchup +# - v0.2.0 (2026-02-03): Added error catch-up on startup +# - v0.1.0 (2025-12-04): Created startup event handler +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - Replaces Prax logger's hardcoded calls with event-based approach +# - Handlers must not import Prax logger +# - Handlers receive fire_event callback via kwargs (no module imports) +# ============================================= + +"""Startup Event Handler - Run startup checks + +Replaces Prax logger's hardcoded calls with event-based approach. +Includes error catch-up: scans system logs for unprocessed errors on each startup. + +DPLAN-037 hardening (2026-02-26): + - MAX_ERRORS_PER_SCAN: Stop scanning after this many new errors (prevents 100K+ event storms) + - MAX_FILE_SIZE_BYTES: Skip log files larger than this threshold (prevents scanning 6MB files) + - SCAN_TIME_BUDGET_SECONDS: Abort scan if it exceeds this duration +""" + +import json +import hashlib +import time +from pathlib import Path +from datetime import datetime, timedelta +from typing import Any, Callable, Dict, List, Optional, Set +from aipass.trigger.apps.config import TRIGGER_ROOT + +AIPASS_HOME = Path.home() + +SYSTEM_LOGS_DIR = AIPASS_HOME / "system_logs" +TRIGGER_DATA_FILE = TRIGGER_ROOT / "trigger_json" / "trigger_data.json" +SUPPRESSED_LOG = TRIGGER_ROOT / "logs" / "medic_suppressed.log" + +MAX_HASHES = 500 +MAX_LOOKBACK_HOURS = 24 + +# DPLAN-037: Safeguards to prevent unbounded scanning +MAX_ERRORS_PER_SCAN = 50 # Stop after this many new errors found +MAX_FILE_SIZE_BYTES = 512_000 # Skip files larger than 500KB +SCAN_TIME_BUDGET_SECONDS = 5.0 # Abort entire scan after this many seconds + + +def _load_trigger_data() -> Dict[str, Any]: + """Load trigger_data.json with error_catchup section.""" + try: + if TRIGGER_DATA_FILE.exists(): + with open(TRIGGER_DATA_FILE, 'r') as f: + data = json.load(f) + if 'error_catchup' not in data: + data['error_catchup'] = { + 'last_scan_timestamp': None, + 'processed_hashes': [], + 'max_hashes': MAX_HASHES, + 'max_lookback_hours': MAX_LOOKBACK_HOURS + } + return data + except Exception: + return { + 'error_catchup': { + 'last_scan_timestamp': None, + 'processed_hashes': [], + 'max_hashes': MAX_HASHES, + 'max_lookback_hours': MAX_LOOKBACK_HOURS + } + } + return { + 'error_catchup': { + 'last_scan_timestamp': None, + 'processed_hashes': [], + 'max_hashes': MAX_HASHES, + 'max_lookback_hours': MAX_LOOKBACK_HOURS + } + } + + +def _save_trigger_data(data: Dict[str, Any]) -> None: + """Save trigger_data.json.""" + try: + TRIGGER_DATA_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(TRIGGER_DATA_FILE, 'w') as f: + json.dump(data, f, indent=2) + except Exception: + return + + +def _log_suppression(reason: str) -> None: + """Log a catchup suppression event to medic_suppressed.log. + + Args: + reason: Description of why scanning was capped or skipped + """ + try: + SUPPRESSED_LOG.parent.mkdir(parents=True, exist_ok=True) + with open(SUPPRESSED_LOG, 'a') as f: + f.write(f"{datetime.now().isoformat()} | error_catchup: {reason}\n") + except Exception: + return + + +def _generate_error_hash(source_module: str, message: str) -> str: + """Generate 8-char hash for error deduplication.""" + content = f"{source_module}:{message}" + return hashlib.md5(content.encode()).hexdigest()[:8] + + +def _parse_log_line(log_line: str) -> Optional[Dict[str, str]]: + """Parse a log line and extract fields if it's an ERROR. + + Uses positional parsing (like log_watcher.py) instead of content-matching + to avoid false positives from lines that mention 'ERROR' in their message text. + + Args: + log_line: Raw log line + + Returns: + Dict with timestamp, module, level, message if ERROR/CRITICAL. + None otherwise. + """ + try: + # Prax format: timestamp | module | LEVEL | message + if ' | ' in log_line: + parts = log_line.split(' | ', 3) + if len(parts) >= 4: + level = parts[2].strip().upper() + if level in ('ERROR', 'CRITICAL'): + return { + 'timestamp': parts[0].strip(), + 'module': parts[1].strip(), + 'level': level, + 'message': parts[3].strip() + } + return None + + # Python logging format: timestamp - module - LEVEL - message + if ' - ' in log_line: + parts = log_line.split(' - ', 3) + if len(parts) >= 4: + level = parts[2].strip().upper() + if level in ('ERROR', 'CRITICAL'): + return { + 'timestamp': parts[0].strip(), + 'module': parts[1].strip(), + 'level': level, + 'message': parts[3].strip() + } + + return None + except Exception: + return None + + +def _extract_timestamp(timestamp_str: str) -> Optional[datetime]: + """Parse timestamp string into datetime. + + Args: + timestamp_str: Timestamp from log line + + Returns: + datetime object, or None if unparseable + """ + formats = [ + '%Y-%m-%d %H:%M:%S,%f', + '%Y-%m-%d %H:%M:%S.%f', + '%Y-%m-%d %H:%M:%S', + ] + for fmt in formats: + try: + return datetime.strptime(timestamp_str.strip(), fmt) + except ValueError: + continue + return None + + +def _detect_branch_from_log(log_file: str) -> str: + """Detect branch from log filename (e.g., drone_ops.log -> DRONE).""" + try: + name = Path(log_file).stem + if '_' in name: + return name.split('_')[0].upper() + return name.upper() + except Exception: + return 'UNKNOWN' + + +def _scan_system_logs_for_errors( + since_timestamp: Optional[datetime], + processed_hashes: Set[str] +) -> List[Dict[str, Any]]: + """Scan system logs for ERROR entries since timestamp. + + DPLAN-037 safeguards: + - Skips files larger than MAX_FILE_SIZE_BYTES + - Stops after MAX_ERRORS_PER_SCAN new errors found + - Aborts if total scan time exceeds SCAN_TIME_BUDGET_SECONDS + + Returns: + List of error dicts with: branch, module, message, log_file, error_hash, timestamp + """ + errors: List[Dict[str, Any]] = [] + scan_start = time.monotonic() + + if not SYSTEM_LOGS_DIR.exists(): + return errors + + cutoff = since_timestamp + if cutoff is None: + cutoff = datetime.now() - timedelta(hours=MAX_LOOKBACK_HOURS) + + files_skipped_size = 0 + + for log_file in SYSTEM_LOGS_DIR.glob("*.log"): + # Time budget check — abort entire scan + elapsed = time.monotonic() - scan_start + if elapsed >= SCAN_TIME_BUDGET_SECONDS: + _log_suppression( + f"Time budget exceeded ({elapsed:.1f}s >= {SCAN_TIME_BUDGET_SECONDS}s). " + f"Found {len(errors)} errors so far, aborting scan." + ) + break + + # File size check — skip oversized files + try: + file_size = log_file.stat().st_size + if file_size > MAX_FILE_SIZE_BYTES: + files_skipped_size += 1 + continue + except Exception: + continue + + try: + with open(log_file, 'r', encoding='utf-8', errors='ignore') as f: + for line in f: + # Error cap check + if len(errors) >= MAX_ERRORS_PER_SCAN: + _log_suppression( + f"MAX_ERRORS_PER_SCAN ({MAX_ERRORS_PER_SCAN}) reached. " + f"Stopping scan to prevent event storm." + ) + break + + # Time budget check (inside file loop) + elapsed = time.monotonic() - scan_start + if elapsed >= SCAN_TIME_BUDGET_SECONDS: + break + + line = line.strip() + if not line: + continue + + parsed = _parse_log_line(line) + if not parsed: + continue + + line_ts = _extract_timestamp(parsed['timestamp']) + if line_ts and line_ts < cutoff: + continue + + module = parsed['module'] + message = parsed['message'] + error_hash = _generate_error_hash(module, message) + + if error_hash in processed_hashes: + continue + + branch = _detect_branch_from_log(str(log_file)) + + errors.append({ + 'branch': branch, + 'module': module, + 'message': message, + 'log_file': str(log_file), + 'error_hash': error_hash, + 'timestamp': line_ts.isoformat() if line_ts else datetime.now().isoformat(), + 'level': parsed['level'].lower() + }) + + processed_hashes.add(error_hash) + + # Break outer loop if error cap reached + if len(errors) >= MAX_ERRORS_PER_SCAN: + break + + except Exception: + continue + + if files_skipped_size > 0: + _log_suppression( + f"Skipped {files_skipped_size} file(s) exceeding " + f"MAX_FILE_SIZE_BYTES ({MAX_FILE_SIZE_BYTES})" + ) + + return errors + + +def _run_error_catchup(fire_event: Optional[Callable[..., None]] = None) -> None: + """Catch-up on errors missed while Trigger wasn't running. + + Loads last_scan_timestamp from trigger_data.json, scans system logs for + ERROR entries since that time, fires error_logged events for new errors, + and updates state with new timestamp and processed hashes. + + DPLAN-037 safeguards applied via _scan_system_logs_for_errors(). + + Args: + fire_event: Callback to fire events (passed from module via kwargs) + """ + try: + data = _load_trigger_data() + catchup = data.get('error_catchup', {}) + + last_scan = catchup.get('last_scan_timestamp') + since_ts = None + if last_scan: + try: + since_ts = datetime.fromisoformat(last_scan) + except Exception: + pass + + processed_hashes = set(catchup.get('processed_hashes', [])) + + errors = _scan_system_logs_for_errors(since_ts, processed_hashes) + + if errors and fire_event is not None: + for error in errors: + fire_event('error_logged', **error) + + hash_list = list(processed_hashes) + max_h = catchup.get('max_hashes', MAX_HASHES) + if len(hash_list) > max_h: + hash_list = hash_list[-max_h:] + + catchup['last_scan_timestamp'] = datetime.now().isoformat() + catchup['processed_hashes'] = hash_list + data['error_catchup'] = catchup + + _save_trigger_data(data) + + except Exception: + return + + +def _run_memory_bank_check() -> None: + """Run Memory Bank rollover check if available. + + Lazy-imports Memory Bank watcher to avoid hard dependency. + Silent failure - handlers cannot use logger or print. + """ + try: + import importlib + mod = importlib.import_module('MEMORY_BANK.apps.handlers.monitor.memory_watcher') + mod.check_and_rollover() + except ImportError: + return # Memory Bank not available + except Exception: + return + + +def handle_startup(**kwargs: Any) -> None: + """Run startup checks - replaces Prax logger's hardcoded calls. + + Args: + **kwargs: Event data, may include 'fire_event' callback + """ + # Error catch-up (scan for missed errors) + fire_event = kwargs.get('fire_event') + _run_error_catchup(fire_event) + + # Memory Bank rollover check + _run_memory_bank_check() diff --git a/src/aipass/trigger/apps/handlers/events/warning_logged.py b/src/aipass/trigger/apps/handlers/events/warning_logged.py new file mode 100644 index 00000000..ce7df42e --- /dev/null +++ b/src/aipass/trigger/apps/handlers/events/warning_logged.py @@ -0,0 +1,78 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: warning_logged.py - Warning Logged Event Handler +# Date: 2026-01-31 +# Version: 1.0.0 +# Category: trigger/handlers/events +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-01-31): Created - Phase 2 migration (FPLAN-0279) +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - NO logger imports (causes infinite recursion in event handlers) +# - NO print statements (handlers must be silent) +# - Silent failure - catch all exceptions, pass +# ============================================= + +""" +Warning Logged Event Handler + +Handles warning_logged events fired by Trigger log watcher. +Logs warning for monitoring but does not send notifications +(warnings are informational, not actionable by default). + +Event data expected: + - branch: Branch where warning occurred + - message: Warning message text + - error_hash: Unique hash for deduplication + - timestamp: When the warning occurred + - log_file: Path to log file + - module_name: Module that logged the warning + - level: Log level (always 'warning' for this handler) +""" + +from pathlib import Path +from typing import Any + + + +def handle_warning_logged( + branch: str | None = None, + message: str | None = None, + error_hash: str | None = None, + timestamp: str | None = None, + log_file: str | None = None, + module_name: str | None = None, + level: str | None = None, + **kwargs: Any +) -> None: + """ + Handle warning_logged event. + + Warnings are logged for monitoring but do not trigger notifications. + This handler exists as a hook point for future warning aggregation. + + Args: + branch: Branch where warning occurred + message: Warning message text + error_hash: Unique hash for deduplication + timestamp: When warning occurred + log_file: Path to source log file + module_name: Module that logged the warning + level: Log level (for reference) + **kwargs: Additional event data (ignored) + + Returns: + None - handlers must not return values + """ + # Warnings are informational - no action needed by default + # This handler exists as a hook point for: + # - Future warning aggregation + # - Warning threshold alerts (e.g., 10+ warnings in 5 min) + # - Warning pattern detection + # + # Suppress unused variable warnings - all params are part of event contract + _ = (branch, message, error_hash, timestamp, log_file, module_name, level, kwargs) diff --git a/src/aipass/trigger/apps/handlers/json/__init__.py b/src/aipass/trigger/apps/handlers/json/__init__.py new file mode 100644 index 00000000..f44e47b3 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/json/__init__.py @@ -0,0 +1 @@ +"""JSON Handlers - Universal JSON operations for Seed branch""" diff --git a/src/aipass/trigger/apps/handlers/json/json_handler.py b/src/aipass/trigger/apps/handlers/json/json_handler.py new file mode 100644 index 00000000..b6b17c27 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/json/json_handler.py @@ -0,0 +1,264 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: json_handler.py - JSON Auto-Creating Handler +# Date: 2025-11-21 +# Version: 1.1.0 +# Category: trigger/handlers/json +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2025-11-21): Refactored to comply with error handling +# - v1.0.0 (2025-11-13): Initial JSON auto-creation system +# +# CODE STANDARDS: +# - Pure functions with proper error raising +# - No Prax imports (handler tier 3) +# ============================================= + +import json +from pathlib import Path +from datetime import datetime +from typing import Dict, List, Any, Optional +import inspect + +# Infrastructure + +# Constants +TRIGGER_ROOT = Path.home() / "aipass_core" / "trigger" +TRIGGER_JSON_DIR = TRIGGER_ROOT / "trigger_json" +JSON_TEMPLATES_DIR = TRIGGER_ROOT / "apps" / "json_templates" + + +def _get_caller_module_name() -> str: + """ + Auto-detect calling module name from call stack + + Returns: + Module name (e.g., "imports_standard" from imports_standard.py) + """ + stack = inspect.stack() + # Skip frames: [0]=this function, [1]=log_operation, [2]=actual caller + if len(stack) > 2: + caller_frame = stack[2] + caller_path = Path(caller_frame.filename) + module_name = caller_path.stem + + # Validate module name + if module_name and not module_name.startswith('_'): + return module_name + + # Fallback + return "unknown" + + +def load_template(json_type: str, module_name: str) -> Any: + """Load JSON template from template file""" + template_path = JSON_TEMPLATES_DIR / "default" / f"{json_type}.json" + + if not template_path.exists(): + raise FileNotFoundError(f"Template not found: {template_path}") + + with open(template_path, 'r', encoding='utf-8') as f: + template = json.load(f) + + # Replace placeholders + template_str = json.dumps(template) + template_str = template_str.replace("{{MODULE_NAME}}", module_name) + template_str = template_str.replace("2025-11-30", datetime.now().date().isoformat()) + + return json.loads(template_str) + + +def validate_json_structure(data: Any, json_type: str) -> bool: + """Validate JSON structure matches expected type""" + if json_type == "config": + if not isinstance(data, dict): + return False + required = ["module_name", "version", "config"] + return all(key in data for key in required) + + elif json_type == "data": + if not isinstance(data, dict): + return False + required = ["created", "last_updated"] + return all(key in data for key in required) + + elif json_type == "log": + return isinstance(data, list) + + return False + + +def get_json_path(module_name: str, json_type: str) -> Path: + """Get path for module JSON file""" + filename = f"{module_name}_{json_type}.json" + return TRIGGER_JSON_DIR / filename + + +def ensure_json_exists(module_name: str, json_type: str) -> bool: + """Ensure JSON file exists, create from template if missing""" + TRIGGER_JSON_DIR.mkdir(parents=True, exist_ok=True) + + json_path = get_json_path(module_name, json_type) + + if json_path.exists(): + try: + with open(json_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + if validate_json_structure(data, json_type): + return True + # If corrupted, fall through to regenerate + except Exception: + # If unreadable, fall through to regenerate + pass + + template = load_template(json_type, module_name) + + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(template, f, indent=2, ensure_ascii=False) + return True + + +def load_json(module_name: str, json_type: str) -> Optional[Any]: + """Load JSON file, auto-create if missing""" + if not ensure_json_exists(module_name, json_type): + return None + + json_path = get_json_path(module_name, json_type) + + with open(json_path, 'r', encoding='utf-8') as f: + return json.load(f) + + +def save_json(module_name: str, json_type: str, data: Any) -> bool: + """Save JSON file""" + json_path = get_json_path(module_name, json_type) + + if not validate_json_structure(data, json_type): + raise ValueError(f"Invalid structure for {json_type} JSON") + + if json_type == "data" and isinstance(data, dict): + data["last_updated"] = datetime.now().date().isoformat() + + with open(json_path, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + + +def ensure_module_jsons(module_name: str) -> bool: + """Ensure all 3 JSON files exist for a module""" + ensure_json_exists(module_name, "config") + ensure_json_exists(module_name, "data") + ensure_json_exists(module_name, "log") + return True + + +def log_operation(operation: str, data: Dict[str, Any] | None = None, module_name: str | None = None) -> bool: + """ + Add entry to module log with automatic rotation + + Auto-detects calling module if module_name not provided. + Implements config-controlled log limits to prevent unbounded growth. + When max_log_entries is reached, removes oldest entries (FIFO). + + Args: + operation: Operation name to log + data: Optional data dict + module_name: Optional module name (auto-detected if not provided) + + Returns: + True if successful, False otherwise + """ + # Auto-detect module name if not provided + if module_name is None: + module_name = _get_caller_module_name() + + ensure_module_jsons(module_name) + + # Load config to get max_log_entries + config = load_json(module_name, "config") + max_entries = 100 # Default + if config and "config" in config: + max_entries = config["config"].get("max_log_entries", 100) + + # Load existing log + log = load_json(module_name, "log") + if log is None: + log = [] + + # Create new entry + entry = { + "timestamp": datetime.now().isoformat(), + "operation": operation + } + + if data: + entry["data"] = data # type: ignore[assignment] + + # Add new entry + log.append(entry) + + # Rotate if exceeds max (keep most recent entries) + if len(log) > max_entries: + log = log[-max_entries:] + + return save_json(module_name, "log", log) + + +def increment_counter(module_name: str, counter_name: str, amount: int = 1) -> bool: + """Increment a counter in data JSON""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + if counter_name not in data: + data[counter_name] = 0 + + data[counter_name] += amount + + return save_json(module_name, "data", data) + + +def update_data_metrics(module_name: str, **metrics) -> bool: + """Update data metrics""" + ensure_module_jsons(module_name) + + data = load_json(module_name, "data") + if data is None: + return False + + for key, value in metrics.items(): + data[key] = value + + return save_json(module_name, "data", data) + + +if __name__ == "__main__": + from rich.console import Console + from rich.panel import Panel + + console = Console() + + console.print() + console.print(Panel.fit( + "[bold cyan]JSON HANDLER - Working Implementation[/bold cyan]", + border_style="bright_blue" + )) + console.print() + console.print("[yellow]TESTING:[/yellow] Creating trigger JSONs...") + + # Test auto-creation + log_operation("test_operation", {"test": "data"}, "trigger") + increment_counter("trigger", "test_counter", 1) + update_data_metrics("trigger", test_metric="working") + + console.print() + console.print("[green]Check /home/aipass/aipass_core/trigger/trigger_json/ for created files:[/green]") + console.print(" [dim]•[/dim] trigger_config.json") + console.print(" [dim]•[/dim] trigger_data.json") + console.print(" [dim]•[/dim] trigger_log.json") + console.print() diff --git a/src/aipass/trigger/apps/handlers/log_watcher.py b/src/aipass/trigger/apps/handlers/log_watcher.py new file mode 100644 index 00000000..8881203c --- /dev/null +++ b/src/aipass/trigger/apps/handlers/log_watcher.py @@ -0,0 +1,782 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: log_watcher.py - Branch Log Watcher Event Producer +# Date: 2026-02-25 +# Version: 2.3.0 +# Category: trigger/handlers +# +# CHANGELOG (Max 5 entries): +# - v2.4.0 (2026-02-27): FPLAN-0382 Phase 3 - Migrate stdlib logging to Prax direct_log() (no-event pipeline) +# - v2.3.0 (2026-02-25): FPLAN-0371 Phase 5 - Add logging when _fire_event callback is None (silent failure path) +# - v2.2.0 (2026-02-25): FPLAN-0371 Phase 2 - False positive elimination: strict parser, semantic exclusion, case-insensitive excludes, stale default +# - v2.1.0 (2026-02-23): Fix false positives - persist positions, exclude self-referential logs, timestamp guard +# - v2.0.0 (2026-02-13): Medic v2 Phase 3 - Registry-based dedup via error_registry.report() +# - v1.0.1 (2026-02-10): FPLAN-0310 Phase 2 - Persist dedup hashes to trigger_data.json, increase max to 2000 +# - v1.0.0 (2026-02-02): Created - FPLAN-0284 Phase 1 +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - NO console.print() - handlers return data to modules +# - Uses Prax direct_log() (no event pipeline) to avoid recursion +# - Fires error_detected events when ERROR entries found in branch logs +# - Primary dedup path: error_registry.report() (Medic v2) +# - Fallback dedup: MD5 hash-based (Medic v1, kept for backward compat) +# - Dispatch threshold: fires event on count==1 (register) AND count==2 (dispatch) +# ============================================= + +""" +Branch Log Watcher Event Producer + +Watches */logs/*.log across all branches for ERROR entries. +Also watches ~/system_logs/ for system-level services. +Fires error_detected events for the Trigger event system. + +Architecture: + - Watches: /home/aipass/aipass_core/*/logs/*.log + - Watches: /home/aipass/system_logs/*.log (mapped to owning branch) + - Parses: Prax format (timestamp | module | LEVEL | message) + - Fires: error_detected event (via callback, branch=..., module=..., message=..., log_path=...) + - Primary dedup: error_registry.report() with SHA1 fingerprinting (Medic v2) + - Fallback dedup: MD5 hash of (module + message) if registry unavailable (Medic v1) +""" + +import json +import re +import sys +import hashlib +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Dict, Set, Optional, Callable +from aipass.trigger.apps.config import TRIGGER_ROOT, AIPASS_PKG_ROOT + +AIPASS_HOME = Path.home() + +from aipass.prax.apps.modules.logger import get_direct_logger + +logger = get_direct_logger() + +# Persistent hash storage +TRIGGER_DATA_FILE = TRIGGER_ROOT / "trigger_data.json" + +# Max age for log entries to be considered fresh (seconds) +STALE_ENTRY_THRESHOLD_SECONDS = 300 # 5 minutes + +# Log filenames to exclude from watching (self-referential / dispatch feedback) +# Compared case-insensitively against Path.name (see on_modified) +EXCLUDED_LOG_FILES: Set[str] = { + 'dispatch.log', + 'medic_suppressed.log', + 'rate_limited.log', + 'error_monitor.log', + 'log_watcher.log', + 'log_watcher.log.1', + # DirectLogger output files for handlers that watch logs (self-referential) + 'trigger_log_watcher.log', + 'trigger_error_registry.log', +} + +# Pre-compute lowercase set for case-insensitive matching +_EXCLUDED_LOG_FILES_LOWER: Set[str] = {f.lower() for f in EXCLUDED_LOG_FILES} + +# Patterns in error *messages* that indicate the line is ABOUT an error +# (e.g. a handler logging that it processed an error) rather than being +# a new error itself. Lines matching any of these are skipped. +_SEMANTIC_EXCLUSION_PATTERNS: re.Pattern = re.compile( + r'error_hash|fingerprint|registry_id|Error ID:|' + r'\[ERROR\]|Processed error|Processing error|' + r'dispatch.*error|error.*dispatch|' + r'suppress.*error|error.*suppress', + re.IGNORECASE, +) + +# Try to import error_registry for Medic v2 registry-based dedup +try: + from aipass.trigger.apps.handlers.error_registry import report as registry_report + _REGISTRY_AVAILABLE = True +except ImportError: + _REGISTRY_AVAILABLE = False + +# Try to import watchdog +try: + from watchdog.observers import Observer as WatchdogObserver + from watchdog.events import FileSystemEventHandler as WatchdogFileSystemEventHandler + WATCHDOG_AVAILABLE = True +except ImportError: + WATCHDOG_AVAILABLE = False + WatchdogObserver = None # type: ignore + WatchdogFileSystemEventHandler = object # type: ignore + +# Global state +_branch_log_observer: Any = None +_active_watcher: Any = None # Reference to BranchLogWatcher for position persistence +_seen_error_hashes: Set[str] = set() +MAX_SEEN_HASHES = 2000 # Limit memory usage + +# Explicit mapping of system_logs filenames to their owning branch. +# Used for files that don't follow the _.log naming convention. +SYSTEM_LOGS_BRANCH_MAP: Dict[str, str] = { + 'telegram_bridge.log': 'API', + 'telegram_chats.log': 'API', +} + +SYSTEM_LOGS_DIR = AIPASS_HOME / "system_logs" + +# Known branch prefixes that appear in system_logs filenames (_.log). +# Sorted longest-first so "MEMORY_BANK" matches before "MEMORY", "backup_system" before "backup", etc. +_SYSTEM_LOGS_BRANCH_PREFIXES: list = sorted([ + 'ai_mail', 'api', 'backup_system', 'cli', 'cortex', 'drone', 'flow', + 'prax', 'trigger', 'seed', 'MEMORY_BANK', 'The_Commons', + 'aipass_os', 'aipass_business', +], key=len, reverse=True) + +# Event fire callback (set by module, avoids handler importing from modules) +_fire_event: Optional[Callable[..., None]] = None + + +def _load_seen_hashes() -> None: + """ + Load persisted dedup hashes from trigger_data.json on startup. + + Populates _seen_error_hashes from disk so deduplication + survives restarts. + """ + global _seen_error_hashes + try: + if TRIGGER_DATA_FILE.exists(): + data = json.loads(TRIGGER_DATA_FILE.read_text(encoding='utf-8')) + stored = data.get('seen_error_hashes', []) + _seen_error_hashes = set(stored) + except Exception: + _seen_error_hashes = set() # Start fresh on read failure + + +def _save_seen_hashes() -> None: + """ + Persist dedup hashes to trigger_data.json. + + Writes current _seen_error_hashes to disk so they survive restarts. + Merges with existing trigger_data.json content to preserve other keys. + """ + try: + data: Dict[str, Any] = {} + if TRIGGER_DATA_FILE.exists(): + data = json.loads(TRIGGER_DATA_FILE.read_text(encoding='utf-8')) + data['seen_error_hashes'] = list(_seen_error_hashes) + TRIGGER_DATA_FILE.parent.mkdir(parents=True, exist_ok=True) + TRIGGER_DATA_FILE.write_text( + json.dumps(data, indent=2), encoding='utf-8' + ) + except Exception: + return # Write failure - hashes remain in memory only + + +def _load_log_positions() -> Dict[str, int]: + """ + Load persisted log positions from trigger_data.json. + + Returns byte offsets for each log file so the watcher resumes + from last-processed position across restarts. + + Returns: + Dict mapping file paths to byte offsets + """ + try: + if TRIGGER_DATA_FILE.exists(): + data = json.loads(TRIGGER_DATA_FILE.read_text(encoding='utf-8')) + stored = data.get('log_positions', {}) + if isinstance(stored, dict): + return {k: int(v) for k, v in stored.items()} + except Exception as e: + logger.warning("Failed to load log positions: %s", e) + return {} + + +def _save_log_positions(positions: Dict[str, int]) -> None: + """ + Persist log positions to trigger_data.json. + + Saves byte offsets for each log file so they survive restarts. + Merges with existing trigger_data.json content to preserve other keys. + + Args: + positions: Dict mapping file paths to byte offsets + """ + try: + data: Dict[str, Any] = {} + if TRIGGER_DATA_FILE.exists(): + data = json.loads(TRIGGER_DATA_FILE.read_text(encoding='utf-8')) + data['log_positions'] = positions + TRIGGER_DATA_FILE.parent.mkdir(parents=True, exist_ok=True) + TRIGGER_DATA_FILE.write_text( + json.dumps(data, indent=2), encoding='utf-8' + ) + except Exception: + return # Write failure - positions remain in memory only + + +def _is_stale_entry(timestamp_str: str) -> bool: + """ + Check if a log entry timestamp is older than the freshness threshold. + + Parses common timestamp formats and returns True if the entry + is too old to process (prevents re-flagging old entries). + + Args: + timestamp_str: Timestamp string from log line + + Returns: + True if the entry is stale (older than STALE_ENTRY_THRESHOLD_SECONDS) + """ + now = datetime.now() + cutoff = now - timedelta(seconds=STALE_ENTRY_THRESHOLD_SECONDS) + + formats = [ + '%Y-%m-%d %H:%M:%S,%f', # Python logging: 2026-02-13 22:51:25,565 + '%Y-%m-%d %H:%M:%S.%f', # Prax: 2026-02-13 22:51:25.565 + '%Y-%m-%d %H:%M:%S', # Simple: 2026-02-13 22:51:25 + '%Y-%m-%dT%H:%M:%S.%f', # ISO: 2026-02-13T22:51:25.565 + '%Y-%m-%dT%H:%M:%S', # ISO simple: 2026-02-13T22:51:25 + ] + + for fmt in formats: + try: + entry_time = datetime.strptime(timestamp_str.strip(), fmt) + return entry_time < cutoff + except ValueError: + continue + + # If we can't parse the timestamp, treat as STALE to avoid re-flagging + # garbage or malformed entries as new errors (false positive prevention). + return True + + +def _generate_error_hash(source_module: str, message: str) -> str: + """ + Generate hash for error deduplication. + + BACKWARD COMPAT: Kept for fallback when error_registry is unavailable. + Primary dedup path is now error_registry.report() (Medic v2). + + Args: + source_module: Module that generated the error + message: Error message content + + Returns: + 8-character hash string + """ + content = f"{source_module}:{message}" + return hashlib.md5(content.encode()).hexdigest()[:8] + + +def _detect_branch_from_path(log_path: str) -> str: + """ + Detect branch name from log file path. + + Handles two path patterns: + - /home/aipass/aipass_core//logs/.log + - /home/aipass/system_logs/.log (mapped via SYSTEM_LOGS_BRANCH_MAP, + falls back to branch prefix in filename like "api_api.log" → API) + + Args: + log_path: Full path to log file + + Returns: + Branch name in uppercase (e.g., 'FLOW', 'PRAX') + """ + try: + path = Path(log_path) + + # Check system_logs/ files first + if path.parent == SYSTEM_LOGS_DIR: + filename = path.name + # Explicit mapping for known services + if filename in SYSTEM_LOGS_BRANCH_MAP: + return SYSTEM_LOGS_BRANCH_MAP[filename] + # Match filename prefix against known branch names (longest-first) + name_stem = path.stem # e.g. "MEMORY_BANK_rollover" from "MEMORY_BANK_rollover.log" + for prefix in _SYSTEM_LOGS_BRANCH_PREFIXES: + if name_stem.startswith(prefix + '_') or name_stem == prefix: + return prefix.upper() + return 'UNKNOWN' + + # Standard aipass_core//logs/ pattern + parts = path.parts + for i, part in enumerate(parts): + if part == 'aipass_core' and i + 1 < len(parts): + return parts[i + 1].upper() + return 'UNKNOWN' + except Exception: + return 'UNKNOWN' + + +def _parse_prax_log_line(log_line: str) -> Optional[Dict[str, str]]: + """ + Parse a log line in Prax format or Python logging format. + + Formats supported: + - Prax: timestamp | module | LEVEL | message + - Python: timestamp - module - LEVEL - message + + Args: + log_line: Raw log line + + Returns: + Dict with keys: timestamp, module, level, message + None if parsing fails or line is not ERROR level + """ + try: + # Try Prax format first (pipe-separated) + if ' | ' in log_line: + parts = log_line.split(' | ', 3) + if len(parts) >= 4: + level = parts[2].strip().upper() + if level in ('ERROR', 'CRITICAL'): + return { + 'timestamp': parts[0].strip(), + 'module': parts[1].strip(), + 'level': level, + 'message': parts[3].strip() + } + return None + + # Fallback: Python logging format (dash-separated) + # Format: 2026-02-10 15:12:29,460 - telegram_bridge - ERROR - message + # NOTE: We do NOT pre-check ' - ERROR - ' in log_line because that + # matches ERROR appearing anywhere in the text (false positive). + # Instead, we split positionally and validate parts[2] is a + # standalone level word. + if ' - ' in log_line: + parts = log_line.split(' - ', 3) + if len(parts) >= 4: + level = parts[2].strip().upper() + # Strict check: level field must be EXACTLY a known level, + # not a longer string that happens to contain one. + if level in ('ERROR', 'CRITICAL'): + return { + 'timestamp': parts[0].strip(), + 'module': parts[1].strip(), + 'level': level, + 'message': parts[3].strip() + } + + return None + except Exception: + return None + + +def _is_duplicate_error(error_hash: str) -> bool: + """ + Check if error has been seen before (deduplication). + + BACKWARD COMPAT: Kept for fallback when error_registry is unavailable. + Primary dedup path is now error_registry.report() (Medic v2). + + Args: + error_hash: Hash of module + message + + Returns: + True if this error has been seen before + """ + global _seen_error_hashes + + if error_hash in _seen_error_hashes: + return True + + # Add to seen set with size limit + _seen_error_hashes.add(error_hash) + if len(_seen_error_hashes) > MAX_SEEN_HASHES: + # Remove oldest entries (convert to list, slice, back to set) + _seen_error_hashes = set(list(_seen_error_hashes)[MAX_SEEN_HASHES // 2:]) + + # Persist to disk after each new hash + _save_seen_hashes() + + return False + + +def set_event_callback(callback: Callable[..., None]) -> None: + """ + Set the callback function for firing events. + + Must be called by the module before starting the watcher. + This avoids handler importing from modules (maintains independence). + + Args: + callback: Function to call with (event_name, **data) + """ + global _fire_event + _fire_event = callback + + +class BranchLogWatcher(WatchdogFileSystemEventHandler if WATCHDOG_AVAILABLE else object): # type: ignore[misc] + """ + Watch branch log files and fire error_detected events. + + Monitors /home/aipass/aipass_core/*/logs/*.log for ERROR entries. + Persists file positions to disk so restarts resume from last-processed offset. + """ + + def __init__(self): + """Initialize log watcher with position tracking.""" + super().__init__() + self.log_positions: Dict[str, int] = {} + self._position_save_counter: int = 0 + self._POSITION_SAVE_INTERVAL: int = 10 # Save positions every N file events + + def on_modified(self, event) -> None: + """ + Handle log file modification events. + + Reads new content and fires error_detected for ERROR entries. + Skips excluded files (dispatch logs, medic logs) to prevent feedback loops. + """ + if event.is_directory: + return + + file_path = str(event.src_path) + + if not file_path.endswith('.log'): + return + + # Skip self-referential logs that could create feedback loops + # Case-insensitive check: Path.name preserves original casing + filename = Path(file_path).name + if filename.lower() in _EXCLUDED_LOG_FILES_LOWER: + return + + # Only process branch logs (aipass_core/*/logs/) or system_logs/ + is_branch_log = '/aipass_core/' in file_path and '/logs/' in file_path + is_system_log = '/system_logs/' in file_path + if not is_branch_log and not is_system_log: + return + + try: + current_size = Path(file_path).stat().st_size + last_pos = self.log_positions.get(file_path, 0) + + # Handle log rotation (file got smaller) + if current_size < last_pos: + last_pos = 0 + + if current_size > last_pos: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + f.seek(last_pos) + new_lines = f.read() + + if new_lines.strip(): + for line in new_lines.strip().split('\n'): + if line.strip(): + self._process_log_line(line, file_path) + + self.log_positions[file_path] = f.tell() + + # Periodically persist positions to disk + self._position_save_counter += 1 + if self._position_save_counter >= self._POSITION_SAVE_INTERVAL: + _save_log_positions(self.log_positions) + self._position_save_counter = 0 + + except Exception: + return # Read failure on this event - skip without raising + + def _process_log_line(self, log_line: str, log_path: str) -> None: + """ + Process a log line and fire error_detected if ERROR found. + + Primary path (Medic v2): Uses error_registry.report() for structured + dedup with SHA1 fingerprinting. Fires event on first occurrence (count==1) + and second occurrence (count==2) so the handler can apply the dispatch + threshold. Subsequent occurrences are silent until backoff allows. + + Fallback path (Medic v1): Uses MD5 hash dedup if error_registry is + unavailable (import failed). + + Args: + log_line: Raw log line + log_path: Path to log file + """ + try: + parsed = _parse_prax_log_line(log_line) + if not parsed: + return + + # Skip lines that reference error artifacts (IDs, fingerprints, + # registry entries). These are logs ABOUT errors, not new errors. + if _SEMANTIC_EXCLUSION_PATTERNS.search(parsed['message']): + return + + # Skip stale entries — prevents re-flagging old log lines + if _is_stale_entry(parsed['timestamp']): + return + + branch = _detect_branch_from_path(log_path) + module = parsed['module'] + message = parsed['message'] + + # Primary path: Medic v2 registry-based dedup + if _REGISTRY_AVAILABLE: + try: + result = registry_report( + error_type=parsed['level'], + message=message, + component=branch, + log_path=log_path, + severity='medium' + ) + + # Fire event for new errors (count == 1) and on second + # occurrence (count == 2) so the handler can apply the + # dispatch threshold. Subsequent occurrences are silent + # until the per-fingerprint backoff schedule allows. + error_count = result.get('count', 1) + if not result.get('is_new', False) and error_count != 2: + return + + # Fire error_detected event with registry data + if _fire_event is not None: + _fire_event( + 'error_detected', + branch=branch, + module=module, + message=message, + log_path=log_path, + error_hash=result.get('id', ''), + timestamp=parsed['timestamp'], + fingerprint=result.get('fingerprint', ''), + registry_id=result.get('id', ''), + first_seen=result.get('first_seen', ''), + last_seen=result.get('last_seen', ''), + count=error_count, + ) + else: + logger.warning( + "Cannot fire error_detected event: _fire_event callback not set " + "(branch=%s, module=%s)", branch, module + ) + return + + except Exception as e: + # Registry unavailable — fall through to legacy MD5 dedup + logger.warning( + "Registry report failed for %s:%s — using MD5 fallback: %s", + branch, module, e + ) + + # Fallback path: Medic v1 hash-based dedup + error_hash = _generate_error_hash(module, message) + + if _is_duplicate_error(error_hash): + return + + if _fire_event is not None: + _fire_event( + 'error_detected', + branch=branch, + module=module, + message=message, + log_path=log_path, + error_hash=error_hash, + timestamp=parsed['timestamp'] + ) + else: + logger.warning( + "Cannot fire error_detected event: _fire_event callback not set " + "(branch=%s, module=%s)", branch, module + ) + + except Exception: + return # Parse/fire failure on this line - skip without raising + + def initialize_positions(self) -> None: + """ + Initialize log positions from persisted state, falling back to END of file. + + Loads saved positions from trigger_data.json first (survives restarts). + For files not in persisted state, snaps to current EOF. + Validates persisted positions against actual file sizes (handles rotation). + Covers both aipass_core/*/logs/ and system_logs/. + """ + # Load persisted positions from disk first + persisted = _load_log_positions() + + # Branch logs under aipass_core/*/logs/ + for branch_dir in AIPASS_PKG_ROOT.iterdir(): + if not branch_dir.is_dir(): + continue + logs_dir = branch_dir / 'logs' + if not logs_dir.exists(): + continue + for log_file in logs_dir.glob('*.log'): + try: + file_path = str(log_file) + current_size = log_file.stat().st_size + saved_pos = persisted.get(file_path, -1) + # Use persisted position if valid (not beyond current file size) + if 0 <= saved_pos <= current_size: + self.log_positions[file_path] = saved_pos + else: + self.log_positions[file_path] = current_size + except Exception: + continue # Skip unreadable log file + + # System-level logs under ~/system_logs/ + if SYSTEM_LOGS_DIR.exists(): + for log_file in SYSTEM_LOGS_DIR.glob('*.log'): + try: + file_path = str(log_file) + current_size = log_file.stat().st_size + saved_pos = persisted.get(file_path, -1) + if 0 <= saved_pos <= current_size: + self.log_positions[file_path] = saved_pos + else: + self.log_positions[file_path] = current_size + except Exception: + continue # Skip unreadable log file + + +def start_branch_log_watcher() -> Any: + """ + Start the branch log watcher. + + Watches /home/aipass/aipass_core/*/logs/*.log for ERROR entries. + Loads persisted positions from disk so restarts resume correctly. + + Returns: + Observer instance (caller must keep reference to keep alive) + None if watchdog not available or error + """ + global _branch_log_observer, _active_watcher + + if not WATCHDOG_AVAILABLE: + return None + + # Stop existing watcher if running + if _branch_log_observer and _branch_log_observer.is_alive(): + stop_branch_log_watcher() + + if not AIPASS_PKG_ROOT.exists(): + return None + + if WatchdogObserver is None: + return None + + # Load persisted dedup hashes from disk + _load_seen_hashes() + + watcher = BranchLogWatcher() + watcher.initialize_positions() + _active_watcher = watcher + + observer = WatchdogObserver() + + # Schedule watcher for each branch's logs directory + for branch_dir in AIPASS_PKG_ROOT.iterdir(): + if not branch_dir.is_dir(): + continue + logs_dir = branch_dir / 'logs' + if logs_dir.exists(): + observer.schedule(watcher, str(logs_dir), recursive=False) + + # Also watch system_logs/ for system-level log files + if SYSTEM_LOGS_DIR.exists(): + observer.schedule(watcher, str(SYSTEM_LOGS_DIR), recursive=False) + + observer.start() + _branch_log_observer = observer + + return observer + + +def stop_branch_log_watcher() -> None: + """Stop the branch log watcher and persist positions to disk.""" + global _branch_log_observer, _active_watcher + + # Persist positions before stopping + if _active_watcher is not None: + _save_log_positions(_active_watcher.log_positions) + _active_watcher = None + + if _branch_log_observer and _branch_log_observer.is_alive(): + _branch_log_observer.stop() + _branch_log_observer.join(timeout=5.0) + _branch_log_observer = None + + +def is_branch_log_watcher_active() -> bool: + """ + Check if branch log watcher is running. + + Returns: + True if watcher is active + """ + return _branch_log_observer is not None and _branch_log_observer.is_alive() + + +def clear_seen_hashes() -> None: + """ + Clear the deduplication hash set (memory and disk). + + Useful for testing or after extended runtime. + """ + global _seen_error_hashes + _seen_error_hashes.clear() + _save_seen_hashes() + + +def get_watcher_status() -> Dict[str, Any]: + """ + Get current watcher status. + + Returns: + Dict with status information + """ + tracked_files = 0 + if _active_watcher is not None: + tracked_files = len(_active_watcher.log_positions) + return { + 'active': is_branch_log_watcher_active(), + 'watchdog_available': WATCHDOG_AVAILABLE, + 'seen_hashes_count': len(_seen_error_hashes), + 'tracked_log_files': tracked_files, + 'excluded_files': list(EXCLUDED_LOG_FILES), + 'stale_threshold_seconds': STALE_ENTRY_THRESHOLD_SECONDS, + 'aipass_root': str(AIPASS_PKG_ROOT) + } + + +if __name__ == '__main__': + """Standalone test for branch log watcher.""" + import time + + def test_fire_event(event_name: str, **data: Any) -> None: + """Test callback that prints events.""" + print(f"[EVENT] {event_name}: {data}") + + # Set callback for standalone testing + set_event_callback(test_fire_event) + + print("Branch Log Watcher Test") + print(f"Monitoring: {AIPASS_PKG_ROOT}/*/logs/*.log") + print(f"Monitoring: {SYSTEM_LOGS_DIR}/*.log") + print("Press Ctrl+C to stop") + print() + + observer = start_branch_log_watcher() + + if not observer: + print("Failed to start branch log watcher") + if not WATCHDOG_AVAILABLE: + print(" - watchdog package not installed") + sys.exit(1) + + print(f"Status: {get_watcher_status()}") + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\nStopping...") + stop_branch_log_watcher() + print("Stopped") diff --git a/src/aipass/trigger/apps/handlers/medic_state.py b/src/aipass/trigger/apps/handlers/medic_state.py new file mode 100644 index 00000000..e845e539 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/medic_state.py @@ -0,0 +1,244 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: medic_state.py - Medic State Handler +# Date: 2026-02-12 +# Version: 1.1.0 +# Category: trigger/handlers +# +# CHANGELOG (Max 5 entries): +# - v1.1.0 (2026-02-12): Added mute/unmute per-branch support (Phase 2) +# - v1.0.0 (2026-02-12): Created - Medic state persistence and status collection +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - NO console.print() - handlers return data to modules +# - Handles file I/O for medic config and status +# ============================================= + +""" +Medic State Handler - Persistence and status for Medic toggle + +Reads/writes medic_enabled flag and muted_branches list in trigger_config.json. +Collects status data from suppression logs and rate limit logs. + +Architecture: + Module (medic.py) orchestrates, this handler manages state. +""" + +import json +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, List + +from aipass.trigger.apps.config import TRIGGER_ROOT + +TRIGGER_CONFIG_FILE = TRIGGER_ROOT / "trigger_json" / "trigger_config.json" +MEDIC_SUPPRESSED_LOG = TRIGGER_ROOT / "logs" / "medic_suppressed.log" +RATE_LIMITED_LOG = TRIGGER_ROOT / "logs" / "rate_limited.log" + + +def read_config() -> dict: + """ + Read trigger_config.json. + + Returns: + Parsed config dict, or empty dict on failure + """ + try: + if TRIGGER_CONFIG_FILE.exists(): + return json.loads(TRIGGER_CONFIG_FILE.read_text(encoding='utf-8')) + except Exception: + return {} + return {} + + +def write_config(data: dict) -> bool: + """ + Write trigger_config.json. + + Args: + data: Config dict to persist + + Returns: + True on success, False on failure + """ + try: + TRIGGER_CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True) + TRIGGER_CONFIG_FILE.write_text( + json.dumps(data, indent=2), encoding='utf-8' + ) + return True + except Exception: + return False + + +def is_enabled() -> bool: + """ + Check if Medic is currently enabled. + + Returns: + True if medic_enabled is True in config (defaults to True) + """ + data = read_config() + return bool(data.get('config', {}).get('medic_enabled', True)) + + +def set_enabled(enabled: bool) -> bool: + """ + Set medic_enabled flag in config. + + Args: + enabled: True to enable, False to disable + + Returns: + True on success + """ + data = read_config() + if 'config' not in data: + data['config'] = {} + data['config']['medic_enabled'] = enabled + data['timestamp'] = datetime.now().strftime("%Y-%m-%d") + + if write_config(data): + return True + return False + + +def _normalize_branch_name(name: str) -> str: + """ + Normalize a branch name - strip @, extract from path if needed. + + Args: + name: Raw branch name (could be path, @-prefixed, etc.) + + Returns: + Lowercase branch name (e.g., 'speakeasy') + """ + cleaned = name.lstrip('@') + if '/' in cleaned: + cleaned = Path(cleaned).name + return cleaned.lower() + + +def get_muted_branches() -> List[str]: + """ + Get list of muted branch names. + + Returns: + List of muted branch names (lowercase, e.g., ['speakeasy', 'api']) + """ + data = read_config() + raw = data.get('config', {}).get('muted_branches', []) + return [_normalize_branch_name(b) for b in raw] + + +def is_branch_muted(branch_name: str) -> bool: + """ + Check if a specific branch is muted. + + Args: + branch_name: Branch name (case-insensitive, with or without @) + + Returns: + True if branch is in the muted list + """ + clean = _normalize_branch_name(branch_name) + return clean in get_muted_branches() + + +def mute_branch(branch_name: str) -> bool: + """ + Add a branch to the muted list. + + Muted branches will have errors detected but NOT dispatched. + Persists in trigger_config.json. + + Args: + branch_name: Branch name (with or without @) + + Returns: + True on success + """ + clean = _normalize_branch_name(branch_name) + data = read_config() + if 'config' not in data: + data['config'] = {} + muted = [_normalize_branch_name(b) for b in data['config'].get('muted_branches', [])] + if clean not in muted: + muted.append(clean) + data['config']['muted_branches'] = muted + data['timestamp'] = datetime.now().strftime("%Y-%m-%d") + return write_config(data) + + +def unmute_branch(branch_name: str) -> bool: + """ + Remove a branch from the muted list. + + Args: + branch_name: Branch name (with or without @) + + Returns: + True on success + """ + clean = _normalize_branch_name(branch_name) + data = read_config() + if 'config' not in data: + data['config'] = {} + muted = [_normalize_branch_name(b) for b in data['config'].get('muted_branches', [])] + muted = [b for b in muted if b != clean] + data['config']['muted_branches'] = muted + data['timestamp'] = datetime.now().strftime("%Y-%m-%d") + return write_config(data) + + +def get_suppression_stats() -> Dict[str, Any]: + """ + Get suppression log statistics. + + Returns: + Dict with suppressed_count and last_suppressed timestamp + """ + suppressed_count = 0 + last_suppressed = "never" + try: + if MEDIC_SUPPRESSED_LOG.exists(): + lines = MEDIC_SUPPRESSED_LOG.read_text(encoding='utf-8').strip().splitlines() + suppressed_count = len(lines) + if lines: + last_line = lines[-1] + last_suppressed = last_line.split(' | ')[0] if ' | ' in last_line else "unknown" + except Exception: + return {'suppressed_count': 0, 'last_suppressed': 'error reading log'} + + return { + 'suppressed_count': suppressed_count, + 'last_suppressed': last_suppressed, + } + + +def get_rate_limit_stats() -> Dict[str, Any]: + """ + Get rate limit log statistics. + + Returns: + Dict with rate_limited_count and last_rate_limited timestamp + """ + dispatch_count = 0 + last_dispatch = "never" + try: + if RATE_LIMITED_LOG.exists(): + lines = RATE_LIMITED_LOG.read_text(encoding='utf-8').strip().splitlines() + dispatch_count = len(lines) + if lines: + last_line = lines[-1] + last_dispatch = last_line.split(' | ')[0] if ' | ' in last_line else "unknown" + except Exception: + return {'rate_limited_count': 0, 'last_rate_limited': 'error reading log'} + + return { + 'rate_limited_count': dispatch_count, + 'last_rate_limited': last_dispatch, + } diff --git a/src/aipass/trigger/apps/handlers/watchers/__init__.py b/src/aipass/trigger/apps/handlers/watchers/__init__.py new file mode 100644 index 00000000..2d073d66 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/watchers/__init__.py @@ -0,0 +1 @@ +"""Trigger Watchers - File system event detection""" diff --git a/src/aipass/trigger/apps/handlers/watchers/log_watcher.py b/src/aipass/trigger/apps/handlers/watchers/log_watcher.py new file mode 100644 index 00000000..173ce838 --- /dev/null +++ b/src/aipass/trigger/apps/handlers/watchers/log_watcher.py @@ -0,0 +1,367 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: log_watcher.py - Centralized Log File Watcher +# Date: 2026-01-31 +# Version: 1.0.0 +# Category: trigger/handlers/watchers +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-01-31): Created - Phase 2 migration from Prax/AI_Mail +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - Centralized log watching for entire system +# - Fires events: error_logged, warning_logged, log_entry +# - All log watching moves to Trigger, other branches respond to events +# ============================================= + +""" +Centralized Log Watcher - Trigger owns all log event detection + +Watches /home/aipass/system_logs for log file changes. +Detects ERROR/WARNING/INFO entries and fires appropriate events. + +Events fired: + - error_logged: When ERROR level log detected + - warning_logged: When WARNING level log detected + - log_entry: All log entries (for monitoring systems) + +Architecture: + - Trigger OWNS all file watching (filesystem events) + - Prax/AI_Mail RESPOND to events, don't watch themselves + - Consolidated from: Prax log_watcher.py, AI_Mail error_monitor.py +""" + +import sys +import hashlib +import logging +from datetime import datetime +from pathlib import Path +from typing import Dict, Any + +AIPASS_HOME = Path.home() + +# Logger - use standard logging to avoid circular imports with Prax +logger = logging.getLogger(__name__) + +# System logs directory +SYSTEM_LOGS_DIR = AIPASS_HOME / "system_logs" + +# Try to import watchdog +try: + from watchdog.observers import Observer as WatchdogObserver + from watchdog.events import FileSystemEventHandler as WatchdogFileSystemEventHandler + WATCHDOG_AVAILABLE = True +except ImportError: + WATCHDOG_AVAILABLE = False + WatchdogObserver = None # type: ignore + WatchdogFileSystemEventHandler = object # type: ignore + +# Global observer instance +_log_observer: Any = None + + +def _generate_error_hash(module_name: str, message: str) -> str: + """ + Generate hash for error deduplication. + + Args: + module_name: Module that generated the error + message: Error message content + + Returns: + 8-character hash string + """ + content = f"{module_name}:{message}" + return hashlib.md5(content.encode()).hexdigest()[:8] + + +def _detect_branch_from_log(log_file: str) -> str: + """ + Detect branch from log filename. + + Log files follow pattern: branch_operation.log + Example: seed_audit.log -> SEED + + Args: + log_file: Log filename or path + + Returns: + Branch name in uppercase + """ + try: + name = Path(log_file).stem + if '_' in name: + parts = name.split('_') + return parts[0].upper() + return name.upper() + except Exception: + return 'UNKNOWN' + + +def _detect_log_level(log_line: str) -> str: + """ + Detect log level from log line content. + + Args: + log_line: Raw log line + + Returns: + 'error', 'warning', 'info', or 'debug' + """ + if ' - ERROR - ' in log_line or ' ERROR ' in log_line or '[ERROR]' in log_line: + return 'error' + if ' - WARNING - ' in log_line or ' WARNING ' in log_line or '[WARNING]' in log_line: + return 'warning' + if ' - CRITICAL - ' in log_line or ' CRITICAL ' in log_line or '[CRITICAL]' in log_line: + return 'error' + if ' - DEBUG - ' in log_line or ' DEBUG ' in log_line or '[DEBUG]' in log_line: + return 'debug' + return 'info' + + +def _parse_log_message(log_line: str) -> str: + """ + Extract clean message from log line. + + Raw format: [BRANCH_NAME] TIMESTAMP | SOURCE | LEVEL | MESSAGE + + Args: + log_line: Raw log line + + Returns: + Cleaned message content + """ + if ' | ' in log_line: + parts = log_line.split(' | ') + if len(parts) >= 4: + return ' | '.join(parts[3:]).strip() + if len(parts) >= 2: + return parts[-1].strip() + return log_line.strip() + + +def _extract_module_name(log_line: str) -> str: + """ + Extract module name from log line. + + Args: + log_line: Raw log line + + Returns: + Module name or 'unknown' + """ + if ' | ' in log_line: + parts = log_line.split(' | ') + if len(parts) >= 2: + return parts[1].strip() + return 'unknown' + + +def _should_skip_log(log_line: str) -> bool: + """ + Filter out initialization noise. + + Args: + log_line: Raw log line + + Returns: + True if line should be skipped + """ + noise_patterns = [ + "Initializing ", + "Module initialized", + "Module initialization completed", + "Configuration loaded", + "Data loaded", + "Registry loaded", + "loaded config from", + "Cleanup completed - Removed 0", + ] + for pattern in noise_patterns: + if pattern in log_line: + return True + return False + + +class LogFileWatcher(WatchdogFileSystemEventHandler if WATCHDOG_AVAILABLE else object): # type: ignore[misc] + """ + Watch log files and fire Trigger events. + + Centralized watcher - all log event detection goes through here. + """ + + def __init__(self): + """Initialize log watcher with position tracking.""" + super().__init__() + self.log_positions: Dict[str, int] = {} + + def on_modified(self, event): + """ + Handle log file modification events. + + Reads new content and fires appropriate Trigger events. + """ + if event.is_directory: + return + + file_path = str(event.src_path) + + if not file_path.endswith('.log'): + return + + if str(SYSTEM_LOGS_DIR) not in file_path: + return + + try: + current_size = Path(file_path).stat().st_size + last_pos = self.log_positions.get(file_path, 0) + + if current_size < last_pos: + last_pos = 0 + + if current_size > last_pos: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + f.seek(last_pos) + new_lines = f.read() + + if new_lines.strip(): + branch = _detect_branch_from_log(file_path) + + for line in new_lines.strip().split('\n'): + if line.strip() and not _should_skip_log(line): + self._process_log_line(branch, line, file_path) + + self.log_positions[file_path] = f.tell() + + except Exception: + pass + + def _process_log_line(self, branch: str, log_line: str, log_file: str) -> None: + """ + Process a log line and fire appropriate events. + + Args: + branch: Branch name + log_line: Raw log line + log_file: Path to log file + """ + try: + from aipass.trigger.apps.modules.core import trigger + + level = _detect_log_level(log_line) + message = _parse_log_message(log_line) + module_name = _extract_module_name(log_line) + timestamp = datetime.now().isoformat() + error_hash = _generate_error_hash(module_name, message) + + event_data = { + 'branch': branch, + 'message': message, + 'level': level, + 'module_name': module_name, + 'timestamp': timestamp, + 'log_file': log_file, + 'error_hash': error_hash + } + + if level == 'error': + trigger.fire('error_logged', **event_data) + elif level == 'warning': + trigger.fire('warning_logged', **event_data) + + except Exception: + pass + + def initialize_positions(self) -> None: + """ + Initialize log positions to END of existing files. + + Only show NEW entries after watcher starts. + """ + if not SYSTEM_LOGS_DIR.exists(): + return + + for log_file in SYSTEM_LOGS_DIR.glob("*.log"): + try: + self.log_positions[str(log_file)] = log_file.stat().st_size + except Exception: + pass + + +def start_log_watcher() -> Any: + """ + Start the centralized log watcher. + + Returns: + Observer instance (caller must keep alive) + """ + global _log_observer + + if not WATCHDOG_AVAILABLE: + return None + + if _log_observer and _log_observer.is_alive(): + stop_log_watcher() + + if not SYSTEM_LOGS_DIR.exists(): + return None + + watcher = LogFileWatcher() + watcher.initialize_positions() + + if WatchdogObserver is None: + return None + observer = WatchdogObserver() + observer.schedule(watcher, str(SYSTEM_LOGS_DIR), recursive=False) + observer.start() + + _log_observer = observer + + return observer + + +def stop_log_watcher() -> None: + """Stop the log watcher.""" + global _log_observer + + if _log_observer and _log_observer.is_alive(): + _log_observer.stop() + _log_observer.join(timeout=5.0) + _log_observer = None + + +def is_log_watcher_active() -> bool: + """ + Check if log watcher is running. + + Returns: + True if watcher is active + """ + return _log_observer is not None and _log_observer.is_alive() + + +if __name__ == '__main__': + """Standalone test for log watcher.""" + import time + + print("Trigger Log Watcher Test") + print(f"Monitoring: {SYSTEM_LOGS_DIR}") + print("Press Ctrl+C to stop") + print() + + observer = start_log_watcher() + + if not observer: + print("Failed to start log watcher") + sys.exit(1) + + try: + while True: + time.sleep(1) + except KeyboardInterrupt: + print("\nStopping...") + stop_log_watcher() + print("Stopped") diff --git a/src/aipass/trigger/apps/json_templates/__init__.py b/src/aipass/trigger/apps/json_templates/__init__.py new file mode 100644 index 00000000..5d00b535 --- /dev/null +++ b/src/aipass/trigger/apps/json_templates/__init__.py @@ -0,0 +1 @@ +# JSON Templates package - Default JSON file templates diff --git a/src/aipass/trigger/apps/json_templates/default/config.json b/src/aipass/trigger/apps/json_templates/default/config.json new file mode 100644 index 00000000..d8e4ce76 --- /dev/null +++ b/src/aipass/trigger/apps/json_templates/default/config.json @@ -0,0 +1,9 @@ +{ + "module_name": "{{MODULE_NAME}}", + "version": "1.0.0", + "timestamp": "2025-11-30", + "config": { + "auto_save": true, + "enabled": true + } +} diff --git a/src/aipass/trigger/apps/json_templates/default/data.json b/src/aipass/trigger/apps/json_templates/default/data.json new file mode 100644 index 00000000..eec43d65 --- /dev/null +++ b/src/aipass/trigger/apps/json_templates/default/data.json @@ -0,0 +1,8 @@ +{ + "module_name": "{{MODULE_NAME}}", + "created": "2025-11-30", + "last_updated": "2025-11-30", + "operations_total": 0, + "operations_successful": 0, + "operations_failed": 0 +} diff --git a/src/aipass/trigger/apps/json_templates/default/log.json b/src/aipass/trigger/apps/json_templates/default/log.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/src/aipass/trigger/apps/json_templates/default/log.json @@ -0,0 +1 @@ +[] diff --git a/src/aipass/trigger/apps/modules/__init__.py b/src/aipass/trigger/apps/modules/__init__.py index e69de29b..c1b2c874 100644 --- a/src/aipass/trigger/apps/modules/__init__.py +++ b/src/aipass/trigger/apps/modules/__init__.py @@ -0,0 +1,5 @@ +# Modules package - Branch-specific functionality modules + +from .core import trigger + +__all__ = ['trigger'] diff --git a/src/aipass/trigger/apps/modules/branch_log_events.py b/src/aipass/trigger/apps/modules/branch_log_events.py new file mode 100644 index 00000000..a8219c92 --- /dev/null +++ b/src/aipass/trigger/apps/modules/branch_log_events.py @@ -0,0 +1,189 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: branch_log_events.py - Branch Log Events Module +# Date: 2026-02-02 +# Version: 1.0.0 +# Category: trigger/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-02-02): Created - FPLAN-0284 Phase 4 +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - Module orchestrates, handlers contain logic +# - Public API for branch log event watching +# ============================================= + +""" +Branch Log Events Module - Public API for branch log watching + +Provides start/stop/status commands for the branch log watcher. +Watches /home/aipass/aipass_core/*/logs/*.log for ERROR entries. +Fires error_detected events handled by AI_Mail's error_handler. + +Commands: start, stop, status +Architecture: Module orchestrates handlers +""" + +import sys +from pathlib import Path + + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.trigger.apps.modules.core import trigger + +from aipass.trigger.apps.handlers.log_watcher import ( + set_event_callback, + start_branch_log_watcher, + stop_branch_log_watcher, + is_branch_log_watcher_active, + get_watcher_status, + clear_seen_hashes +) +from aipass.trigger.apps.config import AIPASS_PKG_ROOT + + +def start() -> bool: + """ + Start the branch log watcher. + + Watches /home/aipass/aipass_core/*/logs/*.log for ERROR entries. + Fires error_detected events to registered handlers. + + Returns: + True if started successfully, False otherwise + """ + logger.info("[TRIGGER] Starting branch log watcher") + + # Set the event callback to trigger.fire + set_event_callback(trigger.fire) + + # Start the watcher + observer = start_branch_log_watcher() + if observer: + logger.info(f"[TRIGGER] Branch log watcher started, monitoring: {AIPASS_PKG_ROOT}/*/logs/*.log") + return True + logger.error("[TRIGGER] Failed to start branch log watcher") + return False + + +def stop() -> None: + """ + Stop the branch log watcher. + """ + logger.info("[TRIGGER] Stopping branch log watcher") + stop_branch_log_watcher() + logger.info("[TRIGGER] Branch log watcher stopped") + + +def status() -> dict: + """ + Get branch log watcher status. + + Returns: + Dict with status info from handler + """ + return get_watcher_status() + + +def reset_hashes() -> None: + """ + Clear the error deduplication hash set. + + Useful after extended runtime or for testing. + """ + clear_seen_hashes() + logger.info("[TRIGGER] Branch log watcher hash set cleared") + + +def print_help() -> None: + """Print module help.""" + from aipass.cli.apps.modules import console + + console.print("Branch Log Events - Branch Log Watcher\n") + console.print("USAGE:") + console.print(" drone @trigger branch_log_events ") + console.print(" python3 branch_log_events.py \n") + console.print("COMMANDS:") + console.print(" start - Start watching branch logs for errors") + console.print(" stop - Stop the branch log watcher") + console.print(" status - Show watcher status") + console.print(" reset - Clear error deduplication hashes\n") + console.print("MONITORING:") + console.print(f" Path: {AIPASS_PKG_ROOT}/*/logs/*.log") + console.print(" Format: Prax log format (timestamp | module | LEVEL | message)\n") + console.print("EVENTS FIRED:") + console.print(" error_detected - When ERROR/CRITICAL level log detected") + console.print(" Handled by AI_Mail's error_handler\n") + + +def handle_command(command: str, args: list) -> bool: + """ + Handle branch_log_events commands - orchestrate handler calls. + + Args: + command: Module name or subcommand (branch_log_events, start, stop, status, reset) + args: Additional arguments + + Returns: + True if command was handled, False otherwise + """ + from aipass.cli.apps.modules import console + + # Handle module-name routing (drone @trigger branch_log_events ) + if command == "branch_log_events": + if not args: + print_help() + return True + subcommand = args[0] + remaining = args[1:] + return handle_command(subcommand, remaining) + + # Handle direct subcommands + if command not in ["start", "stop", "status", "reset"]: + return False + + if args and args[0] in ['--help', '-h', 'help']: + print_help() + return True + + if command == "start": + if start(): + console.print("✅ Branch log watcher started") + console.print(f" Monitoring: {AIPASS_PKG_ROOT}/*/logs/*.log") + console.print(" Events: error_detected → AI_Mail error_handler") + else: + console.print("❌ Failed to start branch log watcher") + console.print(" Check if watchdog package is installed") + elif command == "stop": + stop() + console.print("✅ Branch log watcher stopped") + elif command == "status": + info = status() + console.print("Branch Log Watcher Status") + console.print(f" Active: {info['active']}") + console.print(f" Watchdog available: {info['watchdog_available']}") + console.print(f" Seen error hashes: {info['seen_hashes_count']}") + console.print(f" AIPASS root: {info['aipass_root']}") + elif command == "reset": + reset_hashes() + console.print("✅ Error deduplication hashes cleared") + + return True + + +if __name__ == "__main__": + import argparse + from aipass.cli.apps.modules import console + + if len(sys.argv) == 1 or sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + parser = argparse.ArgumentParser(description='Branch Log Events Module') + parser.add_argument('command', choices=['start', 'stop', 'status', 'reset']) + parsed_args = parser.parse_args() + + handle_command(parsed_args.command, sys.argv[2:]) diff --git a/src/aipass/trigger/apps/modules/core.py b/src/aipass/trigger/apps/modules/core.py new file mode 100644 index 00000000..661619df --- /dev/null +++ b/src/aipass/trigger/apps/modules/core.py @@ -0,0 +1,171 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: core.py - Trigger Event Bus +# Date: 2026-02-03 +# Version: 1.2.0 +# Category: trigger/modules +# +# CHANGELOG (Max 5 entries): +# - v1.2.0 (2026-02-03): Add deferred queue for nested event firing (fixes catch-up blocking) +# - v1.1.4 (2026-02-03): Pass fire_event callback to handlers (enables error catch-up) +# - v1.1.3 (2026-02-03): Disable lazy-start watchers entirely (inotify exhaustion - use prax monitor) +# - v1.1.2 (2026-02-03): Also start system log watcher (fixes missing system_logs monitoring) +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - Event bus pattern for system-wide events +# - Lazy-start pattern from Prax logger +# ============================================= + +""" +Core Trigger class - Event bus for AIPass + +Branches fire events, Trigger handles reactions. +Pattern: Like Prax logger but for events. +""" + +import inspect +from pathlib import Path + + +from aipass.prax.apps.modules.logger import system_logger as logger + +from typing import Callable + + +def _get_caller() -> str: + """Get the calling module/file name""" + stack = inspect.stack() + for frame in stack[2:]: # Skip _get_caller and fire + filepath = frame.filename + if 'trigger/apps/modules/core.py' not in filepath: + # Extract meaningful name from path + path = Path(filepath) + # Try to get branch/module name + parts = path.parts + for i, part in enumerate(parts): + if part in ('aipass_core', 'MEMORY_BANK'): + if i + 1 < len(parts): + return parts[i + 1] # Return branch name + return path.stem # Fallback to filename + return 'unknown' + + +class Trigger: + """Event bus for AIPass system""" + + _handlers = {} + _history = [] # Optional: track recent events + _initialized = False + _firing = False # Recursion guard + _deferred_queue = [] # Queue for events fired during handling + _draining_deferred = False # Prevents nested deferred processing + _log_watcher_started = False # Lazy-start flag for log watcher + + @classmethod + def _ensure_initialized(cls): + """Auto-register handlers on first use""" + if not cls._initialized: + try: + from aipass.trigger.apps.handlers.events.registry import setup_handlers + setup_handlers() + except ImportError as e: + logger.warning(f"[TRIGGER] Handlers not available: {e}") + cls._initialized = True + + @classmethod + def _ensure_log_watcher(cls): + """Lazy-start log watcher - DISABLED to prevent inotify exhaustion. + + The lazy-start pattern causes each process to start its own watchers, + exhausting the system's inotify instance limit (128 by default). + + Log watching should be done by a dedicated persistent process: + - Use `prax monitor` for real-time log watching + - Or use the catch-up pattern to scan logs on demand + + See ERROR 1b5dd1af for details on inotify exhaustion issue. + """ + # DISABLED: Each process starting watchers exhausts inotify instances + # The lock mechanism only prevented simultaneous starts, not accumulation + # over time as processes start/stop throughout the day. + # + # Architecture decision: Log watching belongs in prax monitor (persistent) + # not lazy-started in every trigger.fire() call. + pass + + @classmethod + def on(cls, event: str, handler: Callable): + """Register handler for event""" + cls._handlers.setdefault(event, []).append(handler) + + @classmethod + def off(cls, event: str, handler: Callable): + """Remove handler""" + if event in cls._handlers and handler in cls._handlers[event]: + cls._handlers[event].remove(handler) + + @classmethod + def fire(cls, event: str, **data): + """Fire event to all registered handlers + + Supports nested event firing via deferred queue - events fired during + handler execution are queued and processed after current handler completes. + """ + # If already firing, queue this event for later (prevents recursion, enables nesting) + if cls._firing: + cls._deferred_queue.append((event, data)) + return + + cls._firing = True + try: + cls._ensure_initialized() + cls._ensure_log_watcher() + handlers = cls._handlers.get(event, []) + caller = _get_caller() + logger.info(f"[TRIGGER] {caller} fired: {event}") + logger.info(f"[TRIGGER] {len(handlers)} handlers responding") + + # Provide fire_event callback so handlers can fire events without importing + data['fire_event'] = cls.fire + + for handler in handlers: + try: + handler(**data) + except Exception as e: + logger.error(f"[TRIGGER] Handler error for {event}: {e}") + finally: + cls._firing = False + + # Process deferred events iteratively (NOT recursively) + # Each fire() during drain just appends to queue, loop picks it up + if not cls._draining_deferred: + cls._draining_deferred = True + try: + while cls._deferred_queue: + deferred_event, deferred_data = cls._deferred_queue.pop(0) + cls._firing = True + try: + handlers = cls._handlers.get(deferred_event, []) + data_copy = dict(deferred_data) + data_copy['fire_event'] = cls.fire + for handler in handlers: + try: + handler(**data_copy) + except Exception: + pass # Silent - avoid logger recursion + finally: + cls._firing = False + finally: + cls._draining_deferred = False + + @classmethod + def status(cls) -> dict: + """Show registered handlers""" + return {event: len(handlers) for event, handlers in cls._handlers.items()} + + +# Create instance for import +trigger = Trigger() diff --git a/src/aipass/trigger/apps/modules/errors.py b/src/aipass/trigger/apps/modules/errors.py new file mode 100644 index 00000000..ae919e35 --- /dev/null +++ b/src/aipass/trigger/apps/modules/errors.py @@ -0,0 +1,544 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: errors.py - Error Registry Management Module +# Date: 2026-02-13 +# Version: 1.2.0 +# Category: trigger/modules +# +# CHANGELOG (Max 5 entries): +# - v1.2.0 (2026-02-14): Public API report_error() for cross-branch push reporting +# - v1.1.0 (2026-02-13): Phase 5 - Source fix pipeline (email + fix status tracking) +# - v1.0.0 (2026-02-13): Created - Phase 4 Medic v2 management commands +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - Module orchestrates, handler (error_registry.py) contains data logic +# - No direct file operations - delegates to handler +# ============================================= + +""" +Error Registry Management - View and control tracked errors + +Commands for viewing, filtering, and managing errors in the Medic v2 +error registry. Provides visibility into all tracked errors with +fingerprinting, counts, and status tracking. + +Commands: list, detail, suppress, resolve, clear-resolved, stats, circuit-breaker +Public API: report_error() for cross-branch push reporting (Drone calls this) +Phase 5: Suppress triggers source fix pipeline (email + fix status tracking) +Architecture: Module orchestrates, error_registry handler manages data +""" + +import sys +import time +from pathlib import Path +from typing import Optional + + +from aipass.prax.apps.modules.logger import system_logger as logger +from aipass.trigger.apps.handlers.error_registry import ( + query, get_entry, update_status, clear_resolved, get_stats, + get_circuit_breaker_status, circuit_breaker_reset, + update_source_fix_status, + report as _registry_report, +) + +def report_error( + error_type: str, + message: str, + component: str, + log_path: str = "", + severity: str = "medium", + fire_event: bool = True +) -> dict: + """Report an error to the registry and optionally fire error_detected event. + + Public API for cross-branch push reporting. Drone and other branches + call this instead of importing from handlers directly. + + Pipeline: report() -> registry stores -> fire event -> handler checks + circuit breaker + rate limiting -> dispatch email if allowed. + + Args: + error_type: Error class name (e.g., 'ImportError', 'TimeoutError') + message: Original error message text + component: Branch that generated the error (e.g., 'FLOW', 'API') + log_path: Path to source log file (optional) + severity: Error severity - low, medium, high, critical (default: medium) + fire_event: Whether to fire error_detected event (default: True). + Set False for silent registration without dispatch. + + Returns: + Dict with error entry data + 'is_new' bool + 'dispatched' bool. + """ + result = _registry_report( + error_type=error_type, + message=message, + component=component, + log_path=log_path, + severity=severity, + ) + result["dispatched"] = False + + # Fire event on first occurrence (count==1) for registration and on + # second occurrence (count==2) so the handler can apply the dispatch + # threshold. Skip all other counts (backoff handles later dispatches). + error_count = result.get("count", 1) + if not fire_event or (not result.get("is_new", False) and error_count != 2): + return result + + try: + from aipass.trigger.apps.modules.core import trigger as _trigger_bus + _trigger_bus.fire( + "error_detected", + branch=component, + module=error_type, + message=message, + log_path=log_path, + error_hash=result.get("id", ""), + timestamp=result.get("last_seen", ""), + fingerprint=result.get("fingerprint", ""), + registry_id=result.get("id", ""), + first_seen=result.get("first_seen", ""), + last_seen=result.get("last_seen", ""), + count=error_count, + ) + result["dispatched"] = True + except Exception: + pass + + return result + + +_STATUS_COLORS = {"new": "yellow", "investigating": "cyan", "suppressed": "dim", "resolved": "green"} +_SEVERITY_COLORS = {"low": "dim", "medium": "yellow", "high": "red", "critical": "bold red"} +_CB_COLORS = {"closed": "green", "open": "red", "half_open": "yellow"} +_FIX_STATUS_COLORS = {"none": "dim", "pending_fix": "yellow", "fix_requested": "cyan", "fix_confirmed": "green"} + + +def _parse_args(args: list) -> dict: + """Parse --key=value arguments into a dict (keys without leading --).""" + parsed = {} + for arg in args: + if arg.startswith("--") and "=" in arg: + key, value = arg.split("=", 1) + parsed[key.lstrip("-")] = value + return parsed + + +def _find_by_id_or_fp(identifier: str) -> Optional[dict]: + """Look up error by fingerprint (prefix ok) or short ID field.""" + entry = get_entry(identifier) + if entry: + return entry + for entry in query(limit=1000): + if entry.get("id") == identifier: + return entry + return None + + +def _fmt_time(iso: str) -> str: + """Trim ISO timestamp to 'YYYY-MM-DD HH:MM:SS'.""" + if "T" in iso and "." in iso: + return iso.split(".")[0].replace("T", " ") + if "T" in iso: + return iso.replace("T", " ") + return iso + + +def print_help() -> None: + """Print module help using Rich formatting.""" + from aipass.cli.apps.modules import console + from rich.panel import Panel + + console.print(Panel("Error Registry - Medic v2 Error Management", style="bold")) + console.print() + console.print("View and manage tracked errors in the Medic v2 error registry.") + console.print() + console.rule("USAGE") + console.print() + console.print(" drone @trigger errors ") + console.print(" python3 trigger.py errors ") + console.print() + console.rule("COMMANDS") + console.print() + console.print(" [bold]list[/bold] List tracked errors (default)") + console.print(" [dim]--status=new --component=FLOW --severity=high --limit=20[/dim]") + console.print(" [bold]detail[/bold] Show full details for an error entry") + console.print(" [bold]suppress[/bold] [reason] Mark error as suppressed") + console.print(" [bold]resolve[/bold] Mark error as resolved") + console.print(" [bold]clear-resolved[/bold] Purge old resolved entries [dim](--days=7)[/dim]") + console.print(" [bold]stats[/bold] Summary statistics + circuit breaker state") + console.print(" [bold]circuit-breaker[/bold] Show or reset circuit breaker [dim](reset)[/dim]") + console.print(" [bold]help[/bold] Show this help") + console.print() + console.rule("EXAMPLES") + console.print() + console.print(" drone @trigger errors list --status=new --component=FLOW") + console.print(" drone @trigger errors detail a1b2c3d4") + console.print(" drone @trigger errors suppress a1b2c3d4 known startup issue") + console.print(" drone @trigger errors resolve a1b2c3d4") + console.print(" drone @trigger errors clear-resolved --days=14") + console.print(" drone @trigger errors circuit-breaker reset") + console.print() + + +def handle_command(command: str, args: list) -> bool: + """Handle error management commands. + + Args: + command: Module name (errors) + args: Additional arguments + + Returns: + True if command was handled, False otherwise + """ + from aipass.cli.apps.modules import console + + if command != "errors": + return False + + if not args: + return _cmd_list(console, []) + if args[0] in ['--help', '-h', 'help']: + print_help() + return True + + sub = args[0] + rest = args[1:] + + routes = { + "list": _cmd_list, "detail": _cmd_detail, "suppress": _cmd_suppress, + "resolve": _cmd_resolve, "clear-resolved": _cmd_clear_resolved, + "stats": _cmd_stats, "circuit-breaker": _cmd_circuit_breaker, + } + + if sub in routes: + return routes[sub](console, rest) + + console.print(f"[red]Unknown subcommand: {sub}[/red]") + console.print("Run [dim]drone @trigger errors help[/dim] for available commands") + return True + + +# --------------------------------------------------------------------------- +# Source Fix Pipeline +# --------------------------------------------------------------------------- + +def _send_source_fix_email(entry: dict) -> bool: + """Send recommendation email to source branch about fixing log level. + + When an error is suppressed, the source branch gets notified that + their log level may be wrong. This closes the loop: + error -> investigate -> suppress -> fix source -> error stops + + Args: + entry: Error registry entry dict + + Returns: + True if email sent successfully + """ + try: + from aipass.ai_mail.apps.modules.email import send_email_direct + except ImportError: + logger.info("[ERRORS] Could not import send_email_direct - ai_mail not available") + return False + + try: + component = entry.get("component", "").lower() + if not component or component == "unknown": + return False + + recipient = f"@{component}" + fingerprint = entry.get("fingerprint", "")[:12] + error_type = entry.get("error_type", "?") + message = entry.get("message", "?")[:200] + suppress_reason = entry.get("suppress_reason", "No reason") + log_path = entry.get("log_path", "unknown") + count = entry.get("count", 0) + + subject = f"[LOG FIX] {error_type} classified as non-critical" + + body = f"""Your code is generating an error that has been classified as non-critical. + +Error fingerprint: {fingerprint} +Error type: {error_type} +Occurrences: {count} +Log file: {log_path} +Suppress reason: {suppress_reason} + +Error message: +{message} + +RECOMMENDATION: +This error is currently logged as ERROR but appears to be non-critical based on +investigation. Consider one of: +1. Change the log level from ERROR to WARNING or INFO +2. Add proper error handling to prevent this from being logged +3. If this is actually critical, reply to @trigger explaining why + +This will prevent unnecessary error dispatch for this issue. + +--- +Automated recommendation from Medic v2 Error Registry. +Reply to @trigger with your fix status.""" + + send_email_direct( + to_branch=recipient, + subject=subject, + message=body, + reply_to='@trigger', + from_branch='@trigger' + ) + + logger.info(f"[ERRORS] Source fix email sent to {recipient} for {fingerprint}") + return True + except Exception as exc: + logger.info(f"[ERRORS] Failed to send source fix email: {exc}") + return False + + +# --------------------------------------------------------------------------- +# Command implementations +# --------------------------------------------------------------------------- + +def _cmd_list(console, args: list) -> bool: + """List errors with Rich table. Filters: --status, --component, --severity, --limit.""" + from rich.table import Table + + parsed = _parse_args(args) + sf, cf, svf = parsed.get("status"), parsed.get("component"), parsed.get("severity") + limit = int(parsed.get("limit", "50")) + + entries = query(status=sf, component=cf, severity=svf, limit=limit) + + if not entries: + console.print("[dim]No errors in registry[/dim]") + if sf or cf or svf: + console.print(f" [dim]Filters: status={sf} component={cf} severity={svf}[/dim]") + return True + + filters = [f"{k}={v}" for k, v in [("status", sf), ("component", cf), ("severity", svf)] if v] + ftxt = f" ({', '.join(filters)})" if filters else "" + + table = Table(title=f"Error Registry{ftxt}") + table.add_column("ID", style="dim", width=8) + table.add_column("Fingerprint", style="dim", width=10) + table.add_column("Type", width=18) + table.add_column("Component", width=12) + table.add_column("Count", justify="right", width=6) + table.add_column("Severity", width=10) + table.add_column("Status", width=14) + table.add_column("Last Seen", width=19) + + for e in entries: + sev = e.get("severity", "?") + st = e.get("status", "?") + sc = _STATUS_COLORS.get(st, "white") + svc = _SEVERITY_COLORS.get(sev, "white") + table.add_row( + e.get("id", "?"), e.get("fingerprint", "?")[:8], + e.get("error_type", "?"), e.get("component", "?"), + str(e.get("count", 0)), + f"[{svc}]{sev}[/{svc}]", f"[{sc}]{st}[/{sc}]", + _fmt_time(e.get("last_seen", "?")), + ) + + console.print(table) + console.print(f" [dim]Showing {len(entries)} error(s) (limit {limit})[/dim]") + return True + + +def _cmd_detail(console, args: list) -> bool: + """Show full error details for a fingerprint or ID.""" + from rich.panel import Panel + + if not args: + console.print("[red]Missing error ID or fingerprint[/red]") + console.print("Usage: drone @trigger errors detail ") + return True + + entry = _find_by_id_or_fp(args[0]) + if not entry: + console.print(f"[red]Error not found:[/red] {args[0]}") + console.print(" [dim]Try a fingerprint prefix, full fingerprint, or short ID[/dim]") + return True + + st = entry.get("status", "?") + sev = entry.get("severity", "?") + sc = _STATUS_COLORS.get(st, "white") + svc = _SEVERITY_COLORS.get(sev, "white") + + lines = [ + f" [bold]ID:[/bold] {entry.get('id', '?')}", + f" [bold]Fingerprint:[/bold] {entry.get('fingerprint', '?')}", + f" [bold]Error Type:[/bold] {entry.get('error_type', '?')}", + f" [bold]Component:[/bold] {entry.get('component', '?')}", + f" [bold]Severity:[/bold] [{svc}]{sev}[/{svc}]", + f" [bold]Status:[/bold] [{sc}]{st}[/{sc}]", + f" [bold]Count:[/bold] {entry.get('count', 0)}", + f" [bold]First Seen:[/bold] {entry.get('first_seen', '?')}", + f" [bold]Last Seen:[/bold] {entry.get('last_seen', '?')}", + f" [bold]Log Path:[/bold] {entry.get('log_path', 'N/A') or 'N/A'}", + f" [bold]Fix Status:[/bold] [{_FIX_STATUS_COLORS.get(entry.get('source_fix_status', 'none'), 'white')}]{entry.get('source_fix_status', 'none')}[/{_FIX_STATUS_COLORS.get(entry.get('source_fix_status', 'none'), 'white')}]", + ] + if entry.get("suppress_reason"): + lines.append(f" [bold]Suppress Reason:[/bold] {entry['suppress_reason']}") + lines += ["", " [bold]Message:[/bold]", f" {entry.get('message', '?')}", + "", " [bold]Normalized:[/bold]", f" {entry.get('normalized_message', '?')}"] + + console.print(Panel("\n".join(lines), title=f"Error Detail - {entry.get('id', '?')}", style="bold")) + return True + + +def _cmd_suppress(console, args: list) -> bool: + """Mark error as suppressed with optional reason.""" + if not args: + console.print("[red]Missing error ID or fingerprint[/red]") + console.print("Usage: drone @trigger errors suppress [reason]") + return True + + entry = _find_by_id_or_fp(args[0]) + if not entry: + console.print(f"[red]Error not found:[/red] {args[0]}") + return True + + reason = " ".join(args[1:]) if len(args) > 1 else "No reason provided" + fp = entry.get("fingerprint", args[0]) + + if update_status(fp, "suppressed", reason): + logger.info(f"[ERRORS] Suppressed {entry.get('id', '?')} ({fp[:12]}): {reason}") + console.print(f"[yellow]Suppressed[/yellow] error {entry.get('id', '?')} ({fp[:12]})") + console.print(f" Reason: {reason}") + + # Phase 5: Source fix pipeline - notify source branch + # Reload entry to get updated suppress_reason + updated_entry = get_entry(fp) + if updated_entry: + if _send_source_fix_email(updated_entry): + update_source_fix_status(fp, "fix_requested") + console.print(f" [cyan]Source fix email sent to @{updated_entry.get('component', '?').lower()}[/cyan]") + else: + update_source_fix_status(fp, "pending_fix") + console.print(f" [dim]Source fix email could not be sent (status: pending_fix)[/dim]") + else: + console.print(f"[red]Failed to suppress error[/red] {args[0]}") + return True + + +def _cmd_resolve(console, args: list) -> bool: + """Mark error as resolved.""" + if not args: + console.print("[red]Missing error ID or fingerprint[/red]") + console.print("Usage: drone @trigger errors resolve ") + return True + + entry = _find_by_id_or_fp(args[0]) + if not entry: + console.print(f"[red]Error not found:[/red] {args[0]}") + return True + + fp = entry.get("fingerprint", args[0]) + if update_status(fp, "resolved"): + logger.info(f"[ERRORS] Resolved {entry.get('id', '?')} ({fp[:12]})") + console.print(f"[green]Resolved[/green] error {entry.get('id', '?')} ({fp[:12]})") + else: + console.print(f"[red]Failed to resolve error[/red] {args[0]}") + return True + + +def _cmd_clear_resolved(console, args: list) -> bool: + """Purge old resolved entries. Optional --days=N (default 7).""" + days = int(_parse_args(args).get("days", "7")) + removed = clear_resolved(days=days) + if removed > 0: + logger.info(f"[ERRORS] Cleared {removed} resolved entries older than {days} days") + console.print(f"[green]Cleared {removed} resolved error(s)[/green] older than {days} days") + else: + console.print(f"[dim]No resolved errors older than {days} days to clear[/dim]") + return True + + +def _cmd_stats(console, args: list) -> bool: + """Show summary statistics and circuit breaker state.""" + stats = get_stats() + cb = get_circuit_breaker_status() + + console.print("Error Registry Statistics") + console.print() + console.print(f" [bold]Total errors:[/bold] {stats['total']}") + console.print() + + for label, data, color_fn in [ + ("By Status", stats["by_status"], lambda k: _STATUS_COLORS.get(k, "white")), + ("By Component", stats["by_component"], lambda _: "white"), + ("By Severity", stats["by_severity"], lambda k: _SEVERITY_COLORS.get(k, "white")), + ]: + if data: + console.print(f" [bold]{label}:[/bold]") + for key, count in sorted(data.items()): + c = color_fn(key) + console.print(f" [{c}]{key:<15}[/{c}] {count}") + console.print() + + cb_st = cb.get("state", "unknown") + cc = _CB_COLORS.get(cb_st, "white") + console.print(" [bold]Circuit Breaker:[/bold]") + console.print(f" State: [{cc}]{cb_st}[/{cc}]") + console.print(f" Cooldown: {cb.get('cooldown_seconds', 0)}s") + console.print(f" Recent errors: {cb.get('recent_error_count', 0)}") + console.print() + return True + + +def _cmd_circuit_breaker(console, args: list) -> bool: + """Show or reset the circuit breaker.""" + if args and args[0] == "reset": + circuit_breaker_reset() + logger.info("[ERRORS] Circuit breaker manually reset to closed") + console.print("[green]Circuit breaker reset to CLOSED[/green]") + console.print(" All dispatch now allowed") + return True + + cb = get_circuit_breaker_status() + cb_st = cb.get("state", "unknown") + cc = _CB_COLORS.get(cb_st, "white") + + console.print("Circuit Breaker Status") + console.print() + console.print(f" State: [{cc}]{cb_st}[/{cc}]") + console.print(f" Cooldown: {cb.get('cooldown_seconds', 0)}s") + console.print(f" Recent errors: {cb.get('recent_error_count', 0)}") + console.print(f" Summary sent: {cb.get('summary_sent', False)}") + console.print() + + if cb_st == "closed": + console.print(" [dim]Normal operation - all dispatch allowed[/dim]") + elif cb_st == "open": + opened_at = cb.get("opened_at", 0) + cooldown = cb.get("cooldown_seconds", 0) + if opened_at > 0: + remaining = max(0, cooldown - int(time.time() - opened_at)) + console.print(f" [red]Dispatch paused[/red] - {remaining}s remaining until half-open") + else: + console.print(" [red]Dispatch paused[/red]") + console.print() + console.print(" [dim]Run 'drone @trigger errors circuit-breaker reset' to force close[/dim]") + elif cb_st == "half_open": + console.print(" [yellow]Testing recovery[/yellow] - one probe dispatch allowed") + console.print() + console.print(" [dim]Run 'drone @trigger errors circuit-breaker reset' to force close[/dim]") + + return True + + +if __name__ == "__main__": + from aipass.cli.apps.modules import console + + if len(sys.argv) == 1 or sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + handle_command("errors", sys.argv[1:]) diff --git a/src/aipass/trigger/apps/modules/log_events.py b/src/aipass/trigger/apps/modules/log_events.py new file mode 100644 index 00000000..88084ba2 --- /dev/null +++ b/src/aipass/trigger/apps/modules/log_events.py @@ -0,0 +1,156 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: log_events.py - Log Events Module +# Date: 2026-01-31 +# Version: 1.0.0 +# Category: trigger/modules +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2026-01-31): Created - Phase 2 migration (FPLAN-0279) +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - Module orchestrates, handlers contain logic +# - Public API for log event watching +# ============================================= + +""" +Log Events Module - Public API for log event watching + +Provides start/stop/status commands for the centralized log watcher. +Trigger owns all log event detection - other branches respond to events. + +Commands: start, stop, status +Architecture: Module orchestrates handlers +""" + +import sys +from pathlib import Path + + +from aipass.prax.apps.modules.logger import system_logger as logger + +# Import handler functions +from aipass.trigger.apps.handlers.watchers.log_watcher import ( + start_log_watcher, + stop_log_watcher, + is_log_watcher_active, + SYSTEM_LOGS_DIR +) + + +def start() -> bool: + """ + Start the centralized log watcher. + + Watches /home/aipass/system_logs for log file changes. + Fires error_logged and warning_logged events. + + Returns: + True if started successfully, False otherwise + """ + logger.info("[TRIGGER] Starting log watcher") + observer = start_log_watcher() + if observer: + logger.info(f"[TRIGGER] Log watcher started, monitoring: {SYSTEM_LOGS_DIR}") + return True + logger.error("[TRIGGER] Failed to start log watcher") + return False + + +def stop() -> None: + """ + Stop the centralized log watcher. + """ + logger.info("[TRIGGER] Stopping log watcher") + stop_log_watcher() + logger.info("[TRIGGER] Log watcher stopped") + + +def status() -> dict: + """ + Get log watcher status. + + Returns: + Dict with status info: + { + 'active': bool, + 'log_dir': str + } + """ + return { + 'active': is_log_watcher_active(), + 'log_dir': str(SYSTEM_LOGS_DIR) + } + + +def print_help() -> None: + """Print module help.""" + from aipass.cli.apps.modules import console + + console.print("Log Events - Centralized Log Watcher\n") + console.print("USAGE:") + console.print(" drone trigger log_events ") + console.print(" python3 log_events.py \n") + console.print("COMMANDS:") + console.print(" start - Start watching logs for errors/warnings") + console.print(" stop - Stop the log watcher") + console.print(" status - Show watcher status\n") + console.print("EVENTS FIRED:") + console.print(" error_logged - When ERROR level log detected") + console.print(" warning_logged - When WARNING level log detected\n") + + +def handle_command(command: str, args: list) -> bool: + """ + Handle log_events commands - orchestrate handler calls. + + Args: + command: Command to execute (start, stop, status) + args: Additional arguments + + Returns: + True if command was handled, False otherwise + """ + from aipass.cli.apps.modules import console + + if command not in ["start", "stop", "status"]: + return False + + if args and args[0] in ['--help', '-h', 'help']: + print_help() + return True + + if command == "start": + if start(): + console.print("✅ Log watcher started") + console.print(f" Monitoring: {SYSTEM_LOGS_DIR}") + else: + console.print("❌ Failed to start log watcher") + elif command == "stop": + stop() + console.print("✅ Log watcher stopped") + elif command == "status": + info = status() + console.print("Log Watcher Status") + console.print(f" Active: {info['active']}") + console.print(f" Log dir: {info['log_dir']}") + + return True + + +if __name__ == "__main__": + import argparse + from aipass.cli.apps.modules import console + + if len(sys.argv) == 1 or sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + parser = argparse.ArgumentParser(description='Log Events Module') + parser.add_argument('command', choices=['start', 'stop', 'status']) + parsed_args = parser.parse_args() + + handle_command(parsed_args.command, sys.argv[2:]) diff --git a/src/aipass/trigger/apps/modules/medic.py b/src/aipass/trigger/apps/modules/medic.py new file mode 100644 index 00000000..b1c98674 --- /dev/null +++ b/src/aipass/trigger/apps/modules/medic.py @@ -0,0 +1,308 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: medic.py - Medic Toggle Module +# Date: 2026-03-01 +# Version: 1.4.0 +# Category: trigger/modules +# +# CHANGELOG (Max 5 entries): +# - v1.4.0 (2026-03-01): Persistent watcher via systemd service (trigger-log-watcher.service) +# - v1.3.0 (2026-02-25): FPLAN-0371 Phase 5 - Rich Panel formatting for on/off/mute/unmute output +# - v1.2.0 (2026-02-12): Improved status wording: standby vs stopped, added explanation lines +# - v1.1.0 (2026-02-12): Added mute/unmute per-branch commands (Phase 2) +# - v1.0.0 (2026-02-12): Created - Medic on/off/status toggle for error dispatch +# +# CODE STANDARDS: +# - Follows AIPass Seed standards +# - Module orchestrates, handler (medic_state.py) contains logic +# - No direct file operations - delegates to handler +# ============================================= + +""" +Medic Toggle Module - Control auto-healing error dispatch + +Provides on/off/status/mute/unmute commands for the Medic system +(error detection + auto-dispatch chain). When Medic is off, errors +are still detected and logged but NOT dispatched to branches. +Per-branch muting suppresses dispatch for specific branches only. + +Commands: on, off, status, mute, unmute +Architecture: Module orchestrates, medic_state handler manages persistence +""" + +import subprocess +import sys +from pathlib import Path + + +from aipass.prax.apps.modules.logger import system_logger as logger + +from aipass.trigger.apps.handlers.medic_state import ( + is_enabled, + set_enabled, + get_muted_branches, + mute_branch, + unmute_branch, + get_suppression_stats, + get_rate_limit_stats, +) + +SERVICE_NAME = "trigger-log-watcher.service" + + +def _systemctl(action: str) -> bool: + """Run systemctl --user action on the log watcher service. + + Args: + action: systemctl action (start, stop, restart, is-active) + + Returns: + True if command succeeded (exit code 0) + """ + try: + result = subprocess.run( + ["systemctl", "--user", action, SERVICE_NAME], + capture_output=True, text=True, timeout=10, + ) + return result.returncode == 0 + except Exception as exc: + logger.warning(f"[MEDIC] systemctl {action} failed: {exc}") + return False + + +def _is_service_active() -> bool: + """Check if the log watcher systemd service is running.""" + return _systemctl("is-active") + + +def _extract_branch_name(raw: str) -> str: + """ + Extract branch name from raw argument. + + Handles both direct names (@speakeasy, speakeasy) and + drone-resolved paths (/home/aipass/aipass_core/speakeasy). + + Args: + raw: Raw argument from command line + + Returns: + Lowercase branch name (e.g., 'speakeasy') + """ + cleaned = raw.lstrip('@') + # If it looks like a path, take the last directory component + if '/' in cleaned: + cleaned = Path(cleaned).name + return cleaned.lower() + + +def print_help() -> None: + """Print module help.""" + from aipass.cli.apps.modules import console + from rich.panel import Panel + + console.print(Panel("Medic - Auto-Healing Error Dispatch", style="bold")) + console.print() + console.print("Auto-healing error dispatch system. Watches branch logs for errors") + console.print("and dispatches fix-it emails to affected branches automatically.") + console.print() + console.rule("USAGE") + console.print() + console.print(" drone @trigger medic ") + console.print(" python3 trigger.py medic ") + console.print() + console.rule("COMMANDS") + console.print() + console.print(" [bold]on[/bold] Enable error dispatch (starts log watcher if needed)") + console.print(" [bold]off[/bold] Disable error dispatch globally (errors still logged)") + console.print(" [bold]status[/bold] Show current state, muted branches, and statistics") + console.print(" [bold]mute[/bold] @branch Suppress dispatch for a specific branch") + console.print(" [bold]unmute[/bold] @branch Resume dispatch for a muted branch") + console.print(" [bold]help[/bold] Show this help") + console.print() + console.rule("OFF vs MUTE") + console.print() + console.print(" [yellow]off[/yellow] Global kill switch. ALL error dispatch stops. No branch") + console.print(" receives auto-healing emails. Errors still logged to") + console.print(" medic_suppressed.log for review.") + console.print() + console.print(" [yellow]mute[/yellow] Per-branch suppress. Only the muted branch stops receiving") + console.print(" dispatch. All other branches continue normally. Muted errors") + console.print(" logged to medic_suppressed.log.") + console.print() + console.rule("EXAMPLES") + console.print() + console.print(" [dim]# Enable Medic (starts watching logs for errors)[/dim]") + console.print(" drone @trigger medic on") + console.print() + console.print(" [dim]# Disable all error dispatch globally[/dim]") + console.print(" drone @trigger medic off") + console.print() + console.print(" [dim]# Mute a noisy branch while debugging[/dim]") + console.print(" drone @trigger medic mute @speakeasy") + console.print() + console.print(" [dim]# Resume dispatch for that branch[/dim]") + console.print(" drone @trigger medic unmute @speakeasy") + console.print() + console.print(" [dim]# Check what's happening[/dim]") + console.print(" drone @trigger medic status") + console.print() + console.rule("HOW IT WORKS") + console.print() + console.print(" Trigger watches branch logs -> fires error_detected event") + console.print(" -> handler checks medic_enabled -> checks branch mute list") + console.print(" -> dispatches fix-it email to affected branch (or suppresses)") + console.print() + console.print(" Suppressed errors: trigger/logs/medic_suppressed.log") + console.print() + + +def handle_command(command: str, args: list) -> bool: + """ + Handle medic commands - orchestrate toggle operations. + + Routes on/off/status to handler functions and coordinates + with branch_log_events module for watcher lifecycle. + + Args: + command: Module name or subcommand (medic, on, off, status) + args: Additional arguments + + Returns: + True if command was handled, False otherwise + """ + from aipass.cli.apps.modules import console + + # Handle module-name routing (drone @trigger medic ) + if command == "medic": + if not args: + print_help() + return True + if args[0] in ['--help', '-h', 'help']: + print_help() + return True + subcommand = args[0] + remaining = args[1:] + return handle_command(subcommand, remaining) + + if command not in ["on", "off", "status", "mute", "unmute"]: + return False + + if args and args[0] in ['--help', '-h', 'help']: + print_help() + return True + + if command == "mute": + if not args: + console.print("[red]Missing branch name[/red] - usage: medic mute @branch") + return True + branch_name = _extract_branch_name(args[0]) + if not branch_name: + console.print("[red]Missing branch name[/red] - usage: medic mute @branch") + return True + if mute_branch(branch_name): + logger.info(f"[MEDIC] Muted branch: {branch_name}") + console.print(f" [yellow]Muted[/yellow] @{branch_name} — errors logged but not dispatched") + else: + console.print(f" [red]Failed to mute[/red] @{branch_name} — check trigger_config.json") + return True + + if command == "unmute": + if not args: + console.print("[red]Missing branch name[/red] - usage: medic unmute @branch") + return True + branch_name = _extract_branch_name(args[0]) + if not branch_name: + console.print("[red]Missing branch name[/red] - usage: medic unmute @branch") + return True + if unmute_branch(branch_name): + logger.info(f"[MEDIC] Unmuted branch: {branch_name}") + console.print(f" [green]Unmuted[/green] @{branch_name} — dispatch resumed") + else: + console.print(f" [red]Failed to unmute[/red] @{branch_name} — check trigger_config.json") + return True + + if command == "on": + from rich.panel import Panel + + if set_enabled(True): + logger.info("[MEDIC] Medic ENABLED - error dispatch active") + # Start log watcher via systemd service (persistent) + if not _is_service_active(): + if _systemctl("start"): + logger.info("[MEDIC] Log watcher service started") + else: + logger.warning("[MEDIC] Could not start log watcher service") + console.print(Panel( + "[bold green]Medic ENABLED[/bold green]\n\n" + "Error dispatch is [green]active[/green]. Errors detected in branch logs\n" + "will be dispatched to affected branches automatically.\n" + f"Log watcher: [green]{'running' if _is_service_active() else 'failed to start'}[/green]", + title="Medic", + border_style="green", + )) + else: + console.print("[red]Failed to enable Medic[/red] - check trigger_config.json") + + elif command == "off": + from rich.panel import Panel + + if set_enabled(False): + logger.info("[MEDIC] Medic DISABLED - error dispatch suppressed") + # Stop log watcher service + if _is_service_active(): + _systemctl("stop") + logger.info("[MEDIC] Log watcher service stopped") + console.print(Panel( + "[bold yellow]Medic DISABLED[/bold yellow]\n\n" + "Error dispatch is [yellow]suppressed[/yellow]. Errors are still detected\n" + "and logged to [dim]medic_suppressed.log[/dim] for review.\n" + "Log watcher: [yellow]stopped[/yellow]", + title="Medic", + border_style="yellow", + )) + else: + console.print("[red]Failed to disable Medic[/red] - check trigger_config.json") + + elif command == "status": + enabled = is_enabled() + watcher_active = _is_service_active() + + suppression = get_suppression_stats() + rate_limits = get_rate_limit_stats() + muted = get_muted_branches() + + state_color = "green" if enabled else "yellow" + state_text = "ENABLED" if enabled else "DISABLED" + if watcher_active: + watcher_text = "[green]running[/green] (systemd)" + elif enabled: + watcher_text = "[yellow]stopped[/yellow] — run [bold]medic on[/bold] to start" + else: + watcher_text = "stopped" + muted_text = ", ".join(f"@{b}" for b in muted) if muted else "none" + + console.print("Medic Status") + console.print(f" State: [{state_color}]{state_text}[/{state_color}]") + console.print(f" Log watcher: {watcher_text}") + console.print(f" Muted branches: {muted_text}") + console.print(f" Suppressed: {suppression['suppressed_count']}") + console.print(f" Last suppressed: {suppression['last_suppressed']}") + console.print(f" Rate limited: {rate_limits['rate_limited_count']}") + console.print(f" Last rate limit: {rate_limits['last_rate_limited']}") + console.print() + if not enabled: + console.print(" [dim]All error dispatch suppressed. Errors logged to medic_suppressed.log[/dim]") + + return True + + +if __name__ == "__main__": + from aipass.cli.apps.modules import console + + if len(sys.argv) == 1 or sys.argv[1] in ['--help', '-h', 'help']: + print_help() + sys.exit(0) + + handle_command(sys.argv[1], sys.argv[2:]) diff --git a/src/aipass/trigger/apps/plugins/__init__.py b/src/aipass/trigger/apps/plugins/__init__.py index e69de29b..69b056dd 100644 --- a/src/aipass/trigger/apps/plugins/__init__.py +++ b/src/aipass/trigger/apps/plugins/__init__.py @@ -0,0 +1 @@ +# Plugins package - Pluggable components for branch capabilities diff --git a/src/aipass/trigger/apps/trigger.py b/src/aipass/trigger/apps/trigger.py new file mode 100644 index 00000000..ce71122c --- /dev/null +++ b/src/aipass/trigger/apps/trigger.py @@ -0,0 +1,236 @@ +#!/home/aipass/.venv/bin/python3 + +# ===================AIPASS==================== +# META DATA HEADER +# Name: trigger.py - TRIGGER Branch Entry Point +# Date: 2025-11-30 +# Version: 1.0.0 +# Category: trigger +# +# CHANGELOG (Max 5 entries): +# - v1.0.0 (2025-11-30): Initial branch creation +# +# CODE STANDARDS: +# - Handlers implement logic, modules orchestrate +# ============================================= + +""" +TRIGGER Branch - Main Orchestrator + +Auto-discovery architecture: +- Scans modules/ directory for .py files with handle_command() +- Routes commands to discovered modules automatically +- No manual imports or routing needed +""" + +# INFRASTRUCTURE IMPORT PATTERN +import sys +from pathlib import Path + +# Standard library imports +import importlib +from typing import List, Any + +# Prax logger +from aipass.prax.apps.modules.logger import system_logger as logger + +# CLI services for formatted output +from aipass.cli.apps.modules import console, header + +# ============================================================================= +# MODULE DISCOVERY +# ============================================================================= + +MODULES_DIR = Path(__file__).parent / "modules" + +def discover_modules() -> List[Any]: + """ + Auto-discover modules in modules/ directory + + Modules must implement handle_command(command: str, args: List[str]) -> bool + + Returns: + List of module objects with handle_command function + """ + modules = [] + + if not MODULES_DIR.exists(): + logger.warning(f"[TRIGGER] Modules directory not found: {MODULES_DIR}") + return modules + + # Discover all .py files (except __init__.py and those starting with _) + for file_path in MODULES_DIR.glob("*.py"): + if file_path.name.startswith("_"): + continue + + module_name = f"trigger.apps.modules.{file_path.stem}" + + try: + module = importlib.import_module(module_name) + + # Check if module has handle_command function + if hasattr(module, 'handle_command'): + modules.append(module) + logger.info(f"[TRIGGER] Loaded module: {file_path.stem}") + else: + logger.info(f"[TRIGGER] Skipped {file_path.stem} - no handle_command()") + + except Exception as e: + logger.error(f"[TRIGGER] Failed to load module {module_name}: {e}") + + return modules + + +def route_command(command: str, args: List[str], modules: List[Any]) -> bool: + """ + Route command to appropriate module + + Args: + command: Command name (e.g., 'create', 'update', 'list') + args: Additional arguments + modules: List of discovered modules + + Returns: + True if command was handled, False otherwise + """ + for module in modules: + try: + if module.handle_command(command, args): + return True + except Exception as e: + logger.error(f"[TRIGGER] Module {module.__name__} error: {e}") + + return False + +# ============================================================================= +# INTROSPECTION DISPLAY +# ============================================================================= + +def print_introspection(modules: List[Any]): + """Display discovered modules when run without arguments""" + console.print() + console.print("[bold cyan]TRIGGER - Branch Management System[/bold cyan]") + console.print() + console.print("[dim]Auto-discovered module orchestration[/dim]") + console.print() + + console.print(f"[yellow]Discovered Modules:[/yellow] {len(modules)}") + console.print() + + if modules: + for module in modules: + module_name = module.__name__.split('.')[-1] + # Get first line of docstring + description = "No description" + if module.__doc__: + description = module.__doc__.strip().split('\n')[0] + console.print(f" [cyan]•[/cyan] {module_name:20} [dim]{description}[/dim]") + else: + console.print(" [dim]No modules discovered[/dim]") + + console.print() + console.print("[dim]Run 'python3 trigger.py --help' for usage information[/dim]") + console.print() + + +# ============================================================================= +# DRONE COMPLIANCE - HELP SYSTEM +# ============================================================================= + +def print_help(modules: List[Any]): + """Display Rich-formatted help""" + console.print() + header("TRIGGER - Branch Management System") + console.print() + + console.print("[dim]Auto-discovered module orchestration[/dim]") + console.print() + console.print("─" * 70) + console.print() + + console.print("[bold cyan]USAGE:[/bold cyan]") + console.print() + console.print(" [dim]python3 trigger.py [args...][/dim]") + console.print(" [dim]python3 trigger.py --help[/dim]") + console.print() + console.print("─" * 70) + console.print() + + console.print("[bold cyan]AVAILABLE COMMANDS:[/bold cyan]") + console.print() + + if modules: + for module in modules: + module_name = module.__name__.split('.')[-1] + # Get first line of docstring + description = "No description" + if module.__doc__: + description = module.__doc__.strip().split('\n')[0] + + console.print(f" [green]{module_name:20}[/green] [dim]{description}[/dim]") + else: + console.print(" [dim]No modules discovered[/dim]") + + console.print() + console.print("─" * 70) + console.print() + + console.print("[bold]TIP:[/bold] For module-specific help:") + console.print(" [dim]python3 trigger.py --help[/dim]") + console.print() + + +# ============================================================================= +# MAIN ENTRY POINT +# ============================================================================= + +def main(): + """Main entry point - routes commands or shows help""" + + # Discover available modules + modules = discover_modules() + + # Parse arguments + args = sys.argv[1:] + + # Show introspection when run with no arguments + if len(args) == 0: + print_introspection(modules) + return 0 + + # Show version + if args[0] in ['--version', '-V']: + console.print("TRIGGER v2.2.0") + return 0 + + # Show help for explicit help flags + if args[0] in ['--help', '-h', 'help']: + print_help(modules) + return 0 + + # Extract command and remaining args + command = args[0] + remaining_args = args[1:] if len(args) > 1 else [] + + # Route to modules + if route_command(command, remaining_args, modules): + return 0 + else: + console.print() + console.print(f"[red]Unknown command: {command}[/red]") + console.print() + console.print("Run [dim]python3 trigger.py --help[/dim] for available commands") + console.print() + return 1 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + console.print("\n\nOperation cancelled by user") + sys.exit(0) + except Exception as e: + logger.error(f"TRIGGER entry point error: {e}", exc_info=True) + console.print(f"\n❌ Error: {e}") + sys.exit(1) diff --git a/src/aipass/trigger/trigger_json/trigger_data.json b/src/aipass/trigger/trigger_json/trigger_data.json new file mode 100644 index 00000000..cdce2ad0 --- /dev/null +++ b/src/aipass/trigger/trigger_json/trigger_data.json @@ -0,0 +1,16 @@ +{ + "error_catchup": { + "last_scan_timestamp": "2026-03-05T22:08:52.170487", + "processed_hashes": [ + "68dd0ab6", + "02d93909", + "c7edc03b", + "69de737b", + "ace01637", + "541e0b9b", + "4095ef97" + ], + "max_hashes": 500, + "max_lookback_hours": 24 + } +} \ No newline at end of file