//! Memory Commands - Tauri commands for persistent memory operations //! //! Unified storage: All operations delegate to VikingStorage (SqliteStorage), //! which provides FTS5 full-text search, TF-IDF scoring, and optional embedding. //! //! The previous dual-write to PersistentMemoryStore has been removed. //! PersistentMemory type is retained for frontend API compatibility. use crate::memory::{PersistentMemory, PersistentMemoryStore, MemoryStats, configure_embedding_client, is_embedding_configured, EmbedFn}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use tauri::{AppHandle, State}; use tokio::sync::Mutex; /// Shared memory store state /// NOTE: PersistentMemoryStore is kept only for embedding configuration. /// All actual storage goes through VikingStorage (SqliteStorage). pub type MemoryStoreState = Arc>>; /// Memory entry for frontend API #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemoryEntryInput { pub agent_id: String, pub memory_type: String, pub content: String, pub importance: Option, pub source: Option, pub tags: Option>, pub conversation_id: Option, } /// Memory search options for frontend API #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MemorySearchOptions { pub agent_id: Option, pub memory_type: Option, pub tags: Option>, pub query: Option, pub min_importance: Option, pub limit: Option, pub offset: Option, } /// Initialize the memory store /// /// Now a no-op for storage (VikingStorage initializes itself in viking_commands). /// Only initializes PersistentMemoryStore for backward-compatible embedding config. // @connected #[tauri::command] pub async fn memory_init( app_handle: AppHandle, state: State<'_, MemoryStoreState>, ) -> Result<(), String> { let store = PersistentMemoryStore::new(&app_handle).await?; let mut state_guard = state.lock().await; *state_guard = Some(store); Ok(()) } /// Store a new memory /// /// Writes to VikingStorage (SqliteStorage) with FTS5 + TF-IDF indexing. // @connected #[tauri::command] pub async fn memory_store( entry: MemoryEntryInput, _state: State<'_, MemoryStoreState>, ) -> Result { let storage = crate::viking_commands::get_storage().await?; let memory_type = parse_memory_type(&entry.memory_type); let keywords = entry.tags.unwrap_or_default(); let importance = entry.importance.unwrap_or(5).clamp(1, 10) as u8; let viking_entry = zclaw_growth::MemoryEntry::new( &entry.agent_id, memory_type, &entry.memory_type, entry.content, ) .with_importance(importance) .with_keywords(keywords); let uri = viking_entry.uri.clone(); zclaw_growth::VikingStorage::store(storage.as_ref(), &viking_entry).await .map_err(|e| format!("Failed to store memory: {}", e))?; Ok(uri) } /// Parse a string memory_type into zclaw_growth::MemoryType fn parse_memory_type(type_str: &str) -> zclaw_growth::MemoryType { match type_str.to_lowercase().as_str() { "preference" => zclaw_growth::MemoryType::Preference, "knowledge" | "fact" | "task" | "todo" | "lesson" | "event" => zclaw_growth::MemoryType::Knowledge, "skill" | "experience" => zclaw_growth::MemoryType::Experience, "session" | "conversation" => zclaw_growth::MemoryType::Session, _ => zclaw_growth::MemoryType::Knowledge, } } /// Convert zclaw_growth::MemoryEntry to PersistentMemory (frontend compatibility) fn to_persistent(entry: &zclaw_growth::MemoryEntry) -> PersistentMemory { // Extract agent_id from URI: "agent://{agent_id}/{type}/{category}" let agent_id = entry.uri .strip_prefix("agent://") .and_then(|s| s.split('/').next()) .unwrap_or("unknown") .to_string(); PersistentMemory { id: entry.uri.clone(), agent_id, memory_type: entry.memory_type.to_string(), content: entry.content.clone(), importance: entry.importance as i32, source: "auto".to_string(), tags: serde_json::to_string(&entry.keywords).unwrap_or_else(|_| "[]".to_string()), conversation_id: None, created_at: entry.created_at.to_rfc3339(), last_accessed_at: entry.last_accessed.to_rfc3339(), access_count: entry.access_count as i32, embedding: None, overview: entry.overview.clone(), } } /// Get a memory by ID (URI) // @connected #[tauri::command] pub async fn memory_get( id: String, _state: State<'_, MemoryStoreState>, ) -> Result, String> { let storage = crate::viking_commands::get_storage().await?; let entry = zclaw_growth::VikingStorage::get(storage.as_ref(), &id).await .map_err(|e| format!("Failed to get memory: {}", e))?; Ok(entry.map(|e| to_persistent(&e))) } /// Search memories /// /// Uses VikingStorage::find() for FTS5 + TF-IDF + optional embedding search. // @connected #[tauri::command] pub async fn memory_search( options: MemorySearchOptions, _state: State<'_, MemoryStoreState>, ) -> Result, String> { let storage = crate::viking_commands::get_storage().await?; // Build scope from agent_id let scope = options.agent_id.map(|id| format!("agent://{}/", id)); // Build search query let query = options.query.unwrap_or_default(); // When query is empty, use min_similarity=0.0 to trigger table scan // (FTS5 requires non-empty query; without this, empty query returns 0 results) let min_similarity = if query.trim().is_empty() { Some(0.0) } else { options.min_importance.map(|i| (i as f32) / 10.0) }; let find_options = zclaw_growth::FindOptions { scope, limit: options.limit.or(Some(50)), min_similarity, }; let entries = zclaw_growth::VikingStorage::find(storage.as_ref(), &query, find_options).await .map_err(|e| format!("Failed to search memories: {}", e))?; // Filter by memory_type if specified let filtered: Vec = if let Some(ref type_filter) = options.memory_type { entries .into_iter() .filter(|e| e.memory_type.to_string().eq_ignore_ascii_case(type_filter)) .map(|e| to_persistent(&e)) .collect() } else { entries.into_iter().map(|e| to_persistent(&e)).collect() }; // Apply offset let offset = options.offset.unwrap_or(0); Ok(filtered.into_iter().skip(offset).collect()) } /// Delete a memory by ID (URI) /// /// Deletes from VikingStorage only (PersistentMemoryStore is no longer primary). // @connected #[tauri::command] pub async fn memory_delete( id: String, _state: State<'_, MemoryStoreState>, ) -> Result<(), String> { let storage = crate::viking_commands::get_storage().await?; zclaw_growth::VikingStorage::delete(storage.as_ref(), &id).await .map_err(|e| format!("Failed to delete memory: {}", e))?; Ok(()) } /// Delete all memories for an agent // @connected #[tauri::command] pub async fn memory_delete_all( agent_id: String, _state: State<'_, MemoryStoreState>, ) -> Result { let storage = crate::viking_commands::get_storage().await?; // Find all entries for this agent let options = zclaw_growth::FindOptions { scope: Some(format!("agent://{}/", agent_id)), limit: Some(10000), min_similarity: Some(0.0), }; let entries = zclaw_growth::VikingStorage::find(storage.as_ref(), "", options).await .map_err(|e| format!("Failed to find memories: {}", e))?; let count = entries.len(); for entry in &entries { zclaw_growth::VikingStorage::delete(storage.as_ref(), &entry.uri).await .map_err(|e| format!("Failed to delete memory: {}", e))?; } Ok(count) } /// Get memory statistics // @connected #[tauri::command] pub async fn memory_stats( _state: State<'_, MemoryStoreState>, ) -> Result { let storage = crate::viking_commands::get_storage().await?; // Fetch all entries to compute stats let options = zclaw_growth::FindOptions { scope: None, limit: Some(100000), min_similarity: Some(0.0), }; let entries = zclaw_growth::VikingStorage::find(storage.as_ref(), "", options).await .map_err(|e| format!("Failed to get memories for stats: {}", e))?; let mut by_type: std::collections::HashMap = std::collections::HashMap::new(); let mut by_agent: std::collections::HashMap = std::collections::HashMap::new(); let mut oldest: Option = None; let mut newest: Option = None; for entry in &entries { let type_key = entry.memory_type.to_string(); *by_type.entry(type_key).or_insert(0) += 1; let agent = entry.uri .strip_prefix("agent://") .and_then(|s| s.split('/').next()) .unwrap_or("unknown") .to_string(); *by_agent.entry(agent).or_insert(0) += 1; let created = entry.created_at.to_rfc3339(); if oldest.as_ref().map_or(true, |o| &created < o) { oldest = Some(created.clone()); } if newest.as_ref().map_or(true, |n| &created > n) { newest = Some(created); } } let storage_size = entries.iter() .map(|e| e.content.len() + e.keywords.len() * 10) .sum::() as i64; Ok(MemoryStats { total_entries: entries.len() as i64, by_type, by_agent, oldest_entry: oldest, newest_entry: newest, storage_size_bytes: storage_size, }) } /// Export all memories for backup // @connected #[tauri::command] pub async fn memory_export( _state: State<'_, MemoryStoreState>, ) -> Result, String> { let storage = crate::viking_commands::get_storage().await?; let options = zclaw_growth::FindOptions { scope: None, limit: Some(100000), min_similarity: Some(0.0), }; let entries = zclaw_growth::VikingStorage::find(storage.as_ref(), "", options).await .map_err(|e| format!("Failed to export memories: {}", e))?; Ok(entries.into_iter().map(|e| to_persistent(&e)).collect()) } /// Import memories from backup /// /// Converts PersistentMemory entries to VikingStorage MemoryEntry and stores them. // @connected #[tauri::command] pub async fn memory_import( memories: Vec, _state: State<'_, MemoryStoreState>, ) -> Result { let storage = crate::viking_commands::get_storage().await?; let mut count = 0; for memory in &memories { let memory_type = parse_memory_type(&memory.memory_type); let keywords: Vec = serde_json::from_str(&memory.tags).unwrap_or_default(); let viking_entry = zclaw_growth::MemoryEntry::new( &memory.agent_id, memory_type, &memory.memory_type, memory.content.clone(), ) .with_importance(memory.importance.clamp(1, 10) as u8) .with_keywords(keywords); // Set overview if present let viking_entry = if let Some(ref overview) = memory.overview { if !overview.is_empty() { viking_entry.with_overview(overview) } else { viking_entry } } else { viking_entry }; match zclaw_growth::VikingStorage::store(storage.as_ref(), &viking_entry).await { Ok(()) => count += 1, Err(e) => tracing::warn!("[memory_import] Failed to import {}: {}", memory.id, e), } } Ok(count) } /// Get the database path /// /// Now returns the VikingStorage (SqliteStorage) path. // @connected #[tauri::command] pub async fn memory_db_path( _state: State<'_, MemoryStoreState>, ) -> Result { let storage_dir = crate::viking_commands::get_storage_dir(); let db_path = storage_dir.join("memories.db"); Ok(db_path.to_string_lossy().to_string()) } /// @reserved — no frontend UI yet /// Configure embedding for PersistentMemoryStore (chat memory search) /// This is called alongside viking_configure_embedding to enable vector search in chat flow #[tauri::command] pub async fn memory_configure_embedding( provider: String, api_key: String, model: Option, endpoint: Option, ) -> Result { let config = crate::llm::EmbeddingConfig { provider, api_key, endpoint, model, }; let client = std::sync::Arc::new(crate::llm::EmbeddingClient::new(config)); let embed_fn: EmbedFn = { let client = client.clone(); Arc::new(move |text: &str| { let client = client.clone(); let text = text.to_string(); Box::pin(async move { let response = client.embed(&text).await?; Ok(response.embedding) }) }) }; configure_embedding_client(embed_fn); tracing::info!("[MemoryCommands] Embedding configured"); Ok(true) } /// @reserved — no frontend UI yet /// Check if embedding is configured for PersistentMemoryStore #[tauri::command] pub fn memory_is_embedding_configured() -> bool { is_embedding_configured() } /// Build layered memory context for chat prompt injection /// /// Uses VikingStorage (SqliteStorage) with FTS5 + TF-IDF + optional Embedding. // @connected #[tauri::command] pub async fn memory_build_context( agent_id: String, query: String, max_tokens: Option, _state: State<'_, MemoryStoreState>, ) -> Result { let budget = max_tokens.unwrap_or(500); let storage = crate::viking_commands::get_storage().await?; let options = zclaw_growth::FindOptions { scope: Some(format!("agent://{}/", agent_id)), limit: Some((budget / 25).max(8)), min_similarity: Some(0.2), }; let entries = match zclaw_growth::VikingStorage::find(storage.as_ref(), &query, options).await { Ok(entries) => entries, Err(e) => { tracing::warn!("[memory_build_context] Search failed: {}", e); Vec::new() } }; if entries.is_empty() { return Ok(BuildContextResult { system_prompt_addition: String::new(), total_tokens: 0, memories_used: 0, }); } let mut used_tokens = 0; let mut items: Vec = Vec::new(); let mut memories_used = 0; for entry in &entries { if used_tokens >= budget { break; } let overview_str = entry.overview.as_deref().unwrap_or(""); let display_content = if !overview_str.is_empty() { overview_str.to_string() } else { truncate_for_l1(&entry.content) }; let item_tokens = estimate_tokens_text(&display_content); if used_tokens + item_tokens > budget { continue; } items.push(format!("- [{}] {}", entry.memory_type, display_content)); used_tokens += item_tokens; memories_used += 1; } let system_prompt_addition = if items.is_empty() { String::new() } else { format!("## 相关记忆\n{}", items.join("\n")) }; Ok(BuildContextResult { system_prompt_addition, total_tokens: used_tokens, memories_used, }) } /// Result of building layered memory context #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct BuildContextResult { pub system_prompt_addition: String, pub total_tokens: usize, pub memories_used: usize, } /// Truncate content for L1 overview display (~50 tokens) fn truncate_for_l1(content: &str) -> String { let char_limit = 100; if content.chars().count() <= char_limit { content.to_string() } else { let truncated: String = content.chars().take(char_limit).collect(); format!("{}...", truncated) } } /// Estimate token count for text fn estimate_tokens_text(text: &str) -> usize { let cjk_count = text.chars().filter(|c| ('\u{4E00}'..='\u{9FFF}').contains(c)).count(); let other_count = text.chars().count() - cjk_count; (cjk_count as f32 * 1.5 + other_count as f32 * 0.4).ceil() as usize }