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
11const DEFAULT_MAX_CHARS: usize = 16000;
13
14pub 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 let mut working_paths: Vec<String> = Vec::new();
28 let mut working_deps: Vec<String> = Vec::new();
29
30 let mut warnings: Vec<String> = Vec::new();
34
35 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 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 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 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 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 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 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 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 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; }
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 if json {
193 let output = serde_json::json!({
194 "warnings": warnings,
195 "working_on": working_on.iter().map(|w| {
196 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 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 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 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 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 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 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 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 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)); 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}