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(crate) 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    /// Persist into this diary's project partition. An agent id used in two
91    /// projects keeps one diary per project.
92    pub fn save(&self) -> Result<(), String> {
93        let dir = partition_dir(&self.project_root)?;
94        // Same reasoning as #1619: name the path and the operation, or a
95        // sandbox denial reads as an unattributable "Operation not permitted".
96        std::fs::create_dir_all(&dir)
97            .map_err(|error| format!("create agent diary directory {}: {error}", dir.display()))?;
98        let path = dir.join(format!("{}.json", sanitize_filename(&self.agent_id)));
99        let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
100        crate::config_io::write_atomic(&path, &json)
101            .map_err(|error| format!("write agent diary {}: {error}", path.display()))
102    }
103
104    /// The diary `agent_id` keeps in `project_root`. The stored root, not
105    /// the directory, decides: a diary of releases before 3.11.5 (shared
106    /// directory) or one left in an older partition after the project's
107    /// identity (remote, package name) changed is served to the project it
108    /// records, and the next save moves it into the current partition.
109    pub fn load_for_project(agent_id: &str, project_root: &str) -> Option<Self> {
110        let file = format!("{}.json", sanitize_filename(agent_id));
111        search_dirs(project_root)
112            .iter()
113            .filter_map(|dir| read_diary(&dir.join(&file)))
114            .find(|diary| {
115                diary.agent_id == agent_id && same_root(&diary.project_root, project_root)
116            })
117    }
118
119    pub fn load_or_create(agent_id: &str, agent_type: &str, project_root: &str) -> Self {
120        Self::load_for_project(agent_id, project_root)
121            .unwrap_or_else(|| Self::new(agent_id, agent_type, project_root))
122    }
123
124    /// `(agent_id, entries, updated_at)` of every diary kept in `project_root`.
125    pub fn list_for_project(project_root: &str) -> Vec<(String, usize, DateTime<Utc>)> {
126        Self::load_all_for_project(project_root)
127            .into_iter()
128            .map(|diary| (diary.agent_id, diary.entries.len(), diary.updated_at))
129            .collect()
130    }
131
132    /// Load every diary whose `project_root` matches `project_root`, most
133    /// recently updated first. Used by skillify to mine a project's decisions
134    /// and insights across all its agents (#290).
135    pub(crate) fn load_all_for_project(project_root: &str) -> Vec<AgentDiary> {
136        let mut diaries: Vec<AgentDiary> = Vec::new();
137        // The current partition first: its diary supersedes an older one.
138        for dir in search_dirs(project_root) {
139            let Ok(entries) = std::fs::read_dir(&dir) else {
140                continue;
141            };
142            for entry in entries.flatten() {
143                if let Some(diary) = read_diary(&entry.path())
144                    && same_root(&diary.project_root, project_root)
145                    && !diaries.iter().any(|known| known.agent_id == diary.agent_id)
146                {
147                    diaries.push(diary);
148                }
149            }
150        }
151        diaries.sort_by_key(|d| std::cmp::Reverse(d.updated_at));
152        diaries
153    }
154}
155
156fn read_diary(path: &std::path::Path) -> Option<AgentDiary> {
157    if path.extension().and_then(|e| e.to_str()) != Some("json") {
158        return None;
159    }
160    serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
161}
162
163fn same_root(stored: &str, requested: &str) -> bool {
164    use crate::core::pathutil::{normalize_tool_path, safe_canonicalize_bounded};
165    let canonical = |root: &str| safe_canonicalize_bounded(std::path::Path::new(root), 2000);
166    normalize_tool_path(stored).trim_end_matches('/')
167        == normalize_tool_path(requested).trim_end_matches('/')
168        || canonical(stored) == canonical(requested)
169}
170
171/// Where a diary of `project_root` may be: its current partition first, then
172/// every other partition (an identity change moved the hash), then the shared
173/// directory of releases before 3.11.5. The stored root decides membership.
174fn search_dirs(project_root: &str) -> Vec<PathBuf> {
175    let (Ok(current), Ok(base)) = (partition_dir(project_root), diary_dir()) else {
176        return Vec::new();
177    };
178    let mut dirs = vec![current.clone()];
179    if let Ok(entries) = std::fs::read_dir(&base) {
180        let mut others: Vec<PathBuf> = entries
181            .flatten()
182            .map(|entry| entry.path())
183            .filter(|path| path.is_dir() && *path != current)
184            .collect();
185        others.sort();
186        dirs.extend(others);
187    }
188    dirs.push(base);
189    dirs
190}
191
192fn partition_dir(project_root: &str) -> Result<PathBuf, String> {
193    Ok(diary_dir()?.join(crate::core::project_hash::hash_project_root(project_root)))
194}
195
196impl std::fmt::Display for DiaryEntryType {
197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
198        match self {
199            DiaryEntryType::Discovery => write!(f, "discovery"),
200            DiaryEntryType::Decision => write!(f, "decision"),
201            DiaryEntryType::Blocker => write!(f, "blocker"),
202            DiaryEntryType::Progress => write!(f, "progress"),
203            DiaryEntryType::Insight => write!(f, "insight"),
204        }
205    }
206}
207
208fn diary_dir() -> Result<PathBuf, String> {
209    let dir = crate::core::data_dir::lean_ctx_data_dir()?;
210    Ok(dir.join("agents").join("diaries"))
211}
212
213fn sanitize_filename(name: &str) -> String {
214    name.chars()
215        .map(|c| {
216            if c.is_alphanumeric() || c == '-' || c == '_' {
217                c
218            } else {
219                '_'
220            }
221        })
222        .collect()
223}