Skip to main content

nexus_memory_agent/
activity_monitor.rs

1//! Activity monitoring and sleep detection for dream cycle calibration.
2
3use chrono::{DateTime, Duration, Timelike, Utc};
4use nexus_core::fsutil::atomic_write;
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::path::PathBuf;
8use tracing::debug;
9
10/// Tracks user activity to detect idle periods and ideal times for deep dreaming.
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ActivityMonitor {
13    /// Sampled activity timestamps (rounded to 10 min, retained 7 days)
14    pub activity_log: Vec<DateTime<Utc>>,
15    /// Automatically detected typical sleep hour (0-23)
16    pub detected_sleep_hour: Option<u8>,
17    /// When the last deep dream was successfully completed
18    pub last_deep_dream: Option<DateTime<Utc>>,
19    /// Minimum time between deep dreams
20    #[serde(with = "serde_duration_secs")]
21    pub deep_dream_cooldown: Duration,
22    /// Inactivity threshold (minutes) before deep dream can trigger
23    #[serde(default = "default_inactivity_mins")]
24    pub deep_dream_inactivity_mins: u64,
25}
26
27fn default_inactivity_mins() -> u64 {
28    30
29}
30
31impl Default for ActivityMonitor {
32    fn default() -> Self {
33        Self {
34            activity_log: Vec::new(),
35            detected_sleep_hour: None,
36            last_deep_dream: None,
37            deep_dream_cooldown: Duration::hours(24),
38            deep_dream_inactivity_mins: 30,
39        }
40    }
41}
42
43impl ActivityMonitor {
44    /// Record a sample of user activity.
45    pub fn record_activity(&mut self) {
46        let now = Utc::now();
47        // Round to 10 minute window for sampling efficiency
48        let rounded = DateTime::<Utc>::from_naive_utc_and_offset(
49            now.naive_utc()
50                .with_nanosecond(0)
51                .unwrap()
52                .with_second(0)
53                .unwrap()
54                .with_minute((now.minute() / 10) * 10)
55                .unwrap(),
56            Utc,
57        );
58
59        if !self.activity_log.contains(&rounded) {
60            self.activity_log.push(rounded);
61            // Retain only 7 days
62            let week_ago = now - Duration::days(7);
63            self.activity_log.retain(|t| *t > week_ago);
64            debug!("Activity recorded at {}", rounded);
65        }
66
67        // Recompute sleep hour from updated activity log
68        self.detected_sleep_hour = self.compute_sleep_hour();
69    }
70
71    /// Compute the most likely sleep hour from the activity log.
72    ///
73    /// Counts activity samples per UTC hour (0–23) and returns the
74    /// hour with the *fewest* samples — the user is typically away.
75    /// Returns `None` when the log has fewer than 14 entries (insufficient data).
76    pub fn compute_sleep_hour(&self) -> Option<u8> {
77        if self.activity_log.len() < 14 {
78            return None;
79        }
80
81        let mut hour_counts = [0usize; 24];
82        for ts in &self.activity_log {
83            let h = ts.hour() as usize;
84            hour_counts[h] += 1;
85        }
86
87        // Find the hour with minimum activity (earliest on tie).
88        let (sleep_hour, _min_count) = hour_counts
89            .iter()
90            .enumerate()
91            .min_by_key(|&(h, &count)| (count, h))
92            .expect("24 elements guaranteed");
93
94        Some(sleep_hour as u8)
95    }
96    /// Check if deep dream conditions are met.
97    ///
98    /// Returns false if no activity samples exist (fresh install), if within
99    /// cooldown since the last deep dream, or if insufficient inactivity time.
100    /// When `detected_sleep_hour` matches the current hour (±2h window),
101    /// the inactivity threshold is relaxed to one-third of the configured value
102    /// (minimum 10 minutes) since the user is likely away during their sleep window.
103    pub fn should_deep_dream(&self) -> bool {
104        // No activity samples — cannot determine inactivity, don't trigger
105        if self.activity_log.is_empty() {
106            return false;
107        }
108
109        let now = Utc::now();
110
111        // 1. Cooldown check
112        if let Some(last) = self.last_deep_dream {
113            if now < last + self.deep_dream_cooldown {
114                return false;
115            }
116        }
117
118        // 2. Inactivity check — threshold depends on sleep window
119        let in_sleep_window = self.detected_sleep_hour.is_some_and(|sleep_h| {
120            let current_h = now.hour() as i32;
121            let sleep_h = sleep_h as i32;
122            let diff = (current_h - sleep_h).rem_euclid(24);
123            diff <= 2 || diff >= 22
124        });
125
126        let base_mins = self.deep_dream_inactivity_mins as i64;
127        let inactivity_threshold = if in_sleep_window {
128            Duration::minutes((base_mins / 3).max(10))
129        } else {
130            Duration::minutes(base_mins)
131        };
132
133        if let Some(last_active) = self.activity_log.last() {
134            if now < *last_active + inactivity_threshold {
135                return false;
136            }
137        }
138
139        true
140    }
141
142    /// Default path for global activity monitor persistence.
143    pub fn persistence_path() -> PathBuf {
144        dirs::data_dir()
145            .unwrap_or_else(|| PathBuf::from("."))
146            .join("nexus-memory-system")
147            .join("activity_monitor.json")
148    }
149
150    /// Load from disk.
151    pub fn load() -> Self {
152        let path = Self::persistence_path();
153        if path.exists() {
154            match fs::read_to_string(&path) {
155                Ok(content) => match serde_json::from_str(&content) {
156                    Ok(monitor) => return monitor,
157                    Err(e) => {
158                        tracing::warn!(
159                            path = %path.display(),
160                            error = %e,
161                            "Failed to parse activity monitor; using defaults"
162                        );
163                    }
164                },
165                Err(e) => {
166                    tracing::warn!(
167                        path = %path.display(),
168                        error = %e,
169                        "Failed to read activity monitor; using defaults"
170                    );
171                }
172            }
173        }
174        Self::default()
175    }
176
177    /// Save to disk.
178    pub fn save(&self) -> anyhow::Result<()> {
179        let path = Self::persistence_path();
180        if let Some(parent) = path.parent() {
181            fs::create_dir_all(parent)?;
182        }
183        let content = serde_json::to_string(self)?;
184        atomic_write(&path, &content)?;
185        Ok(())
186    }
187}
188
189mod serde_duration_secs {
190    use super::*;
191    use serde::{Deserializer, Serializer};
192
193    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
194    where
195        S: Serializer,
196    {
197        serializer.serialize_i64(duration.num_seconds())
198    }
199
200    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
201    where
202        D: Deserializer<'de>,
203    {
204        let secs = i64::deserialize(deserializer)?;
205        Ok(Duration::seconds(secs))
206    }
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    #[test]
214    fn test_activity_deduplication() {
215        let mut monitor = ActivityMonitor::default();
216        monitor.record_activity();
217        let len = monitor.activity_log.len();
218        monitor.record_activity(); // Same 10min window
219        assert_eq!(monitor.activity_log.len(), len);
220    }
221
222    #[test]
223    fn test_should_deep_dream_cooldown() {
224        let mut monitor = ActivityMonitor {
225            last_deep_dream: Some(Utc::now() - Duration::hours(12)),
226            ..ActivityMonitor::default()
227        };
228        assert!(!monitor.should_deep_dream());
229
230        monitor.last_deep_dream = Some(Utc::now() - Duration::hours(25));
231        // Need an activity log entry to pass inactivity check
232        monitor.activity_log.push(Utc::now() - Duration::hours(2));
233        assert!(monitor.should_deep_dream());
234    }
235
236    #[test]
237    fn test_should_deep_dream_with_empty_log() {
238        let monitor = ActivityMonitor::default();
239        // Fresh install: no activity samples means deep dream should not trigger
240        assert!(!monitor.should_deep_dream());
241    }
242
243    #[test]
244    fn test_compute_sleep_hour_insufficient_data() {
245        let monitor = ActivityMonitor::default();
246        assert_eq!(monitor.compute_sleep_hour(), None);
247    }
248
249    #[test]
250    fn test_compute_sleep_hour_from_work_hours() {
251        let mut monitor = ActivityMonitor::default();
252        // Add activities only during hours 9-17 (work hours) over multiple days
253        for day_offset in 0..7 {
254            for hour in 9..=17_u32 {
255                let base = Utc::now() - Duration::days(day_offset);
256                let ts = DateTime::<Utc>::from_naive_utc_and_offset(
257                    base.naive_utc()
258                        .with_hour(hour)
259                        .unwrap()
260                        .with_minute(0)
261                        .unwrap()
262                        .with_second(0)
263                        .unwrap(),
264                    Utc,
265                );
266                monitor.activity_log.push(ts);
267            }
268        }
269        // 63 entries, well above the 14 minimum
270        let sleep_hour = monitor.compute_sleep_hour().unwrap();
271        // Should pick a non-work hour (0-8 or 18-23)
272        assert!(
273            !(9..=17).contains(&sleep_hour),
274            "Expected sleep hour outside 9-17, got {}",
275            sleep_hour
276        );
277    }
278}