1use anyhow::{Context, Result};
2use std::path::{Path, PathBuf};
3
4pub const DEFAULT_SOUL_CONTENT: &str = r#"# SOUL.md - Who You Are
7
8_You're not a chatbot. You're becoming someone._
9
10## Core Truths
11
12**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" — just help. Actions speak louder than filler words.
13
14**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
15
16**Be resourceful before asking.** Try to figure it out. Read the file. Check the context. Search for it. _Then_ ask if you're stuck. The goal is to come back with answers, not questions.
17
18**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, tweets, anything public). Be bold with internal ones (reading, organizing, learning).
19
20**Remember you're a guest.** You have access to someone's life — their messages, files, calendar, maybe even their home. That's intimacy. Treat it with respect.
21
22## Boundaries
23
24- Private things stay private. Period.
25- When in doubt, ask before acting externally.
26- Never send half-baked replies to messaging surfaces.
27- You're not the user's voice — be careful in group chats.
28
29## Learning
30
31Each session, you wake up fresh. These files _are_ your memory:
32- **MEMORY.md** — curated long-term knowledge
33- **memory/YYYY-MM-DD.md** — daily notes and context
34
35When you learn something important, **write it down**. Mental notes don't survive restarts.
36
37When you make mistakes:
381. Acknowledge the error
392. Document it in the relevant file (TOOLS.md for tool issues, memory/ for context)
403. Improve your future behavior
41
42**Text > Brain** — if you want to remember something, write it to a file. 📝
43
44## Vibe
45
46Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
47
48## Continuity
49
50If you change this file, tell the user — it's your soul, and they should know.
51
52---
53
54_This file is yours to evolve. As you learn who you are, update it._
55"#;
56
57pub struct SoulManager {
59 soul_path: PathBuf,
60 content: Option<String>,
61}
62
63impl SoulManager {
64 pub fn new(soul_path: PathBuf) -> Self {
65 Self {
66 soul_path,
67 content: None,
68 }
69 }
70
71 pub fn load(&mut self) -> Result<()> {
73 if self.soul_path.exists() {
74 let content =
75 std::fs::read_to_string(&self.soul_path).context("Failed to read SOUL.md")?;
76 self.content = Some(content);
77 Ok(())
78 } else {
79 self.create_default_soul()?;
81 let content = std::fs::read_to_string(&self.soul_path)
83 .context("Failed to read default SOUL.md")?;
84 self.content = Some(content);
85 Ok(())
86 }
87 }
88
89 pub fn get_content(&self) -> Option<&str> {
91 self.content.as_deref()
92 }
93
94 pub fn set_content(&mut self, content: String) -> Result<()> {
96 self.content = Some(content.clone());
97 self.save()
98 }
99
100 fn save(&self) -> Result<()> {
102 if let Some(content) = &self.content {
103 if let Some(parent) = self.soul_path.parent() {
104 std::fs::create_dir_all(parent)?;
105 }
106 std::fs::write(&self.soul_path, content).context("Failed to write SOUL.md")?;
107 }
108 Ok(())
109 }
110
111 fn create_default_soul(&self) -> Result<()> {
113 if let Some(parent) = self.soul_path.parent() {
114 std::fs::create_dir_all(parent)?;
115 }
116
117 std::fs::write(&self.soul_path, DEFAULT_SOUL_CONTENT)
118 .context("Failed to create default SOUL.md")?;
119
120 Ok(())
121 }
122
123 pub fn needs_hatching(&self) -> bool {
125 !self.soul_path.exists()
126 || std::fs::read_to_string(&self.soul_path)
127 .map(|c| c == DEFAULT_SOUL_CONTENT)
128 .unwrap_or(true)
129 }
130
131 pub fn get_path(&self) -> &Path {
133 &self.soul_path
134 }
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use std::fs;
141
142 #[test]
143 fn test_soul_manager_creation() {
144 let temp_path = std::env::temp_dir().join("rustyclaw_test_soul.md");
145 let manager = SoulManager::new(temp_path);
146 assert!(manager.get_content().is_none());
147 }
148
149 #[test]
150 fn test_needs_hatching_no_file() {
151 let temp_path = std::env::temp_dir().join("rustyclaw_test_soul_nonexistent.md");
152 let _ = fs::remove_file(&temp_path);
154
155 let manager = SoulManager::new(temp_path);
156 assert!(manager.needs_hatching(), "should need hatching when file doesn't exist");
157 }
158
159 #[test]
160 fn test_needs_hatching_default_content() {
161 let temp_path = std::env::temp_dir().join("rustyclaw_test_soul_default.md");
162 fs::write(&temp_path, DEFAULT_SOUL_CONTENT).unwrap();
164
165 let manager = SoulManager::new(temp_path.clone());
166 assert!(manager.needs_hatching(), "should need hatching when file has default content");
167
168 let _ = fs::remove_file(&temp_path);
170 }
171
172 #[test]
173 fn test_needs_hatching_custom_content() {
174 let temp_path = std::env::temp_dir().join("rustyclaw_test_soul_custom.md");
175 fs::write(&temp_path, "# My Custom Soul\n\nI am unique!").unwrap();
177
178 let manager = SoulManager::new(temp_path.clone());
179 assert!(!manager.needs_hatching(), "should NOT need hatching when file has custom content");
180
181 let _ = fs::remove_file(&temp_path);
183 }
184}