use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectFact {
pub key: String,
pub value: String,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Episode {
pub session_id: String,
pub summary: String,
pub files_touched: Vec<String>,
pub outcome: String,
pub timestamp: DateTime<Utc>,
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct MemoryData {
facts: Vec<ProjectFact>,
episodes: Vec<Episode>,
}
pub struct MemoryStore {
path: PathBuf,
data: MemoryData,
}
impl MemoryStore {
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 }
}
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 }
}
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(())
}
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);
}
}
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()
}
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
}
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);
}
}
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()
}
}
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())
}