use regex::Regex; use std::sync::LazyLock; use tauri::AppHandle; use tauri_plugin_opener::OpenerExt; use crate::config::write_text_file; use crate::openclaw_config::get_openclaw_dir; /// Allowed workspace filenames (whitelist for security) const ALLOWED_FILES: &[&str] = &[ "AGENTS.md", "SOUL.md", "USER.md", "IDENTITY.md", "TOOLS.md", "MEMORY.md", "HEARTBEAT.md", "BOOTSTRAP.md", "BOOT.md", ]; fn validate_filename(filename: &str) -> Result<(), String> { if !ALLOWED_FILES.contains(&filename) { return Err(format!( "Invalid workspace filename: {filename}. Allowed: {}", ALLOWED_FILES.join(", ") )); } Ok(()) } // --- Daily memory files (memory/YYYY-MM-DD.md) --- static DAILY_MEMORY_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\d{4}-\d{2}-\d{2}\.md$").unwrap()); fn validate_daily_memory_filename(filename: &str) -> Result<(), String> { if !DAILY_MEMORY_RE.is_match(filename) { return Err(format!( "Invalid daily memory filename: {filename}. Expected: YYYY-MM-DD.md" )); } Ok(()) } #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct DailyMemoryFileInfo { pub filename: String, pub date: String, pub size_bytes: u64, pub modified_at: u64, pub preview: String, } // --- Daily memory commands --- /// List all daily memory files under `workspace/memory/`. #[tauri::command] pub async fn list_daily_memory_files() -> Result, String> { let memory_dir = get_openclaw_dir().join("workspace").join("memory"); if !memory_dir.exists() { return Ok(Vec::new()); } let mut files: Vec = Vec::new(); let entries = std::fs::read_dir(&memory_dir) .map_err(|e| format!("Failed to read memory directory: {e}"))?; for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); if !name.ends_with(".md") { continue; } let meta = match entry.metadata() { Ok(m) => m, Err(_) => continue, }; if !meta.is_file() { continue; } let date = name.trim_end_matches(".md").to_string(); let size_bytes = meta.len(); let modified_at = meta .modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); let preview = std::fs::read_to_string(entry.path()) .unwrap_or_default() .chars() .take(200) .collect::(); files.push(DailyMemoryFileInfo { filename: name, date, size_bytes, modified_at, preview, }); } // Sort by filename descending (newest date first, YYYY-MM-DD.md) files.sort_by(|a, b| b.filename.cmp(&a.filename)); Ok(files) } /// Read a daily memory file. #[tauri::command] pub async fn read_daily_memory_file(filename: String) -> Result, String> { validate_daily_memory_filename(&filename)?; let path = get_openclaw_dir() .join("workspace") .join("memory") .join(&filename); if !path.exists() { return Ok(None); } std::fs::read_to_string(&path) .map(Some) .map_err(|e| format!("Failed to read daily memory file {filename}: {e}")) } /// Write a daily memory file (atomic write). #[tauri::command] pub async fn write_daily_memory_file(filename: String, content: String) -> Result<(), String> { validate_daily_memory_filename(&filename)?; let memory_dir = get_openclaw_dir().join("workspace").join("memory"); std::fs::create_dir_all(&memory_dir) .map_err(|e| format!("Failed to create memory directory: {e}"))?; let path = memory_dir.join(&filename); write_text_file(&path, &content) .map_err(|e| format!("Failed to write daily memory file {filename}: {e}")) } /// Find the largest index `<= i` that is a valid UTF-8 char boundary. /// Equivalent to the unstable `str::floor_char_boundary` (stabilized in 1.91). fn floor_char_boundary(s: &str, mut i: usize) -> usize { if i >= s.len() { return s.len(); } while !s.is_char_boundary(i) { i -= 1; } i } /// Find the smallest index `>= i` that is a valid UTF-8 char boundary. /// Equivalent to the unstable `str::ceil_char_boundary` (stabilized in 1.91). fn ceil_char_boundary(s: &str, mut i: usize) -> usize { if i >= s.len() { return s.len(); } while !s.is_char_boundary(i) { i += 1; } i } /// Search result for daily memory full-text search. #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct DailyMemorySearchResult { pub filename: String, pub date: String, pub size_bytes: u64, pub modified_at: u64, pub snippet: String, pub match_count: usize, } /// Full-text search across all daily memory files. /// /// Performs case-insensitive search on both the date field and file content. /// Returns results sorted by filename descending (newest first), each with a /// snippet showing ~120 characters of context around the first match. #[tauri::command] pub async fn search_daily_memory_files( query: String, ) -> Result, String> { let memory_dir = get_openclaw_dir().join("workspace").join("memory"); if !memory_dir.exists() || query.is_empty() { return Ok(Vec::new()); } let query_lower = query.to_lowercase(); let mut results: Vec = Vec::new(); let entries = std::fs::read_dir(&memory_dir) .map_err(|e| format!("Failed to read memory directory: {e}"))?; for entry in entries.flatten() { let name = entry.file_name().to_string_lossy().to_string(); if !name.ends_with(".md") { continue; } let meta = match entry.metadata() { Ok(m) if m.is_file() => m, _ => continue, }; let date = name.trim_end_matches(".md").to_string(); let content = std::fs::read_to_string(entry.path()).unwrap_or_default(); let content_lower = content.to_lowercase(); // Count matches in content let content_matches: Vec = content_lower .match_indices(&query_lower) .map(|(i, _)| i) .collect(); // Also check date field let date_matches = date.to_lowercase().contains(&query_lower); if content_matches.is_empty() && !date_matches { continue; } // Build snippet around first content match (~120 chars of context) let snippet = if let Some(&first_pos) = content_matches.first() { let start = if first_pos > 50 { floor_char_boundary(&content, first_pos - 50) } else { 0 }; let end = ceil_char_boundary(&content, (first_pos + 70).min(content.len())); let mut s = String::new(); if start > 0 { s.push_str("..."); } s.push_str(&content[start..end]); if end < content.len() { s.push_str("..."); } s } else { // Date-only match — use beginning of file as preview let end = ceil_char_boundary(&content, 120.min(content.len())); let mut s = content[..end].to_string(); if end < content.len() { s.push_str("..."); } s }; let size_bytes = meta.len(); let modified_at = meta .modified() .ok() .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .map(|d| d.as_secs()) .unwrap_or(0); results.push(DailyMemorySearchResult { filename: name, date, size_bytes, modified_at, snippet, match_count: content_matches.len(), }); } // Sort by filename descending (newest date first) results.sort_by(|a, b| b.filename.cmp(&a.filename)); Ok(results) } /// Delete a daily memory file (idempotent). #[tauri::command] pub async fn delete_daily_memory_file(filename: String) -> Result<(), String> { validate_daily_memory_filename(&filename)?; let path = get_openclaw_dir() .join("workspace") .join("memory") .join(&filename); if path.exists() { std::fs::remove_file(&path) .map_err(|e| format!("Failed to delete daily memory file {filename}: {e}"))?; } Ok(()) } // --- Workspace file commands --- /// Read an OpenClaw workspace file content. /// Returns None if the file does not exist. #[tauri::command] pub async fn read_workspace_file(filename: String) -> Result, String> { validate_filename(&filename)?; let path = get_openclaw_dir().join("workspace").join(&filename); if !path.exists() { return Ok(None); } std::fs::read_to_string(&path) .map(Some) .map_err(|e| format!("Failed to read workspace file {filename}: {e}")) } /// Write content to an OpenClaw workspace file (atomic write). /// Creates the workspace directory if it does not exist. #[tauri::command] pub async fn write_workspace_file(filename: String, content: String) -> Result<(), String> { validate_filename(&filename)?; let workspace_dir = get_openclaw_dir().join("workspace"); // Ensure workspace directory exists std::fs::create_dir_all(&workspace_dir) .map_err(|e| format!("Failed to create workspace directory: {e}"))?; let path = workspace_dir.join(&filename); write_text_file(&path, &content) .map_err(|e| format!("Failed to write workspace file {filename}: {e}")) } /// Open the workspace or memory directory in the system file manager. /// `subdir`: "workspace" opens `~/.openclaw/workspace/`, /// "memory" opens `~/.openclaw/workspace/memory/`. #[tauri::command] pub async fn open_workspace_directory(handle: AppHandle, subdir: String) -> Result { let dir = match subdir.as_str() { "memory" => get_openclaw_dir().join("workspace").join("memory"), _ => get_openclaw_dir().join("workspace"), }; if !dir.exists() { std::fs::create_dir_all(&dir).map_err(|e| format!("Failed to create directory: {e}"))?; } handle .opener() .open_path(dir.to_string_lossy().to_string(), None::) .map_err(|e| format!("Failed to open directory: {e}"))?; Ok(true) }