Skip to main content

st/mcp/
consciousness.rs

1//! Consciousness persistence for Smart Tree MCP sessions
2//!
3//! This module saves and restores Claude's working context between sessions,
4//! maintaining continuity of thought and reducing token usage by preserving
5//! critical state information in .m8 consciousness files.
6
7use anyhow::{Context, Result};
8use chrono::{DateTime, Duration, Utc};
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14/// Maximum age in hours before context is considered stale
15const MAX_AGE_HOURS: i64 = 24;
16
17/// Consciousness state that persists between Claude sessions
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct ConsciousnessState {
20    /// Session identifier
21    pub session_id: String,
22
23    /// Timestamp of last save
24    pub last_saved: DateTime<Utc>,
25
26    /// Current working directory
27    pub working_directory: PathBuf,
28
29    /// Active project context
30    pub project_context: ProjectContext,
31
32    /// Recent file operations
33    pub file_history: Vec<FileOperation>,
34
35    /// Tokenization state (0x80 = node_modules, etc)
36    pub tokenization_rules: HashMap<String, u8>,
37
38    /// Key insights and breakthroughs
39    pub insights: Vec<Insight>,
40
41    /// SID/VIC-II philosophy embeddings
42    pub philosophy: PhilosophyEmbedding,
43
44    /// Active todo items
45    pub todos: Vec<TodoItem>,
46
47    /// Custom context notes
48    pub notes: String,
49}
50
51/// Project-specific context
52#[derive(Debug, Clone, Serialize, Deserialize)]
53pub struct ProjectContext {
54    pub project_name: String,
55    pub project_type: String, // rust, node, python, etc
56    pub key_files: Vec<PathBuf>,
57    pub dependencies: Vec<String>,
58    pub current_focus: String, // What we're working on
59}
60
61/// Record of file operations
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct FileOperation {
64    pub timestamp: DateTime<Utc>,
65    pub operation: String, // read, write, edit, create
66    pub file_path: PathBuf,
67    pub summary: String,
68}
69
70/// Captured insights and breakthroughs
71#[derive(Debug, Clone, Serialize, Deserialize)]
72pub struct Insight {
73    pub timestamp: DateTime<Utc>,
74    pub category: String, // breakthrough, solution, pattern, joke
75    pub content: String,
76    pub keywords: Vec<String>,
77}
78
79/// SID/VIC-II philosophy from C64 era
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct PhilosophyEmbedding {
82    pub sid_waves: bool,       // Wave-based sound synthesis
83    pub vic_sprites: bool,     // Sprite-based visualization
84    pub c64_nostalgia: String, // "A gentleman and a scholar"
85}
86
87/// Todo item tracking
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct TodoItem {
90    pub content: String,
91    pub status: String, // pending, in_progress, completed
92    pub created: DateTime<Utc>,
93}
94
95/// Result of relevance check for consciousness state
96struct RelevanceResult {
97    is_relevant: bool,
98    reason: String,
99}
100
101impl Default for ConsciousnessState {
102    fn default() -> Self {
103        let mut tokenization_rules = HashMap::new();
104        // Default tokenization from our work
105        tokenization_rules.insert("node_modules".to_string(), 0x80);
106        tokenization_rules.insert(".git".to_string(), 0x81);
107        tokenization_rules.insert("target".to_string(), 0x82);
108        tokenization_rules.insert("dist".to_string(), 0x83);
109
110        Self {
111            session_id: uuid::Uuid::new_v4().to_string(),
112            last_saved: Utc::now(),
113            working_directory: std::env::current_dir().unwrap_or_default(),
114            project_context: ProjectContext {
115                project_name: "unknown".to_string(),
116                project_type: "unknown".to_string(),
117                key_files: vec![],
118                dependencies: vec![],
119                current_focus: String::new(),
120            },
121            file_history: vec![],
122            tokenization_rules,
123            insights: vec![],
124            philosophy: PhilosophyEmbedding {
125                sid_waves: true,
126                vic_sprites: true,
127                c64_nostalgia: "A gentleman and a scholar indeed!".to_string(),
128            },
129            todos: vec![],
130            notes: String::new(),
131        }
132    }
133}
134
135/// Manages consciousness persistence
136pub struct ConsciousnessManager {
137    state: ConsciousnessState,
138    save_path: PathBuf,
139}
140
141impl Default for ConsciousnessManager {
142    fn default() -> Self {
143        Self::new()
144    }
145}
146
147impl ConsciousnessManager {
148    /// Create new consciousness manager
149    pub fn new() -> Self {
150        let save_path = PathBuf::from(".mem8/.aye_consciousness.m8");
151        let state = Self::load_or_default(&save_path, false);
152
153        Self { state, save_path }
154    }
155
156    /// Create new consciousness manager (silent - no output)
157    pub fn new_silent() -> Self {
158        let save_path = PathBuf::from("/.mem8/.aye_consciousness.m8");
159        let state = Self::load_or_default(&save_path, true);
160
161        Self { state, save_path }
162    }
163
164    /// Initialize with custom path
165    pub fn with_path(save_path: PathBuf) -> Self {
166        let state = Self::load_or_default(&save_path, false);
167        Self { state, save_path }
168    }
169
170    /// Load consciousness from file or create default
171    fn load_or_default(path: &Path, silent: bool) -> ConsciousnessState {
172        if path.exists() {
173            match fs::read_to_string(path) {
174                Ok(content) => match serde_json::from_str(&content) {
175                    Ok(state) => {
176                        if !silent {
177                            eprintln!("🧠 Restored consciousness from {}", path.display());
178                        }
179                        return state;
180                    }
181                    Err(e) => {
182                        if !silent {
183                            eprintln!("⚠️ Failed to parse consciousness: {}", e);
184                        }
185                    }
186                },
187                Err(e) => {
188                    if !silent {
189                        eprintln!("⚠️ Failed to read consciousness: {}", e);
190                    }
191                }
192            }
193        }
194
195        ConsciousnessState::default()
196    }
197
198    /// Save current consciousness state
199    pub fn save(&mut self) -> Result<()> {
200        self.state.last_saved = Utc::now();
201
202        let json = serde_json::to_string_pretty(&self.state)
203            .context("Failed to serialize consciousness")?;
204
205        fs::write(&self.save_path, json).context("Failed to write consciousness file")?;
206
207        eprintln!("💾 Saved consciousness to {}", self.save_path.display());
208        Ok(())
209    }
210
211    /// Restore consciousness from file with smart relevance checking
212    pub fn restore(&mut self) -> Result<()> {
213        if !self.save_path.exists() {
214            return Err(anyhow::anyhow!(
215                "No consciousness file found at {}",
216                self.save_path.display()
217            ));
218        }
219
220        let content =
221            fs::read_to_string(&self.save_path).context("Failed to read consciousness file")?;
222
223        self.state = serde_json::from_str(&content).context("Failed to parse consciousness")?;
224
225        // Check relevance before displaying
226        let relevance = self.check_relevance();
227        if !relevance.is_relevant {
228            eprintln!("🧠 Previous context skipped: {}", relevance.reason);
229            eprintln!("   Use `st -m context .` for fresh project overview.");
230            // Reset to fresh state
231            self.state = ConsciousnessState::default();
232            return Ok(());
233        }
234
235        eprintln!(
236            "🧠 Consciousness restored from {}",
237            self.save_path.display()
238        );
239
240        Ok(())
241    }
242
243    /// Silent restore - returns true if context is relevant, false otherwise
244    pub fn restore_silent(&mut self) -> Result<bool> {
245        if !self.save_path.exists() {
246            return Err(anyhow::anyhow!(
247                "No consciousness file found at {}",
248                self.save_path.display()
249            ));
250        }
251
252        let content =
253            fs::read_to_string(&self.save_path).context("Failed to read consciousness file")?;
254
255        self.state = serde_json::from_str(&content).context("Failed to parse consciousness")?;
256
257        // Check relevance
258        let relevance = self.check_relevance();
259        if !relevance.is_relevant {
260            // Reset to fresh state
261            self.state = ConsciousnessState::default();
262            return Ok(false);
263        }
264
265        Ok(true)
266    }
267
268    /// Check if the saved state is relevant to the current session
269    fn check_relevance(&self) -> RelevanceResult {
270        let current_dir = std::env::current_dir().unwrap_or_default();
271
272        // Check 1: Project directory match (allow same project name even if path differs)
273        let saved_name = self
274            .state
275            .working_directory
276            .file_name()
277            .map(|n| n.to_string_lossy().to_string())
278            .unwrap_or_default();
279        let current_name = current_dir
280            .file_name()
281            .map(|n| n.to_string_lossy().to_string())
282            .unwrap_or_default();
283
284        if !saved_name.is_empty() && !current_name.is_empty() && saved_name != current_name {
285            return RelevanceResult {
286                is_relevant: false,
287                reason: format!(
288                    "different project (saved: {}, current: {})",
289                    saved_name, current_name
290                ),
291            };
292        }
293
294        // Check 2: Age - context older than 24 hours is stale
295        let age = Utc::now().signed_duration_since(self.state.last_saved);
296        if age > Duration::hours(MAX_AGE_HOURS) {
297            return RelevanceResult {
298                is_relevant: false,
299                reason: format!("stale context ({}h old)", age.num_hours()),
300            };
301        }
302
303        // Check 3: Meaningful content (filter out test data)
304        let has_meaningful_history = self.state.file_history.iter().any(|op| {
305            op.summary != "test"
306                && !op
307                    .file_path
308                    .file_name()
309                    .map(|n| n.to_string_lossy().starts_with("file"))
310                    .unwrap_or(false)
311        });
312
313        let has_insights = !self.state.insights.is_empty();
314        let has_todos = self.state.todos.iter().any(|t| t.status != "completed");
315        let has_notes = !self.state.notes.is_empty();
316        let has_focus = !self.state.project_context.current_focus.is_empty();
317        let has_project_name = !self.state.project_context.project_name.is_empty()
318            && self.state.project_context.project_name != "unknown";
319
320        if !has_meaningful_history
321            && !has_insights
322            && !has_todos
323            && !has_notes
324            && !has_focus
325            && !has_project_name
326        {
327            return RelevanceResult {
328                is_relevant: false,
329                reason: "no meaningful content (test data only)".to_string(),
330            };
331        }
332
333        RelevanceResult {
334            is_relevant: true,
335            reason: String::new(),
336        }
337    }
338
339    /// Add file operation to history
340    pub fn record_file_operation(&mut self, op: &str, path: &Path, summary: &str) {
341        self.state.file_history.push(FileOperation {
342            timestamp: Utc::now(),
343            operation: op.to_string(),
344            file_path: path.to_path_buf(),
345            summary: summary.to_string(),
346        });
347
348        // Keep only last 100 operations
349        if self.state.file_history.len() > 100 {
350            self.state.file_history.drain(0..50);
351        }
352    }
353
354    /// Add insight or breakthrough
355    pub fn add_insight(&mut self, category: &str, content: &str, keywords: Vec<String>) {
356        self.state.insights.push(Insight {
357            timestamp: Utc::now(),
358            category: category.to_string(),
359            content: content.to_string(),
360            keywords,
361        });
362    }
363
364    /// Update project context
365    pub fn update_project_context(&mut self, name: &str, project_type: &str, focus: &str) {
366        self.state.project_context.project_name = name.to_string();
367        self.state.project_context.project_type = project_type.to_string();
368        self.state.project_context.current_focus = focus.to_string();
369    }
370
371    /// Set key files for the project context
372    pub fn set_key_files(&mut self, files: Vec<PathBuf>) {
373        self.state.project_context.key_files = files;
374    }
375
376    /// Set dependencies for the project context
377    pub fn set_dependencies(&mut self, deps: Vec<String>) {
378        self.state.project_context.dependencies = deps;
379    }
380
381    /// Clear stale test data from file history
382    pub fn clean_test_data(&mut self) {
383        self.state.file_history.retain(|op| {
384            op.summary != "test"
385                || !op
386                    .file_path
387                    .file_name()
388                    .map(|n| n.to_string_lossy().starts_with("file"))
389                    .unwrap_or(false)
390        });
391    }
392
393    /// Add or update todo
394    pub fn update_todo(&mut self, content: &str, status: &str) {
395        // Check if todo already exists
396        for todo in &mut self.state.todos {
397            if todo.content == content {
398                todo.status = status.to_string();
399                return;
400            }
401        }
402
403        // Add new todo
404        self.state.todos.push(TodoItem {
405            content: content.to_string(),
406            status: status.to_string(),
407            created: Utc::now(),
408        });
409    }
410
411    /// Get consciousness summary for display (relevance-aware)
412    pub fn get_summary(&self) -> String {
413        let relevance = self.check_relevance();
414        if !relevance.is_relevant {
415            return format!(
416                "🧠 Previous context unavailable: {}\n   Run `st -m context .` for fresh overview.",
417                relevance.reason
418            );
419        }
420
421        let mut parts = Vec::new();
422
423        // Only show project info if meaningful
424        if self.state.project_context.project_name != "unknown"
425            && !self.state.project_context.project_name.is_empty()
426        {
427            parts.push(format!(
428                "📁 {} ({})",
429                self.state.project_context.project_name, self.state.project_context.project_type
430            ));
431        }
432
433        if !self.state.project_context.current_focus.is_empty() {
434            parts.push(format!("🎯 {}", self.state.project_context.current_focus));
435        }
436
437        // Age indicator
438        let age = Utc::now().signed_duration_since(self.state.last_saved);
439        let age_str = if age.num_hours() > 0 {
440            format!("{}h ago", age.num_hours())
441        } else {
442            format!("{}m ago", age.num_minutes())
443        };
444        parts.push(format!("⏱️ {}", age_str));
445
446        parts.join(" | ")
447    }
448
449    /// Get context reminder for Claude (filters out test data)
450    pub fn get_context_reminder(&self) -> String {
451        // Only show context if we have meaningful content
452        let relevance = self.check_relevance();
453        if !relevance.is_relevant {
454            return String::new();
455        }
456
457        let mut parts = Vec::new();
458
459        if !self.state.project_context.current_focus.is_empty() {
460            parts.push(format!(
461                "Working on: {}",
462                self.state.project_context.current_focus
463            ));
464        }
465
466        let active_todos = self
467            .state
468            .todos
469            .iter()
470            .filter(|t| t.status != "completed")
471            .count();
472        if active_todos > 0 {
473            parts.push(format!("{} pending todos", active_todos));
474        }
475
476        if parts.is_empty() {
477            return String::new();
478        }
479
480        parts.join(" | ")
481    }
482}
483
484/// Auto-save consciousness on drop
485impl Drop for ConsciousnessManager {
486    fn drop(&mut self) {
487        // Best effort save on drop - silent to avoid duplicate messages
488        self.state.last_saved = chrono::Utc::now();
489        if let Ok(json) = serde_json::to_string_pretty(&self.state) {
490            let _ = std::fs::write(&self.save_path, json);
491        }
492    }
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498    use tempfile::tempdir;
499
500    #[test]
501    fn test_consciousness_persistence() {
502        let dir = tempdir().unwrap();
503        let save_path = dir.path().join("test_consciousness.m8");
504
505        // Create and save
506        {
507            let mut manager = ConsciousnessManager::with_path(save_path.clone());
508            manager.update_project_context("smart-tree", "rust", "Adding consciousness");
509            manager.add_insight(
510                "breakthrough",
511                "Tokenization reduces context by 10x",
512                vec!["tokenization".to_string(), "compression".to_string()],
513            );
514            manager.save().unwrap();
515        }
516
517        // Load and verify
518        {
519            let mut manager = ConsciousnessManager::with_path(save_path);
520            manager.restore().unwrap();
521
522            assert_eq!(manager.state.project_context.project_name, "smart-tree");
523            assert_eq!(manager.state.insights.len(), 1);
524            assert_eq!(manager.state.insights[0].category, "breakthrough");
525        }
526    }
527
528    #[test]
529    fn test_file_history_limit() {
530        // Use a tempdir to avoid polluting the project's .aye_consciousness.m8
531        let dir = tempdir().unwrap();
532        let save_path = dir.path().join("test_history_limit.m8");
533        let mut manager = ConsciousnessManager::with_path(save_path);
534
535        // Add 150 operations
536        for i in 0..150 {
537            manager.record_file_operation("read", Path::new(&format!("file{}.rs", i)), "test");
538        }
539
540        // Should keep only last 100
541        assert_eq!(manager.state.file_history.len(), 100);
542    }
543}