nexus_memory_agent/
soul.rs1use std::fs;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use anyhow::Context;
8
9use nexus_llm::{ChatMessage, GenerateParams, LlmClient};
10use serde::{Deserialize, Serialize};
11use tracing::{debug, info, warn};
12
13use crate::prompts::{SOUL_EVALUATION_PROMPT, SOUL_NORMALIZATION_PROMPT};
14
15const SOUL_MAX_TOKENS: usize = 2048;
17
18pub fn soul_path() -> PathBuf {
20 dirs::config_dir()
21 .unwrap_or_else(|| PathBuf::from("."))
22 .join("nexus")
23 .join("soul.md")
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "PascalCase")]
28pub enum SoulCategory {
29 IdentityPreference,
30 TechnicalLearning,
31 WorkingPattern,
32 AgentNote,
33}
34
35#[derive(Debug, Clone, Serialize, Deserialize)]
36pub struct SoulCandidate {
37 pub content: String,
38 pub source_project: String,
39 pub observation_count: u32,
40 pub category: String,
41 pub source_agent: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct NormalizedLearning {
46 pub content: String,
47 pub category: SoulCategory,
48 pub confidence: f32,
49 pub observation_count: u32,
50}
51
52#[derive(Debug, Clone, Serialize, Deserialize)]
53struct NormalizationResponse {
54 pub normalized: Vec<NormalizedLearning>,
55 pub discarded_count: usize,
56}
57
58pub struct SoulBuilder {
59 llm: Arc<dyn LlmClient>,
60}
61
62impl SoulBuilder {
63 pub fn new(llm: Arc<dyn LlmClient>) -> Self {
64 Self { llm }
65 }
66
67 pub fn read_current_soul(&self) -> anyhow::Result<String> {
70 let path = soul_path();
71 match fs::read_to_string(&path) {
72 Ok(soul) => Ok(soul),
73 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
74 Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
75 }
76 }
77
78 pub async fn normalize_candidates(
80 &self,
81 candidates: &[SoulCandidate],
82 ) -> anyhow::Result<Vec<NormalizedLearning>> {
83 if candidates.is_empty() {
84 return Ok(Vec::new());
85 }
86
87 let candidates_json = serde_json::to_string_pretty(candidates)?;
88 let system_prompt = SOUL_NORMALIZATION_PROMPT;
89 let user_prompt = format!(
90 "Normalize the following project-specific candidates for the user's Soul:\n\n{}",
91 candidates_json
92 );
93
94 let messages = vec![
95 ChatMessage::system(system_prompt),
96 ChatMessage::user(user_prompt),
97 ];
98
99 let params = GenerateParams {
100 messages,
101 json_mode: true,
102 ..Default::default()
103 };
104
105 let response = self.llm.generate(params).await?;
106
107 let content = response.content.trim();
108 let clean_response = if let Some(start) = content.find('{') {
109 if let Some(end) = content.rfind('}') {
110 if end > start {
111 &content[start..=end]
112 } else {
113 content
114 }
115 } else {
116 content
117 }
118 } else {
119 content
120 };
121
122 match serde_json::from_str::<NormalizationResponse>(clean_response) {
123 Ok(res) => {
124 let filtered = res
125 .normalized
126 .into_iter()
127 .filter(|l| l.confidence >= 0.70)
128 .collect();
129 Ok(filtered)
130 }
131 Err(e) => {
132 warn!(
133 "Soul normalization parse failed; response length: {} chars, error: {}",
134 clean_response.len(),
135 e
136 );
137 Err(e).context("Failed to parse soul normalization response")
138 }
139 }
140 }
141
142 pub async fn evaluate_and_merge(
144 &self,
145 current_soul: &str,
146 normalized: &[NormalizedLearning],
147 ) -> anyhow::Result<String> {
148 if normalized.is_empty() {
149 return Ok(current_soul.to_string());
150 }
151
152 let normalized_json = serde_json::to_string_pretty(normalized)?;
153 let system_prompt = SOUL_EVALUATION_PROMPT;
154 let user_prompt = format!(
155 "CURRENT SOUL:\n{}\n\nNEW CANDIDATES:\n{}",
156 current_soul, normalized_json
157 );
158
159 let messages = vec![
160 ChatMessage::system(system_prompt),
161 ChatMessage::user(user_prompt),
162 ];
163
164 let params = GenerateParams {
165 messages,
166 ..Default::default()
167 };
168
169 let response = self.llm.generate(params).await?;
170 let new_soul = response.content;
171
172 let required_headers = [
173 "# Nexus Soul",
174 "## Identity & Preferences",
175 "## Technical Learnings",
176 "## Working Patterns",
177 "## Agent Notes",
178 ];
179 if new_soul.len() < 50 || required_headers.iter().any(|h| !new_soul.contains(h)) {
180 warn!("LLM returned invalid or incomplete soul document. Keeping existing.");
181 return Ok(current_soul.to_string());
182 }
183
184 if new_soul.len() / 4 > SOUL_MAX_TOKENS {
186 warn!(
187 "Compressed soul exceeds token budget: {} estimated tokens > {}",
188 new_soul.len() / 4,
189 SOUL_MAX_TOKENS
190 );
191 return Ok(current_soul.to_string());
192 }
193
194 Ok(new_soul)
195 }
196
197 pub async fn rebuild_soul(&self, candidates: &[SoulCandidate]) -> anyhow::Result<String> {
199 info!(
200 "Starting Soul rebuild pipeline with {} candidates",
201 candidates.len()
202 );
203
204 let normalized = self.normalize_candidates(candidates).await?;
205 debug!("Normalized into {} general learnings", normalized.len());
206
207 let current = self.read_current_soul()?;
208 let mut new_soul = self.evaluate_and_merge(¤t, &normalized).await?;
209
210 if new_soul.len() / 4 > SOUL_MAX_TOKENS {
211 debug!("Soul exceeded budget. Attempting compression.");
212 let messages = vec![
213 ChatMessage::system("You are a text compression engine. Compress the following markdown soul profile while preserving all high-signal patterns. Keep it under 1500 words."),
214 ChatMessage::user(&new_soul),
215 ];
216 let params = GenerateParams {
217 messages,
218 ..Default::default()
219 };
220 if let Ok(res) = self.llm.generate(params).await {
221 let compressed = res.content;
222 let has_header = compressed.contains("# Nexus Soul");
223 let within_budget = compressed.len() / 4 <= SOUL_MAX_TOKENS;
224 if has_header && within_budget {
225 new_soul = compressed;
226 } else {
227 debug!(
228 has_header,
229 within_budget,
230 compressed_len = compressed.len(),
231 "Soul compression output failed validation; keeping pre-compression version"
232 );
233 }
234 }
235 }
236
237 if new_soul.len() / 4 > SOUL_MAX_TOKENS {
239 anyhow::bail!(
240 "Refusing to write oversized soul: {} estimated tokens > {}",
241 new_soul.len() / 4,
242 SOUL_MAX_TOKENS
243 );
244 }
245
246 let path = soul_path();
247 if path.exists() {
248 let bak = path.with_extension("md.bak");
249 fs::copy(&path, &bak).with_context(|| {
250 format!(
251 "Failed to create backup at {}. Aborting soul rebuild.",
252 bak.display()
253 )
254 })?;
255 }
256
257 if let Some(parent) = path.parent() {
258 fs::create_dir_all(parent).with_context(|| {
259 format!("Failed to create soul directory at {}", parent.display())
260 })?;
261 }
262 nexus_core::fsutil::atomic_write(&path, &new_soul)?;
263
264 info!(
265 "Soul rebuild complete. Wrote {} bytes to {}",
266 new_soul.len(),
267 path.display()
268 );
269 Ok(new_soul)
270 }
271}