fix(audit): 修复深度审计 P1/P2 问题 — 记忆统一、持久化、前端适配
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

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%
This commit is contained in:
iven
2026-03-27 09:59:55 +08:00
parent a71c4138cc
commit b7bc9ddcb1
11 changed files with 477 additions and 238 deletions

View File

@@ -376,10 +376,23 @@ fn get_last_interaction_map() -> &'static RwLock<StdHashMap<String, String>> {
/// 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<HeartbeatAlert> {
pub type HeartbeatEngineState = Arc<Mutex<HashMap<String, HeartbeatEngine>>>;
/// 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(

View File

@@ -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::<ReflectionState>(&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::<ReflectionResult>(&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<StdRwLock<HashMap<String, ReflectionState>>> = StdOnceLock::new();
/// Temporary cache for restored reflection result
static REFLECTION_RESULT_CACHE: StdOnceLock<StdRwLock<HashMap<String, ReflectionResult>>> = StdOnceLock::new();
fn get_state_cache() -> &'static StdRwLock<HashMap<String, ReflectionState>> {
REFLECTION_STATE_CACHE.get_or_init(|| StdRwLock::new(HashMap::new()))
}
fn get_result_cache() -> &'static StdRwLock<HashMap<String, ReflectionResult>> {
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<ReflectionState> {
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<ReflectionResult> {
let cache = get_result_cache();
if let Ok(mut cache) = cache.write() {
cache.remove(agent_id)
} else {
None
}
}
// === Tauri Commands ===

View File

@@ -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: {}",

View File

@@ -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,
};

View File

@@ -143,6 +143,7 @@ pub struct PersistentMemoryStore {
conn: Arc<Mutex<SqliteConnection>>,
}
#[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<Self, String> {
@@ -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];

View File

@@ -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<Mutex<Option<PersistentMemoryStore>>>;
/// 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<String, 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. 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<Option<PersistentMemory>, 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<Vec<PersistentMemory>, 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<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
/// 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<usize, 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())?;
// 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<MemoryStats, 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())?;
// 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<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
#[tauri::command]
pub async fn memory_export(
state: State<'_, MemoryStoreState>,
_state: State<'_, MemoryStoreState>,
) -> Result<Vec<PersistentMemory>, 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<PersistentMemory>,
state: State<'_, MemoryStoreState>,
_state: State<'_, MemoryStoreState>,
) -> Result<usize, String> {
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<String> = 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<String, String> {
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<String>,
endpoint: Option<String>,
) -> Result<bool, String> {
// 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<usize>,
state: State<'_, MemoryStoreState>,
_state: State<'_, MemoryStoreState>,
) -> Result<BuildContextResult, String> {
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<String> = 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<String> = 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 {

View File

@@ -80,7 +80,7 @@ pub struct EmbeddingConfigResult {
static STORAGE: OnceCell<Arc<SqliteStorage>> = 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")