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