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 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}