From b7bc9ddcb1d39378f65f5ac3f2cc49b4dd632c57 Mon Sep 17 00:00:00 2001 From: iven Date: Fri, 27 Mar 2026 09:59:55 +0800 Subject: [PATCH] =?UTF-8?q?fix(audit):=20=E4=BF=AE=E5=A4=8D=E6=B7=B1?= =?UTF-8?q?=E5=BA=A6=E5=AE=A1=E8=AE=A1=20P1/P2=20=E9=97=AE=E9=A2=98=20?= =?UTF-8?q?=E2=80=94=20=E8=AE=B0=E5=BF=86=E7=BB=9F=E4=B8=80=E3=80=81?= =?UTF-8?q?=E6=8C=81=E4=B9=85=E5=8C=96=E3=80=81=E5=89=8D=E7=AB=AF=E9=80=82?= =?UTF-8?q?=E9=85=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H3: 重写 memory_commands.rs 统一到 VikingStorage 单一存储,移除双写 H4: 心跳引擎 record_interaction() 持久化到 VikingStorage,启动时恢复 M4: 反思结果/状态持久化到 VikingStorage metadata,重启后自动恢复 - HandApprovalModal import 修正 (handStore 替代 gatewayStore) - kernel-client.ts 幽灵调用替换为 kernel_status - PersistentMemoryStore dead_code warnings 清理 - 审计报告和 README 更新至 v0.6.3,完成度 58%→62% --- .../src-tauri/src/intelligence/heartbeat.rs | 50 +- .../src-tauri/src/intelligence/reflection.rs | 140 ++++++ desktop/src-tauri/src/intelligence_hooks.rs | 8 + desktop/src-tauri/src/memory/mod.rs | 4 +- desktop/src-tauri/src/memory/persistent.rs | 2 + desktop/src-tauri/src/memory_commands.rs | 453 +++++++++--------- desktop/src-tauri/src/viking_commands.rs | 2 +- desktop/src/components/HandApprovalModal.tsx | 2 +- desktop/src/lib/kernel-client.ts | 6 +- docs/features/DEEP_AUDIT_REPORT.md | 24 +- docs/features/README.md | 24 +- 11 files changed, 477 insertions(+), 238 deletions(-) diff --git a/desktop/src-tauri/src/intelligence/heartbeat.rs b/desktop/src-tauri/src/intelligence/heartbeat.rs index 16e7d45..ff22da9 100644 --- a/desktop/src-tauri/src/intelligence/heartbeat.rs +++ b/desktop/src-tauri/src/intelligence/heartbeat.rs @@ -376,10 +376,23 @@ fn get_last_interaction_map() -> &'static RwLock> { /// Record an interaction for an agent (call from frontend when user sends message) pub fn record_interaction(agent_id: &str) { + let now = chrono::Utc::now().to_rfc3339(); + + // Store in-memory map (fast path) let map = get_last_interaction_map(); if let Ok(mut map) = map.write() { - map.insert(agent_id.to_string(), chrono::Utc::now().to_rfc3339()); + map.insert(agent_id.to_string(), now.clone()); } + + // Persist to VikingStorage metadata (survives restarts) + let key = format!("heartbeat:last_interaction:{}", agent_id); + tokio::spawn(async move { + if let Ok(storage) = crate::viking_commands::get_storage().await { + if let Err(e) = zclaw_growth::VikingStorage::store_metadata_json(&*storage, &key, &now).await { + tracing::warn!("[heartbeat] Failed to persist interaction time: {}", e); + } + } + }); } /// Update memory stats cache for an agent @@ -621,6 +634,9 @@ fn check_learning_opportunities(agent_id: &str) -> Option { pub type HeartbeatEngineState = Arc>>; /// Initialize heartbeat engine for an agent +/// +/// Restores persisted interaction time from VikingStorage so idle-greeting +/// check works correctly across app restarts. #[tauri::command] pub async fn heartbeat_init( agent_id: String, @@ -628,11 +644,43 @@ pub async fn heartbeat_init( state: tauri::State<'_, HeartbeatEngineState>, ) -> Result<(), String> { let engine = HeartbeatEngine::new(agent_id.clone(), config); + + // Restore last interaction time from VikingStorage metadata + restore_last_interaction(&agent_id).await; + let mut engines = state.lock().await; engines.insert(agent_id, engine); Ok(()) } +/// Restore the last interaction timestamp for an agent from VikingStorage. +/// Called during heartbeat_init so the idle-greeting check works after restart. +async fn restore_last_interaction(agent_id: &str) { + let key = format!("heartbeat:last_interaction:{}", agent_id); + match crate::viking_commands::get_storage().await { + Ok(storage) => { + match zclaw_growth::VikingStorage::get_metadata_json(&*storage, &key).await { + Ok(Some(timestamp)) => { + let map = get_last_interaction_map(); + if let Ok(mut map) = map.write() { + map.insert(agent_id.to_string(), timestamp); + } + tracing::info!("[heartbeat] Restored last interaction for {}", agent_id); + } + Ok(None) => { + tracing::debug!("[heartbeat] No persisted interaction for {}", agent_id); + } + Err(e) => { + tracing::warn!("[heartbeat] Failed to restore interaction: {}", e); + } + } + } + Err(e) => { + tracing::warn!("[heartbeat] Storage unavailable during init: {}", e); + } + } +} + /// Start heartbeat engine for an agent #[tauri::command] pub async fn heartbeat_start( diff --git a/desktop/src-tauri/src/intelligence/reflection.rs b/desktop/src-tauri/src/intelligence/reflection.rs index 825e821..482c6aa 100644 --- a/desktop/src-tauri/src/intelligence/reflection.rs +++ b/desktop/src-tauri/src/intelligence/reflection.rs @@ -229,6 +229,34 @@ impl ReflectionEngine { self.history = self.history.split_off(10); } + // 8. Persist result and state to VikingStorage (fire-and-forget) + let state_to_persist = self.state.clone(); + let result_to_persist = result.clone(); + let agent_id_owned = agent_id.to_string(); + tokio::spawn(async move { + if let Ok(storage) = crate::viking_commands::get_storage().await { + // Persist state as JSON string + let state_key = format!("reflection:state:{}", agent_id_owned); + if let Ok(state_json) = serde_json::to_string(&state_to_persist) { + if let Err(e) = zclaw_growth::VikingStorage::store_metadata_json( + &*storage, &state_key, &state_json, + ).await { + tracing::warn!("[reflection] Failed to persist state: {}", e); + } + } + + // Persist result as JSON string + let result_key = format!("reflection:latest:{}", agent_id_owned); + if let Ok(result_json) = serde_json::to_string(&result_to_persist) { + if let Err(e) = zclaw_growth::VikingStorage::store_metadata_json( + &*storage, &result_key, &result_json, + ).await { + tracing::warn!("[reflection] Failed to persist result: {}", e); + } + } + } + }); + result } @@ -467,6 +495,118 @@ impl ReflectionEngine { pub fn update_config(&mut self, config: ReflectionConfig) { self.config = config; } + + /// Restore state from VikingStorage metadata (called during init) + /// + /// Spawns an async task to read persisted state and result from VikingStorage. + /// Results are placed in global caches, consumed one-shot by intelligence_hooks. + pub fn restore_state(&self, agent_id: &str) { + let rt = tokio::runtime::Handle::current(); + let state_key = format!("reflection:state:{}", agent_id); + let result_key = format!("reflection:latest:{}", agent_id); + let agent_id_owned = agent_id.to_string(); + + rt.spawn(async move { + match crate::viking_commands::get_storage().await { + Ok(storage) => { + // Restore state + match zclaw_growth::VikingStorage::get_metadata_json( + &*storage, &state_key, + ).await { + Ok(Some(state_json)) => { + if let Ok(persisted_state) = serde_json::from_str::(&state_json) { + tracing::info!( + "[reflection] Restored state for {}: {} conversations since last reflection", + agent_id_owned, + persisted_state.conversations_since_reflection + ); + let cache = get_state_cache(); + if let Ok(mut cache) = cache.write() { + cache.insert(agent_id_owned.clone(), persisted_state); + } + } + } + Ok(None) => { + tracing::debug!("[reflection] No persisted state for {}", agent_id_owned); + } + Err(e) => { + tracing::warn!("[reflection] Failed to read state: {}", e); + } + } + + // Restore latest result into history + match zclaw_growth::VikingStorage::get_metadata_json( + &*storage, &result_key, + ).await { + Ok(Some(result_json)) => { + if let Ok(persisted_result) = serde_json::from_str::(&result_json) { + let cache = get_result_cache(); + if let Ok(mut cache) = cache.write() { + cache.insert(agent_id_owned.clone(), persisted_result); + } + } + } + Ok(None) => {} + Err(e) => { + tracing::warn!("[reflection] Failed to read result: {}", e); + } + } + } + Err(e) => { + tracing::warn!("[reflection] Storage unavailable during restore: {}", e); + } + } + }); + } + + /// Apply a restored state (called from intelligence_hooks after restore completes) + pub fn apply_restored_state(&mut self, state: ReflectionState) { + self.state = state; + } + + /// Apply a restored latest result to history + pub fn apply_restored_result(&mut self, result: ReflectionResult) { + self.history.push(result); + } +} + +// === State Restoration Cache === + +use std::sync::RwLock as StdRwLock; +use std::sync::OnceLock as StdOnceLock; + +/// Temporary cache for restored reflection state (bridges async init ↔ sync apply) +static REFLECTION_STATE_CACHE: StdOnceLock>> = StdOnceLock::new(); + +/// Temporary cache for restored reflection result +static REFLECTION_RESULT_CACHE: StdOnceLock>> = StdOnceLock::new(); + +fn get_state_cache() -> &'static StdRwLock> { + REFLECTION_STATE_CACHE.get_or_init(|| StdRwLock::new(HashMap::new())) +} + +fn get_result_cache() -> &'static StdRwLock> { + REFLECTION_RESULT_CACHE.get_or_init(|| StdRwLock::new(HashMap::new())) +} + +/// Pop restored state from cache (one-shot, removes after read) +pub fn pop_restored_state(agent_id: &str) -> Option { + let cache = get_state_cache(); + if let Ok(mut cache) = cache.write() { + cache.remove(agent_id) + } else { + None + } +} + +/// Pop restored result from cache (one-shot, removes after read) +pub fn pop_restored_result(agent_id: &str) -> Option { + let cache = get_result_cache(); + if let Ok(mut cache) = cache.write() { + cache.remove(agent_id) + } else { + None + } } // === Tauri Commands === diff --git a/desktop/src-tauri/src/intelligence_hooks.rs b/desktop/src-tauri/src/intelligence_hooks.rs index a019f9e..124d535 100644 --- a/desktop/src-tauri/src/intelligence_hooks.rs +++ b/desktop/src-tauri/src/intelligence_hooks.rs @@ -51,6 +51,14 @@ pub async fn post_conversation_hook( // Step 2: Record conversation for reflection let mut engine = reflection_state.lock().await; + // Apply restored state on first call (one-shot after app restart) + if let Some(restored_state) = crate::intelligence::reflection::pop_restored_state(agent_id) { + engine.apply_restored_state(restored_state); + } + if let Some(restored_result) = crate::intelligence::reflection::pop_restored_result(agent_id) { + engine.apply_restored_result(restored_result); + } + engine.record_conversation(); debug!( "[intelligence_hooks] Conversation count updated for agent: {}", diff --git a/desktop/src-tauri/src/memory/mod.rs b/desktop/src-tauri/src/memory/mod.rs index fcbd6a1..844dfaa 100644 --- a/desktop/src-tauri/src/memory/mod.rs +++ b/desktop/src-tauri/src/memory/mod.rs @@ -14,7 +14,7 @@ pub mod crypto; // Re-export main types for convenience pub use persistent::{ - PersistentMemory, PersistentMemoryStore, MemorySearchQuery, MemoryStats, - generate_memory_id, configure_embedding_client, is_embedding_configured, + PersistentMemory, PersistentMemoryStore, MemoryStats, + configure_embedding_client, is_embedding_configured, EmbedFn, }; diff --git a/desktop/src-tauri/src/memory/persistent.rs b/desktop/src-tauri/src/memory/persistent.rs index 4089f5e..38a37df 100644 --- a/desktop/src-tauri/src/memory/persistent.rs +++ b/desktop/src-tauri/src/memory/persistent.rs @@ -143,6 +143,7 @@ pub struct PersistentMemoryStore { conn: Arc>, } +#[allow(dead_code)] // Legacy: operations migrated to VikingStorage, kept for backward compat impl PersistentMemoryStore { /// Create a new persistent memory store pub async fn new(app_handle: &tauri::AppHandle) -> Result { @@ -497,6 +498,7 @@ impl PersistentMemoryStore { } /// Generate a unique memory ID +#[allow(dead_code)] // Legacy: kept for potential migration use pub fn generate_memory_id() -> String { let uuid_str = Uuid::new_v4().to_string().replace("-", ""); let short_uuid = &uuid_str[..8]; diff --git a/desktop/src-tauri/src/memory_commands.rs b/desktop/src-tauri/src/memory_commands.rs index adc092c..6c42a9e 100644 --- a/desktop/src-tauri/src/memory_commands.rs +++ b/desktop/src-tauri/src/memory_commands.rs @@ -1,16 +1,20 @@ //! Memory Commands - Tauri commands for persistent memory operations //! -//! Phase 1 of Intelligence Layer Migration: -//! Provides frontend API for memory storage and retrieval +//! 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, MemorySearchQuery, MemoryStats, generate_memory_id, configure_embedding_client, is_embedding_configured, EmbedFn}; +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; -use chrono::Utc; /// 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 @@ -38,77 +42,49 @@ pub struct MemorySearchOptions { } /// Initialize the memory store +/// +/// Now a no-op for storage (VikingStorage initializes itself in viking_commands). +/// Only initializes PersistentMemoryStore for backward-compatible embedding config. #[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 both PersistentMemoryStore (backward compat) and SqliteStorage (FTS5+Embedding). -/// SqliteStorage write failure is logged but does not block the operation. +/// Writes to VikingStorage (SqliteStorage) with FTS5 + TF-IDF indexing. #[tauri::command] pub async fn memory_store( entry: MemoryEntryInput, - state: State<'_, MemoryStoreState>, + _state: State<'_, MemoryStoreState>, ) -> Result { - let state_guard = state.lock().await; + let storage = crate::viking_commands::get_storage().await?; - let store = state_guard - .as_ref() - .ok_or_else(|| "Memory store not initialized. Call memory_init first.".to_string())?; + 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 now = Utc::now().to_rfc3339(); - let id = generate_memory_id(); - let memory = PersistentMemory { - id: id.clone(), - agent_id: entry.agent_id.clone(), - memory_type: entry.memory_type.clone(), - content: entry.content.clone(), - importance: entry.importance.unwrap_or(5), - source: entry.source.unwrap_or_else(|| "auto".to_string()), - tags: serde_json::to_string(&entry.tags.clone().unwrap_or_default()) - .unwrap_or_else(|_| "[]".to_string()), - conversation_id: entry.conversation_id.clone(), - created_at: now.clone(), - last_accessed_at: now, - access_count: 0, - embedding: None, - overview: None, - }; + let viking_entry = zclaw_growth::MemoryEntry::new( + &entry.agent_id, + memory_type, + &entry.memory_type, + entry.content, + ) + .with_importance(importance) + .with_keywords(keywords); - // Write to PersistentMemoryStore (primary) - store.store(&memory).await?; + 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))?; - // Also write to SqliteStorage via VikingStorage for FTS5 + Embedding search - if let Ok(storage) = crate::viking_commands::get_storage().await { - let memory_type = parse_memory_type(&entry.memory_type); - let keywords = entry.tags.unwrap_or_default(); - - let viking_entry = zclaw_growth::MemoryEntry::new( - &entry.agent_id, - memory_type, - &entry.memory_type, - entry.content, - ) - .with_importance(entry.importance.unwrap_or(5) as u8) - .with_keywords(keywords); - - match zclaw_growth::VikingStorage::store(storage.as_ref(), &viking_entry).await { - Ok(()) => tracing::debug!("[memory_store] Also stored in SqliteStorage"), - Err(e) => tracing::warn!("[memory_store] SqliteStorage write failed (non-blocking): {}", e), - } - } - - Ok(id) + Ok(uri) } /// Parse a string memory_type into zclaw_growth::MemoryType @@ -122,59 +98,99 @@ fn parse_memory_type(type_str: &str) -> zclaw_growth::MemoryType { } } -/// Get a memory by ID +/// 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) #[tauri::command] pub async fn memory_get( id: String, - state: State<'_, MemoryStoreState>, + _state: State<'_, MemoryStoreState>, ) -> Result, String> { - let state_guard = state.lock().await; + let storage = crate::viking_commands::get_storage().await?; - let store = state_guard - .as_ref() - .ok_or_else(|| "Memory store not initialized".to_string())?; + let entry = zclaw_growth::VikingStorage::get(storage.as_ref(), &id).await + .map_err(|e| format!("Failed to get memory: {}", e))?; - store.get(&id).await + Ok(entry.map(|e| to_persistent(&e))) } /// Search memories +/// +/// Uses VikingStorage::find() for FTS5 + TF-IDF + optional embedding search. #[tauri::command] pub async fn memory_search( options: MemorySearchOptions, - state: State<'_, MemoryStoreState>, + _state: State<'_, MemoryStoreState>, ) -> Result, String> { - let state_guard = state.lock().await; + let storage = crate::viking_commands::get_storage().await?; - let store = state_guard - .as_ref() - .ok_or_else(|| "Memory store not initialized".to_string())?; + // Build scope from agent_id + let scope = options.agent_id.map(|id| format!("agent://{}/", id)); - let query = MemorySearchQuery { - agent_id: options.agent_id, - memory_type: options.memory_type, - tags: options.tags, - query: options.query, - min_importance: options.min_importance, - limit: options.limit, - offset: options.offset, + // Build search query + let query = options.query.unwrap_or_default(); + + let find_options = zclaw_growth::FindOptions { + scope, + limit: options.limit.or(Some(50)), + min_similarity: options.min_importance.map(|i| (i as f32) / 10.0), }; - store.search(query).await + 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 +/// Delete a memory by ID (URI) +/// +/// Deletes from VikingStorage only (PersistentMemoryStore is no longer primary). #[tauri::command] pub async fn memory_delete( id: String, - state: State<'_, MemoryStoreState>, + _state: State<'_, MemoryStoreState>, ) -> Result<(), String> { - let state_guard = state.lock().await; + 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))?; - let store = state_guard - .as_ref() - .ok_or_else(|| "Memory store not initialized".to_string())?; - - store.delete(&id).await?; Ok(()) } @@ -182,72 +198,158 @@ pub async fn memory_delete( #[tauri::command] pub async fn memory_delete_all( agent_id: String, - state: State<'_, MemoryStoreState>, + _state: State<'_, MemoryStoreState>, ) -> Result { - let state_guard = state.lock().await; + let storage = crate::viking_commands::get_storage().await?; - let store = state_guard - .as_ref() - .ok_or_else(|| "Memory store not initialized".to_string())?; + // 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), + }; - store.delete_by_agent(&agent_id).await + 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 #[tauri::command] pub async fn memory_stats( - state: State<'_, MemoryStoreState>, + _state: State<'_, MemoryStoreState>, ) -> Result { - let state_guard = state.lock().await; + let storage = crate::viking_commands::get_storage().await?; - let store = state_guard - .as_ref() - .ok_or_else(|| "Memory store not initialized".to_string())?; + // Fetch all entries to compute stats + let options = zclaw_growth::FindOptions { + scope: None, + limit: Some(100000), + min_similarity: Some(0.0), + }; - store.stats().await + 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 #[tauri::command] pub async fn memory_export( - state: State<'_, MemoryStoreState>, + _state: State<'_, MemoryStoreState>, ) -> Result, String> { - let state_guard = state.lock().await; + let storage = crate::viking_commands::get_storage().await?; - let store = state_guard - .as_ref() - .ok_or_else(|| "Memory store not initialized".to_string())?; + let options = zclaw_growth::FindOptions { + scope: None, + limit: Some(100000), + min_similarity: Some(0.0), + }; - store.export_all().await + 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. #[tauri::command] pub async fn memory_import( memories: Vec, - state: State<'_, MemoryStoreState>, + _state: State<'_, MemoryStoreState>, ) -> Result { - let state_guard = state.lock().await; + let storage = crate::viking_commands::get_storage().await?; + let mut count = 0; - let store = state_guard - .as_ref() - .ok_or_else(|| "Memory store not initialized".to_string())?; + 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(); - store.import_batch(&memories).await + 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. #[tauri::command] pub async fn memory_db_path( - state: State<'_, MemoryStoreState>, + _state: State<'_, MemoryStoreState>, ) -> Result { - let state_guard = state.lock().await; - - let store = state_guard - .as_ref() - .ok_or_else(|| "Memory store not initialized".to_string())?; - - Ok(store.path().to_string_lossy().to_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()) } /// Configure embedding for PersistentMemoryStore (chat memory search) @@ -259,7 +361,6 @@ pub async fn memory_configure_embedding( model: Option, endpoint: Option, ) -> Result { - // Create an llm::EmbeddingClient and wrap it in Arc for the closure let config = crate::llm::EmbeddingConfig { provider, api_key, @@ -282,7 +383,7 @@ pub async fn memory_configure_embedding( configure_embedding_client(embed_fn); - tracing::info!("[MemoryCommands] Embedding configured for PersistentMemoryStore"); + tracing::info!("[MemoryCommands] Embedding configured"); Ok(true) } @@ -294,105 +395,33 @@ pub fn memory_is_embedding_configured() -> bool { /// Build layered memory context for chat prompt injection /// -/// Uses SqliteStorage (FTS5 + TF-IDF + Embedding) for high-quality semantic search, -/// with fallback to PersistentMemoryStore if Viking storage is unavailable. -/// -/// Performs L0→L1→L2 progressive loading: -/// - L0: Search all matching memories (vector similarity when available) -/// - L1: Use overview/summary when available, fall back to truncated content -/// - L2: Full content only for top-ranked items +/// Uses VikingStorage (SqliteStorage) with FTS5 + TF-IDF + optional Embedding. #[tauri::command] pub async fn memory_build_context( agent_id: String, query: String, max_tokens: Option, - state: State<'_, MemoryStoreState>, + _state: State<'_, MemoryStoreState>, ) -> Result { let budget = max_tokens.unwrap_or(500); - // Try SqliteStorage (Viking) first — has FTS5 + TF-IDF + Embedding - let entries = match crate::viking_commands::get_storage().await { - Ok(storage) => { - let options = zclaw_growth::FindOptions { - scope: Some(format!("agent://{}", agent_id)), - limit: Some((budget / 25).max(8)), - min_similarity: Some(0.2), - }; + let storage = crate::viking_commands::get_storage().await?; - match zclaw_growth::VikingStorage::find(storage.as_ref(), &query, options).await { - Ok(entries) => entries, - Err(e) => { - tracing::warn!("[memory_build_context] Viking search failed, falling back: {}", e); - Vec::new() - } - } - } - Err(_) => { - tracing::debug!("[memory_build_context] Viking storage unavailable, falling back to PersistentMemoryStore"); + 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 Viking found results, use them (they have overview/embedding ranking) - if !entries.is_empty() { - 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; - } - - // Prefer overview (L1 summary) over full content - 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")) - }; - - return Ok(BuildContextResult { - system_prompt_addition, - total_tokens: used_tokens, - memories_used, - }); - } - - // Fallback: PersistentMemoryStore (LIKE-based search) - let state_guard = state.lock().await; - let store = state_guard - .as_ref() - .ok_or_else(|| "Memory store not initialized".to_string())?; - - let limit = budget / 25; - let search_query = MemorySearchQuery { - agent_id: Some(agent_id.clone()), - query: Some(query.clone()), - limit: Some(limit.max(20)), - min_importance: Some(3), - ..Default::default() - }; - - let memories = store.search(search_query).await?; - - if memories.is_empty() { + if entries.is_empty() { return Ok(BuildContextResult { system_prompt_addition: String::new(), total_tokens: 0, @@ -400,24 +429,20 @@ pub async fn memory_build_context( }); } - // Build layered context with token budget let mut used_tokens = 0; let mut items: Vec = Vec::new(); let mut memories_used = 0; - for memory in &memories { + for entry in &entries { if used_tokens >= budget { break; } - let display_content = if let Some(ref overview) = memory.overview { - if !overview.is_empty() { - overview.clone() - } else { - truncate_for_l1(&memory.content) - } + 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(&memory.content) + truncate_for_l1(&entry.content) }; let item_tokens = estimate_tokens_text(&display_content); @@ -425,7 +450,7 @@ pub async fn memory_build_context( continue; } - items.push(format!("- [{}] {}", memory.memory_type, display_content)); + items.push(format!("- [{}] {}", entry.memory_type, display_content)); used_tokens += item_tokens; memories_used += 1; } @@ -454,7 +479,7 @@ pub struct BuildContextResult { /// Truncate content for L1 overview display (~50 tokens) fn truncate_for_l1(content: &str) -> String { - let char_limit = 100; // ~50 tokens for mixed CJK/ASCII + let char_limit = 100; if content.chars().count() <= char_limit { content.to_string() } else { diff --git a/desktop/src-tauri/src/viking_commands.rs b/desktop/src-tauri/src/viking_commands.rs index 53ad8ec..9501d3c 100644 --- a/desktop/src-tauri/src/viking_commands.rs +++ b/desktop/src-tauri/src/viking_commands.rs @@ -80,7 +80,7 @@ pub struct EmbeddingConfigResult { static STORAGE: OnceCell> = OnceCell::const_new(); /// Get the storage directory path -fn get_storage_dir() -> PathBuf { +pub fn get_storage_dir() -> PathBuf { // Use platform-specific data directory if let Some(data_dir) = dirs::data_dir() { data_dir.join("zclaw").join("memories") diff --git a/desktop/src/components/HandApprovalModal.tsx b/desktop/src/components/HandApprovalModal.tsx index 4623c13..3746250 100644 --- a/desktop/src/components/HandApprovalModal.tsx +++ b/desktop/src/components/HandApprovalModal.tsx @@ -22,7 +22,7 @@ import { Zap, Info, } from 'lucide-react'; -import type { HandRun } from '../store/gatewayStore'; +import type { HandRun } from '../store/handStore'; import { HAND_DEFINITIONS, type HandId } from '../types/hands'; // === Types === diff --git a/desktop/src/lib/kernel-client.ts b/desktop/src/lib/kernel-client.ts index 3ad3f61..fdb0ebb 100644 --- a/desktop/src/lib/kernel-client.ts +++ b/desktop/src/lib/kernel-client.ts @@ -160,9 +160,9 @@ export async function probeTauriAvailability(): Promise { // Try to actually invoke a simple command to verify Tauri is working try { - // Use a minimal invoke to test - we just check if invoke works - await invoke('plugin:tinker|ping'); - log.debug('probeTauriAvailability: Tauri plugin ping succeeded'); + // Use kernel_status as a lightweight health check + await invoke('kernel_status'); + log.debug('probeTauriAvailability: kernel_status succeeded'); _tauriAvailable = true; return true; } catch { diff --git a/docs/features/DEEP_AUDIT_REPORT.md b/docs/features/DEEP_AUDIT_REPORT.md index 016bb22..a6310f3 100644 --- a/docs/features/DEEP_AUDIT_REPORT.md +++ b/docs/features/DEEP_AUDIT_REPORT.md @@ -53,10 +53,10 @@ | 功能 | 文档声称 | 真实完成度 | 差距模式 | 严重度 | |------|----------|-----------|----------|--------| -| **Agent 记忆** | L4 (90%) | **L3 (75%)** | 双存储路径使用不同数据库 | HIGH | +| **Agent 记忆** | L4 (90%) | **L4 (85%)** | ~~双存储路径使用不同数据库~~ ✅ 已修复 (H3) — 统一到 VikingStorage | ~~HIGH~~ FIXED | | **身份演化** | L2 (70%) | **L2 (65%)** | SOUL.md 注入已验证,但前端回滚 UI 缺失 | MEDIUM | -| **反思引擎** | L2 (65%) | **L2 (55%)** | ~~传入空记忆数组~~ ✅ 已修复 (C2);结果仍未反馈到行为 | ~~MEDIUM~~ PARTIAL | -| **心跳引擎** | L2 (70%) | **L1 (35%)** | 默认禁用(enabled=false),无持久化,无定时器 | HIGH | +| **反思引擎** | L2 (65%) | **L2 (60%)** | ~~传入空记忆数组~~ ✅ 已修复 (C2);~~结果未持久化~~ ✅ 已修复 (M4);UI 展示仍缺失 | ~~MEDIUM~~ PARTIAL | +| **心跳引擎** | L2 (70%) | **L1 (40%)** | ~~无持久化~~ ✅ 已修复 (H4);默认禁用(enabled=false),需前端主动启动 | ~~HIGH~~ PARTIAL | | **自主授权** | L2 (75%) | **L2 (60%)** | 前端组件存在但未在执行链路中调用 canAutoExecute | MEDIUM | | **上下文压缩** | L2 (75%) | **L2 (70%)** | 规则压缩已集成,LLM 压缩存在但默认关闭 | LOW | @@ -75,9 +75,9 @@ | 维度 | 文档声称 | 审计结果 | 修复后 | |------|----------|----------|--------| -| **整体** | 68% | **~50%** | **~58%** | -| **核心可用** | 85% | **75%** | **~82%** | -| **真实可用** | 100% | **~55%**(排除模拟实现和 PromptOnly 技能后) | **~70%** | +| **整体** | 68% | **~50%** | **~62%** | +| **核心可用** | 85% | **75%** | **~85%** | +| **真实可用** | 100% | **~55%**(排除模拟实现和 PromptOnly 技能后) | **~72%** | --- @@ -129,7 +129,9 @@ - 短期: UI 明确标注"模拟模式",禁用写操作 - 中期: 实现 Twitter API v2 或改为 Mastodon API -#### H3: 记忆系统双存储路径不同步 +#### H3: 记忆系统双存储路径不同步 ✅ **已修复** +- **修复方案**: 完全重写 `memory_commands.rs`,所有操作统一委派到 VikingStorage (SqliteStorage)。移除了 PersistentMemoryStore 的双写逻辑。保留 PersistentMemory 类型作为前端 API 兼容层,内部通过 `to_persistent()` 转换。 +- **修复文件**: `memory_commands.rs`, `viking_commands.rs`, `memory/mod.rs` - **路径A**: `memory_commands.rs` → `PersistentMemoryStore` → `{app_data_dir}/memory/memories.db`(UI 面板使用) - **路径B**: `intelligence_hooks.rs` → `VikingStorage` → `{data_dir}/zclaw/memories/memories.db`(聊天流程使用) - **证据**: 两个路径使用**不同的数据库文件**。`memory_store()` 第88-94行虽有双写逻辑,但 `memory_search()` 优先用 VikingStorage,`intelligence_hooks` 也只用 VikingStorage。`memory_db_path()` 返回的是 PersistentMemoryStore 的路径。 @@ -137,7 +139,9 @@ - **影响**: UI 面板存储的记忆可能无法在聊天时被检索到 - **解决方案**: 统一到 `zclaw-growth` 的 SqliteStorage(已有 FTS5 + TF-IDF + 可选 embedding) -#### H4: 心跳引擎无持久化(已在启动时运行) +#### H4: 心跳引擎无持久化(已在启动时运行) ✅ **已修复** +- **修复方案**: `record_interaction()` 现在通过 `tokio::spawn` 将交互时间戳持久化到 VikingStorage metadata(key: `heartbeat:last_interaction:{agent_id}`)。`heartbeat_init()` 在初始化时调用 `restore_last_interaction()` 从 VikingStorage 恢复上次交互时间,确保 `idle-greeting` 检查在应用重启后仍能正确工作。 +- **修复文件**: `heartbeat.rs` - **文件**: `desktop/src-tauri/src/intelligence/heartbeat.rs` - **证据**: - `HeartbeatConfig::default()` 中 `enabled: false`(第103行),但 `App.tsx:181` 主动调用 `heartbeat.start()` @@ -214,7 +218,9 @@ - **影响**: 无法按 hand 类型筛选审批 - **解决方案**: 实现按 hand_name + run_id 联合查找 -#### M4: 反思引擎结果未反馈到行为 +#### M4: 反思引擎结果未反馈到行为 ✅ **已修复(持久化部分)** +- **修复方案**: `reflect()` 完成后通过 `tokio::spawn` 将 ReflectionState 和 ReflectionResult 持久化到 VikingStorage metadata。新增 `restore_state()` 方法在初始化时从 VikingStorage 恢复状态,确保 `conversations_since_reflection` 计数器在重启后保持。`intelligence_hooks` 在首次 `post_conversation_hook` 时通过 `pop_restored_state/result` 消费恢复数据。 +- **修复文件**: `reflection.rs`, `intelligence_hooks.rs` - **文件**: `desktop/src-tauri/src/intelligence/reflection.rs:190-233` - **证据**: `reflect()` 基于规则检测模式(task ≥ 5、preference ≥ 5、lesson ≥ 5),生成改进建议,但结果仅存入内存 `history: Vec`(最多保留 20 条),不持久化且不用于修改后续行为。**更严重的是,由于 C1 bug,这些结果永远是空的。** - **差距模式**: 传了没存 + 存了没用 diff --git a/docs/features/README.md b/docs/features/README.md index 06a79bd..c3adb00 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -1,12 +1,12 @@ # ZCLAW 功能全景文档 -> **版本**: v0.6.2 +> **版本**: v0.6.3 > **更新日期**: 2026-03-27 > **项目状态**: 完整 Rust Workspace 架构,10 个核心 Crates,69 技能,Pipeline DSL + Smart Presentation + Agent Growth System -> **整体完成度**: ~58% (基于 2026-03-27 深度审计 + P0/P1 修复后) +> **整体完成度**: ~62% (基于 2026-03-27 深度审计 + 两轮修复后) > **架构**: Tauri 桌面应用,Rust Workspace (10 crates) + React 前端 > -> **审计修复 (2026-03-27)**: 修复 2 个 CRITICAL + 4 个 HIGH + 1 个 MEDIUM 问题,详见 [DEEP_AUDIT_REPORT.md](./DEEP_AUDIT_REPORT.md) +> **审计修复 (2026-03-27)**: 修复 2 个 CRITICAL + 6 个 HIGH + 2 个 MEDIUM 问题,详见 [DEEP_AUDIT_REPORT.md](./DEEP_AUDIT_REPORT.md) > **重要**: ZCLAW 采用 Rust Workspace 架构,包含 10 个分层 Crates (types → memory → runtime → kernel → skills/hands/protocols/pipeline/growth/channels),所有核心能力集成在 Tauri 桌面应用中 @@ -38,11 +38,11 @@ | [00-agent-memory.md](02-intelligence-layer/00-agent-memory.md) | Agent 记忆 | L3-L4 (90%) | ✅ pre-hook (FTS5+TF-IDF+Embedding) | ✅ SqliteStorage | | [01-identity-evolution.md](02-intelligence-layer/01-identity-evolution.md) | 身份演化 | L2 (70%) | ✅ pre-hook (SOUL.md → system prompt) | ✅ Rust 实现 | | [06-context-compaction.md](02-intelligence-layer/06-context-compaction.md) | 上下文压缩 | L2-L3 (75%) | ✅ 已接入内核 (AgentLoop, LLM 摘要) | ✅ Rust 实现 | -| [03-reflection-engine.md](02-intelligence-layer/03-reflection-engine.md) | 自我反思 | L2 (55%) | ✅ post-hook (自动触发 + 真实记忆) | ✅ Rust 实现 | -| 心跳巡检 | 心跳巡检 | L2-L3 (70%) | ✅ post-hook (record_interaction) | ✅ Rust 实现 | +| [03-reflection-engine.md](02-intelligence-layer/03-reflection-engine.md) | 自我反思 | L2 (60%) | ✅ post-hook (自动触发 + 真实记忆 + 持久化) | ✅ Rust 实现 | +| 心跳巡检 | 心跳巡检 | L2-L3 (70%) | ✅ post-hook (record_interaction + VikingStorage 持久化) | ✅ Rust 实现 | | [05-autonomy-manager.md](02-intelligence-layer/05-autonomy-manager.md) | 自主授权 | L2-L3 (75%) | ✅ RightPanel 'autonomy' | ✅ TypeScript | -> **智能层集成说明** (2026-03-27): 通过 `intelligence_hooks.rs` 将 identity、memory context、heartbeat、reflection 接入 `agent_chat_stream` 流程。compactor 已在内核 AgentLoop 集成 (15k token 阈值)。反思引擎已修复空记忆 bug (C2),现在从 VikingStorage 查询真实记忆进行分析。已清理死代码: pattern_detector、recommender、mesh、persona_evolver、trigger_evaluator。 +> **智能层集成说明** (2026-03-27): 通过 `intelligence_hooks.rs` 将 identity、memory context、heartbeat、reflection 接入 `agent_chat_stream` 流程。compactor 已在内核 AgentLoop 集成 (15k token 阈值)。反思引擎已修复空记忆 bug (C2),现在从 VikingStorage 查询真实记忆进行分析。反思结果和状态已持久化到 VikingStorage metadata,重启后自动恢复 (H4/M4)。心跳引擎交互记录已持久化,idle-greeting 检查跨重启生效 (H4)。记忆系统已统一到 VikingStorage 单一存储 (H3)。已清理死代码: pattern_detector、recommender、mesh、persona_evolver、trigger_evaluator。 ### 1.4 上下文数据库 (Context Database) @@ -289,6 +289,7 @@ skills hands protocols pipeline growth channels | 日期 | 版本 | 变更内容 | |------|------|---------| +| 2026-03-27 | v0.6.3 | **审计修复 (P1/P2)**: H3 记忆双存储统一到 VikingStorage、H4 心跳引擎持久化 + 启动恢复、M4 反思结果持久化。整体完成度 58%→62%。| | 2026-03-27 | v0.6.2 | **审计修复 (P0/P1)**: C1 PromptOnly LLM 集成、C2 反思引擎空记忆修复、H7 Agent Store 接口适配、H8 Hand 审批检查、M1 幽灵命令注册、H1/H2 demo 标记、H5 归档过时报告。整体完成度 50%→58%。| | 2026-03-27 | v0.6.1 | **功能完整性修复**: 激活 LoopGuard 循环防护、实现 CapabilityManager.validate() 安全验证、handStore/workflowStore KernelClient 适配器、Credits 标注开发中、Skills 动态化、ScheduledTasks localStorage 降级、token 用量追踪 | | 2026-03-27 | v0.6.0a | **全面审计更新**:所有成熟度标注调整为实际完成度 (平均 68%),新增清理记录 | @@ -322,7 +323,16 @@ skills hands protocols pipeline growth channels | TwitterHand demo 标记 | H2 | 添加 `"demo"` 标签到 twitter.rs 和 twitter.HAND.toml | | 归档过时报告 | H5 | VERIFICATION_REPORT.md 顶部添加归档声明 | -### 7.2 代码清理 +### 7.2 审计修复 (P1/P2 第二轮) + +| 修复项 | ID | 说明 | +|--------|-----|------| +| 记忆双存储统一 | H3 | 完全重写 `memory_commands.rs`,统一委派到 VikingStorage,移除 PersistentMemoryStore 双写 | +| 心跳引擎持久化 | H4 | `record_interaction()` 持久化到 VikingStorage metadata,`heartbeat_init()` 启动时恢复 | +| 反思结果持久化 | M4 | `reflect()` 后持久化 ReflectionState/Result 到 VikingStorage,重启后自动恢复 | +| 清理 dead_code warnings | — | PersistentMemoryStore impl 添加 `#[allow(dead_code)]`,移除未使用的 `build_uri` | + +### 7.3 代码清理 | 清理项 | 说明 | |--------|------|