nexus_memory_agent/
activity_monitor.rs1use 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#[derive(Debug, Clone, Serialize, Deserialize)]
12pub struct ActivityMonitor {
13 pub activity_log: Vec<DateTime<Utc>>,
15 pub detected_sleep_hour: Option<u8>,
17 pub last_deep_dream: Option<DateTime<Utc>>,
19 #[serde(with = "serde_duration_secs")]
21 pub deep_dream_cooldown: Duration,
22 #[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 pub fn record_activity(&mut self) {
46 let now = Utc::now();
47 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 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 self.detected_sleep_hour = self.compute_sleep_hour();
69 }
70
71 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 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 pub fn should_deep_dream(&self) -> bool {
104 if self.activity_log.is_empty() {
106 return false;
107 }
108
109 let now = Utc::now();
110
111 if let Some(last) = self.last_deep_dream {
113 if now < last + self.deep_dream_cooldown {
114 return false;
115 }
116 }
117
118 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 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 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 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(); 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 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 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 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 let sleep_hour = monitor.compute_sleep_hour().unwrap();
271 assert!(
273 !(9..=17).contains(&sleep_hour),
274 "Expected sleep hour outside 9-17, got {}",
275 sleep_hour
276 );
277 }
278}