Skip to main content

construct/agent/
personality.rs

1//! Personality system — loads workspace identity files (SOUL.md, IDENTITY.md,
2//! USER.md) and injects them into the system prompt pipeline.
3//!
4//! Ported from RustyClaw `src/agent/personality.rs`.  The loader reads markdown
5//! files from the workspace root, validates size limits, and produces a
6//! [`PersonalityProfile`] that the prompt builder can render.
7
8use std::fmt::Write;
9use std::path::{Path, PathBuf};
10
11/// Maximum characters per personality file before truncation.
12const MAX_FILE_CHARS: usize = 20_000;
13
14/// Well-known personality files loaded from the workspace root.
15const PERSONALITY_FILES: &[&str] = &[
16    "SOUL.md",
17    "IDENTITY.md",
18    "USER.md",
19    "AGENTS.md",
20    "TOOLS.md",
21    "HEARTBEAT.md",
22    "BOOTSTRAP.md",
23    "MEMORY.md",
24];
25
26/// A single personality file loaded from the workspace.
27#[derive(Debug, Clone)]
28pub struct PersonalityFile {
29    /// Filename (e.g. `SOUL.md`).
30    pub name: String,
31    /// Raw content (possibly truncated).
32    pub content: String,
33    /// Whether the content was truncated due to size limits.
34    pub truncated: bool,
35    /// Full path on disk.
36    pub path: PathBuf,
37}
38
39/// Aggregated personality profile loaded from a workspace.
40#[derive(Debug, Clone, Default)]
41pub struct PersonalityProfile {
42    /// Successfully loaded personality files.
43    pub files: Vec<PersonalityFile>,
44    /// Files that were expected but not found.
45    pub missing: Vec<String>,
46}
47
48impl PersonalityProfile {
49    /// Returns the content of a specific file by name, if loaded.
50    pub fn get(&self, name: &str) -> Option<&str> {
51        self.files
52            .iter()
53            .find(|f| f.name == name)
54            .map(|f| f.content.as_str())
55    }
56
57    /// Returns `true` if no personality files were loaded.
58    pub fn is_empty(&self) -> bool {
59        self.files.is_empty()
60    }
61
62    /// Render all loaded personality files into a prompt fragment.
63    pub fn render(&self) -> String {
64        let mut out = String::new();
65        for file in &self.files {
66            let _ = writeln!(out, "### {}\n", file.name);
67            out.push_str(&file.content);
68            if file.truncated {
69                let _ = writeln!(
70                    out,
71                    "\n\n[... truncated at {MAX_FILE_CHARS} chars — use `read` for full file]\n"
72                );
73            } else {
74                out.push_str("\n\n");
75            }
76        }
77        out
78    }
79}
80
81/// Loads personality files from a workspace directory.
82///
83/// Each well-known file is read and validated.  Missing files are recorded
84/// in `PersonalityProfile::missing` rather than treated as errors.
85pub fn load_personality(workspace_dir: &Path) -> PersonalityProfile {
86    load_personality_files(workspace_dir, PERSONALITY_FILES)
87}
88
89/// Load a specific set of personality files from a workspace directory.
90pub fn load_personality_files(workspace_dir: &Path, filenames: &[&str]) -> PersonalityProfile {
91    let mut profile = PersonalityProfile::default();
92
93    for &filename in filenames {
94        let path = workspace_dir.join(filename);
95        match std::fs::read_to_string(&path) {
96            Ok(raw) => {
97                let trimmed = raw.trim();
98                if trimmed.is_empty() {
99                    profile.missing.push(filename.to_string());
100                    continue;
101                }
102                let (content, truncated) = truncate_content(trimmed);
103                profile.files.push(PersonalityFile {
104                    name: filename.to_string(),
105                    content,
106                    truncated,
107                    path,
108                });
109            }
110            Err(_) => {
111                profile.missing.push(filename.to_string());
112            }
113        }
114    }
115
116    profile
117}
118
119/// Truncate content to `MAX_FILE_CHARS` if necessary.
120fn truncate_content(content: &str) -> (String, bool) {
121    if content.chars().count() <= MAX_FILE_CHARS {
122        return (content.to_string(), false);
123    }
124    let truncated = content
125        .char_indices()
126        .nth(MAX_FILE_CHARS)
127        .map(|(idx, _)| &content[..idx])
128        .unwrap_or(content);
129    (truncated.to_string(), true)
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    fn setup_workspace(files: &[(&str, &str)]) -> PathBuf {
137        let dir = std::env::temp_dir().join(format!(
138            "construct_personality_test_{}",
139            uuid::Uuid::new_v4()
140        ));
141        std::fs::create_dir_all(&dir).unwrap();
142        for (name, content) in files {
143            std::fs::write(dir.join(name), content).unwrap();
144        }
145        dir
146    }
147
148    #[test]
149    fn load_personality_reads_existing_files() {
150        let ws = setup_workspace(&[
151            ("SOUL.md", "I am a helpful assistant."),
152            ("IDENTITY.md", "Name: Nova"),
153        ]);
154
155        let profile = load_personality(&ws);
156        assert_eq!(profile.files.len(), 2);
157        assert_eq!(profile.get("SOUL.md").unwrap(), "I am a helpful assistant.");
158        assert_eq!(profile.get("IDENTITY.md").unwrap(), "Name: Nova");
159        assert!(!profile.is_empty());
160
161        let _ = std::fs::remove_dir_all(ws);
162    }
163
164    #[test]
165    fn load_personality_records_missing_files() {
166        let ws = setup_workspace(&[("SOUL.md", "soul content")]);
167
168        let profile = load_personality(&ws);
169        assert_eq!(profile.files.len(), 1);
170        assert!(profile.missing.contains(&"IDENTITY.md".to_string()));
171        assert!(profile.missing.contains(&"USER.md".to_string()));
172
173        let _ = std::fs::remove_dir_all(ws);
174    }
175
176    #[test]
177    fn load_personality_treats_empty_files_as_missing() {
178        let ws = setup_workspace(&[("SOUL.md", "   \n  ")]);
179
180        let profile = load_personality(&ws);
181        assert!(profile.is_empty());
182        assert!(profile.missing.contains(&"SOUL.md".to_string()));
183
184        let _ = std::fs::remove_dir_all(ws);
185    }
186
187    #[test]
188    fn load_personality_truncates_large_files() {
189        let large = "x".repeat(MAX_FILE_CHARS + 500);
190        let ws = setup_workspace(&[("SOUL.md", &large)]);
191
192        let profile = load_personality(&ws);
193        let soul = profile.files.iter().find(|f| f.name == "SOUL.md").unwrap();
194        assert!(soul.truncated);
195        assert_eq!(soul.content.chars().count(), MAX_FILE_CHARS);
196
197        let _ = std::fs::remove_dir_all(ws);
198    }
199
200    #[test]
201    fn render_produces_markdown_sections() {
202        let ws = setup_workspace(&[("SOUL.md", "Be kind."), ("IDENTITY.md", "Name: Nova")]);
203
204        let profile = load_personality(&ws);
205        let rendered = profile.render();
206        assert!(rendered.contains("### SOUL.md"));
207        assert!(rendered.contains("Be kind."));
208        assert!(rendered.contains("### IDENTITY.md"));
209        assert!(rendered.contains("Name: Nova"));
210
211        let _ = std::fs::remove_dir_all(ws);
212    }
213
214    #[test]
215    fn render_truncated_file_shows_notice() {
216        let large = "y".repeat(MAX_FILE_CHARS + 100);
217        let ws = setup_workspace(&[("SOUL.md", &large)]);
218
219        let profile = load_personality(&ws);
220        let rendered = profile.render();
221        assert!(rendered.contains("[... truncated at"));
222
223        let _ = std::fs::remove_dir_all(ws);
224    }
225
226    #[test]
227    fn get_returns_none_for_missing_file() {
228        let ws = setup_workspace(&[]);
229        let profile = load_personality(&ws);
230        assert!(profile.get("SOUL.md").is_none());
231        let _ = std::fs::remove_dir_all(ws);
232    }
233
234    #[test]
235    fn load_personality_files_custom_subset() {
236        let ws = setup_workspace(&[("SOUL.md", "soul"), ("USER.md", "user")]);
237
238        let profile = load_personality_files(&ws, &["SOUL.md", "USER.md"]);
239        assert_eq!(profile.files.len(), 2);
240        assert!(profile.missing.is_empty());
241
242        let _ = std::fs::remove_dir_all(ws);
243    }
244
245    #[test]
246    fn empty_workspace_yields_empty_profile() {
247        let ws = setup_workspace(&[]);
248        let profile = load_personality(&ws);
249        assert!(profile.is_empty());
250        assert!(!profile.missing.is_empty());
251        let _ = std::fs::remove_dir_all(ws);
252    }
253}