Skip to main content

bn/commands/
memory_context.rs

1use std::path::Path;
2
3use anyhow::Result;
4use chrono::{Duration, Utc};
5
6use crate::bean::{AttemptOutcome, Bean, Status};
7use crate::discovery::{find_archived_bean, find_bean_file};
8use crate::index::Index;
9use crate::relevance::relevance_score;
10
11/// Default token budget for context output (~4000 tokens ≈ ~16000 chars).
12const DEFAULT_MAX_CHARS: usize = 16000;
13
14/// Output memory context for session-start injection.
15///
16/// When `bn context` is called without a bean ID, it returns relevant memories:
17/// 1. WARNINGS — stale facts, past failures (never truncated)
18/// 2. WORKING ON — claimed beans with attempt history
19/// 3. RELEVANT FACTS — scored by path overlap, dependencies
20/// 4. RECENT WORK — closed beans from last 7 days
21pub fn cmd_memory_context(beans_dir: &Path, json: bool) -> Result<()> {
22    let now = Utc::now();
23    let index = Index::load_or_rebuild(beans_dir)?;
24    let archived = Index::collect_archived(beans_dir).unwrap_or_default();
25
26    // Collect working paths and deps from claimed beans for relevance scoring
27    let mut working_paths: Vec<String> = Vec::new();
28    let mut working_deps: Vec<String> = Vec::new();
29
30    // =========================================================================
31    // Section 1: WARNINGS (stale facts, failing facts)
32    // =========================================================================
33    let mut warnings: Vec<String> = Vec::new();
34
35    // =========================================================================
36    // Section 2: WORKING ON (claimed beans with attempt history)
37    // =========================================================================
38    let mut working_on: Vec<String> = Vec::new();
39
40    for entry in &index.beans {
41        if entry.status != Status::InProgress {
42            continue;
43        }
44
45        let bean_path = match find_bean_file(beans_dir, &entry.id) {
46            Ok(p) => p,
47            Err(_) => continue,
48        };
49
50        let bean = match Bean::from_file(&bean_path) {
51            Ok(b) => b,
52            Err(_) => continue,
53        };
54
55        // Collect paths and deps for relevance scoring
56        working_paths.extend(bean.paths.clone());
57        working_deps.extend(bean.requires.clone());
58        working_deps.extend(bean.produces.clone());
59
60        let mut line = format!("[{}] {}", bean.id, bean.title);
61
62        // Show attempt history
63        let failed_attempts: Vec<_> = bean
64            .attempt_log
65            .iter()
66            .filter(|a| a.outcome == AttemptOutcome::Failed)
67            .collect();
68
69        if !failed_attempts.is_empty() {
70            line.push_str(&format!(
71                "\n│   Attempt #{} (previous failures: {})",
72                failed_attempts.len() + 1,
73                failed_attempts.len()
74            ));
75            // Show last failure notes
76            if let Some(last) = failed_attempts.last() {
77                if let Some(ref notes) = last.notes {
78                    let preview: String = notes.chars().take(100).collect();
79                    line.push_str(&format!("\n│   Last failure: {}", preview));
80
81                    // Add to warnings
82                    warnings.push(format!(
83                        "PAST FAILURE [{}]: \"{}\"",
84                        bean.id,
85                        notes.chars().take(80).collect::<String>()
86                    ));
87                }
88            }
89        }
90
91        working_on.push(line);
92    }
93
94    // Check all facts for staleness
95    for entry in index.beans.iter().chain(archived.iter()) {
96        let bean_path = match find_bean_file(beans_dir, &entry.id)
97            .or_else(|_| find_archived_bean(beans_dir, &entry.id))
98        {
99            Ok(p) => p,
100            Err(_) => continue,
101        };
102
103        let bean = match Bean::from_file(&bean_path) {
104            Ok(b) => b,
105            Err(_) => continue,
106        };
107
108        if bean.bean_type != "fact" {
109            continue;
110        }
111
112        // Check staleness
113        if let Some(stale_after) = bean.stale_after {
114            if now > stale_after {
115                let days_stale = (now - stale_after).num_days();
116                warnings.push(format!(
117                    "STALE: \"{}\" — not verified in {}d",
118                    bean.title, days_stale
119                ));
120            }
121        }
122    }
123
124    // =========================================================================
125    // Section 3: RELEVANT FACTS (scored by path overlap, dependencies)
126    // =========================================================================
127    let mut relevant_facts: Vec<(Bean, u32)> = Vec::new();
128
129    for entry in index.beans.iter().chain(archived.iter()) {
130        let bean_path = match find_bean_file(beans_dir, &entry.id)
131            .or_else(|_| find_archived_bean(beans_dir, &entry.id))
132        {
133            Ok(p) => p,
134            Err(_) => continue,
135        };
136
137        let bean = match Bean::from_file(&bean_path) {
138            Ok(b) => b,
139            Err(_) => continue,
140        };
141
142        if bean.bean_type != "fact" {
143            continue;
144        }
145
146        let score = relevance_score(&bean, &working_paths, &working_deps);
147        if score > 0 {
148            relevant_facts.push((bean, score));
149        }
150    }
151
152    relevant_facts.sort_by(|a, b| b.1.cmp(&a.1));
153
154    // =========================================================================
155    // Section 4: RECENT WORK (closed beans from last 7 days)
156    // =========================================================================
157    let mut recent_work: Vec<Bean> = Vec::new();
158    let seven_days_ago = now - Duration::days(7);
159
160    for entry in &archived {
161        if entry.status != Status::Closed {
162            continue;
163        }
164
165        let bean_path = match find_archived_bean(beans_dir, &entry.id) {
166            Ok(p) => p,
167            Err(_) => continue,
168        };
169
170        let bean = match Bean::from_file(&bean_path) {
171            Ok(b) => b,
172            Err(_) => continue,
173        };
174
175        if bean.bean_type == "fact" {
176            continue; // facts shown separately
177        }
178
179        if let Some(closed_at) = bean.closed_at {
180            if closed_at > seven_days_ago {
181                recent_work.push(bean);
182            }
183        }
184    }
185
186    recent_work.sort_by(|a, b| b.closed_at.unwrap_or(now).cmp(&a.closed_at.unwrap_or(now)));
187
188    // =========================================================================
189    // Output
190    // =========================================================================
191
192    if json {
193        let output = serde_json::json!({
194            "warnings": warnings,
195            "working_on": working_on.iter().map(|w| {
196                // Parse out the bean ID for structured output
197                w.split(']').next().unwrap_or("").trim_start_matches('[').to_string()
198            }).collect::<Vec<_>>(),
199            "relevant_facts": relevant_facts.iter().map(|(b, s)| {
200                serde_json::json!({
201                    "id": b.id,
202                    "title": b.title,
203                    "score": s,
204                    "verified": b.last_verified,
205                })
206            }).collect::<Vec<_>>(),
207            "recent_work": recent_work.iter().map(|b| {
208                serde_json::json!({
209                    "id": b.id,
210                    "title": b.title,
211                    "closed_at": b.closed_at,
212                    "close_reason": b.close_reason,
213                })
214            }).collect::<Vec<_>>(),
215        });
216        println!("{}", serde_json::to_string_pretty(&output)?);
217        return Ok(());
218    }
219
220    // Check if there's any content to show
221    let has_content = !warnings.is_empty()
222        || !working_on.is_empty()
223        || !relevant_facts.is_empty()
224        || !recent_work.is_empty();
225
226    if !has_content {
227        println!("No memory context available.");
228        return Ok(());
229    }
230
231    let mut output = String::new();
232    #[allow(unused_assignments)]
233    let mut chars_used = 0;
234
235    output.push_str("═══ BEANS CONTEXT ═══════════════════════════════════════════\n\n");
236
237    // Warnings (never truncated)
238    if !warnings.is_empty() {
239        output.push_str("⚠ WARNINGS\n");
240        for w in &warnings {
241            output.push_str(&format!("│ {}\n", w));
242        }
243        output.push('\n');
244    }
245
246    // Working on
247    if !working_on.is_empty() {
248        output.push_str("► WORKING ON\n");
249        for w in &working_on {
250            output.push_str(&format!("│ {}\n", w));
251        }
252        output.push('\n');
253    }
254
255    chars_used = output.len();
256
257    // Relevant facts (truncate if over budget)
258    if !relevant_facts.is_empty() && chars_used < DEFAULT_MAX_CHARS {
259        output.push_str("✓ RELEVANT FACTS\n");
260        for (bean, _score) in &relevant_facts {
261            if chars_used > DEFAULT_MAX_CHARS {
262                break;
263            }
264            let verified_ago = bean
265                .last_verified
266                .map(|lv| {
267                    let ago = now - lv;
268                    if ago.num_days() > 0 {
269                        format!("✓ {}d ago", ago.num_days())
270                    } else if ago.num_hours() > 0 {
271                        format!("✓ {}h ago", ago.num_hours())
272                    } else {
273                        "✓ just now".to_string()
274                    }
275                })
276                .unwrap_or_else(|| "unverified".to_string());
277
278            let line = format!("│ \"{}\" {}\n", bean.title, verified_ago);
279            chars_used += line.len();
280            output.push_str(&line);
281        }
282        output.push('\n');
283    }
284
285    // Recent work (truncate from bottom first)
286    if !recent_work.is_empty() && chars_used < DEFAULT_MAX_CHARS {
287        output.push_str("◷ RECENT WORK\n");
288        for bean in &recent_work {
289            if chars_used > DEFAULT_MAX_CHARS {
290                break;
291            }
292            let closed_ago = bean
293                .closed_at
294                .map(|ca| {
295                    let ago = now - ca;
296                    if ago.num_days() > 0 {
297                        format!("{}d ago", ago.num_days())
298                    } else if ago.num_hours() > 0 {
299                        format!("{}h ago", ago.num_hours())
300                    } else {
301                        "just now".to_string()
302                    }
303                })
304                .unwrap_or_else(|| "recently".to_string());
305
306            let mut line = format!("│ [{}] {} (closed {})\n", bean.id, bean.title, closed_ago);
307
308            if let Some(ref reason) = bean.close_reason {
309                line.push_str(&format!(
310                    "│   \"{}\"\n",
311                    reason.chars().take(80).collect::<String>()
312                ));
313            }
314
315            chars_used += line.len();
316            output.push_str(&line);
317        }
318        output.push('\n');
319    }
320
321    print!("{}", output);
322
323    Ok(())
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329    use std::fs;
330    use tempfile::TempDir;
331
332    fn setup_beans_dir_with_config() -> (TempDir, std::path::PathBuf) {
333        let dir = TempDir::new().unwrap();
334        let beans_dir = dir.path().join(".beans");
335        fs::create_dir(&beans_dir).unwrap();
336
337        let config = crate::config::Config {
338            project: "test".to_string(),
339            next_id: 10,
340            auto_close_parent: true,
341            max_tokens: 30000,
342            run: None,
343            plan: None,
344            max_loops: 10,
345            max_concurrent: 4,
346            poll_interval: 30,
347            extends: vec![],
348            rules_file: None,
349            file_locking: false,
350            on_close: None,
351            on_fail: None,
352            post_plan: None,
353            verify_timeout: None,
354            review: None,
355        };
356        config.save(&beans_dir).unwrap();
357
358        (dir, beans_dir)
359    }
360
361    #[test]
362    fn memory_context_empty() {
363        let (_dir, beans_dir) = setup_beans_dir_with_config();
364
365        // Should not error with no beans
366        let result = cmd_memory_context(&beans_dir, false);
367        assert!(result.is_ok());
368    }
369
370    #[test]
371    fn memory_context_shows_claimed_beans() {
372        let (_dir, beans_dir) = setup_beans_dir_with_config();
373
374        // Create a claimed bean
375        let mut bean = Bean::new("1", "Working on auth");
376        bean.status = Status::InProgress;
377        bean.claimed_by = Some("agent-1".to_string());
378        bean.claimed_at = Some(Utc::now());
379        let slug = crate::util::title_to_slug(&bean.title);
380        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
381            .unwrap();
382
383        let result = cmd_memory_context(&beans_dir, false);
384        assert!(result.is_ok());
385    }
386
387    #[test]
388    fn memory_context_shows_stale_facts() {
389        let (_dir, beans_dir) = setup_beans_dir_with_config();
390
391        // Create a stale fact
392        let mut bean = Bean::new("1", "Auth uses RS256");
393        bean.bean_type = "fact".to_string();
394        bean.stale_after = Some(Utc::now() - Duration::days(5)); // 5 days past stale
395        bean.verify = Some("true".to_string());
396        let slug = crate::util::title_to_slug(&bean.title);
397        bean.to_file(beans_dir.join(format!("1-{}.md", slug)))
398            .unwrap();
399
400        let result = cmd_memory_context(&beans_dir, false);
401        assert!(result.is_ok());
402    }
403
404    #[test]
405    fn memory_context_json_output() {
406        let (_dir, beans_dir) = setup_beans_dir_with_config();
407
408        let result = cmd_memory_context(&beans_dir, true);
409        assert!(result.is_ok());
410    }
411}