Skip to main content

imp_core/
mana_prompt_context.rs

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/// Session-start mana-backed prompt context owned by imp runtime assembly.
16///
17/// Facts remain a distinct verified-fact seam. Dynamic status-like project
18/// memory is carried separately as a compact optional text block.
19#[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}