construct/agent/
personality.rs1use std::fmt::Write;
9use std::path::{Path, PathBuf};
10
11const MAX_FILE_CHARS: usize = 20_000;
13
14const 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#[derive(Debug, Clone)]
28pub struct PersonalityFile {
29 pub name: String,
31 pub content: String,
33 pub truncated: bool,
35 pub path: PathBuf,
37}
38
39#[derive(Debug, Clone, Default)]
41pub struct PersonalityProfile {
42 pub files: Vec<PersonalityFile>,
44 pub missing: Vec<String>,
46}
47
48impl PersonalityProfile {
49 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 pub fn is_empty(&self) -> bool {
59 self.files.is_empty()
60 }
61
62 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
81pub fn load_personality(workspace_dir: &Path) -> PersonalityProfile {
86 load_personality_files(workspace_dir, PERSONALITY_FILES)
87}
88
89pub 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
119fn 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}