i-self 0.4.3

Personal developer-companion CLI: scans your repos, indexes code semantically, watches your activity, and moves AI-agent sessions between tools (Claude Code, Aider, Goose, OpenAI Codex CLI, Continue.dev, OpenCode).
#![allow(dead_code)]

use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::sync::RwLock;
use tokio::time::interval;

pub mod screenshot;
pub mod input;
pub mod notification;

pub use screenshot::ScreenshotCapture;
pub use input::{InputTracker, UserActivity};
pub use notification::NotificationManager;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MonitorConfig {
    pub screenshot_interval_secs: u64,
    pub screenshot_dir: PathBuf,
    pub track_keyboard: bool,
    pub track_mouse: bool,
    pub track_window: bool,
    pub idle_threshold_secs: u64,
    pub enable_notifications: bool,
    pub analysis_interval_secs: u64,
}

impl Default for MonitorConfig {
    fn default() -> Self {
        Self {
            screenshot_interval_secs: 5,
            screenshot_dir: dirs::data_dir()
                .unwrap_or_else(|| PathBuf::from("."))
                .join("i-self")
                .join("screenshots"),
            track_keyboard: true,
            track_mouse: true,
            track_window: true,
            idle_threshold_secs: 300,
            enable_notifications: true,
            analysis_interval_secs: 30,
        }
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActivitySnapshot {
    pub timestamp: chrono::DateTime<chrono::Utc>,
    pub active_window: Option<String>,
    pub keyboard_events: u64,
    pub mouse_clicks: u64,
    pub mouse_moves: u64,
    pub is_idle: bool,
    pub screenshot_path: Option<PathBuf>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Suggestion {
    pub id: String,
    pub title: String,
    pub description: String,
    pub category: SuggestionCategory,
    pub confidence: f64,
    pub screenshot_path: Option<PathBuf>,
}

#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum SuggestionCategory {
    Automation,
    BetterPractice,
    Shortcut,
    Refactoring,
    Security,
    Performance,
}

pub struct ActivityMonitor {
    config: MonitorConfig,
    input_tracker: Arc<RwLock<InputTracker>>,
    screenshot_capture: Arc<RwLock<ScreenshotCapture>>,
    notification_manager: NotificationManager,
    is_running: Arc<RwLock<bool>>,
    last_activity: Arc<RwLock<Instant>>,
    suggestions: Arc<RwLock<Vec<Suggestion>>>,
}

impl ActivityMonitor {
    pub fn new(config: MonitorConfig) -> Result<Self> {
        let input_tracker = Arc::new(RwLock::new(InputTracker::new(config.clone())?));
        let screenshot_capture = Arc::new(RwLock::new(ScreenshotCapture::new(
            config.screenshot_dir.clone(),
            config.screenshot_interval_secs,
        )?));
        let notification_manager = NotificationManager::new();

        Ok(Self {
            config,
            input_tracker,
            screenshot_capture,
            notification_manager,
            is_running: Arc::new(RwLock::new(false)),
            last_activity: Arc::new(RwLock::new(Instant::now())),
            suggestions: Arc::new(RwLock::new(Vec::new())),
        })
    }

    pub async fn start(&self) -> Result<()> {
        *self.is_running.write().await = true;

        // Task 1: poll OS keyboard/mouse state and update counters.
        // Without this loop, the InputTracker's `record_*` methods are never
        // called and the activity heuristics see zeroed counters forever.
        {
            let input_tracker = self.input_tracker.clone();
            let is_running = self.is_running.clone();
            let track_keyboard = self.config.track_keyboard;
            let track_mouse = self.config.track_mouse;
            let track_window = self.config.track_window;

            tokio::spawn(async move {
                use device_query::{DeviceQuery, DeviceState, MouseState};

                let device = DeviceState::new();
                let mut last_keys = device.get_keys();
                let initial_mouse: MouseState = device.get_mouse();
                let mut last_coords = initial_mouse.coords;
                let mut last_buttons: Vec<bool> = initial_mouse.button_pressed.clone();
                let mut last_window: Option<String> = None;

                let mut ticker = interval(Duration::from_millis(50));
                while *is_running.read().await {
                    ticker.tick().await;

                    if track_keyboard {
                        let keys = device.get_keys();
                        let new_presses = keys.iter().filter(|k| !last_keys.contains(k)).count();
                        if new_presses > 0 {
                            let mut t = input_tracker.write().await;
                            for _ in 0..new_presses {
                                t.record_keyboard();
                            }
                        }
                        last_keys = keys;
                    }

                    if track_mouse {
                        let mouse = device.get_mouse();
                        if mouse.coords != last_coords {
                            input_tracker.write().await.record_mouse_move();
                            last_coords = mouse.coords;
                        }
                        // Count rising edges only — a held button is one click.
                        for (i, pressed) in mouse.button_pressed.iter().enumerate() {
                            let was_pressed = last_buttons.get(i).copied().unwrap_or(false);
                            if *pressed && !was_pressed {
                                input_tracker.write().await.record_mouse_click();
                            }
                        }
                        last_buttons = mouse.button_pressed.clone();
                    }

                    if track_window {
                        let win = InputTracker::get_active_window();
                        if win.is_some() && win != last_window {
                            input_tracker
                                .write()
                                .await
                                .record_window_change(win.clone().unwrap_or_default());
                            last_window = win;
                        }
                    }
                }
            });
        }

        // Task 2: 1Hz analysis loop — screenshot + suggestion synthesis.
        let is_running = self.is_running.clone();
        let input_tracker = self.input_tracker.clone();
        let screenshot_capture = self.screenshot_capture.clone();
        let last_activity = self.last_activity.clone();
        let config = self.config.clone();
        let suggestions = self.suggestions.clone();
        let notification_manager = self.notification_manager.clone();

        tokio::spawn(async move {
            let mut ticker = interval(Duration::from_secs(1));

            while *is_running.read().await {
                ticker.tick().await;

                let activity = input_tracker.read().await.get_current_activity();

                if !activity.is_idle {
                    *last_activity.write().await = Instant::now();
                }

                if let Ok(screenshot_path) = screenshot_capture.write().await.capture().await {
                    if let Some(suggestion) = Self::analyze_activity(&activity, &screenshot_path).await {
                        let mut suggs = suggestions.write().await;
                        if !suggs.iter().any(|s| s.id == suggestion.id) {
                            suggs.push(suggestion.clone());

                            if config.enable_notifications {
                                let _ = notification_manager.send(&suggestion.title, &suggestion.description);
                            }
                        }
                    }
                }
            }
        });

        Ok(())
    }

    pub async fn stop(&self) {
        *self.is_running.write().await = false;
    }

    pub async fn is_running(&self) -> bool {
        *self.is_running.read().await
    }

    pub async fn get_suggestions(&self) -> Vec<Suggestion> {
        self.suggestions.read().await.clone()
    }

    pub async fn clear_suggestions(&self) {
        self.suggestions.write().await.clear();
    }

    pub async fn get_current_snapshot(&self) -> Result<ActivitySnapshot> {
        let activity = self.input_tracker.read().await.get_current_activity();
        let screenshot = self.screenshot_capture.read().await.get_latest_path();
        
        let idle_duration = self.last_activity.read().await.elapsed();
        let is_idle = idle_duration.as_secs() > self.config.idle_threshold_secs;

        Ok(ActivitySnapshot {
            timestamp: chrono::Utc::now(),
            active_window: activity.active_window,
            keyboard_events: activity.keyboard_events,
            mouse_clicks: activity.mouse_clicks,
            mouse_moves: activity.mouse_moves,
            is_idle,
            screenshot_path: screenshot,
        })
    }

    async fn analyze_activity(activity: &UserActivity, screenshot: &Option<PathBuf>) -> Option<Suggestion> {
        if activity.is_idle {
            return None;
        }

        if activity.keyboard_events > 100 && activity.mouse_clicks < 5 {
            return Some(Suggestion {
                id: uuid::Uuid::new_v4().to_string(),
                title: "Consider using keyboard shortcuts".to_string(),
                description: "High keyboard activity with few clicks. You might benefit from learning keyboard shortcuts for your IDE.".to_string(),
                category: SuggestionCategory::Shortcut,
                confidence: 0.7,
                screenshot_path: screenshot.clone(),
            });
        }

        if activity.mouse_moves > 500 && activity.keyboard_events < 20 {
            return Some(Suggestion {
                id: uuid::Uuid::new_v4().to_string(),
                title: "Repetitive mouse usage detected".to_string(),
                description: "Lots of mouse movements. Consider keyboard navigation or macros for repetitive tasks.".to_string(),
                category: SuggestionCategory::Automation,
                confidence: 0.6,
                screenshot_path: screenshot.clone(),
            });
        }

        if let Some(ref window) = activity.active_window {
            let window_lower = window.to_lowercase();
            if window_lower.contains("terminal") || window_lower.contains("bash") || window_lower.contains("powershell") {
                if activity.keyboard_events > 50 {
                    return Some(Suggestion {
                        id: uuid::Uuid::new_v4().to_string(),
                        title: "Create a shell alias or script".to_string(),
                        description: "Repeated terminal commands detected. Consider creating an alias or script for automation.".to_string(),
                        category: SuggestionCategory::Automation,
                        confidence: 0.75,
                        screenshot_path: screenshot.clone(),
                    });
                }
            }
        }

        None
    }
}