Skip to main content

hematite/memory/
deep_reflect.rs

1/// DeepReflect v2 — idle-triggered session memory synthesis.
2///
3/// After 5 minutes of TUI inactivity, reads the day's transcript and calls
4/// the local model to extract structured memories: files changed, decisions
5/// made, patterns observed, next steps.
6///
7/// Outputs are written to `.hematite/memories/<YYYY-MM-DD>.md`.
8/// These files are automatically injected into the system prompt at startup
9/// so Hematite knows what you were working on when you come back.
10use std::path::PathBuf;
11use std::sync::{Arc, Mutex};
12use std::time::Instant;
13
14use crate::agent::truncation::{safe_head, safe_tail};
15use tokio::time::{sleep, Duration};
16
17pub fn spawn_deep_reflect_system(
18    last_interaction: Arc<Mutex<Instant>>,
19    engine: Arc<crate::agent::inference::InferenceEngine>,
20) {
21    tokio::spawn(async move {
22        let mut last_synthesized_hash: u64 = 0;
23
24        loop {
25            sleep(Duration::from_secs(60)).await;
26
27            let idle = { last_interaction.lock().unwrap().elapsed() };
28            if idle < Duration::from_secs(300) {
29                continue;
30            }
31
32            let today = date_string();
33            let log_path = crate::tools::file_ops::hematite_dir()
34                .join("logs")
35                .join(format!("{}.log", today));
36            if !log_path.exists() {
37                continue;
38            }
39
40            let Ok(log_content) = std::fs::read_to_string(&log_path) else {
41                continue;
42            };
43            if log_content.trim().is_empty() {
44                continue;
45            }
46
47            // Skip if we already synthesized this exact content.
48            let hash = fast_hash(&log_content);
49            if hash == last_synthesized_hash {
50                continue;
51            }
52
53            // Cap transcript to avoid blowing the context window.
54            let transcript_slice = safe_tail(&log_content, 8_000);
55
56            let prompt = format!(
57                "You are a memory synthesizer for a coding agent. Analyze this session transcript \
58                 and extract the key information in structured form.\n\n\
59                 SESSION TRANSCRIPT:\n{}\n\n\
60                 Output ONLY this structure (no preamble, no explanation):\n\
61                 ## Files Modified\n\
62                 - list each file that was created, edited, or deleted\n\n\
63                 ## Decisions Made\n\
64                 - list key architectural or design decisions\n\n\
65                 ## Patterns Observed\n\
66                 - list any recurring issues, model behaviour patterns, or code patterns noted\n\n\
67                 ## Next Steps\n\
68                 - list any unfinished work, TODOs, or follow-up tasks mentioned\n\n\
69                 Be concise. Maximum 250 words total.",
70                transcript_slice
71            );
72
73            if let Ok(summary) = engine.generate_task(&prompt, true).await {
74                let memory_dir = PathBuf::from(".hematite").join("memories");
75                let _ = std::fs::create_dir_all(&memory_dir);
76                let mem_file = memory_dir.join(format!("{}.md", today));
77
78                let content = format!(
79                    "# Session Memory — {}\n_Synthesized by DeepReflect after idle period_\n\n{}\n",
80                    today, summary
81                );
82                let _ = std::fs::write(&mem_file, content);
83                last_synthesized_hash = hash;
84            }
85
86            // Reset idle timer so we don't re-synthesize immediately.
87            *last_interaction.lock().unwrap() = Instant::now();
88        }
89    });
90}
91
92/// Load recent memory files (last 3 days) to inject into the system prompt.
93/// Returns a formatted string ready for system prompt injection, or empty string.
94/// Cached for 60 seconds — DeepReflect only writes after 5+ minutes of idle,
95/// so a 60-second window never returns stale data during active sessions.
96pub fn load_recent_memories() -> String {
97    static CACHE: std::sync::Mutex<Option<(std::time::Instant, String)>> =
98        std::sync::Mutex::new(None);
99
100    if let Ok(g) = CACHE.lock() {
101        if let Some((t, ref v)) = *g {
102            if t.elapsed().as_secs() < 60 {
103                return v.clone();
104            }
105        }
106    }
107
108    let result = load_recent_memories_uncached();
109    if let Ok(mut g) = CACHE.lock() {
110        *g = Some((std::time::Instant::now(), result.clone()));
111    }
112    result
113}
114
115fn load_recent_memories_uncached() -> String {
116    let memory_dir = PathBuf::from(".hematite").join("memories");
117    if !memory_dir.exists() {
118        return String::new();
119    }
120
121    // Get last 3 memory files sorted by name (date-named, so lexicographic = chronological).
122    let mut files: Vec<_> = std::fs::read_dir(&memory_dir)
123        .ok()
124        .into_iter()
125        .flatten()
126        .filter_map(|e| e.ok())
127        .filter(|e| e.path().extension().map(|x| x == "md").unwrap_or(false))
128        .collect();
129    files.sort_by_key(|e| e.file_name());
130    files.reverse(); // newest first
131
132    let mut result = String::with_capacity(3_000);
133    let mut total = 0usize;
134    const MAX_TOTAL: usize = 3_000;
135
136    for entry in files.into_iter().take(3) {
137        let Ok(content) = std::fs::read_to_string(entry.path()) else {
138            continue;
139        };
140        if content.trim().is_empty() {
141            continue;
142        }
143        let snippet = if content.len() > 1_000 {
144            format!("{}...", safe_head(&content, 1_000))
145        } else {
146            content
147        };
148        if total + snippet.len() > MAX_TOTAL {
149            break;
150        }
151        total += snippet.len();
152        result.push_str(&snippet);
153        result.push('\n');
154    }
155
156    if result.is_empty() {
157        return String::new();
158    }
159    format!("\n\n# Cross-Session Memory (DeepReflect)\n{}", result)
160}
161
162/// Fast non-cryptographic hash for change detection.
163fn fast_hash(s: &str) -> u64 {
164    use std::collections::hash_map::DefaultHasher;
165    use std::hash::{Hash, Hasher};
166    let mut h = DefaultHasher::new();
167    s.hash(&mut h);
168    h.finish()
169}
170
171/// Returns today's date as YYYY-MM-DD using the system clock.
172fn date_string() -> String {
173    let secs = std::time::SystemTime::now()
174        .duration_since(std::time::UNIX_EPOCH)
175        .unwrap_or_default()
176        .as_secs();
177
178    // Days since epoch
179    let days = secs / 86_400;
180    // Gregorian approximation (accurate for ~100 years from 1970)
181    let mut year = 1970u64;
182    let mut remaining = days;
183    loop {
184        let days_in_year = if is_leap(year) { 366 } else { 365 };
185        if remaining < days_in_year {
186            break;
187        }
188        remaining -= days_in_year;
189        year += 1;
190    }
191    let months = [
192        31u64,
193        if is_leap(year) { 29 } else { 28 },
194        31,
195        30,
196        31,
197        30,
198        31,
199        31,
200        30,
201        31,
202        30,
203        31,
204    ];
205    let mut month = 1u64;
206    for days_in_month in &months {
207        if remaining < *days_in_month {
208            break;
209        }
210        remaining -= days_in_month;
211        month += 1;
212    }
213    let day = remaining + 1;
214    format!("{:04}-{:02}-{:02}", year, month, day)
215}
216
217fn is_leap(year: u64) -> bool {
218    (year.is_multiple_of(4) && !year.is_multiple_of(100)) || year.is_multiple_of(400)
219}