518 lines
16 KiB
Rust
518 lines
16 KiB
Rust
//! 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<Mutex<Option<PersistentMemoryStore>>>;
|
|
|
|
/// 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<i32>,
|
|
pub source: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
pub conversation_id: Option<String>,
|
|
}
|
|
|
|
/// Memory search options for frontend API
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct MemorySearchOptions {
|
|
pub agent_id: Option<String>,
|
|
pub memory_type: Option<String>,
|
|
pub tags: Option<Vec<String>>,
|
|
pub query: Option<String>,
|
|
pub min_importance: Option<i32>,
|
|
pub limit: Option<usize>,
|
|
pub offset: Option<usize>,
|
|
}
|
|
|
|
/// 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<String, String> {
|
|
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<Option<PersistentMemory>, 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<Vec<PersistentMemory>, 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<PersistentMemory> = 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<usize, String> {
|
|
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<MemoryStats, String> {
|
|
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<String, i64> = std::collections::HashMap::new();
|
|
let mut by_agent: std::collections::HashMap<String, i64> = std::collections::HashMap::new();
|
|
let mut oldest: Option<String> = None;
|
|
let mut newest: Option<String> = 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::<usize>() 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<Vec<PersistentMemory>, 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<PersistentMemory>,
|
|
_state: State<'_, MemoryStoreState>,
|
|
) -> Result<usize, String> {
|
|
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<String> = 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<String, String> {
|
|
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<String>,
|
|
endpoint: Option<String>,
|
|
) -> Result<bool, String> {
|
|
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<usize>,
|
|
_state: State<'_, MemoryStoreState>,
|
|
) -> Result<BuildContextResult, String> {
|
|
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<String> = 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
|
|
}
|