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