cersei_agent/
auto_dream.rs1use serde::{Deserialize, Serialize};
9use std::path::PathBuf;
10use std::time::{SystemTime, UNIX_EPOCH};
11
12const MIN_HOURS_DEFAULT: f64 = 24.0;
15const MIN_SESSIONS_DEFAULT: usize = 5;
16const LOCK_STALE_SECS: u64 = 3600; #[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 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 pub fn time_gate_passes(&self, state: &ConsolidationState) -> bool {
100 match state.last_consolidated_at {
101 None => true, 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 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; }
137 }
138 }
139
140 false
141 }
142
143 pub fn lock_gate_passes(&self) -> bool {
145 let lock = self.lock_path();
146 if !lock.exists() {
147 return true;
148 }
149
150 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 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 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 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#[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), ..Default::default()
241 };
242 assert!(!dream.time_gate_passes(&state)); }
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), ..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 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), ..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()); }
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 let state = dream.load_state();
307 assert!(state.last_consolidated_at.is_none());
308
309 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}