branch rewiring after math

This commit is contained in:
AIOSAI
2026-03-05 22:10:29 -08:00
parent 2140d4bc50
commit 430b5ecbac
308 changed files with 50347 additions and 677 deletions
+1 -1
View File
@@ -1 +1 @@
# AI_MAIL apps package
# Apps package
+255
View File
@@ -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 <command> [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())
-86
View File
@@ -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())
@@ -0,0 +1 @@
# Extensions package - Drop-in extensions for branch functionality
+132
View File
@@ -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 <string> or <stdin>
stack = inspect.stack()
for frame in stack:
if frame.filename in ("<string>", "<stdin>"):
# 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.<module> import <function>\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.<module> import <function>\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()
@@ -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
@@ -0,0 +1 @@
# Dispatch handlers package
@@ -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()
@@ -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 <branch_email> <lock_file> <sender> <stderr_log> -- <claude_args...>
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()
@@ -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
@@ -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"
@@ -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)
@@ -0,0 +1 @@
"""Email Handlers - Email delivery, creation, and formatting for AI_Mail"""
@@ -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")
@@ -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
@@ -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")
@@ -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 <plan_id>
□ 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")
@@ -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")
@@ -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")
@@ -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")
@@ -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
@@ -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")
@@ -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
@@ -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")
@@ -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")
@@ -0,0 +1 @@
"""JSON handler package for AI_MAIL."""
@@ -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,
)
@@ -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'
]
@@ -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")
@@ -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).
"""
@@ -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)
@@ -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")
@@ -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")
@@ -0,0 +1 @@
# Persistence Handler - JSON Operations for persistent data
@@ -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)
@@ -0,0 +1,5 @@
"""Registry Handlers - Branch registry operations for AI_Mail"""
from .load import load_registry
__all__ = ['load_registry']
@@ -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)
@@ -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")
@@ -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")
@@ -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")
@@ -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']
@@ -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
@@ -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',
]
@@ -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")
@@ -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")
@@ -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)
@@ -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 {}
@@ -0,0 +1 @@
# JSON Templates package - Default JSON file templates
@@ -0,0 +1,9 @@
{
"module_name": "{{MODULE_NAME}}",
"version": "1.0.0",
"timestamp": "2025-11-13",
"config": {
"auto_save": true,
"enabled": true
}
}
@@ -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
}
@@ -0,0 +1 @@
[]
@@ -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 <command>
python3 branch_ping.py <command>
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 <command>")
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()
+234
View File
@@ -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)
+946
View File
@@ -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 <id>' to read content (marks as "opened")
3. Use 'reply <id> "msg"' to respond (auto-closes + archives)
OR 'close <id>' to close without reply (archives to deleted)
USAGE:
ai_mail send @recipient "subject" "message" [--dispatch] [--reply-to @branch]
ai_mail inbox
ai_mail view <message_id> # View and mark as opened
ai_mail reply <message_id> "msg" # Reply and close original
ai_mail close <message_id> # 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 <message_id> 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 <message_id> 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 <message_id>")
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 <id> - Close single email
close <id1> <id2> ... - 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 <id> [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 <message_id> \"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)
@@ -0,0 +1 @@
# Plugins package - Pluggable components for branch capabilities
+1 -1
View File
@@ -1 +1 @@
# API apps package
# Apps package
+295
View File
@@ -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())
-86
View File
@@ -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())
@@ -0,0 +1 @@
# Extensions package - Drop-in extensions for branch functionality
+90
View File
@@ -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 ("<string>", "<stdin>"):
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.<module> import <function>\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.<module> import <function>\n"
f"{'='*60}"
)
_guard_branch_access()
@@ -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"
+324
View File
@@ -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", # <api_root>/.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()
+340
View File
@@ -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
@@ -0,0 +1,7 @@
"""
Configuration Domain
Handlers for provider configuration management.
Load, validate, and update API provider settings.
"""
__version__ = "1.0.0"
@@ -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
@@ -0,0 +1 @@
"""JSON Handlers - Universal JSON operations for Seed branch"""
+282
View File
@@ -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()
@@ -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"
@@ -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()
@@ -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())
}
@@ -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', '')}")
@@ -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 {}
@@ -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"
]
File diff suppressed because it is too large Load Diff
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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())
@@ -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, ""
@@ -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)
@@ -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")
@@ -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)
@@ -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,
)
@@ -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
@@ -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()
@@ -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
@@ -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
@@ -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",
]
@@ -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}"
@@ -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"
@@ -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 {}
@@ -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
@@ -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 {}
@@ -0,0 +1 @@
# JSON Templates package - Default JSON file templates
@@ -0,0 +1,9 @@
{
"module_name": "{{MODULE_NAME}}",
"version": "1.0.0",
"timestamp": "2025-11-13",
"config": {
"auto_save": true,
"enabled": true
}
}
@@ -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
}
@@ -0,0 +1 @@
[]
+215
View File
@@ -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 <command> [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)
@@ -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 <prompt> [--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)

Some files were not shown because too many files have changed in this diff Show More