1use std::path::{Path, PathBuf};
2
3use chrono::{DateTime, Utc};
4
5use crate::system_prompt::Fact;
6
7const MAX_RELEVANT_FACTS: usize = 8;
8const MAX_FACT_TEXT_CHARS: usize = 160;
9const MAX_STATUS_WARNINGS: usize = 3;
10const MAX_STATUS_WORKING_ON: usize = 3;
11const MAX_STATUS_RECENT_WORK: usize = 3;
12const MAX_STATUS_TITLE_CHARS: usize = 80;
13const MAX_WARNING_TEXT_CHARS: usize = 120;
14
15#[derive(Debug, Default)]
20pub struct SessionPromptContext {
21 pub facts: Vec<Fact>,
22 pub project_memory_status: Option<String>,
23}
24
25pub fn load_session_prompt_context(cwd: &Path) -> SessionPromptContext {
26 let Some(mana_dir) = nearest_mana_dir(cwd) else {
27 return SessionPromptContext::default();
28 };
29
30 load_session_prompt_context_from_mana_dir(&mana_dir).unwrap_or_default()
31}
32
33pub fn load_task_prompt_context(mana_dir: &Path, task_paths: &[String]) -> SessionPromptContext {
34 load_task_prompt_context_from_mana_dir(mana_dir, task_paths).unwrap_or_default()
35}
36
37pub fn nearest_mana_dir(cwd: &Path) -> Option<PathBuf> {
38 mana_core::api::find_mana_dir(cwd).ok()
39}
40
41fn load_session_prompt_context_from_mana_dir(
42 mana_dir: &Path,
43) -> Result<SessionPromptContext, String> {
44 let memory = mana_core::api::memory_context(mana_dir).map_err(|err| err.to_string())?;
45
46 Ok(SessionPromptContext {
47 facts: map_relevant_facts(&memory),
48 project_memory_status: format_project_memory_status(&memory),
49 })
50}
51
52fn load_task_prompt_context_from_mana_dir(
53 mana_dir: &Path,
54 task_paths: &[String],
55) -> Result<SessionPromptContext, String> {
56 let memory = mana_core::api::memory_context(mana_dir).map_err(|err| err.to_string())?;
57
58 Ok(SessionPromptContext {
59 facts: map_task_relevant_facts(&memory, task_paths),
60 project_memory_status: None,
61 })
62}
63
64fn map_relevant_facts(memory: &mana_core::api::MemoryContext) -> Vec<Fact> {
65 memory
66 .relevant_facts
67 .iter()
68 .take(MAX_RELEVANT_FACTS)
69 .map(|relevant| Fact {
70 text: truncate_for_prompt(&relevant.unit.title, MAX_FACT_TEXT_CHARS),
71 verified_ago: format_verified_ago(relevant.unit.last_verified),
72 })
73 .collect()
74}
75
76fn map_task_relevant_facts(
77 memory: &mana_core::api::MemoryContext,
78 task_paths: &[String],
79) -> Vec<Fact> {
80 let mut relevant: Vec<_> = memory.relevant_facts.iter().collect();
81 if !task_paths.is_empty() {
82 relevant.retain(|fact| {
83 fact.unit.paths.iter().any(|fact_path| {
84 task_paths
85 .iter()
86 .any(|task_path| path_overlap(fact_path, task_path))
87 })
88 });
89 }
90
91 relevant
92 .into_iter()
93 .take(MAX_RELEVANT_FACTS)
94 .map(|relevant| Fact {
95 text: truncate_for_prompt(&relevant.unit.title, MAX_FACT_TEXT_CHARS),
96 verified_ago: format_verified_ago(relevant.unit.last_verified),
97 })
98 .collect()
99}
100
101fn path_overlap(a: &str, b: &str) -> bool {
102 a.starts_with(b) || b.starts_with(a) || a == b
103}
104
105fn format_project_memory_status(memory: &mana_core::api::MemoryContext) -> Option<String> {
106 let warnings = format_warning_lines(memory);
107 let working_on = format_working_on_lines(memory);
108 let recent_work = format_recent_work_lines(memory);
109
110 if warnings.is_empty() && working_on.is_empty() && recent_work.is_empty() {
111 return None;
112 }
113
114 let mut sections = Vec::new();
115
116 if !warnings.is_empty() {
117 sections.push(format!("Warnings:\n{}", warnings.join("\n")));
118 }
119
120 if !working_on.is_empty() {
121 sections.push(format!("Working on:\n{}", working_on.join("\n")));
122 }
123
124 if !recent_work.is_empty() {
125 sections.push(format!("Recent work:\n{}", recent_work.join("\n")));
126 }
127
128 Some(format!("Project memory status:\n{}", sections.join("\n\n")))
129}
130
131fn format_warning_lines(memory: &mana_core::api::MemoryContext) -> Vec<String> {
132 memory
133 .warnings
134 .iter()
135 .take(MAX_STATUS_WARNINGS)
136 .map(|warning| format!("- {}", truncate_for_prompt(warning, MAX_WARNING_TEXT_CHARS)))
137 .collect()
138}
139
140fn format_working_on_lines(memory: &mana_core::api::MemoryContext) -> Vec<String> {
141 memory
142 .working_on
143 .iter()
144 .take(MAX_STATUS_WORKING_ON)
145 .map(|working| {
146 let mut parts = vec![format!(
147 "[{}] {}",
148 working.unit.id,
149 truncate_for_prompt(&working.unit.title, MAX_STATUS_TITLE_CHARS)
150 )];
151
152 if working.failed_attempts > 0 {
153 parts.push(format!("{} failed attempt(s)", working.failed_attempts));
154 }
155
156 if let Some(claimed_by) = working.unit.claimed_by.as_deref() {
157 parts.push(format!("claimed by {}", claimed_by));
158 }
159
160 format!("- {}", parts.join(" — "))
161 })
162 .collect()
163}
164
165fn format_recent_work_lines(memory: &mana_core::api::MemoryContext) -> Vec<String> {
166 memory
167 .recent_work
168 .iter()
169 .take(MAX_STATUS_RECENT_WORK)
170 .map(|recent| {
171 let closed = recent
172 .unit
173 .closed_at
174 .map(|closed_at| format_verified_ago(Some(closed_at)))
175 .unwrap_or_else(|| "recently".to_string());
176
177 format!(
178 "- [{}] {} — closed {}",
179 recent.unit.id,
180 truncate_for_prompt(&recent.unit.title, MAX_STATUS_TITLE_CHARS),
181 closed
182 )
183 })
184 .collect()
185}
186
187fn truncate_for_prompt(text: &str, max_chars: usize) -> String {
188 let mut chars = text.chars();
189 let truncated: String = chars.by_ref().take(max_chars).collect();
190 if chars.next().is_some() {
191 format!("{}…", truncated.trim_end())
192 } else {
193 text.to_string()
194 }
195}
196
197fn format_verified_ago(last_verified: Option<DateTime<Utc>>) -> String {
198 let Some(last_verified) = last_verified else {
199 return "unverified".to_string();
200 };
201
202 let ago = Utc::now() - last_verified;
203 if ago.num_days() > 0 {
204 format!("{}d ago", ago.num_days())
205 } else if ago.num_hours() > 0 {
206 format!("{}h ago", ago.num_hours())
207 } else if ago.num_minutes() > 0 {
208 format!("{}m ago", ago.num_minutes())
209 } else {
210 "just now".to_string()
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use chrono::Duration;
218 use mana_core::config::Config;
219 use mana_core::ops::memory_context::MemoryContext;
220 use mana_core::unit::{Status, Unit};
221 use tempfile::TempDir;
222
223 fn setup_mana_dir() -> (TempDir, std::path::PathBuf) {
224 let dir = TempDir::new().unwrap();
225 let mana_dir = dir.path().join(".mana");
226 std::fs::create_dir(&mana_dir).unwrap();
227
228 let mut config = Config::default();
229 config.project = "test".to_string();
230 config.save(&mana_dir).unwrap();
231
232 (dir, mana_dir)
233 }
234
235 fn write_unit(mana_dir: &Path, unit: &Unit) {
236 let slug = mana_core::util::title_to_slug(&unit.title);
237 unit.to_file(mana_dir.join(format!("{}-{}.md", unit.id, slug)))
238 .unwrap();
239 }
240
241 #[test]
242 fn finds_nearest_mana_dir_from_nested_cwd() {
243 let (dir, mana_dir) = setup_mana_dir();
244 let nested = dir.path().join("project/src/module");
245 std::fs::create_dir_all(&nested).unwrap();
246
247 assert_eq!(nearest_mana_dir(&nested), Some(mana_dir));
248 }
249
250 #[test]
251 fn missing_mana_dir_yields_empty_prompt_context() {
252 let dir = TempDir::new().unwrap();
253 let context = load_session_prompt_context(dir.path());
254 assert!(context.facts.is_empty());
255 assert!(context.project_memory_status.is_none());
256 }
257
258 #[test]
259 fn invalid_mana_dir_load_yields_empty_prompt_context() {
260 let dir = TempDir::new().unwrap();
261 let mana_dir = dir.path().join(".mana");
262 std::fs::create_dir(&mana_dir).unwrap();
263
264 let context = load_session_prompt_context(dir.path());
265 assert!(context.facts.is_empty());
266 assert!(context.project_memory_status.is_none());
267 }
268
269 #[test]
270 fn maps_memory_context_to_bounded_prompt_facts() {
271 let mut recent = Unit::new("1", "Recent verified fact");
272 recent.last_verified = Some(Utc::now() - Duration::hours(2));
273
274 let mut stale = Unit::new(
275 "2",
276 "A very long fact title that should be truncated before it reaches the prompt because prompt context should stay bounded and selective for interactive startup",
277 );
278 stale.last_verified = None;
279
280 let memory = MemoryContext {
281 warnings: vec!["warn".into()],
282 working_on: vec![],
283 relevant_facts: vec![
284 mana_core::ops::memory_context::RelevantFact {
285 unit: recent,
286 score: 10,
287 },
288 mana_core::ops::memory_context::RelevantFact {
289 unit: stale,
290 score: 9,
291 },
292 ],
293 recent_work: vec![],
294 };
295
296 let facts = map_relevant_facts(&memory);
297
298 assert_eq!(facts.len(), 2);
299 assert_eq!(facts[0].text, "Recent verified fact");
300 assert_eq!(facts[0].verified_ago, "2h ago");
301 assert!(facts[1].text.ends_with('…'));
302 assert_eq!(facts[1].verified_ago, "unverified");
303 }
304
305 #[test]
306 fn loads_relevant_facts_from_mana_memory_context() {
307 let (_dir, mana_dir) = setup_mana_dir();
308
309 let mut working = Unit::new("1", "Implement auth flow");
310 working.status = Status::InProgress;
311 working.paths = vec!["src/auth.rs".to_string()];
312 working.requires = vec!["AuthProvider".to_string()];
313 write_unit(&mana_dir, &working);
314
315 let mut fact = Unit::new("2", "Auth uses RS256 signing");
316 fact.unit_type = "fact".to_string();
317 fact.paths = vec!["src/auth.rs".to_string()];
318 fact.produces = vec!["AuthProvider".to_string()];
319 fact.last_verified = Some(Utc::now() - Duration::minutes(30));
320 write_unit(&mana_dir, &fact);
321
322 let context = load_session_prompt_context_from_mana_dir(&mana_dir).unwrap();
323 assert_eq!(context.facts.len(), 1);
324 assert_eq!(context.facts[0].text, "Auth uses RS256 signing");
325 assert_eq!(context.facts[0].verified_ago, "30m ago");
326 assert!(context.project_memory_status.is_some());
327 let status = context.project_memory_status.as_deref().unwrap();
328 assert!(status.contains("Project memory status:"));
329 assert!(status.contains("Working on:"));
330 assert!(status.contains("[1] Implement auth flow"));
331 assert!(!status.contains("Auth uses RS256 signing"));
332 }
333
334 #[test]
335 fn loads_task_specific_relevant_facts_from_context_paths() {
336 let (_dir, mana_dir) = setup_mana_dir();
337
338 let mut fact_auth = Unit::new("2", "Auth uses RS256 signing");
339 fact_auth.unit_type = "fact".to_string();
340 fact_auth.paths = vec!["src/auth.rs".to_string()];
341 fact_auth.last_verified = Some(Utc::now() - Duration::minutes(30));
342 write_unit(&mana_dir, &fact_auth);
343
344 let mut fact_cache = Unit::new("3", "Cache keys must include tenant id");
345 fact_cache.unit_type = "fact".to_string();
346 fact_cache.paths = vec!["src/cache.rs".to_string()];
347 fact_cache.last_verified = Some(Utc::now() - Duration::minutes(45));
348 write_unit(&mana_dir, &fact_cache);
349
350 let context = load_task_prompt_context(
351 &mana_dir,
352 &["src/auth.rs".to_string(), "tests/auth.rs".to_string()],
353 );
354 assert_eq!(context.facts.len(), 1);
355 assert_eq!(context.facts[0].text, "Auth uses RS256 signing");
356 }
357
358 #[test]
359 fn formats_compact_project_memory_status_block() {
360 let mut working = Unit::new(
361 "1",
362 "A very long working unit title that should be truncated before it reaches the prompt because startup context should stay compact and preview oriented",
363 );
364 working.status = Status::InProgress;
365 working.claimed_by = Some("imp".into());
366
367 let mut recent = Unit::new("9", "Recently closed cleanup task");
368 recent.closed_at = Some(Utc::now() - Duration::hours(3));
369
370 let status = format_project_memory_status(&MemoryContext {
371 warnings: vec![
372 "STALE: \"Old fact\" — not verified in 5d".into(),
373 "PAST FAILURE [1]: \"retry with narrower verify\"".into(),
374 "warn three".into(),
375 "warn four should be omitted".into(),
376 ],
377 working_on: vec![mana_core::ops::memory_context::WorkingUnit {
378 unit: working,
379 failed_attempts: 2,
380 last_failure_notes: Some("narrow verify first".into()),
381 }],
382 relevant_facts: vec![],
383 recent_work: vec![mana_core::ops::memory_context::RecentWork { unit: recent }],
384 })
385 .unwrap();
386
387 assert!(status.contains("Project memory status:"));
388 assert!(status.contains("Warnings:"));
389 assert!(status.contains("Working on:"));
390 assert!(status.contains("Recent work:"));
391 assert!(status.contains("warn three"));
392 assert!(!status.contains("warn four should be omitted"));
393 assert!(status.contains("[1]"));
394 assert!(status.contains("2 failed attempt(s)"));
395 assert!(status.contains("claimed by imp"));
396 assert!(status.contains("[9] Recently closed cleanup task — closed 3h ago"));
397 assert!(status.contains('…'));
398 }
399
400 #[test]
401 fn caps_fact_count_for_prompt_budget() {
402 let relevant_facts = (0..12)
403 .map(|idx| {
404 let mut unit = Unit::new(format!("{}", idx + 1), format!("Fact {idx}"));
405 unit.last_verified = Some(Utc::now() - Duration::minutes(idx.into()));
406 mana_core::ops::memory_context::RelevantFact {
407 unit,
408 score: 100 - idx,
409 }
410 })
411 .collect();
412
413 let facts = map_relevant_facts(&MemoryContext {
414 warnings: vec![],
415 working_on: vec![],
416 relevant_facts,
417 recent_work: vec![],
418 });
419
420 assert_eq!(facts.len(), MAX_RELEVANT_FACTS);
421 assert_eq!(facts[0].text, "Fact 0");
422 assert_eq!(facts[MAX_RELEVANT_FACTS - 1].text, "Fact 7");
423 }
424}