Skip to main content

cersei_agent/
auto_dream.rs

1//! Auto-dream: background memory consolidation daemon.
2//!
3//! Three-gate system (cheapest first):
4//! 1. Time gate: ≥24 hours since last consolidation
5//! 2. Session gate: ≥5 new sessions since last
6//! 3. Lock gate: no concurrent consolidation (stale after 1 hour)
7
8use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12// ─── Constants ───────────────────────────────────────────────────────────────
13
14const MIN_HOURS_DEFAULT: f64 = 24.0;
15const MIN_SESSIONS_DEFAULT: usize = 5;
16const LOCK_STALE_SECS: u64 = 3600; // 1 hour
17
18// ─── Types ───────────────────────────────────────────────────────────────────
19
20#[derive(Debug, Clone, Serialize, Deserialize, Default)]
21pub struct ConsolidationState {
22    pub last_consolidated_at: Option<u64>,
23    pub lock_etag: Option<String>,
24}
25
26#[derive(Debug, Clone)]
27pub struct AutoDreamConfig {
28    pub min_hours: f64,
29    pub min_sessions: usize,
30}
31
32impl Default for AutoDreamConfig {
33    fn default() -> Self {
34        Self {
35            min_hours: MIN_HOURS_DEFAULT,
36            min_sessions: MIN_SESSIONS_DEFAULT,
37        }
38    }
39}
40
41pub struct AutoDream {
42    pub memory_dir: PathBuf,
43    pub conversations_dir: PathBuf,
44    pub config: AutoDreamConfig,
45}
46
47impl AutoDream {
48    pub fn new(memory_dir: PathBuf, conversations_dir: PathBuf) -> Self {
49        Self {
50            memory_dir,
51            conversations_dir,
52            config: AutoDreamConfig::default(),
53        }
54    }
55
56    pub fn with_config(mut self, config: AutoDreamConfig) -> Self {
57        self.config = config;
58        self
59    }
60
61    // ─── State persistence ───────────────────────────────────────────────
62
63    fn state_path(&self) -> PathBuf {
64        self.memory_dir.join(".consolidation_state.json")
65    }
66
67    fn lock_path(&self) -> PathBuf {
68        self.memory_dir.join(".consolidation_lock")
69    }
70
71    pub fn load_state(&self) -> ConsolidationState {
72        let path = self.state_path();
73        std::fs::read_to_string(&path)
74            .ok()
75            .and_then(|s| serde_json::from_str(&s).ok())
76            .unwrap_or_default()
77    }
78
79    pub fn save_state(&self, state: &ConsolidationState) -> std::io::Result<()> {
80        let path = self.state_path();
81        if let Some(parent) = path.parent() {
82            std::fs::create_dir_all(parent)?;
83        }
84        std::fs::write(&path, serde_json::to_string_pretty(state)?)
85    }
86
87    pub fn update_state(&self) -> std::io::Result<()> {
88        let now = now_secs();
89        let state = ConsolidationState {
90            last_consolidated_at: Some(now),
91            lock_etag: None,
92        };
93        self.save_state(&state)
94    }
95
96    // ─── Gate checks ─────────────────────────────────────────────────────
97
98    /// Gate 1: Has enough time passed since last consolidation?
99    pub fn time_gate_passes(&self, state: &ConsolidationState) -> bool {
100        match state.last_consolidated_at {
101            None => true, // never consolidated
102            Some(last) => {
103                let now = now_secs();
104                let hours_elapsed = (now - last) as f64 / 3600.0;
105                hours_elapsed >= self.config.min_hours
106            }
107        }
108    }
109
110    /// Gate 2: Are there enough new sessions?
111    pub fn session_gate_passes(&self, state: &ConsolidationState) -> bool {
112        let since = state.last_consolidated_at.unwrap_or(0);
113
114        let entries = match std::fs::read_dir(&self.conversations_dir) {
115            Ok(e) => e,
116            Err(_) => return false,
117        };
118
119        let mut count = 0;
120        for entry in entries.flatten() {
121            let path = entry.path();
122            if path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
123                continue;
124            }
125            let mtime = std::fs::metadata(&path)
126                .and_then(|m| m.modified())
127                .ok()
128                .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
129                .map(|d| d.as_secs())
130                .unwrap_or(0);
131
132            if mtime > since {
133                count += 1;
134                if count >= self.config.min_sessions {
135                    return true; // early exit
136                }
137            }
138        }
139
140        false
141    }
142
143    /// Gate 3: Is there no active consolidation lock?
144    pub fn lock_gate_passes(&self) -> bool {
145        let lock = self.lock_path();
146        if !lock.exists() {
147            return true;
148        }
149
150        // Check if lock is stale
151        let mtime = std::fs::metadata(&lock)
152            .and_then(|m| m.modified())
153            .ok()
154            .and_then(|t| t.duration_since(UNIX_EPOCH).ok())
155            .map(|d| d.as_secs())
156            .unwrap_or(0);
157
158        let now = now_secs();
159        (now - mtime) > LOCK_STALE_SECS
160    }
161
162    /// Check all gates in order (cheapest first).
163    pub fn should_consolidate(&self) -> bool {
164        let state = self.load_state();
165        self.time_gate_passes(&state) && self.session_gate_passes(&state) && self.lock_gate_passes()
166    }
167
168    // ─── Lock management ─────────────────────────────────────────────────
169
170    pub fn acquire_lock(&self) -> std::io::Result<()> {
171        let lock = self.lock_path();
172        if let Some(parent) = lock.parent() {
173            std::fs::create_dir_all(parent)?;
174        }
175        std::fs::write(&lock, now_secs().to_string())
176    }
177
178    pub fn release_lock(&self) -> std::io::Result<()> {
179        let lock = self.lock_path();
180        if lock.exists() {
181            std::fs::remove_file(&lock)?;
182        }
183        Ok(())
184    }
185
186    // ─── Consolidation prompt ────────────────────────────────────────────
187
188    pub fn consolidation_prompt(&self) -> String {
189        format!(
190            "You are a memory consolidation agent. Your job is to organize and \
191            prune the memory directory at {}.\n\n\
192            Follow these phases:\n\
193            1. **Orient**: ls the memory directory, read MEMORY.md, skim topic files\n\
194            2. **Gather**: Read recent session logs, identify new facts\n\
195            3. **Consolidate**: Merge new signal into existing files, convert relative dates to absolute\n\
196            4. **Prune**: Keep MEMORY.md under 200 lines / 25KB, remove contradicted facts\n\n\
197            Only use read-only tools: ls, find, grep, cat, stat, wc, head, tail.\n\
198            Write changes to memory files using Write and Edit tools.",
199            self.memory_dir.display()
200        )
201    }
202}
203
204fn now_secs() -> u64 {
205    SystemTime::now()
206        .duration_since(UNIX_EPOCH)
207        .map(|d| d.as_secs())
208        .unwrap_or(0)
209}
210
211// ─── Tests ───────────────────────────────────────────────────────────────────
212
213#[cfg(test)]
214mod tests {
215    use super::*;
216
217    fn setup() -> (tempfile::TempDir, AutoDream) {
218        let tmp = tempfile::tempdir().unwrap();
219        let mem_dir = tmp.path().join("memory");
220        let conv_dir = tmp.path().join("conversations");
221        std::fs::create_dir_all(&mem_dir).unwrap();
222        std::fs::create_dir_all(&conv_dir).unwrap();
223
224        let dream = AutoDream::new(mem_dir, conv_dir);
225        (tmp, dream)
226    }
227
228    #[test]
229    fn test_time_gate_never_consolidated() {
230        let (_tmp, dream) = setup();
231        let state = ConsolidationState::default();
232        assert!(dream.time_gate_passes(&state));
233    }
234
235    #[test]
236    fn test_time_gate_recent() {
237        let (_tmp, dream) = setup();
238        let state = ConsolidationState {
239            last_consolidated_at: Some(now_secs() - 3600), // 1 hour ago
240            ..Default::default()
241        };
242        assert!(!dream.time_gate_passes(&state)); // need 24 hours
243    }
244
245    #[test]
246    fn test_time_gate_old() {
247        let (_tmp, dream) = setup();
248        let state = ConsolidationState {
249            last_consolidated_at: Some(now_secs() - 90_000), // 25 hours ago
250            ..Default::default()
251        };
252        assert!(dream.time_gate_passes(&state));
253    }
254
255    #[test]
256    fn test_session_gate_no_sessions() {
257        let (_tmp, dream) = setup();
258        let state = ConsolidationState::default();
259        assert!(!dream.session_gate_passes(&state));
260    }
261
262    #[test]
263    fn test_session_gate_enough_sessions() {
264        let (tmp, dream) = setup();
265        let conv_dir = tmp.path().join("conversations");
266
267        // Create 6 session files
268        for i in 0..6 {
269            std::fs::write(conv_dir.join(format!("session-{}.jsonl", i)), "{}").unwrap();
270        }
271
272        let state = ConsolidationState {
273            last_consolidated_at: Some(now_secs() - 86400), // yesterday
274            ..Default::default()
275        };
276        assert!(dream.session_gate_passes(&state));
277    }
278
279    #[test]
280    fn test_lock_gate_no_lock() {
281        let (_tmp, dream) = setup();
282        assert!(dream.lock_gate_passes());
283    }
284
285    #[test]
286    fn test_lock_gate_active_lock() {
287        let (_tmp, dream) = setup();
288        dream.acquire_lock().unwrap();
289        assert!(!dream.lock_gate_passes()); // lock is fresh
290    }
291
292    #[test]
293    fn test_lock_acquire_release() {
294        let (_tmp, dream) = setup();
295        dream.acquire_lock().unwrap();
296        assert!(dream.lock_path().exists());
297        dream.release_lock().unwrap();
298        assert!(!dream.lock_path().exists());
299    }
300
301    #[test]
302    fn test_state_persistence() {
303        let (_tmp, dream) = setup();
304
305        // Initially empty
306        let state = dream.load_state();
307        assert!(state.last_consolidated_at.is_none());
308
309        // Update
310        dream.update_state().unwrap();
311        let state = dream.load_state();
312        assert!(state.last_consolidated_at.is_some());
313        assert!(state.last_consolidated_at.unwrap() > now_secs() - 10);
314    }
315
316    #[test]
317    fn test_consolidation_prompt() {
318        let (_tmp, dream) = setup();
319        let prompt = dream.consolidation_prompt();
320        assert!(prompt.contains("memory consolidation"));
321        assert!(prompt.contains("Orient"));
322        assert!(prompt.contains("Prune"));
323    }
324}