#![allow( clippy::cast_possible_truncation, clippy::cast_sign_loss, clippy::cast_precision_loss, clippy::cast_possible_wrap )] //! Long-term agent memory: markdown files with YAML-ish frontmatter storing //! lessons/references, plus slugified filenames and export/import helpers. use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; /// A single memory entry (lesson, reference, etc.) with frontmatter /// metadata and free-form markdown content. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Memory { pub name: String, pub description: String, pub content: String, pub kind: String, pub created_at: i64, pub updated_at: i64, pub outcome: Option, pub lifecycle: String, pub scope: Option, pub before_snippet: Option, pub after_snippet: Option, pub provenances: Vec, } impl Memory { /// Convert an arbitrary string into a filesystem-safe slug. /// /// Flow: lowercase → replace non-alphanumeric chars with `-` → /// collapse/trim repeated `-` by splitting on it and rejoining /// non-empty parts. /// /// Why: rejects empty or overly long (>80 char) results so callers /// don't write memories with degenerate or unwieldy filenames. /// /// Return: `Some(slug)` on success, `None` if the input slugifies to /// empty or exceeds 80 characters. pub fn slugify(s: &str) -> Option { let slug: String = s .to_lowercase() .chars() .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' }) .collect(); let slug: String = slug .split('-') .filter(|s| !s.is_empty()) .collect::>() .join("-"); if slug.is_empty() || slug.len() > 80 { return None; } Some(slug) } /// Compute the on-disk path for a memory of the given name. /// /// Why: falls back to a fixed `"memory"` slug when `name` slugifies /// to nothing, so a path is always produced. pub fn path(memory_dir: &Path, name: &str) -> PathBuf { let slug = Self::slugify(name).unwrap_or_else(|| "memory".to_string()); slug_path(memory_dir, &format!("{slug}.md")) } /// Serialize this memory to markdown-with-frontmatter and write it /// atomically to disk. /// /// Flow: build the frontmatter block (name/description/kind/timestamps/ /// lifecycle/optional fields) → concatenate with body content → write /// to a temp file → rename into place. /// /// Why: write-then-rename avoids leaving a half-written memory file if /// the process is interrupted mid-write. /// /// Return: `Ok(())` on success, or an `io::Error` from directory /// creation, the temp write, or the rename. pub fn write(&self, memory_dir: &Path) -> std::io::Result<()> { let path = Self::path(memory_dir, &self.name); let parent = path.parent().unwrap(); std::fs::create_dir_all(parent)?; let outcome_line = self .outcome .as_ref() .map(|o| format!("outcome: {o}")) .unwrap_or_default(); let scope_line = self .scope .as_ref() .map(|s| format!("scope: {s}")) .unwrap_or_default(); let before_line = self .before_snippet .as_ref() .map(|s| format!("before: {s}")) .unwrap_or_default(); let after_line = self .after_snippet .as_ref() .map(|s| format!("after: {s}")) .unwrap_or_default(); let prov_line = if self.provenances.is_empty() { String::new() } else { format!("provenances: {}", self.provenances.join(", ")) }; let content = format!( "---\nname: {}\ndescription: {}\nkind: {}\ncreated_at: {}\nupdated_at: {}\nlifecycle: {}\n{}\n{}\n{}\n{}\n{}\n---\n\n{}", self.name, self.description, self.kind, self.created_at, self.updated_at, self.lifecycle, outcome_line, scope_line, before_line, after_line, prov_line, self.content ); let tmp = parent.join(format!(".{}.tmp", uuid::Uuid::new_v4())); // Write to temp file with fsync for crash safety { use std::io::Write; let mut f = std::fs::OpenOptions::new() .create(true) .truncate(true) .write(true) .open(&tmp)?; f.write_all(content.as_bytes())?; f.sync_all()?; } std::fs::rename(&tmp, &path)?; // Sync the parent directory so the rename is durable. if let Some(p) = path.parent() { let _ = std::fs::File::open(p).and_then(|d| d.sync_all()); } Ok(()) } /// Read and parse a memory file by name. /// /// Return: the parsed `Memory`, or an `io::Error` if the file is /// missing or its frontmatter is malformed (see `parse`). pub fn read(memory_dir: &Path, name: &str) -> std::io::Result { let path = Self::path(memory_dir, name); let content = std::fs::read_to_string(&path)?; Self::parse(&content) } /// Parse a memory file's contents (frontmatter + body) into a `Memory`. /// /// Flow: strip leading `---\n` → split on the first `\n---\n` into /// frontmatter and body → parse frontmatter lines as `key: value` /// pairs into a map → build `Memory` fields from the map with /// sensible defaults for missing keys. /// /// Why: unknown/missing frontmatter keys degrade to defaults (e.g. /// `kind` → "reference", `lifecycle` → "new") rather than failing, /// so older or hand-edited memory files still parse. /// /// Return: `Err(InvalidData)` only if the `---` frontmatter delimiter /// itself is missing; otherwise `Ok(Memory)`. pub fn parse(content: &str) -> std::io::Result { let content = content.strip_prefix("---\n").unwrap_or(content); let parts: Vec<&str> = content.splitn(2, "\n---\n").collect(); if parts.len() < 2 { return Err(std::io::Error::new( std::io::ErrorKind::InvalidData, "missing frontmatter", )); } let front: std::collections::HashMap = parts[0] .lines() .filter_map(|l| { let mut it = l.splitn(2, ':'); Some(( it.next()?.trim().to_string(), it.next()?.trim().to_string(), )) }) .collect(); let body = parts.get(1).unwrap_or(&"").trim().to_string(); Ok(Memory { name: front.get("name").cloned().unwrap_or_default(), description: front.get("description").cloned().unwrap_or_default(), content: body, kind: front .get("kind") .cloned() .unwrap_or_else(|| "reference".to_string()), created_at: front .get("created_at") .and_then(|v| v.parse().ok()) .unwrap_or(0), updated_at: front .get("updated_at") .and_then(|v| v.parse().ok()) .unwrap_or(0), outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()), lifecycle: front .get("lifecycle") .cloned() .unwrap_or_else(|| "new".to_string()), scope: front.get("scope").cloned().filter(|s| !s.is_empty()), before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()), after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()), provenances: front .get("provenances") .cloned() .map(|s| { s.split(", ") .map(std::string::ToString::to_string) .collect() }) .unwrap_or_default(), }) } /// Delete a memory file by name, if it exists. /// /// Return: `Ok(())` whether or not the file existed. pub fn remove(memory_dir: &Path, name: &str) -> std::io::Result<()> { let path = Self::path(memory_dir, name); if path.exists() { std::fs::remove_file(path)?; } Ok(()) } /// List the slugs of all memory files in a directory. /// /// Flow: read the directory → keep entries ending in `.md` → exclude /// the special `MEMORY.md` summary file → strip the `.md` suffix. /// /// Return: slugs (without extension); empty `Vec` if the directory /// can't be read. pub fn list(memory_dir: &Path) -> Vec { let Ok(entries) = std::fs::read_dir(memory_dir) else { return Vec::new(); }; entries .filter_map(std::result::Result::ok) .filter(|e| e.path().extension().is_some_and(|x| x == "md")) .filter_map(|e| { let name = e.file_name().to_string_lossy().to_string(); if name == "MEMORY.md" { return None; } let slug = name.strip_suffix(".md")?.to_string(); Some(slug) }) .collect() } } /// Sanitize a raw filename into a safe path under `memory_dir`. /// /// Flow: replace any char that isn't alphanumeric, `.`, or `-` with `-` → /// strip leading dots (prevents dotfiles / path traversal via `..`) → /// join to `memory_dir`, falling back to `"memory.md"` if empty. pub fn slug_path(memory_dir: &Path, raw: &str) -> PathBuf { let clean: String = raw .chars() .map(|c| { if c.is_ascii_alphanumeric() || c == '.' || c == '-' { c } else { '-' } }) .collect(); let clean = clean.trim_start_matches('.').to_string(); memory_dir.join(if clean.is_empty() { "memory.md" } else { &clean }) }