ralph-coder 0.2.1

An agentic code generation CLI powered by multiple LLM backends
Documentation
/// Per-workspace persistent memory store.
///
/// Stores facts Ralph learns about a project and a log of past session episodes.
/// Loaded at session start, injected into the system prompt, and updated via the
/// `remember` tool.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};

// ── Types ─────────────────────────────────────────────────────────────────────

/// A single fact Ralph has learned about the project.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectFact {
    pub key: String,
    pub value: String,
    pub updated_at: DateTime<Utc>,
}

/// A summary of one completed session.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Episode {
    pub session_id: String,
    pub summary: String,
    pub files_touched: Vec<String>,
    /// "done" | "failed" | "interrupted"
    pub outcome: String,
    pub timestamp: DateTime<Utc>,
}

#[derive(Debug, Default, Serialize, Deserialize)]
struct MemoryData {
    facts: Vec<ProjectFact>,
    episodes: Vec<Episode>,
}

// ── Store ─────────────────────────────────────────────────────────────────────

pub struct MemoryStore {
    path: PathBuf,
    data: MemoryData,
}

impl MemoryStore {
    /// Load memory for `workspace`, or start empty if none exists.
    pub fn load(workspace: &Path) -> Self {
        let path = memory_path(workspace);
        let data = if path.exists() {
            std::fs::read_to_string(&path)
                .ok()
                .and_then(|s| serde_json::from_str(&s).ok())
                .unwrap_or_default()
        } else {
            MemoryData::default()
        };
        Self { path, data }
    }

    /// Load memory from a specific file path (useful for testing).
    pub fn from_path(path: PathBuf) -> Self {
        let data = if path.exists() {
            std::fs::read_to_string(&path)
                .ok()
                .and_then(|s| serde_json::from_str(&s).ok())
                .unwrap_or_default()
        } else {
            MemoryData::default()
        };
        Self { path, data }
    }

    /// Persist the current state to disk.
    pub fn save(&self) -> crate::errors::Result<()> {
        if let Some(parent) = self.path.parent() {
            std::fs::create_dir_all(parent).map_err(|e| crate::errors::RalphError::ToolFailed {
                tool: "memory".to_string(),
                message: format!("Failed to create memory directory: {}", e),
            })?;
        }
        let json = serde_json::to_string_pretty(&self.data).map_err(|e| {
            crate::errors::RalphError::ToolFailed {
                tool: "memory".to_string(),
                message: format!("Failed to serialize memory: {}", e),
            }
        })?;
        std::fs::write(&self.path, json).map_err(|e| crate::errors::RalphError::ToolFailed {
            tool: "memory".to_string(),
            message: format!("Failed to write memory file: {}", e),
        })?;
        Ok(())
    }

    /// Add or update a fact by key (case-insensitive key match).
    pub fn upsert(&mut self, key: &str, value: &str) {
        let key = key.trim().to_string();
        let value = value.trim().to_string();
        if let Some(fact) = self
            .data
            .facts
            .iter_mut()
            .find(|f| f.key.eq_ignore_ascii_case(&key))
        {
            fact.value = value;
            fact.updated_at = Utc::now();
        } else {
            self.data.facts.push(ProjectFact {
                key,
                value,
                updated_at: Utc::now(),
            });
        }
        if let Err(e) = self.save() {
            eprintln!("[memory] Warning: failed to persist memory: {}", e);
        }
    }

    /// Search facts whose key or value contains `query` (case-insensitive).
    pub fn search(&self, query: &str) -> Vec<&ProjectFact> {
        let q = query.to_lowercase();
        self.data
            .facts
            .iter()
            .filter(|f| f.key.to_lowercase().contains(&q) || f.value.to_lowercase().contains(&q))
            .collect()
    }

    /// All facts, most recently updated first.
    pub fn all_facts(&self) -> Vec<&ProjectFact> {
        let mut v: Vec<&ProjectFact> = self.data.facts.iter().collect();
        v.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
        v
    }

    /// Append a session episode (keeps at most 50).
    pub fn add_episode(
        &mut self,
        session_id: &str,
        summary: &str,
        files: Vec<String>,
        outcome: &str,
    ) {
        self.data.episodes.push(Episode {
            session_id: session_id.to_string(),
            summary: summary.to_string(),
            files_touched: files,
            outcome: outcome.to_string(),
            timestamp: Utc::now(),
        });
        if self.data.episodes.len() > 50 {
            let excess = self.data.episodes.len() - 50;
            self.data.episodes.drain(..excess);
        }
        if let Err(e) = self.save() {
            eprintln!("[memory] Warning: failed to persist memory: {}", e);
        }
    }

    /// Format facts and recent episodes as a prompt section.
    /// Returns `None` if memory is empty.
    pub fn format_for_prompt(&self) -> Option<String> {
        if self.data.facts.is_empty() && self.data.episodes.is_empty() {
            return None;
        }

        let mut out = String::from("## Project Memory\n\n");

        if !self.data.facts.is_empty() {
            out.push_str("**Known facts about this project:**\n");
            for fact in &self.data.facts {
                out.push_str(&format!("- {}: {}\n", fact.key, fact.value));
            }
            out.push('\n');
        }

        if !self.data.episodes.is_empty() {
            out.push_str("**Recent session history (most recent first):**\n");
            for ep in self.data.episodes.iter().rev().take(5) {
                let files = if ep.files_touched.is_empty() {
                    String::new()
                } else {
                    format!(" ({})", ep.files_touched.join(", "))
                };
                out.push_str(&format!(
                    "- [{}] {} [{}]{}\n",
                    ep.timestamp.format("%Y-%m-%d"),
                    ep.summary,
                    ep.outcome,
                    files,
                ));
            }
            out.push('\n');
        }

        Some(out)
    }

    pub fn facts_count(&self) -> usize {
        self.data.facts.len()
    }
    pub fn episodes_count(&self) -> usize {
        self.data.episodes.len()
    }
}

// ── Path helpers ──────────────────────────────────────────────────────────────

fn memory_path(workspace: &Path) -> PathBuf {
    crate::config::ralph_data_dir()
        .join("memory")
        .join(workspace_hash(workspace))
        .join("memory.json")
}

fn workspace_hash(workspace: &Path) -> String {
    use std::collections::hash_map::DefaultHasher;
    use std::hash::{Hash, Hasher};
    let mut h = DefaultHasher::new();
    workspace.to_string_lossy().as_ref().hash(&mut h);
    format!("{:016x}", h.finish())
}