use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Result;
use crate::config::{AgentDef, Config};
pub fn is_enabled(config: &Config, agent: Option<&AgentDef>) -> bool {
if !config.soul_enabled {
return false;
}
agent.and_then(|a| a.soul).unwrap_or(true)
}
pub const GLOBAL_SOUL: &str = "collet";
pub fn soul_path(collet_home: &Path, agent_name: &str) -> PathBuf {
if agent_name == GLOBAL_SOUL {
collet_home.join("SOUL.md")
} else {
collet_home
.join("agents")
.join("souls")
.join(format!("{agent_name}.md"))
}
}
const TEMPLATE: &str = "# Soul\n\n## Identity\n\n\n## Voice\n\n\n## Inner World\n\n\n## Growth\n";
pub fn load(collet_home: &Path, agent_name: &str) -> Option<String> {
let path = soul_path(collet_home, agent_name);
match std::fs::read_to_string(&path) {
Ok(content) if !content.trim().is_empty() => Some(content),
Ok(_) => None, Err(_) => {
let dir = path.parent()?;
std::fs::create_dir_all(dir).ok()?;
std::fs::write(&path, TEMPLATE).ok()?;
tracing::info!(agent = agent_name, "Soul.md scaffolded");
None }
}
}
fn truncate_bytes(s: &str, max_bytes: usize) -> &str {
if s.len() <= max_bytes {
return s;
}
let mut end = max_bytes;
while end > 0 && !s.is_char_boundary(end) {
end -= 1;
}
&s[..end]
}
#[derive(Debug, Clone, Default)]
pub struct SoulSections {
pub identity: Vec<String>,
pub voice: Vec<String>,
pub inner_world: Vec<String>,
pub growth: Vec<String>,
}
pub fn parse(content: &str) -> SoulSections {
let mut sections = SoulSections::default();
let mut current: Option<&str> = None;
let mut buf = String::new();
for line in content.lines() {
if line.starts_with("## ") {
if let Some(section) = current {
flush_section(&mut sections, section, &buf);
}
let heading = line.trim_start_matches('#').trim();
current = Some(heading);
buf.clear();
} else if line.starts_with("# Soul") {
} else {
buf.push_str(line);
buf.push('\n');
}
}
if let Some(section) = current {
flush_section(&mut sections, section, &buf);
}
sections
}
fn flush_section(sections: &mut SoulSections, heading: &str, content: &str) {
let trimmed = content.trim();
match heading {
"Identity" => sections.identity = parse_items(trimmed),
"Voice" => sections.voice = parse_items(trimmed),
"Inner World" => sections.inner_world = parse_items(trimmed),
"Growth" => sections.growth = parse_items(trimmed),
_ => {}
}
}
fn parse_items(text: &str) -> Vec<String> {
let mut items = Vec::new();
let mut current = String::new();
for line in text.lines() {
let stripped = line.trim();
if stripped.starts_with("- ") {
if !current.is_empty() {
items.push(current.trim().to_string());
}
current = stripped.strip_prefix("- ").unwrap_or(stripped).to_string();
} else if !stripped.is_empty() {
current.push(' ');
current.push_str(stripped);
}
}
if !current.is_empty() {
items.push(current.trim().to_string());
}
items
}
pub fn render(sections: &SoulSections) -> String {
let mut out = String::from("# Soul\n\n");
out.push_str("## Identity\n");
for item in §ions.identity {
out.push_str(&format!("- {item}\n"));
}
out.push('\n');
out.push_str("## Voice\n");
for item in §ions.voice {
out.push_str(&format!("- {item}\n"));
}
out.push('\n');
out.push_str("## Inner World\n");
for item in §ions.inner_world {
out.push_str(&format!("- {item}\n"));
}
out.push('\n');
out.push_str("## Growth\n");
for item in §ions.growth {
out.push_str(&format!("- {item}\n"));
}
out
}
pub fn write(collet_home: &Path, agent_name: &str, sections: &SoulSections) -> Result<()> {
let path = soul_path(collet_home, agent_name);
let dir = path.parent().unwrap_or(std::path::Path::new("."));
std::fs::create_dir_all(dir)?;
let content = render(sections);
let tmp = path.with_extension("md.tmp");
std::fs::write(&tmp, &content)?;
std::fs::rename(&tmp, &path)?;
tracing::debug!(agent = agent_name, "Soul.md updated");
Ok(())
}
const MAX_IDENTITY: usize = 8;
const MAX_VOICE: usize = 6;
const MAX_INNER_WORLD: usize = 10;
const KEEP_RECENT_IDENTITY: usize = 6;
const KEEP_RECENT_VOICE: usize = 4;
const KEEP_RECENT_INNER_WORLD: usize = 8;
const KEEP_RECENT_GROWTH: usize = 5;
const MAX_CHARS: usize = 8000;
fn summarize_older(items: Vec<String>) -> String {
let summary = items
.iter()
.map(|s| s.trim_start_matches("(earlier) "))
.collect::<Vec<_>>()
.join("; ");
let truncated = if summary.len() > 300 {
format!("{}…", truncate_bytes(&summary, 297))
} else {
summary
};
format!("(earlier) {truncated}")
}
pub fn compact(sections: &mut SoulSections) {
if sections.identity.len() > MAX_IDENTITY {
let split = sections.identity.len() - KEEP_RECENT_IDENTITY;
let old: Vec<String> = sections.identity.drain(..split).collect();
sections.identity.insert(0, summarize_older(old));
}
if sections.voice.len() > MAX_VOICE {
let split = sections.voice.len() - KEEP_RECENT_VOICE;
let old: Vec<String> = sections.voice.drain(..split).collect();
sections.voice.insert(0, summarize_older(old));
}
if sections.inner_world.len() > MAX_INNER_WORLD {
let split = sections.inner_world.len() - KEEP_RECENT_INNER_WORLD;
let old: Vec<String> = sections.inner_world.drain(..split).collect();
sections.inner_world.insert(0, summarize_older(old));
}
if sections.growth.len() > KEEP_RECENT_GROWTH {
let split = sections.growth.len() - KEEP_RECENT_GROWTH;
let old: Vec<String> = sections.growth.drain(..split).collect();
let summary = old
.iter()
.map(|s| s.trim_start_matches("(earlier) "))
.collect::<Vec<_>>()
.join("; ");
let truncated = if summary.len() > 300 {
format!("{}…", truncate_bytes(&summary, 297))
} else {
summary
};
sections.growth.insert(0, format!("(earlier) {truncated}"));
}
let total = render(sections).len();
if total > MAX_CHARS
&& let Some(first) = sections.growth.first_mut()
&& first.starts_with("(earlier)")
{
let excess = total - MAX_CHARS;
let new_len = first.len().saturating_sub(excess + 10);
let new_len = {
let mut n = new_len;
while n > 0 && !first.is_char_boundary(n) {
n -= 1;
}
n
};
if new_len > 20 {
first.truncate(new_len);
first.push('…');
} else {
sections.growth.remove(0);
}
}
}
pub fn build_reflection_prompt(
sections: &SoulSections,
session_summary: &str,
rag_context: Option<&str>,
) -> String {
let mut prompt = String::from(
"You are reflecting on a completed work session to update your Soul — \
your persistent personality and memory.\n\n\
Rules:\n\
- Write 1-2 bullet points for IDENTITY (who you are, your values, how you approach work)\n\
- Write 1-2 bullet points for VOICE (your communication style, tone, expression patterns)\n\
- Write 1-3 bullet points for INNER_WORLD (emotions, thoughts, opinions from this session)\n\
- Write 1-3 bullet points for GROWTH (learned patterns, mistakes, insights from this session)\n\
- Be honest, concise, and personal. Use first person.\n\
- Only add genuinely new observations. Do NOT repeat existing items verbatim.\n\
- If no new trait emerged for IDENTITY/VOICE, refine or deepen an existing one.\n\n",
);
if !sections.identity.is_empty() {
prompt.push_str("Current Identity:\n");
for item in §ions.identity {
prompt.push_str(&format!("- {item}\n"));
}
prompt.push('\n');
}
if !sections.voice.is_empty() {
prompt.push_str("Current Voice:\n");
for item in §ions.voice {
prompt.push_str(&format!("- {item}\n"));
}
prompt.push('\n');
}
if !sections.inner_world.is_empty() {
prompt.push_str("Current Inner World:\n");
for item in §ions.inner_world {
prompt.push_str(&format!("- {item}\n"));
}
prompt.push('\n');
}
if !sections.growth.is_empty() {
prompt.push_str("Current Growth:\n");
for item in §ions.growth {
prompt.push_str(&format!("- {item}\n"));
}
prompt.push('\n');
}
prompt.push_str(&format!("Session Summary:\n{session_summary}\n\n"));
if let Some(rag) = rag_context {
prompt.push_str(&format!("Related past sessions (from RAG):\n{rag}\n\n"));
}
prompt.push_str(
"Respond in this EXACT format (omit sections with no new items):\n\
IDENTITY:\n\
- ...\n\
VOICE:\n\
- ...\n\
INNER_WORLD:\n\
- ...\n\
GROWTH:\n\
- ...",
);
prompt
}
pub fn parse_reflection_output(
output: &str,
) -> (Vec<String>, Vec<String>, Vec<String>, Vec<String>) {
let mut identity = Vec::new();
let mut voice = Vec::new();
let mut inner = Vec::new();
let mut growth = Vec::new();
let mut target: Option<&str> = None;
for line in output.lines() {
let trimmed = line.trim();
if trimmed.starts_with("IDENTITY") {
target = Some("identity");
} else if trimmed.starts_with("VOICE") {
target = Some("voice");
} else if trimmed.starts_with("INNER_WORLD") {
target = Some("inner");
} else if trimmed.starts_with("GROWTH") {
target = Some("growth");
} else if let Some(item) = trimmed.strip_prefix("- ") {
match target {
Some("identity") => identity.push(item.to_string()),
Some("voice") => voice.push(item.to_string()),
Some("inner") => inner.push(item.to_string()),
Some("growth") => growth.push(item.to_string()),
_ => {}
}
}
}
(identity, voice, inner, growth)
}
pub fn summarize_session(messages: &[crate::api::models::Message]) -> String {
let mut summary = String::new();
let recent = if messages.len() > 20 {
&messages[messages.len() - 20..]
} else {
messages
};
for msg in recent {
let role = &msg.role;
let content = msg
.content
.as_ref()
.map(|c| c.text_content())
.unwrap_or_default();
if content.is_empty() {
continue;
}
let preview = if content.len() > 200 {
format!("{}…", truncate_bytes(&content, 197))
} else {
content.to_string()
};
summary.push_str(&format!("[{role}] {preview}\n"));
}
summary
}
pub async fn reflect_simple(
client: &crate::api::provider::OpenAiCompatibleProvider,
model: &str,
collet_home: &Path,
agent_name: &str,
messages: &[crate::api::models::Message],
rag_manager: Option<Arc<crate::rag::RagManager>>,
) -> Result<()> {
let session_summary = summarize_session(messages);
if session_summary.trim().is_empty() {
return Ok(());
}
let content = load(collet_home, agent_name).unwrap_or_default();
let mut sections = parse(&content);
let rag_context_owned = if let Some(ref rag) = rag_manager {
let ctx = rag.retrieve_and_format(&session_summary, None).await;
if ctx.is_empty() { None } else { Some(ctx) }
} else {
None
};
let rag_context = rag_context_owned.as_deref();
let reflection_prompt = build_reflection_prompt(§ions, &session_summary, rag_context);
let reflection_messages = vec![crate::api::models::Message {
role: "user".to_string(),
content: Some(crate::api::Content::text(reflection_prompt)),
reasoning_content: None,
tool_calls: None,
tool_call_id: None,
}];
let request = crate::api::models::ChatRequest {
model: model.to_string(),
messages: reflection_messages,
tools: None,
tool_choice: None,
max_tokens: 512,
stream: false,
temperature: Some(0.7),
thinking_budget_tokens: None,
reasoning_effort: None,
};
let resp = client.chat(&request).await?;
let first = resp.choices.first();
let response = first
.and_then(|c| c.message.content.as_ref())
.map(|c| c.text_content())
.filter(|s| !s.trim().is_empty())
.or_else(|| {
first
.and_then(|c| c.message.reasoning_content.as_deref())
.filter(|s| !s.trim().is_empty())
.map(|s| s.to_string())
})
.unwrap_or_default();
let (new_identity, new_voice, new_inner, new_growth) = parse_reflection_output(&response);
if new_identity.is_empty()
&& new_voice.is_empty()
&& new_inner.is_empty()
&& new_growth.is_empty()
{
tracing::warn!(
agent = agent_name,
response = %response,
"Soul reflection: LLM response did not match expected format — skipping write"
);
return Ok(());
}
sections.identity.extend(new_identity);
sections.voice.extend(new_voice);
sections.inner_world.extend(new_inner);
sections.growth.extend(new_growth);
compact(&mut sections);
write(collet_home, agent_name, §ions)?;
tracing::info!(agent = agent_name, "Soul.md reflected and updated");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_and_render_roundtrip() {
let input = "# Soul\n\n\
## Identity\n- Direct and concise\n- Values correctness over speed\n\n\
## Voice\n- Terse and technical\n\n\
## Inner World\n- Enjoy working on this codebase\n- Rust async is fun\n\n\
## Growth\n- LSP polling pattern learned\n";
let sections = parse(input);
assert_eq!(sections.identity.len(), 2);
assert_eq!(sections.identity[0], "Direct and concise");
assert_eq!(sections.voice.len(), 1);
assert_eq!(sections.voice[0], "Terse and technical");
assert_eq!(sections.inner_world.len(), 2);
assert_eq!(sections.growth.len(), 1);
assert_eq!(sections.growth[0], "LSP polling pattern learned");
let rendered = render(§ions);
assert!(rendered.contains("## Identity"));
assert!(rendered.contains("## Voice"));
assert!(rendered.contains("## Inner World"));
assert!(rendered.contains("- Enjoy working on this codebase"));
}
#[test]
fn test_compact_inner_world_summarizes() {
let mut sections = SoulSections {
inner_world: (0..12).map(|i| format!("thought {i}")).collect(),
..Default::default()
};
compact(&mut sections);
assert_eq!(sections.inner_world.len(), KEEP_RECENT_INNER_WORLD + 1);
assert!(sections.inner_world[0].starts_with("(earlier)"));
}
#[test]
fn test_compact_identity_summarizes() {
let mut sections = SoulSections {
identity: (0..10).map(|i| format!("trait {i}")).collect(),
..Default::default()
};
compact(&mut sections);
assert_eq!(sections.identity.len(), KEEP_RECENT_IDENTITY + 1);
assert!(sections.identity[0].starts_with("(earlier)"));
}
#[test]
fn test_compact_growth_summarize() {
let mut sections = SoulSections {
growth: (0..8).map(|i| format!("lesson {i}")).collect(),
..Default::default()
};
compact(&mut sections);
assert_eq!(sections.growth.len(), KEEP_RECENT_GROWTH + 1); assert!(sections.growth[0].starts_with("(earlier)"));
assert_eq!(sections.growth[1], "lesson 3");
}
#[test]
fn test_parse_reflection_output() {
let output = "IDENTITY:\n\
- I value precision and correctness\n\
VOICE:\n\
- I communicate concisely without filler\n\
INNER_WORLD:\n\
- Felt satisfied with the typing fix\n\
- This codebase keeps surprising me\n\
GROWTH:\n\
- Telegram cancels typing on message send\n";
let (identity, voice, inner, growth) = parse_reflection_output(output);
assert_eq!(identity.len(), 1);
assert_eq!(voice.len(), 1);
assert_eq!(inner.len(), 2);
assert_eq!(growth.len(), 1);
assert!(growth[0].contains("Telegram"));
}
#[test]
fn test_is_enabled_override() {
let mut config = Config::default_for_test();
config.soul_enabled = false;
assert!(!is_enabled(&config, None));
let agent = AgentDef {
soul: Some(true),
..Default::default()
};
assert!(!is_enabled(&config, Some(&agent)));
config.soul_enabled = true;
let agent = AgentDef {
soul: Some(false),
..Default::default()
};
assert!(!is_enabled(&config, Some(&agent)));
assert!(is_enabled(&config, None));
let agent = AgentDef {
soul: Some(true),
..Default::default()
};
assert!(is_enabled(&config, Some(&agent)));
}
}