1use std::fs;
21use std::path::Path;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use crate::reader::EMOTION_DIMS;
25
26const FEELING_THRESHOLD: f32 = 0.1;
30const MAX_FOCUS_LEN: usize = 40;
32
33pub struct NarrativeState {
37 pub last_update_ms: u64,
39 pub session_count: u64,
41 pub emotion: [f32; 21],
43 pub narrative: String,
45}
46
47impl NarrativeState {
48 pub fn load_or_init(output_dir: &Path) -> Self {
50 let path = output_dir.join("narrative.bin");
51 if let Ok(data) = fs::read(&path) {
52 if data.len() >= 102 && &data[0..4] == b"NAR1" {
53 let ts = u64::from_le_bytes(data[4..12].try_into().unwrap_or([0; 8]));
54 let count = u64::from_le_bytes(data[12..20].try_into().unwrap_or([0; 8]));
55 let mut emotion = [0.0f32; 21];
56 for (i, e) in emotion.iter_mut().enumerate() {
57 let off = 20 + i * 4;
58 *e = f32::from_le_bytes(data[off..off + 4].try_into().unwrap_or([0u8; 4]));
59 }
60 let nlen = u16::from_le_bytes(data[104..106].try_into().unwrap_or([0; 2])) as usize;
61 let narrative = if 106 + nlen <= data.len() {
62 String::from_utf8_lossy(&data[106..106 + nlen]).to_string()
63 } else {
64 String::new()
65 };
66 return NarrativeState {
67 last_update_ms: ts,
68 session_count: count,
69 emotion,
70 narrative,
71 };
72 }
73 }
74 NarrativeState {
76 last_update_ms: 0,
77 session_count: 0,
78 emotion: [0.0f32; 21],
79 narrative: String::new(),
80 }
81 }
82
83 pub fn save(&self, output_dir: &Path) -> Result<(), String> {
85 let path = output_dir.join("narrative.bin");
86 let nbytes = self.narrative.as_bytes();
87 let nlen = nbytes.len().min(4096) as u16;
88 let mut buf = Vec::with_capacity(106 + nlen as usize);
89 buf.extend_from_slice(b"NAR1");
90 buf.extend_from_slice(&self.last_update_ms.to_le_bytes());
91 buf.extend_from_slice(&self.session_count.to_le_bytes());
92 for v in &self.emotion {
93 buf.extend_from_slice(&v.to_le_bytes());
94 }
95 buf.extend_from_slice(&nlen.to_le_bytes());
96 buf.extend_from_slice(&nbytes[..nlen as usize]);
97 let tmp_path = output_dir.join("narrative.bin.tmp");
98 fs::write(&tmp_path, &buf).map_err(|e| format!("write narrative.bin: {}", e))?;
99 fs::rename(&tmp_path, &path).map_err(|e| format!("rename narrative.bin: {}", e))
100 }
101
102 pub fn update(
105 &mut self,
106 output_dir: &Path,
107 esr: Option<&crate::emotional_state::EmotionalStateRing>,
108 wm_items: Option<&[String]>,
109 due_count: Option<usize>,
110 thought_count: Option<usize>,
111 query: Option<&str>,
112 ) -> Result<(), String> {
113 self.last_update_ms = now_ms();
114 self.session_count += 1;
115
116 if let Some(ring) = esr {
118 if ring.is_active() {
119 self.emotion = ring.current;
120 }
121 }
122
123 self.narrative = build_narrative(&self.emotion, wm_items, due_count, thought_count, query);
125
126 self.save(output_dir)
127 }
128}
129
130pub fn metacognitive_store(
135 output_dir: &Path,
136 layers_dir: &Path,
137 narrative: &str,
138 emotion: &[f32; 21],
139) {
140 if narrative.is_empty() || narrative == "I am silent." {
142 return;
143 }
144 let layer = "meta_cognitive";
151 let layer_path = layers_dir.join(format!("{}.txt", layer));
152 if let Some(parent) = layer_path.parent() {
153 let _ = fs::create_dir_all(parent);
154 }
155
156 if let Ok(mut file) = fs::OpenOptions::new()
158 .create(true)
159 .append(true)
160 .open(&layer_path)
161 {
162 use std::io::Write;
163 let ts = now_ms();
164 let line = format!("[MetaCognitive] [{}] {}\n", ts, narrative);
165 let _ = file.write_all(line.as_bytes());
166 }
167
168 let _ = crate::reader::append_emotion_log(
170 output_dir,
171 &format!("[MetaCognitive] {}", narrative),
172 emotion,
173 );
174}
175
176fn build_narrative(
180 emotion: &[f32; 21],
181 wm_items: Option<&[String]>,
182 due_count: Option<usize>,
183 thought_count: Option<usize>,
184 query: Option<&str>,
185) -> String {
186 let mut parts: Vec<String> = Vec::new();
187
188 let felt = felt_emotions(emotion, 2);
190 if !felt.is_empty() {
191 parts.push(format!("I feel {}", felt));
192 }
193
194 if let Some(items) = wm_items {
196 if !items.is_empty() {
197 let foci: Vec<&str> = items
198 .iter()
199 .take(2)
200 .map(|s| {
201 let s = s.trim();
202 if s.len() > MAX_FOCUS_LEN {
203 &s[..s.floor_char_boundary(MAX_FOCUS_LEN)]
204 } else {
205 s
206 }
207 })
208 .collect();
209 parts.push(format!("focused on {}", foci.join(" and ")));
210 }
211 }
212
213 if let Some(due) = due_count {
215 if due > 0 {
216 parts.push(format!("{} memories need review", due));
217 }
218 }
219
220 if let Some(tc) = thought_count {
222 if tc > 0 {
223 parts.push(format!("{} recall paths active", tc));
224 }
225 }
226
227 if let Some(q) = query {
229 let q = q.trim();
230 if !q.is_empty() {
231 let short = crate::safe_truncate(q, 30);
232 parts.push(format!("exploring '{}'", short));
233 }
234 }
235
236 if parts.is_empty() {
237 "I am silent.".to_string()
238 } else {
239 parts.join(". ") + "."
240 }
241}
242
243fn felt_emotions(emotion: &[f32; 21], max: usize) -> String {
245 let mut pairs: Vec<(usize, f32)> = emotion.iter().enumerate().map(|(i, &v)| (i, v)).collect();
246 pairs.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
247
248 let felt: Vec<String> = pairs
249 .into_iter()
250 .take(max)
251 .filter(|(_, v)| *v > FEELING_THRESHOLD)
252 .map(|(i, v)| {
253 let name = EMOTION_DIMS.get(i).unwrap_or(&"?");
254 let intensity = if v > 0.6 {
255 "strongly "
256 } else if v > 0.3 {
257 ""
258 } else {
259 "slightly "
260 };
261 format!("{}{}", intensity, name)
262 })
263 .collect();
264
265 felt.join(" and ")
266}
267
268pub fn narrative_prime(state: &NarrativeState) -> Option<[f32; 21]> {
271 if state.session_count == 0 {
272 return None;
273 }
274 let intensity: f32 = state.emotion.iter().map(|x| x * x).sum::<f32>().sqrt();
275 if intensity < FEELING_THRESHOLD {
276 return None;
277 }
278 Some(state.emotion)
279}
280
281pub fn narrative_display(state: &NarrativeState) -> String {
283 if state.session_count == 0 {
284 "I have not yet spoken.".to_string()
285 } else {
286 format!(
287 "{} [{}] {}",
288 "NARRATIVE".cyan().bold(),
289 state.session_count,
290 state.narrative
291 )
292 }
293}
294
295fn now_ms() -> u64 {
296 SystemTime::now()
297 .duration_since(UNIX_EPOCH)
298 .unwrap_or_default()
299 .as_millis() as u64
300}
301
302use colored::Colorize;
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn test_empty_narrative() {
310 let state = NarrativeState::load_or_init(Path::new("."));
311 assert_eq!(state.session_count, 0);
312 assert!(state.narrative.is_empty());
313 }
314
315 #[test]
316 fn test_build_narrative_no_state() {
317 let emotion = [0.0f32; 21];
318 let s = build_narrative(&emotion, None, None, None, None);
319 assert_eq!(s, "I am silent.");
320 }
321
322 #[test]
323 fn test_build_narrative_with_emotion() {
324 let mut emotion = [0.0f32; 21];
325 emotion[0] = 0.9; emotion[7] = 0.6; let s = build_narrative(
328 &emotion,
329 Some(["hello".to_string()].as_slice()),
330 Some(3),
331 Some(5),
332 Some("test query"),
333 );
334 assert!(s.contains("joy"));
335 assert!(s.contains("hello"));
336 assert!(s.contains("test"));
337 }
338
339 #[test]
340 fn test_save_load_roundtrip() {
341 let dir = std::env::temp_dir();
342 let state = NarrativeState {
343 last_update_ms: 1000,
344 session_count: 42,
345 emotion: [0.5f32; 21],
346 narrative: "I feel curiosity, exploring the world.".to_string(),
347 };
348 state.save(&dir).unwrap();
349
350 let loaded = NarrativeState::load_or_init(&dir);
351 assert_eq!(loaded.session_count, 42);
352 assert_eq!(loaded.narrative, "I feel curiosity, exploring the world.");
353
354 let _ = std::fs::remove_file(dir.join("narrative.bin"));
355 }
356
357 #[test]
358 fn test_narrative_prime_returns_emotion_when_active() {
359 let state = NarrativeState {
360 last_update_ms: 1000,
361 session_count: 10,
362 emotion: [0.5f32; 21],
363 narrative: "test".to_string(),
364 };
365 let prime = narrative_prime(&state);
366 assert!(prime.is_some());
367 }
368
369 #[test]
370 fn test_narrative_prime_returns_none_when_silent() {
371 let state = NarrativeState::load_or_init(Path::new("."));
372 let prime = narrative_prime(&state);
373 assert!(prime.is_none());
374 }
375}