Skip to main content

lean_ctx/core/agents/
diary.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5const MAX_DIARY_ENTRIES: usize = 100;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
8pub struct AgentDiary {
9    pub agent_id: String,
10    pub agent_type: String,
11    pub project_root: String,
12    pub entries: Vec<DiaryEntry>,
13    pub created_at: DateTime<Utc>,
14    pub updated_at: DateTime<Utc>,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct DiaryEntry {
19    pub entry_type: DiaryEntryType,
20    pub content: String,
21    pub context: Option<String>,
22    pub timestamp: DateTime<Utc>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub enum DiaryEntryType {
27    Discovery,
28    Decision,
29    Blocker,
30    Progress,
31    Insight,
32}
33
34impl AgentDiary {
35    pub fn new(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
36        let now = Utc::now();
37        Self {
38            agent_id: agent_id.to_string(),
39            agent_type: agent_type.to_string(),
40            project_root: project_root.to_string(),
41            entries: Vec::new(),
42            created_at: now,
43            updated_at: now,
44        }
45    }
46
47    pub fn add_entry(&mut self, entry_type: DiaryEntryType, content: &str, context: Option<&str>) {
48        self.entries.push(DiaryEntry {
49            entry_type,
50            content: content.to_string(),
51            context: context.map(std::string::ToString::to_string),
52            timestamp: Utc::now(),
53        });
54        if self.entries.len() > MAX_DIARY_ENTRIES {
55            self.entries
56                .drain(0..self.entries.len() - MAX_DIARY_ENTRIES);
57        }
58        self.updated_at = Utc::now();
59    }
60
61    pub fn format_summary(&self) -> String {
62        if self.entries.is_empty() {
63            return format!("Diary [{}]: empty", self.agent_id);
64        }
65        let mut out = format!(
66            "Diary [{}] ({} entries):\n",
67            self.agent_id,
68            self.entries.len()
69        );
70        let now = Utc::now();
71        for e in self.entries.iter().rev().take(10) {
72            let age = (now - e.timestamp).num_minutes();
73            let prefix = match e.entry_type {
74                DiaryEntryType::Discovery => "FOUND",
75                DiaryEntryType::Decision => "DECIDED",
76                DiaryEntryType::Blocker => "BLOCKED",
77                DiaryEntryType::Progress => "DONE",
78                DiaryEntryType::Insight => "INSIGHT",
79            };
80            let ctx = e
81                .context
82                .as_deref()
83                .map(|c| format!(" [{c}]"))
84                .unwrap_or_default();
85            out.push_str(&format!("  [{prefix}] {}{ctx} ({age}m ago)\n", e.content));
86        }
87        out
88    }
89
90    pub fn format_compact(&self) -> String {
91        if self.entries.is_empty() {
92            return String::new();
93        }
94        let items: Vec<String> = self
95            .entries
96            .iter()
97            .rev()
98            .take(5)
99            .map(|e| {
100                let prefix = match e.entry_type {
101                    DiaryEntryType::Discovery => "F",
102                    DiaryEntryType::Decision => "D",
103                    DiaryEntryType::Blocker => "B",
104                    DiaryEntryType::Progress => "P",
105                    DiaryEntryType::Insight => "I",
106                };
107                format!("{prefix}:{}", truncate(&e.content, 50))
108            })
109            .collect();
110        format!("diary:{}|{}", self.agent_id, items.join("|"))
111    }
112
113    pub fn save(&self) -> Result<(), String> {
114        let dir = diary_dir()?;
115        std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
116        let path = dir.join(format!("{}.json", sanitize_filename(&self.agent_id)));
117        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
118        std::fs::write(&path, json).map_err(|e| e.to_string())
119    }
120
121    pub fn load(agent_id: &str) -> Option<Self> {
122        let dir = diary_dir().ok()?;
123        let path = dir.join(format!("{}.json", sanitize_filename(agent_id)));
124        let content = std::fs::read_to_string(&path).ok()?;
125        serde_json::from_str(&content).ok()
126    }
127
128    pub fn load_or_create(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
129        Self::load(agent_id).unwrap_or_else(|| Self::new(agent_id, agent_type, project_root))
130    }
131
132    pub fn list_all() -> Vec<(String, usize, DateTime<Utc>)> {
133        let Ok(dir) = diary_dir() else {
134            return Vec::new();
135        };
136        if !dir.exists() {
137            return Vec::new();
138        }
139        let mut results = Vec::new();
140        if let Ok(entries) = std::fs::read_dir(&dir) {
141            for entry in entries.flatten() {
142                if entry.path().extension().and_then(|e| e.to_str()) == Some("json")
143                    && let Ok(content) = std::fs::read_to_string(entry.path())
144                    && let Ok(diary) = serde_json::from_str::<AgentDiary>(&content)
145                {
146                    results.push((diary.agent_id, diary.entries.len(), diary.updated_at));
147                }
148            }
149        }
150        results.sort_by_key(|x| std::cmp::Reverse(x.2));
151        results
152    }
153
154    /// Load every diary whose `project_root` matches `project_root`, most
155    /// recently updated first. Used by skillify to mine a project's decisions
156    /// and insights across all its agents (#290).
157    pub fn load_all_for_project(project_root: &str) -> Vec<AgentDiary> {
158        let Ok(dir) = diary_dir() else {
159            return Vec::new();
160        };
161        if !dir.exists() {
162            return Vec::new();
163        }
164        let want = project_root.trim_end_matches('/');
165        let mut diaries: Vec<AgentDiary> = Vec::new();
166        if let Ok(entries) = std::fs::read_dir(&dir) {
167            for entry in entries.flatten() {
168                if entry.path().extension().and_then(|e| e.to_str()) != Some("json") {
169                    continue;
170                }
171                if let Ok(content) = std::fs::read_to_string(entry.path())
172                    && let Ok(diary) = serde_json::from_str::<AgentDiary>(&content)
173                    && diary.project_root.trim_end_matches('/') == want
174                {
175                    diaries.push(diary);
176                }
177            }
178        }
179        diaries.sort_by_key(|d| std::cmp::Reverse(d.updated_at));
180        diaries
181    }
182}
183
184impl std::fmt::Display for DiaryEntryType {
185    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
186        match self {
187            DiaryEntryType::Discovery => write!(f, "discovery"),
188            DiaryEntryType::Decision => write!(f, "decision"),
189            DiaryEntryType::Blocker => write!(f, "blocker"),
190            DiaryEntryType::Progress => write!(f, "progress"),
191            DiaryEntryType::Insight => write!(f, "insight"),
192        }
193    }
194}
195
196fn diary_dir() -> Result<PathBuf, String> {
197    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
198    Ok(dir.join("agents").join("diaries"))
199}
200
201fn sanitize_filename(name: &str) -> String {
202    name.chars()
203        .map(|c| {
204            if c.is_alphanumeric() || c == '-' || c == '_' {
205                c
206            } else {
207                '_'
208            }
209        })
210        .collect()
211}
212
213pub(super) fn truncate(s: &str, max: usize) -> String {
214    if s.len() <= max {
215        s.to_string()
216    } else {
217        format!("{}...", &s[..s.floor_char_boundary(max.saturating_sub(3))])
218    }
219}