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#[derive(Debug)]
13pub struct WorkingUnit {
14 pub unit: Unit,
15 pub failed_attempts: usize,
16 pub last_failure_notes: Option<String>,
17}
18
19#[derive(Debug)]
21pub struct RelevantFact {
22 pub unit: Unit,
23 pub score: u32,
24}
25
26#[derive(Debug)]
28pub struct RecentWork {
29 pub unit: Unit,
30}
31
32pub 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
40pub 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 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 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 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 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}