Skip to main content

mana_core/ops/
memory_context.rs

1use std::path::Path;
2
3use anyhow::Result;
4use chrono::{Duration, Utc};
5
6use crate::discovery::{find_archived_unit, find_unit_file};
7use crate::index::Index;
8use crate::relevance::relevance_score;
9use crate::unit::{AttemptOutcome, Status, Unit};
10
11/// A working unit with attempt context.
12#[derive(Debug)]
13pub struct WorkingUnit {
14    pub unit: Unit,
15    pub failed_attempts: usize,
16    pub last_failure_notes: Option<String>,
17}
18
19/// A fact with its relevance score.
20#[derive(Debug)]
21pub struct RelevantFact {
22    pub unit: Unit,
23    pub score: u32,
24}
25
26/// A recently closed unit.
27#[derive(Debug)]
28pub struct RecentWork {
29    pub unit: Unit,
30}
31
32/// Assembled memory context for session-start injection.
33pub struct MemoryContext {
34    pub warnings: Vec<String>,
35    pub working_on: Vec<WorkingUnit>,
36    pub relevant_facts: Vec<RelevantFact>,
37    pub recent_work: Vec<RecentWork>,
38}
39
40/// Assemble memory context: warnings, working units, relevant facts, recent work.
41///
42/// This is the core logic behind `mana context` (without a unit ID) — it collects
43/// information relevant to the current session without any formatting.
44pub fn memory_context(mana_dir: &Path) -> Result<MemoryContext> {
45    let now = Utc::now();
46    let index = Index::load_or_rebuild(mana_dir)?;
47    let archived = Index::collect_archived(mana_dir).unwrap_or_default();
48
49    let mut working_paths: Vec<String> = Vec::new();
50    let mut working_deps: Vec<String> = Vec::new();
51    let mut warnings: Vec<String> = Vec::new();
52    let mut working_on: Vec<WorkingUnit> = Vec::new();
53
54    // Collect working units
55    for entry in &index.units {
56        if entry.status != Status::InProgress {
57            continue;
58        }
59
60        let unit_path = match find_unit_file(mana_dir, &entry.id) {
61            Ok(p) => p,
62            Err(_) => continue,
63        };
64
65        let unit = match Unit::from_file(&unit_path) {
66            Ok(b) => b,
67            Err(_) => continue,
68        };
69
70        working_paths.extend(unit.paths.clone());
71        working_deps.extend(unit.requires.clone());
72        working_deps.extend(unit.produces.clone());
73
74        let failed_attempts: Vec<_> = unit
75            .attempt_log
76            .iter()
77            .filter(|a| a.outcome == AttemptOutcome::Failed)
78            .collect();
79
80        let last_failure_notes = failed_attempts.last().and_then(|a| a.notes.clone());
81
82        if let Some(ref notes) = last_failure_notes {
83            warnings.push(format!(
84                "PAST FAILURE [{}]: \"{}\"",
85                unit.id,
86                notes.chars().take(80).collect::<String>()
87            ));
88        }
89
90        working_on.push(WorkingUnit {
91            failed_attempts: failed_attempts.len(),
92            last_failure_notes,
93            unit,
94        });
95    }
96
97    // Check facts for staleness
98    for entry in index.units.iter().chain(archived.iter()) {
99        let unit_path = match find_unit_file(mana_dir, &entry.id)
100            .or_else(|_| find_archived_unit(mana_dir, &entry.id))
101        {
102            Ok(p) => p,
103            Err(_) => continue,
104        };
105
106        let unit = match Unit::from_file(&unit_path) {
107            Ok(b) => b,
108            Err(_) => continue,
109        };
110
111        if unit.unit_type != "fact" {
112            continue;
113        }
114
115        if let Some(stale_after) = unit.stale_after {
116            if now > stale_after {
117                let days_stale = (now - stale_after).num_days();
118                warnings.push(format!(
119                    "STALE: \"{}\" — not verified in {}d",
120                    unit.title, days_stale
121                ));
122            }
123        }
124    }
125
126    // Score relevant facts
127    let mut relevant_facts: Vec<RelevantFact> = Vec::new();
128
129    for entry in index.units.iter().chain(archived.iter()) {
130        let unit_path = match find_unit_file(mana_dir, &entry.id)
131            .or_else(|_| find_archived_unit(mana_dir, &entry.id))
132        {
133            Ok(p) => p,
134            Err(_) => continue,
135        };
136
137        let unit = match Unit::from_file(&unit_path) {
138            Ok(b) => b,
139            Err(_) => continue,
140        };
141
142        if unit.unit_type != "fact" {
143            continue;
144        }
145
146        let score = relevance_score(&unit, &working_paths, &working_deps);
147        if score > 0 {
148            relevant_facts.push(RelevantFact { unit, score });
149        }
150    }
151
152    relevant_facts.sort_by(|a, b| b.score.cmp(&a.score));
153
154    // Recent work (closed in last 7 days)
155    let mut recent_work: Vec<RecentWork> = Vec::new();
156    let seven_days_ago = now - Duration::days(7);
157
158    for entry in &archived {
159        if entry.status != Status::Closed {
160            continue;
161        }
162
163        let unit_path = match find_archived_unit(mana_dir, &entry.id) {
164            Ok(p) => p,
165            Err(_) => continue,
166        };
167
168        let unit = match Unit::from_file(&unit_path) {
169            Ok(b) => b,
170            Err(_) => continue,
171        };
172
173        if unit.unit_type == "fact" {
174            continue;
175        }
176
177        if let Some(closed_at) = unit.closed_at {
178            if closed_at > seven_days_ago {
179                recent_work.push(RecentWork { unit });
180            }
181        }
182    }
183
184    recent_work.sort_by(|a, b| {
185        b.unit
186            .closed_at
187            .unwrap_or(now)
188            .cmp(&a.unit.closed_at.unwrap_or(now))
189    });
190
191    Ok(MemoryContext {
192        warnings,
193        working_on,
194        relevant_facts,
195        recent_work,
196    })
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use std::fs;
203    use tempfile::TempDir;
204
205    fn setup() -> (TempDir, std::path::PathBuf) {
206        let dir = TempDir::new().unwrap();
207        let mana_dir = dir.path().join(".mana");
208        fs::create_dir(&mana_dir).unwrap();
209
210        crate::config::Config {
211            project: "test".to_string(),
212            next_id: 10,
213            auto_close_parent: true,
214            run: None,
215            plan: None,
216            max_loops: 10,
217            max_concurrent: 4,
218            poll_interval: 30,
219            extends: vec![],
220            rules_file: None,
221            file_locking: false,
222            worktree: false,
223            on_close: None,
224            on_fail: None,
225            post_plan: None,
226            verify_timeout: None,
227            review: None,
228            user: None,
229            user_email: None,
230            auto_commit: false,
231            commit_template: None,
232            research: None,
233            run_model: None,
234            plan_model: None,
235            review_model: None,
236            research_model: None,
237            batch_verify: false,
238            memory_reserve_mb: 0,
239            notify: None,
240        }
241        .save(&mana_dir)
242        .unwrap();
243
244        (dir, mana_dir)
245    }
246
247    #[test]
248    fn memory_context_empty() {
249        let (_dir, mana_dir) = setup();
250        let result = memory_context(&mana_dir).unwrap();
251        assert!(result.warnings.is_empty());
252        assert!(result.working_on.is_empty());
253        assert!(result.relevant_facts.is_empty());
254        assert!(result.recent_work.is_empty());
255    }
256
257    #[test]
258    fn memory_context_shows_claimed_units() {
259        let (_dir, mana_dir) = setup();
260
261        let mut unit = Unit::new("1", "Working on auth");
262        unit.status = Status::InProgress;
263        unit.claimed_by = Some("agent-1".to_string());
264        unit.claimed_at = Some(Utc::now());
265        let slug = crate::util::title_to_slug(&unit.title);
266        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
267            .unwrap();
268
269        let result = memory_context(&mana_dir).unwrap();
270        assert_eq!(result.working_on.len(), 1);
271        assert_eq!(result.working_on[0].unit.id, "1");
272    }
273
274    #[test]
275    fn memory_context_shows_stale_facts() {
276        let (_dir, mana_dir) = setup();
277
278        let mut unit = Unit::new("1", "Auth uses RS256");
279        unit.unit_type = "fact".to_string();
280        unit.stale_after = Some(Utc::now() - Duration::days(5));
281        unit.verify = Some("true".to_string());
282        let slug = crate::util::title_to_slug(&unit.title);
283        unit.to_file(mana_dir.join(format!("1-{}.md", slug)))
284            .unwrap();
285
286        let result = memory_context(&mana_dir).unwrap();
287        assert!(!result.warnings.is_empty());
288        assert!(result.warnings[0].contains("STALE"));
289    }
290}