Skip to main content

telos_agent/knowledge/memory/
profile.rs

1use std::io;
2use std::io::ErrorKind;
3use std::path::PathBuf;
4use std::{fs, path::Path};
5
6/// Manages three context profiles: user, project, and active state.
7/// Profiles are always injected into the system prompt.
8pub struct ProfileManager {
9    user_profile_path: PathBuf,
10    project_profile_path: PathBuf,
11    active_state_path: PathBuf,
12}
13
14impl ProfileManager {
15    /// Create a ProfileManager.
16    /// `user_dir` — user config directory (~/.tiny-agent/profile/)
17    /// `project_dir` — project config directory (.tiny-agent/profile/)
18    pub fn new(user_dir: PathBuf, project_dir: PathBuf) -> io::Result<Self> {
19        fs::create_dir_all(&user_dir)?;
20        fs::create_dir_all(&project_dir)?;
21        Ok(Self {
22            user_profile_path: user_dir.join("user.md"),
23            project_profile_path: project_dir.join("project.md"),
24            active_state_path: project_dir.join("active.md"),
25        })
26    }
27
28    /// Read the user profile.
29    pub fn user_profile(&self) -> String {
30        self.try_user_profile().unwrap_or_else(|err| {
31            tracing::warn!(
32                path = %self.user_profile_path.display(),
33                error = %err,
34                "failed to read user profile"
35            );
36            String::new()
37        })
38    }
39
40    /// Read the user profile, returning filesystem errors except missing files.
41    pub fn try_user_profile(&self) -> io::Result<String> {
42        read_optional_profile(&self.user_profile_path)
43    }
44
45    /// Read the project profile.
46    pub fn project_profile(&self) -> String {
47        self.try_project_profile().unwrap_or_else(|err| {
48            tracing::warn!(
49                path = %self.project_profile_path.display(),
50                error = %err,
51                "failed to read project profile"
52            );
53            String::new()
54        })
55    }
56
57    /// Read the project profile, returning filesystem errors except missing files.
58    pub fn try_project_profile(&self) -> io::Result<String> {
59        read_optional_profile(&self.project_profile_path)
60    }
61
62    /// Read the active state.
63    pub fn active_state(&self) -> String {
64        self.try_active_state().unwrap_or_else(|err| {
65            tracing::warn!(
66                path = %self.active_state_path.display(),
67                error = %err,
68                "failed to read active state"
69            );
70            String::new()
71        })
72    }
73
74    /// Read the active state, returning filesystem errors except missing files.
75    pub fn try_active_state(&self) -> io::Result<String> {
76        read_optional_profile(&self.active_state_path)
77    }
78
79    /// Write/update the user profile.
80    pub fn set_user_profile(&self, content: &str) -> std::io::Result<()> {
81        fs::write(&self.user_profile_path, content)
82    }
83
84    /// Write/update the project profile.
85    pub fn set_project_profile(&self, content: &str) -> std::io::Result<()> {
86        fs::write(&self.project_profile_path, content)
87    }
88
89    /// Update the active state — called at session end with a summary.
90    pub fn update_active_state(&self, summary: &str) -> std::io::Result<()> {
91        let mut content = String::from("## Active Work\n\n");
92        content.push_str(summary);
93        content.push('\n');
94        fs::write(&self.active_state_path, &content)
95    }
96
97    /// Canonicalize the user profile file during a consolidation pass.
98    ///
99    /// This lightweight implementation does not infer facts. It preserves any
100    /// existing content, trims surrounding whitespace, and creates an empty
101    /// profile skeleton when the file does not exist yet.
102    pub fn consolidate_user_profile(&self) -> io::Result<()> {
103        consolidate_profile_file(&self.user_profile_path, "user")
104    }
105
106    /// Canonicalize the project profile file during a consolidation pass.
107    ///
108    /// This lightweight implementation does not infer project conventions. It
109    /// preserves any existing content, trims surrounding whitespace, and creates
110    /// an empty profile skeleton when the file does not exist yet.
111    pub fn consolidate_project_profile(&self) -> io::Result<()> {
112        consolidate_profile_file(&self.project_profile_path, "project")
113    }
114
115    /// Render all three profiles as a combined prompt section.
116    pub fn render_all(&self) -> String {
117        self.try_render_all().unwrap_or_else(|err| {
118            tracing::warn!(error = %err, "failed to render profiles");
119            String::new()
120        })
121    }
122
123    /// Render all three profiles as a combined prompt section.
124    pub fn try_render_all(&self) -> io::Result<String> {
125        let mut parts = Vec::new();
126
127        let user = self.try_user_profile()?;
128        if !user.is_empty() {
129            parts.push(format!("## User Profile\n\n{user}"));
130        }
131
132        let project = self.try_project_profile()?;
133        if !project.is_empty() {
134            parts.push(format!("## Project Profile\n\n{project}"));
135        }
136
137        let active = self.try_active_state()?;
138        if !active.is_empty() {
139            parts.push(format!("## Active State\n\n{active}"));
140        }
141
142        Ok(parts.join("\n\n"))
143    }
144}
145
146fn read_optional_profile(path: &Path) -> io::Result<String> {
147    match fs::read_to_string(path) {
148        Ok(content) => Ok(content),
149        Err(err) if err.kind() == ErrorKind::NotFound => Ok(String::new()),
150        Err(err) => Err(err),
151    }
152}
153
154fn consolidate_profile_file(path: &Path, profile: &str) -> io::Result<()> {
155    if let Some(parent) = path.parent() {
156        fs::create_dir_all(parent)?;
157    }
158
159    let current = read_optional_profile(path)?;
160    let content = if current.trim().is_empty() {
161        format!("---\ntype: profile\nprofile: {profile}\n---\n")
162    } else {
163        format!("{}\n", current.trim())
164    };
165    fs::write(path, content)
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn write_and_read_user_profile() {
174        let dir = tempfile::tempdir().unwrap();
175        let mgr = ProfileManager::new(dir.path().to_path_buf(), dir.path().to_path_buf()).unwrap();
176        mgr.set_user_profile("Test user profile").unwrap();
177        assert_eq!(mgr.user_profile(), "Test user profile");
178        assert_eq!(mgr.try_user_profile().unwrap(), "Test user profile");
179    }
180
181    #[test]
182    fn update_active_state() {
183        let dir = tempfile::tempdir().unwrap();
184        let mgr = ProfileManager::new(dir.path().to_path_buf(), dir.path().to_path_buf()).unwrap();
185        mgr.update_active_state("Working on Phase 1").unwrap();
186        assert!(mgr.active_state().contains("Working on Phase 1"));
187        assert!(mgr.active_state().contains("Active Work"));
188    }
189
190    #[test]
191    fn render_all_combines_available_profiles() {
192        let dir = tempfile::tempdir().unwrap();
193        let mgr = ProfileManager::new(dir.path().to_path_buf(), dir.path().to_path_buf()).unwrap();
194        mgr.set_user_profile("User pref: short names").unwrap();
195        mgr.set_project_profile("Project: Rust 2024").unwrap();
196        mgr.update_active_state("Task: building profiles").unwrap();
197
198        let rendered = mgr.render_all();
199        assert!(rendered.contains("User Profile"));
200        assert!(rendered.contains("User pref: short names"));
201        assert!(rendered.contains("Project Profile"));
202        assert!(rendered.contains("Active State"));
203    }
204
205    #[test]
206    fn empty_profiles_render_empty_strings() {
207        let dir = tempfile::tempdir().unwrap();
208        let mgr = ProfileManager::new(dir.path().to_path_buf(), dir.path().to_path_buf()).unwrap();
209        assert_eq!(mgr.user_profile(), "");
210        assert_eq!(mgr.project_profile(), "");
211        assert_eq!(mgr.render_all(), "");
212    }
213
214    #[test]
215    fn consolidation_creates_profile_skeletons() {
216        let dir = tempfile::tempdir().unwrap();
217        let mgr = ProfileManager::new(dir.path().to_path_buf(), dir.path().to_path_buf()).unwrap();
218
219        mgr.consolidate_user_profile().unwrap();
220        mgr.consolidate_project_profile().unwrap();
221
222        assert!(mgr.try_user_profile().unwrap().contains("profile: user"));
223        assert!(mgr.try_project_profile().unwrap().contains("profile: project"));
224    }
225
226    #[test]
227    fn consolidation_preserves_existing_content_and_trims() {
228        let dir = tempfile::tempdir().unwrap();
229        let mgr = ProfileManager::new(dir.path().to_path_buf(), dir.path().to_path_buf()).unwrap();
230        mgr.set_user_profile("\n\nExisting profile\n\n").unwrap();
231
232        mgr.consolidate_user_profile().unwrap();
233
234        assert_eq!(mgr.try_user_profile().unwrap(), "Existing profile\n");
235    }
236}