1use std::cmp::Reverse;
2use std::path::Path;
3
4use anyhow::Result;
5use chrono::{Duration, Utc};
6
7use crate::discovery::{find_archived_unit, find_unit_file};
8use crate::index::Index;
9use crate::relevance::relevance_score;
10use crate::unit::{AttemptOutcome, Status, Unit};
11
12#[derive(Debug)]
14pub struct WorkingUnit {
15 pub unit: Unit,
16 pub failed_attempts: usize,
17 pub last_failure_notes: Option<String>,
18}
19
20#[derive(Debug)]
22pub struct RelevantFact {
23 pub unit: Unit,
24 pub score: u32,
25}
26
27#[derive(Debug)]
29pub struct RecentWork {
30 pub unit: Unit,
31}
32
33pub struct MemoryContext {
35 pub warnings: Vec<String>,
36 pub working_on: Vec<WorkingUnit>,
37 pub relevant_facts: Vec<RelevantFact>,
38 pub recent_work: Vec<RecentWork>,
39}
40
41pub fn memory_context(mana_dir: &Path) -> Result<MemoryContext> {
46 let now = Utc::now();
47 let index = Index::load_or_rebuild(mana_dir)?;
48 let archived = Index::collect_archived(mana_dir).unwrap_or_default();
49
50 let mut working_paths: Vec<String> = Vec::new();
51 let mut working_deps: Vec<String> = Vec::new();
52 let mut warnings: Vec<String> = Vec::new();
53 let mut working_on: Vec<WorkingUnit> = Vec::new();
54
55 for entry in &index.units {
57 if entry.status != Status::InProgress {
58 continue;
59 }
60
61 let unit_path = match find_unit_file(mana_dir, &entry.id) {
62 Ok(p) => p,
63 Err(_) => continue,
64 };
65
66 let unit = match Unit::from_file(&unit_path) {
67 Ok(b) => b,
68 Err(_) => continue,
69 };
70
71 working_paths.extend(unit.paths.clone());
72 working_deps.extend(unit.requires.clone());
73 working_deps.extend(unit.produces.clone());
74
75 let failed_attempts: Vec<_> = unit
76 .attempt_log
77 .iter()
78 .filter(|a| a.outcome == AttemptOutcome::Failed)
79 .collect();
80
81 let last_failure_notes = failed_attempts.last().and_then(|a| a.notes.clone());
82
83 if let Some(ref notes) = last_failure_notes {
84 warnings.push(format!(
85 "PAST FAILURE [{}]: \"{}\"",
86 unit.id,
87 notes.chars().take(80).collect::<String>()
88 ));
89 }
90
91 working_on.push(WorkingUnit {
92 failed_attempts: failed_attempts.len(),
93 last_failure_notes,
94 unit,
95 });
96 }
97
98 for entry in index.units.iter().chain(archived.iter()) {
100 let unit_path = match find_unit_file(mana_dir, &entry.id)
101 .or_else(|_| find_archived_unit(mana_dir, &entry.id))
102 {
103 Ok(p) => p,
104 Err(_) => continue,
105 };
106
107 let unit = match Unit::from_file(&unit_path) {
108 Ok(b) => b,
109 Err(_) => continue,
110 };
111
112 if unit.unit_type != "fact" {
113 continue;
114 }
115
116 if let Some(stale_after) = unit.stale_after {
117 if now > stale_after {
118 let days_stale = (now - stale_after).num_days();
119 warnings.push(format!(
120 "STALE: \"{}\" — not verified in {}d",
121 unit.title, days_stale
122 ));
123 }
124 }
125 }
126
127 let mut relevant_facts: Vec<RelevantFact> = Vec::new();
129
130 for entry in index.units.iter().chain(archived.iter()) {
131 let unit_path = match find_unit_file(mana_dir, &entry.id)
132 .or_else(|_| find_archived_unit(mana_dir, &entry.id))
133 {
134 Ok(p) => p,
135 Err(_) => continue,
136 };
137
138 let unit = match Unit::from_file(&unit_path) {
139 Ok(b) => b,
140 Err(_) => continue,
141 };
142
143 if unit.unit_type != "fact" {
144 continue;
145 }
146
147 let score = relevance_score(&unit, &working_paths, &working_deps);
148 if score > 0 {
149 relevant_facts.push(RelevantFact { unit, score });
150 }
151 }
152
153 relevant_facts.sort_by_key(|fact| Reverse(fact.score));
154
155 let mut recent_work: Vec<RecentWork> = Vec::new();
157 let seven_days_ago = now - Duration::days(7);
158
159 for entry in &archived {
160 if entry.status != Status::Closed {
161 continue;
162 }
163
164 let unit_path = match find_archived_unit(mana_dir, &entry.id) {
165 Ok(p) => p,
166 Err(_) => continue,
167 };
168
169 let unit = match Unit::from_file(&unit_path) {
170 Ok(b) => b,
171 Err(_) => continue,
172 };
173
174 if unit.unit_type == "fact" {
175 continue;
176 }
177
178 if let Some(closed_at) = unit.closed_at {
179 if closed_at > seven_days_ago {
180 recent_work.push(RecentWork { unit });
181 }
182 }
183 }
184
185 recent_work.sort_by(|a, b| {
186 b.unit
187 .closed_at
188 .unwrap_or(now)
189 .cmp(&a.unit.closed_at.unwrap_or(now))
190 });
191
192 Ok(MemoryContext {
193 warnings,
194 working_on,
195 relevant_facts,
196 recent_work,
197 })
198}
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use std::fs;
204 use tempfile::TempDir;
205
206 fn setup() -> (TempDir, std::path::PathBuf) {
207 let dir = TempDir::new().unwrap();
208 let mana_dir = dir.path().join(".mana");
209 fs::create_dir(&mana_dir).unwrap();
210
211 crate::config::Config {
212 project: "test".to_string(),
213 next_id: 10,
214 auto_close_parent: true,
215 run: None,
216 plan: None,
217 max_loops: 10,
218 max_concurrent: 4,
219 poll_interval: 30,
220 extends: vec![],
221 rules_file: None,
222 file_locking: false,
223 worktree: false,
224 on_close: None,
225 on_fail: 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}