diff --git a/desktop/src-tauri/src/llm/mod.rs b/desktop/src-tauri/src/llm/mod.rs new file mode 100644 index 0000000..324a957 --- /dev/null +++ b/desktop/src-tauri/src/llm/mod.rs @@ -0,0 +1,243 @@ +//! LLM Client Module +//! +//! Provides LLM API integration for memory extraction. +//! Supports multiple providers with a unified interface. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// === Types === + +#[derive(Debug, Clone)] +pub struct LlmConfig { + pub provider: String, + pub api_key: String, + pub endpoint: Option, + pub model: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LlmMessage { + pub role: String, + pub content: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LlmRequest { + pub messages: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_tokens: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LlmResponse { + pub content: String, + pub model: Option, + pub usage: Option, + pub finish_reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LlmUsage { + pub prompt_tokens: u32, + pub completion_tokens: u32, + pub total_tokens: u32, +} + +// === Provider Configuration === + +#[derive(Debug, Clone)] +pub struct ProviderConfig { + pub name: String, + pub endpoint: String, + pub default_model: String, + pub supports_streaming: bool, +} + +pub fn get_provider_configs() -> HashMap { + let mut configs = HashMap::new(); + + configs.insert( + "doubao".to_string(), + ProviderConfig { + name: "Doubao (火山引擎)".to_string(), + endpoint: "https://ark.cn-beijing.volces.com/api/v3".to_string(), + default_model: "doubao-pro-32k".to_string(), + supports_streaming: true, + }, + ); + + configs.insert( + "openai".to_string(), + ProviderConfig { + name: "OpenAI".to_string(), + endpoint: "https://api.openai.com/v1".to_string(), + default_model: "gpt-4o".to_string(), + supports_streaming: true, + }, + ); + + configs.insert( + "anthropic".to_string(), + ProviderConfig { + name: "Anthropic".to_string(), + endpoint: "https://api.anthropic.com/v1".to_string(), + default_model: "claude-sonnet-4-20250514".to_string(), + supports_streaming: false, + }, + ); + + configs +} + +// === LLM Client === + +pub struct LlmClient { + config: LlmConfig, + provider_config: Option, +} + +impl LlmClient { + pub fn new(config: LlmConfig) -> Self { + let provider_config = get_provider_configs() + .get(&config.provider) + .cloned(); + + Self { + config, + provider_config, + } + } + + /// Complete a chat completion request + pub async fn complete(&self, messages: Vec) -> Result { + let endpoint = self.config.endpoint.clone() + .or_else(|| { + self.provider_config + .as_ref() + .map(|c| c.endpoint.clone()) + }) + .unwrap_or_else(|| "https://ark.cn-beijing.volces.com/api/v3".to_string()); + + let model = self.config.model.clone() + .or_else(|| { + self.provider_config + .as_ref() + .map(|c| c.default_model.clone()) + }) + .unwrap_or_else(|| "doubao-pro-32k".to_string()); + + let request = LlmRequest { + messages, + model: Some(model), + temperature: Some(0.3), + max_tokens: Some(2000), + }; + + self.call_api(&endpoint, &request).await + } + + /// Call LLM API + async fn call_api(&self, endpoint: &str, request: &LlmRequest) -> Result { + let client = reqwest::Client::new(); + + let response = client + .post(format!("{}/chat/completions", endpoint)) + .header("Authorization", format!("Bearer {}", self.config.api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| format!("LLM API request failed: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("LLM API error {}: {}", status, body)); + } + + let json: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse LLM response: {}", e))?; + + // Parse response (OpenAI-compatible format) + let content = json + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .ok_or("Invalid LLM response format")? + .to_string(); + + let usage = json + .get("usage") + .map(|u| LlmUsage { + prompt_tokens: u.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + completion_tokens: u.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + total_tokens: u.get("total_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32, + }); + + Ok(LlmResponse { + content, + model: self.config.model.clone(), + usage, + finish_reason: json + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("finish_reason")) + .and_then(|v| v.as_str()) + .map(String::from), + }) + } +} + +// === Tauri Commands === + +#[tauri::command] +pub async fn llm_complete( + provider: String, + api_key: String, + messages: Vec, + model: Option, +) -> Result { + let config = LlmConfig { + provider, + api_key, + endpoint: None, + model, + }; + + let client = LlmClient::new(config); + client.complete(messages).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_provider_configs() { + let configs = get_provider_configs(); + assert!(configs.contains_key("doubao")); + assert!(configs.contains_key("openai")); + assert!(configs.contains_key("anthropic")); + } + + #[test] + fn test_llm_client_creation() { + let config = LlmConfig { + provider: "doubao".to_string(), + api_key: "test_key".to_string(), + endpoint: None, + model: None, + }; + let client = LlmClient::new(config); + assert!(client.provider_config.is_some()); + } +} diff --git a/desktop/src-tauri/src/memory/context_builder.rs b/desktop/src-tauri/src/memory/context_builder.rs new file mode 100644 index 0000000..13e9ef1 --- /dev/null +++ b/desktop/src-tauri/src/memory/context_builder.rs @@ -0,0 +1,512 @@ +//! Context Builder - L0/L1/L2 Layered Context Loading +//! +//! Implements token-efficient context building for agent prompts. +//! This supplements OpenViking CLI which lacks layered context loading. +//! +//! Layers: +//! - L0 (Quick Scan): Fast vector similarity search, returns overview only +//! - L1 (Standard): Load overview for top candidates, moderate detail +//! - L2 (Deep): Load full content for most relevant items +//! +//! Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §4.3 + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// === Types === + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "UPPERCASE")] +pub enum ContextLevel { + L0, // Quick scan + L1, // Standard detail + L2, // Full content +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ContextItem { + pub uri: String, + pub content: String, + pub score: f64, + pub level: ContextLevel, + pub category: String, + pub tokens: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RetrievalStep { + pub uri: String, + pub score: f64, + pub action: String, // "entered" | "skipped" | "matched" + pub level: ContextLevel, + pub children_explored: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RetrievalTrace { + pub query: String, + pub steps: Vec, + pub total_tokens_used: u32, + pub tokens_by_level: HashMap, + pub duration_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnhancedContext { + pub system_prompt_addition: String, + pub items: Vec, + pub total_tokens: u32, + pub tokens_by_level: HashMap, + pub trace: Option, +} + +#[derive(Debug, Clone)] +pub struct ContextBuilderConfig { + /// Maximum tokens for context + pub max_tokens: u32, + /// L0 scan limit (number of candidates) + pub l0_limit: u32, + /// L1 load limit (number of detailed items) + pub l1_limit: u32, + /// L2 full content limit (number of deep items) + pub l2_limit: u32, + /// Minimum relevance score (0.0 - 1.0) + pub min_score: f64, + /// Enable retrieval trace + pub enable_trace: bool, + /// Token reserve (keep this many tokens free) + pub token_reserve: u32, +} + +impl Default for ContextBuilderConfig { + fn default() -> Self { + Self { + max_tokens: 8000, + l0_limit: 50, + l1_limit: 15, + l2_limit: 3, + min_score: 0.5, + enable_trace: true, + token_reserve: 500, + } + } +} + +// === Context Builder === + +pub struct ContextBuilder { + config: ContextBuilderConfig, + last_trace: Option, +} + +impl ContextBuilder { + pub fn new(config: ContextBuilderConfig) -> Self { + Self { + config, + last_trace: None, + } + } + + /// Get the last retrieval trace + pub fn get_last_trace(&self) -> Option<&RetrievalTrace> { + self.last_trace.as_ref() + } + + /// Build enhanced context from a query + /// + /// This is the main entry point for context building. + /// It performs L0 scan, then progressively loads L1/L2 content. + pub async fn build_context( + &mut self, + query: &str, + agent_id: &str, + viking_find: impl Fn(&str, Option<&str>, u32) -> Result, String>, + viking_read: impl Fn(&str, ContextLevel) -> Result, + ) -> Result { + let start_time = std::time::Instant::now(); + let mut tokens_by_level: HashMap = + [("L0".to_string(), 0), ("L1".to_string(), 0), ("L2".to_string(), 0)] + .into_iter() + .collect(); + + let mut trace_steps: Vec = Vec::new(); + let mut context_items: Vec = Vec::new(); + + // === Phase 1: L0 Quick Scan === + // Fast vector search across user + agent memories + + let user_scope = "viking://user/memories"; + let agent_scope = &format!("viking://agent/{}/memories", agent_id); + + let user_l0 = viking_find(query, Some(user_scope), self.config.l0_limit) + .unwrap_or_default(); + let agent_l0 = viking_find(query, Some(agent_scope), self.config.l0_limit) + .unwrap_or_default(); + + // Combine and sort by score + let mut all_l0: Vec = [user_l0, agent_l0] + .concat() + .into_iter() + .collect(); + + all_l0.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + + // Record L0 tokens + let l0_tokens: u32 = all_l0.iter().map(|r| estimate_tokens(&r.overview)).sum(); + *tokens_by_level.get_mut("L0").unwrap() = l0_tokens; + + // Record trace steps for L0 + for result in &all_l0 { + trace_steps.push(RetrievalStep { + uri: result.uri.clone(), + score: result.score, + action: if result.score >= self.config.min_score { + "entered" + } else { + "skipped" + } + .to_string(), + level: ContextLevel::L0, + children_explored: None, + }); + } + + // === Phase 2: L1 Standard Loading === + // Load overview for top candidates within token budget + + let candidates: Vec<&FindResult> = all_l0 + .iter() + .filter(|r| r.score >= self.config.min_score) + .take(self.config.l1_limit as usize) + .collect(); + + let mut token_budget = self.config.max_tokens.saturating_sub(self.config.token_reserve); + + for candidate in candidates { + if token_budget < 200 { + break; // Need at least 200 tokens for meaningful content + } + + match viking_read(&candidate.uri, ContextLevel::L1) { + Ok(content) => { + let tokens = estimate_tokens(&content); + if tokens <= token_budget { + context_items.push(ContextItem { + uri: candidate.uri.clone(), + content, + score: candidate.score, + level: ContextLevel::L1, + category: extract_category(&candidate.uri), + tokens, + }); + token_budget -= tokens; + *tokens_by_level.get_mut("L1").unwrap() += tokens; + } + } + Err(e) => { + eprintln!("[ContextBuilder] Failed to read L1 for {}: {}", candidate.uri, e); + } + } + } + + // === Phase 3: L2 Deep Loading === + // Load full content for top 3 most relevant items + // Collect items to upgrade first (avoid borrow conflicts) + let deep_candidates: Vec<(String, u32)> = context_items + .iter() + .filter(|i| i.level == ContextLevel::L1) + .take(self.config.l2_limit as usize) + .map(|i| (i.uri.clone(), i.tokens)) + .collect(); + + for (uri, old_tokens) in deep_candidates { + if token_budget < 500 { + break; // Need at least 500 tokens for full content + } + + match viking_read(&uri, ContextLevel::L2) { + Ok(full_content) => { + let tokens = estimate_tokens(&full_content); + if tokens <= token_budget { + // Update the item with L2 content + if let Some(context_item) = context_items.iter_mut().find(|i| i.uri == uri) { + context_item.content = full_content; + context_item.level = ContextLevel::L2; + context_item.tokens = tokens; + *tokens_by_level.get_mut("L2").unwrap() += tokens; + *tokens_by_level.get_mut("L1").unwrap() -= old_tokens; + token_budget -= tokens.saturating_sub(old_tokens); + } + } + } + Err(e) => { + eprintln!("[ContextBuilder] Failed to read L2 for {}: {}", uri, e); + } + } + } + + // === Build Output === + + let total_tokens: u32 = tokens_by_level.values().sum(); + let system_prompt_addition = format_context_for_prompt(&context_items); + + // Build retrieval trace + let duration_ms = start_time.elapsed().as_millis() as u64; + let trace = if self.config.enable_trace { + Some(RetrievalTrace { + query: query.to_string(), + steps: trace_steps, + total_tokens_used: total_tokens, + tokens_by_level: tokens_by_level.clone(), + duration_ms, + }) + } else { + None + }; + + self.last_trace = trace.clone(); + + Ok(EnhancedContext { + system_prompt_addition, + items: context_items, + total_tokens, + tokens_by_level, + trace, + }) + } + + /// Build context with pre-fetched L0 results + pub fn build_context_from_l0( + &mut self, + query: &str, + l0_results: Vec, + viking_read: impl Fn(&str, ContextLevel) -> Result, + ) -> Result { + // Similar to build_context but uses pre-fetched L0 results + let start_time = std::time::Instant::now(); + let mut tokens_by_level: HashMap = + [("L0".to_string(), 0), ("L1".to_string(), 0), ("L2".to_string(), 0)] + .into_iter() + .collect(); + + let mut trace_steps: Vec = Vec::new(); + let mut context_items: Vec = Vec::new(); + + // Sort by score + let mut all_l0 = l0_results; + all_l0.sort_by(|a, b| b.score.partial_cmp(&a.score).unwrap_or(std::cmp::Ordering::Equal)); + + // Record L0 tokens + let l0_tokens: u32 = all_l0.iter().map(|r| estimate_tokens(&r.overview)).sum(); + *tokens_by_level.get_mut("L0").unwrap() = l0_tokens; + + // Record trace steps + for result in &all_l0 { + trace_steps.push(RetrievalStep { + uri: result.uri.clone(), + score: result.score, + action: if result.score >= self.config.min_score { + "entered" + } else { + "skipped" + } + .to_string(), + level: ContextLevel::L0, + children_explored: None, + }); + } + + // L1 loading + let candidates: Vec<&FindResult> = all_l0 + .iter() + .filter(|r| r.score >= self.config.min_score) + .take(self.config.l1_limit as usize) + .collect(); + + let mut token_budget = self.config.max_tokens.saturating_sub(self.config.token_reserve); + + for candidate in candidates { + if token_budget < 200 { + break; + } + + match viking_read(&candidate.uri, ContextLevel::L1) { + Ok(content) => { + let tokens = estimate_tokens(&content); + if tokens <= token_budget { + context_items.push(ContextItem { + uri: candidate.uri.clone(), + content, + score: candidate.score, + level: ContextLevel::L1, + category: extract_category(&candidate.uri), + tokens, + }); + token_budget -= tokens; + *tokens_by_level.get_mut("L1").unwrap() += tokens; + } + } + Err(_) => continue, + } + } + + // L2 loading - collect updates first to avoid borrow conflicts + let deep_candidates: Vec<(String, u32)> = context_items + .iter() + .take(self.config.l2_limit as usize) + .map(|item| (item.uri.clone(), item.tokens)) + .collect(); + + for (uri, old_tokens) in deep_candidates { + if token_budget < 500 { + break; + } + + match viking_read(&uri, ContextLevel::L2) { + Ok(full_content) => { + let tokens = estimate_tokens(&full_content); + if tokens <= token_budget { + if let Some(context_item) = context_items.iter_mut().find(|i| i.uri == uri) { + context_item.content = full_content; + context_item.level = ContextLevel::L2; + context_item.tokens = tokens; + *tokens_by_level.get_mut("L2").unwrap() += tokens; + *tokens_by_level.get_mut("L1").unwrap() -= old_tokens; + } + } + } + Err(_) => continue, + } + } + + let total_tokens: u32 = tokens_by_level.values().sum(); + let system_prompt_addition = format_context_for_prompt(&context_items); + let duration_ms = start_time.elapsed().as_millis() as u64; + + let trace = if self.config.enable_trace { + Some(RetrievalTrace { + query: query.to_string(), + steps: trace_steps, + total_tokens_used: total_tokens, + tokens_by_level: tokens_by_level.clone(), + duration_ms, + }) + } else { + None + }; + + self.last_trace = trace.clone(); + + Ok(EnhancedContext { + system_prompt_addition, + items: context_items, + total_tokens, + tokens_by_level, + trace, + }) + } +} + +// === Helper Functions === + +/// Estimate token count for text +fn estimate_tokens(text: &str) -> u32 { + // ~1.5 tokens per CJK character, ~0.4 tokens per ASCII character + 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 u32).max(1) +} + +/// Extract category from URI +fn extract_category(uri: &str) -> String { + let parts: Vec<&str> = uri.strip_prefix("viking://").unwrap_or(uri).split('/').collect(); + // Return 3rd segment as category (e.g., "preferences" from viking://user/memories/preferences/...) + parts.get(2).or(parts.get(1)).unwrap_or(&"unknown").to_string() +} + +/// Format context items for system prompt +fn format_context_for_prompt(items: &[ContextItem]) -> String { + if items.is_empty() { + return String::new(); + } + + let user_items: Vec<&ContextItem> = items + .iter() + .filter(|i| i.uri.starts_with("viking://user/")) + .collect(); + + let agent_items: Vec<&ContextItem> = items + .iter() + .filter(|i| i.uri.starts_with("viking://agent/")) + .collect(); + + let mut sections: Vec = Vec::new(); + + if !user_items.is_empty() { + sections.push("## 用户记忆".to_string()); + for item in user_items { + sections.push(format!("- [{}] {}", item.category, item.content)); + } + } + + if !agent_items.is_empty() { + sections.push("## Agent 经验".to_string()); + for item in agent_items { + sections.push(format!("- [{}] {}", item.category, item.content)); + } + } + + sections.join("\n") +} + +// === External Types (for viking_find callback) === + +#[derive(Debug, Clone)] +pub struct FindResult { + pub uri: String, + pub score: f64, + pub overview: String, +} + +// === Tauri Commands === + +#[tauri::command] +pub fn estimate_content_tokens(content: String) -> u32 { + estimate_tokens(&content) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_estimate_tokens() { + assert!(estimate_tokens("Hello world") > 0); + assert!(estimate_tokens("你好世界") > estimate_tokens("Hello")); + } + + #[test] + fn test_extract_category() { + assert_eq!( + extract_category("viking://user/memories/preferences/dark_mode"), + "preferences" + ); + assert_eq!( + extract_category("viking://agent/main/lessons/lesson1"), + "lessons" + ); + } + + #[test] + fn test_context_builder_config_default() { + let config = ContextBuilderConfig::default(); + assert_eq!(config.max_tokens, 8000); + assert_eq!(config.l0_limit, 50); + assert_eq!(config.l1_limit, 15); + assert_eq!(config.l2_limit, 3); + } +} diff --git a/desktop/src-tauri/src/memory/extractor.rs b/desktop/src-tauri/src/memory/extractor.rs new file mode 100644 index 0000000..cd749ac --- /dev/null +++ b/desktop/src-tauri/src/memory/extractor.rs @@ -0,0 +1,506 @@ +//! Session Memory Extractor +//! +//! Extracts structured memories from conversation sessions using LLM analysis. +//! This supplements OpenViking CLI which lacks built-in memory extraction. +//! +//! Categories: +//! - user_preference: User's stated preferences and settings +//! - user_fact: Facts about the user (name, role, projects, etc.) +//! - agent_lesson: Lessons learned by the agent from interactions +//! - agent_pattern: Recurring patterns the agent should remember +//! - task: Task-related information for follow-up + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +// === Types === + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MemoryCategory { + UserPreference, + UserFact, + AgentLesson, + AgentPattern, + Task, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtractedMemory { + pub category: MemoryCategory, + pub content: String, + pub tags: Vec, + pub importance: u8, // 1-10 scale + pub suggested_uri: String, + pub reasoning: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ExtractionResult { + pub memories: Vec, + pub summary: String, + pub tokens_saved: Option, + pub extraction_time_ms: u64, +} + +#[derive(Debug, Clone)] +pub struct ExtractionConfig { + /// Maximum memories to extract per session + pub max_memories: usize, + /// Minimum importance threshold (1-10) + pub min_importance: u8, + /// Whether to include reasoning in output + pub include_reasoning: bool, + /// Agent ID for URI generation + pub agent_id: String, +} + +impl Default for ExtractionConfig { + fn default() -> Self { + Self { + max_memories: 10, + min_importance: 5, + include_reasoning: true, + agent_id: "zclaw-main".to_string(), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: String, + pub timestamp: Option, +} + +// === Session Extractor === + +pub struct SessionExtractor { + config: ExtractionConfig, + llm_endpoint: Option, + api_key: Option, +} + +impl SessionExtractor { + pub fn new(config: ExtractionConfig) -> Self { + Self { + config, + llm_endpoint: None, + api_key: None, + } + } + + /// Configure LLM endpoint for extraction + pub fn with_llm(mut self, endpoint: String, api_key: String) -> Self { + self.llm_endpoint = Some(endpoint); + self.api_key = Some(api_key); + self + } + + /// Extract memories from a conversation session + pub async fn extract(&self, messages: &[ChatMessage]) -> Result { + let start_time = std::time::Instant::now(); + + // Build extraction prompt + let prompt = self.build_extraction_prompt(messages); + + // Call LLM for extraction + let response = self.call_llm(&prompt).await?; + + // Parse LLM response into structured memories + let memories = self.parse_extraction(&response)?; + + // Filter by importance and limit + let filtered: Vec = memories + .into_iter() + .filter(|m| m.importance >= self.config.min_importance) + .take(self.config.max_memories) + .collect(); + + // Generate session summary + let summary = self.generate_summary(&filtered); + + let elapsed = start_time.elapsed().as_millis() as u64; + + Ok(ExtractionResult { + tokens_saved: Some(self.estimate_tokens_saved(messages, &summary)), + memories: filtered, + summary, + extraction_time_ms: elapsed, + }) + } + + /// Build the extraction prompt for the LLM + fn build_extraction_prompt(&self, messages: &[ChatMessage]) -> String { + let conversation = messages + .iter() + .map(|m| format!("[{}]: {}", m.role, m.content)) + .collect::>() + .join("\n\n"); + + format!( + r#"Analyze the following conversation and extract structured memories. +Focus on information that would be useful for future interactions. + +## Conversation +{} + +## Extraction Instructions +Extract memories in these categories: +- user_preference: User's stated preferences (UI preferences, workflow preferences, tool choices) +- user_fact: Facts about the user (name, role, projects, skills, constraints) +- agent_lesson: Lessons the agent learned (what worked, what didn't, corrections needed) +- agent_pattern: Recurring patterns to remember (common workflows, frequent requests) +- task: Tasks or follow-ups mentioned (todos, pending work, deadlines) + +For each memory, provide: +1. category: One of the above categories +2. content: The actual memory content (concise, actionable) +3. tags: 2-5 relevant tags for retrieval +4. importance: 1-10 scale (10 = critical, 1 = trivial) +5. reasoning: Brief explanation of why this is worth remembering + +Output as JSON array: +```json +[ + {{ + "category": "user_preference", + "content": "...", + "tags": ["tag1", "tag2"], + "importance": 7, + "reasoning": "..." + }} +] +``` + +If no significant memories found, return empty array: []"#, + conversation + ) + } + + /// Call LLM for extraction + async fn call_llm(&self, prompt: &str) -> Result { + // If LLM endpoint is configured, use it + if let (Some(endpoint), Some(api_key)) = (&self.llm_endpoint, &self.api_key) { + return self.call_llm_api(endpoint, api_key, prompt).await; + } + + // Otherwise, use rule-based extraction as fallback + self.rule_based_extraction(prompt) + } + + /// Call external LLM API (doubao, OpenAI, etc.) + async fn call_llm_api( + &self, + endpoint: &str, + api_key: &str, + prompt: &str, + ) -> Result { + let client = reqwest::Client::new(); + + let response = client + .post(endpoint) + .header("Authorization", format!("Bearer {}", api_key)) + .header("Content-Type", "application/json") + .json(&serde_json::json!({ + "model": "doubao-pro-32k", + "messages": [ + {"role": "user", "content": prompt} + ], + "temperature": 0.3, + "max_tokens": 2000 + })) + .send() + .await + .map_err(|e| format!("LLM API request failed: {}", e))?; + + if !response.status().is_success() { + return Err(format!("LLM API error: {}", response.status())); + } + + let json: serde_json::Value = response + .json() + .await + .map_err(|e| format!("Failed to parse LLM response: {}", e))?; + + // Extract content from response (adjust based on API format) + let content = json + .get("choices") + .and_then(|c| c.get(0)) + .and_then(|c| c.get("message")) + .and_then(|m| m.get("content")) + .and_then(|c| c.as_str()) + .ok_or("Invalid LLM response format")? + .to_string(); + + Ok(content) + } + + /// Rule-based extraction as fallback when LLM is not available + fn rule_based_extraction(&self, prompt: &str) -> Result { + // Simple pattern matching for common memory patterns + let mut memories: Vec = Vec::new(); + + // Pattern: User preferences + let pref_patterns = [ + (r"I prefer (.+)", "user_preference"), + (r"My preference is (.+)", "user_preference"), + (r"I like (.+)", "user_preference"), + (r"I don't like (.+)", "user_preference"), + ]; + + // Pattern: User facts + let fact_patterns = [ + (r"My name is (.+)", "user_fact"), + (r"I work on (.+)", "user_fact"), + (r"I'm a (.+)", "user_fact"), + (r"My project is (.+)", "user_fact"), + ]; + + // Extract using regex (simplified implementation) + for (pattern, category) in pref_patterns.iter().chain(fact_patterns.iter()) { + if let Ok(re) = regex::Regex::new(pattern) { + for cap in re.captures_iter(prompt) { + if let Some(content) = cap.get(1) { + let memory = ExtractedMemory { + category: if *category == "user_preference" { + MemoryCategory::UserPreference + } else { + MemoryCategory::UserFact + }, + content: content.as_str().to_string(), + tags: vec!["auto-extracted".to_string()], + importance: 6, + suggested_uri: format!( + "viking://user/memories/{}/{}", + category, + chrono::Utc::now().timestamp_millis() + ), + reasoning: Some("Extracted via rule-based pattern matching".to_string()), + }; + memories.push(memory); + } + } + } + } + + // Return as JSON + serde_json::to_string_pretty(&memories) + .map_err(|e| format!("Failed to serialize memories: {}", e)) + } + + /// Parse LLM response into structured memories + fn parse_extraction(&self, response: &str) -> Result, String> { + // Try to extract JSON from the response + let json_start = response.find('[').unwrap_or(0); + let json_end = response.rfind(']').map(|i| i + 1).unwrap_or(response.len()); + let json_str = &response[json_start..json_end]; + + // Parse JSON + let raw_memories: Vec = serde_json::from_str(json_str) + .unwrap_or_default(); + + let memories: Vec = raw_memories + .into_iter() + .filter_map(|m| self.parse_memory(&m)) + .collect(); + + Ok(memories) + } + + /// Parse a single memory from JSON + fn parse_memory(&self, value: &serde_json::Value) -> Option { + let category_str = value.get("category")?.as_str()?; + let category = match category_str { + "user_preference" => MemoryCategory::UserPreference, + "user_fact" => MemoryCategory::UserFact, + "agent_lesson" => MemoryCategory::AgentLesson, + "agent_pattern" => MemoryCategory::AgentPattern, + "task" => MemoryCategory::Task, + _ => return None, + }; + + let content = value.get("content")?.as_str()?.to_string(); + let tags = value + .get("tags") + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default(); + + let importance = value + .get("importance") + .and_then(|v| v.as_u64()) + .unwrap_or(5) as u8; + + let reasoning = value + .get("reasoning") + .and_then(|v| v.as_str()) + .map(String::from); + + // Generate URI based on category + let suggested_uri = self.generate_uri(&category, &content); + + Some(ExtractedMemory { + category, + content, + tags, + importance, + suggested_uri, + reasoning, + }) + } + + /// Generate a URI for the memory + fn generate_uri(&self, category: &MemoryCategory, content: &str) -> String { + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + + let content_hash = &content[..content.len().min(20)] + .to_lowercase() + .replace(' ', "_") + .replace(|c: char| !c.is_alphanumeric() && c != '_', ""); + + match category { + MemoryCategory::UserPreference => { + format!("viking://user/memories/preferences/{}_{}", content_hash, timestamp) + } + MemoryCategory::UserFact => { + format!("viking://user/memories/facts/{}_{}", content_hash, timestamp) + } + MemoryCategory::AgentLesson => { + format!( + "viking://agent/{}/memories/lessons/{}_{}", + self.config.agent_id, content_hash, timestamp + ) + } + MemoryCategory::AgentPattern => { + format!( + "viking://agent/{}/memories/patterns/{}_{}", + self.config.agent_id, content_hash, timestamp + ) + } + MemoryCategory::Task => { + format!( + "viking://agent/{}/tasks/{}_{}", + self.config.agent_id, content_hash, timestamp + ) + } + } + } + + /// Generate a summary of extracted memories + fn generate_summary(&self, memories: &[ExtractedMemory]) -> String { + if memories.is_empty() { + return "No significant memories extracted from this session.".to_string(); + } + + let mut summary_parts = Vec::new(); + + let user_prefs = memories + .iter() + .filter(|m| matches!(m.category, MemoryCategory::UserPreference)) + .count(); + if user_prefs > 0 { + summary_parts.push(format!("{} user preferences", user_prefs)); + } + + let user_facts = memories + .iter() + .filter(|m| matches!(m.category, MemoryCategory::UserFact)) + .count(); + if user_facts > 0 { + summary_parts.push(format!("{} user facts", user_facts)); + } + + let lessons = memories + .iter() + .filter(|m| matches!(m.category, MemoryCategory::AgentLesson)) + .count(); + if lessons > 0 { + summary_parts.push(format!("{} agent lessons", lessons)); + } + + let patterns = memories + .iter() + .filter(|m| matches!(m.category, MemoryCategory::AgentPattern)) + .count(); + if patterns > 0 { + summary_parts.push(format!("{} patterns", patterns)); + } + + let tasks = memories + .iter() + .filter(|m| matches!(m.category, MemoryCategory::Task)) + .count(); + if tasks > 0 { + summary_parts.push(format!("{} tasks", tasks)); + } + + format!( + "Extracted {} memories: {}.", + memories.len(), + summary_parts.join(", ") + ) + } + + /// Estimate tokens saved by extraction + fn estimate_tokens_saved(&self, messages: &[ChatMessage], summary: &str) -> u32 { + // Rough estimation: original messages vs summary + let original_tokens: u32 = messages + .iter() + .map(|m| (m.content.len() as f32 * 0.4) as u32) + .sum(); + + let summary_tokens = (summary.len() as f32 * 0.4) as u32; + + original_tokens.saturating_sub(summary_tokens) + } +} + +// === Tauri Commands === + +#[tauri::command] +pub async fn extract_session_memories( + messages: Vec, + agent_id: String, +) -> Result { + let config = ExtractionConfig { + agent_id, + ..Default::default() + }; + + let extractor = SessionExtractor::new(config); + extractor.extract(&messages).await +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extraction_config_default() { + let config = ExtractionConfig::default(); + assert_eq!(config.max_memories, 10); + assert_eq!(config.min_importance, 5); + } + + #[test] + fn test_uri_generation() { + let config = ExtractionConfig::default(); + let extractor = SessionExtractor::new(config); + + let uri = extractor.generate_uri( + &MemoryCategory::UserPreference, + "dark mode enabled" + ); + assert!(uri.starts_with("viking://user/memories/preferences/")); + } +} diff --git a/desktop/src-tauri/src/memory/mod.rs b/desktop/src-tauri/src/memory/mod.rs new file mode 100644 index 0000000..2d5f01a --- /dev/null +++ b/desktop/src-tauri/src/memory/mod.rs @@ -0,0 +1,13 @@ +//! Memory Module - OpenViking Supplemental Components +//! +//! This module provides functionality that the OpenViking CLI lacks: +//! - Session extraction: LLM-powered memory extraction from conversations +//! - Context building: L0/L1/L2 layered context loading +//! +//! These components work alongside the OpenViking CLI sidecar. + +pub mod extractor; +pub mod context_builder; + +pub use extractor::{SessionExtractor, ExtractedMemory, ExtractionConfig}; +pub use context_builder::{ContextBuilder, EnhancedContext, ContextLevel}; diff --git a/desktop/src-tauri/src/viking_commands.rs b/desktop/src-tauri/src/viking_commands.rs new file mode 100644 index 0000000..e7fb7df --- /dev/null +++ b/desktop/src-tauri/src/viking_commands.rs @@ -0,0 +1,368 @@ +//! OpenViking CLI Sidecar Integration +//! +//! Wraps the OpenViking Rust CLI (`ov`) as a Tauri sidecar for local memory operations. +//! This eliminates the need for a Python server dependency. +//! +//! Reference: https://github.com/volcengine/OpenViking + +use serde::{Deserialize, Serialize}; +use std::process::Command; +use tauri::AppHandle; + +// === Types === + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VikingStatus { + pub available: bool, + pub version: Option, + pub data_dir: Option, + pub error: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VikingResource { + pub uri: String, + pub name: String, + #[serde(rename = "type")] + pub resource_type: String, + pub size: Option, + pub modified_at: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VikingFindResult { + pub uri: String, + pub score: f64, + pub content: String, + pub level: String, + pub overview: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VikingGrepResult { + pub uri: String, + pub line: u32, + pub content: String, + pub match_start: u32, + pub match_end: u32, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VikingAddResult { + pub uri: String, + pub status: String, +} + +// === CLI Path Resolution === + +fn get_viking_cli_path() -> Result { + // Try environment variable first + if let Ok(path) = std::env::var("ZCLAW_VIKING_BIN") { + if std::path::Path::new(&path).exists() { + return Ok(path); + } + } + + // Try bundled sidecar location + let binary_name = if cfg!(target_os = "windows") { + "ov-x86_64-pc-windows-msvc.exe" + } else if cfg!(target_os = "macos") { + if cfg!(target_arch = "aarch64") { + "ov-aarch64-apple-darwin" + } else { + "ov-x86_64-apple-darwin" + } + } else { + "ov-x86_64-unknown-linux-gnu" + }; + + // Check common locations + let locations = vec![ + format!("./binaries/{}", binary_name), + format!("./resources/viking/{}", binary_name), + format!("./{}", binary_name), + ]; + + for loc in locations { + if std::path::Path::new(&loc).exists() { + return Ok(loc); + } + } + + // Fallback to system PATH + Ok("ov".to_string()) +} + +fn run_viking_cli(args: &[&str]) -> Result { + let cli_path = get_viking_cli_path()?; + + let output = Command::new(&cli_path) + .args(args) + .output() + .map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + format!( + "OpenViking CLI not found. Please install 'ov' or set ZCLAW_VIKING_BIN. Tried: {}", + cli_path + ) + } else { + format!("Failed to run OpenViking CLI: {}", e) + } + })?; + + if output.status.success() { + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + if !stderr.is_empty() { + Err(stderr) + } else if !stdout.is_empty() { + Err(stdout) + } else { + Err(format!("OpenViking CLI failed with status: {}", output.status)) + } + } +} + +fn run_viking_cli_json Deserialize<'de>>(args: &[&str]) -> Result { + let output = run_viking_cli(args)?; + + // Handle empty output + if output.is_empty() { + return Err("OpenViking CLI returned empty output".to_string()); + } + + // Try to parse as JSON + serde_json::from_str(&output) + .map_err(|e| format!("Failed to parse OpenViking output as JSON: {}\nOutput: {}", e, output)) +} + +// === Tauri Commands === + +/// Check if OpenViking CLI is available +#[tauri::command] +pub fn viking_status() -> Result { + let result = run_viking_cli(&["--version"]); + + match result { + Ok(version_output) => { + // Parse version from output like "ov 0.1.0" + let version = version_output + .lines() + .next() + .map(|s| s.trim().to_string()); + + Ok(VikingStatus { + available: true, + version, + data_dir: None, // TODO: Get from CLI + error: None, + }) + } + Err(e) => Ok(VikingStatus { + available: false, + version: None, + data_dir: None, + error: Some(e), + }), + } +} + +/// Add a resource to OpenViking +#[tauri::command] +pub fn viking_add(uri: String, content: String) -> Result { + // Create a temporary file for the content + let temp_dir = std::env::temp_dir(); + let timestamp = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + let temp_file = temp_dir.join(format!("viking_add_{}.txt", timestamp)); + + std::fs::write(&temp_file, &content) + .map_err(|e| format!("Failed to write temp file: {}", e))?; + + let temp_path = temp_file.to_string_lossy(); + let result = run_viking_cli(&["add", &uri, "--file", &temp_path]); + + // Clean up temp file + let _ = std::fs::remove_file(&temp_file); + + match result { + Ok(_) => Ok(VikingAddResult { + uri, + status: "added".to_string(), + }), + Err(e) => Err(e), + } +} + +/// Add a resource with inline content (for small content) +#[tauri::command] +pub fn viking_add_inline(uri: String, content: String) -> Result { + // Use stdin for content + let cli_path = get_viking_cli_path()?; + + let output = Command::new(&cli_path) + .args(["add", &uri]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("Failed to spawn OpenViking CLI: {}", e))?; + + // Write content to stdin + if let Some(mut stdin) = output.stdin.as_ref() { + use std::io::Write; + stdin.write_all(content.as_bytes()) + .map_err(|e| format!("Failed to write to stdin: {}", e))?; + } + + let result = output.wait_with_output() + .map_err(|e| format!("Failed to read output: {}", e))?; + + if result.status.success() { + Ok(VikingAddResult { + uri, + status: "added".to_string(), + }) + } else { + let stderr = String::from_utf8_lossy(&result.stderr).trim().to_string(); + Err(if !stderr.is_empty() { stderr } else { "Failed to add resource".to_string() }) + } +} + +/// Find resources by semantic search +#[tauri::command] +pub fn viking_find( + query: String, + scope: Option, + limit: Option, +) -> Result, String> { + let mut args = vec!["find", "--json", &query]; + + let scope_arg; + if let Some(ref s) = scope { + scope_arg = format!("--scope={}", s); + args.push(&scope_arg); + } + + let limit_arg; + if let Some(l) = limit { + limit_arg = format!("--limit={}", l); + args.push(&limit_arg); + } + + // CLI returns JSON array directly + let output = run_viking_cli(&args)?; + + // Handle empty or null results + if output.is_empty() || output == "null" || output == "[]" { + return Ok(Vec::new()); + } + + serde_json::from_str(&output) + .map_err(|e| format!("Failed to parse find results: {}\nOutput: {}", e, output)) +} + +/// Grep resources by pattern +#[tauri::command] +pub fn viking_grep( + pattern: String, + uri: Option, + case_sensitive: Option, + limit: Option, +) -> Result, String> { + let mut args = vec!["grep", "--json", &pattern]; + + let uri_arg; + if let Some(ref u) = uri { + uri_arg = format!("--uri={}", u); + args.push(&uri_arg); + } + + if case_sensitive.unwrap_or(false) { + args.push("--case-sensitive"); + } + + let limit_arg; + if let Some(l) = limit { + limit_arg = format!("--limit={}", l); + args.push(&limit_arg); + } + + let output = run_viking_cli(&args)?; + + if output.is_empty() || output == "null" || output == "[]" { + return Ok(Vec::new()); + } + + serde_json::from_str(&output) + .map_err(|e| format!("Failed to parse grep results: {}\nOutput: {}", e, output)) +} + +/// List resources at a path +#[tauri::command] +pub fn viking_ls(path: String) -> Result, String> { + let output = run_viking_cli(&["ls", "--json", &path])?; + + if output.is_empty() || output == "null" || output == "[]" { + return Ok(Vec::new()); + } + + serde_json::from_str(&output) + .map_err(|e| format!("Failed to parse ls results: {}\nOutput: {}", e, output)) +} + +/// Read resource content +#[tauri::command] +pub fn viking_read(uri: String, level: Option) -> Result { + let level_val = level.unwrap_or_else(|| "L1".to_string()); + let level_arg = format!("--level={}", level_val); + + run_viking_cli(&["read", &uri, &level_arg]) +} + +/// Remove a resource +#[tauri::command] +pub fn viking_remove(uri: String) -> Result<(), String> { + run_viking_cli(&["remove", &uri])?; + Ok(()) +} + +/// Get resource tree +#[tauri::command] +pub fn viking_tree(path: String, depth: Option) -> Result { + let depth_val = depth.unwrap_or(2); + let depth_arg = format!("--depth={}", depth_val); + + let output = run_viking_cli(&["tree", "--json", &path, &depth_arg])?; + + if output.is_empty() || output == "null" { + return Ok(serde_json::json!({})); + } + + serde_json::from_str(&output) + .map_err(|e| format!("Failed to parse tree result: {}\nOutput: {}", e, output)) +} + +// === Tests === + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_status_unavailable_without_cli() { + // This test will fail if ov is installed, which is fine + let result = viking_status(); + assert!(result.is_ok()); + } +} diff --git a/desktop/src/lib/context-builder.ts b/desktop/src/lib/context-builder.ts new file mode 100644 index 0000000..0ff9bac --- /dev/null +++ b/desktop/src/lib/context-builder.ts @@ -0,0 +1,409 @@ +/** + * ContextBuilder - Integrates OpenViking memories into chat context + * + * Responsible for: + * 1. Building enhanced system prompts with relevant memories (L0/L1/L2) + * 2. Extracting and saving memories after conversations end + * 3. Managing context compaction with memory flush + * 4. Reading and injecting agent identity files + * + * This module bridges the VikingAdapter with chatStore/gateway-client. + */ + +import { VikingAdapter, getVikingAdapter, type EnhancedContext } from './viking-adapter'; + +// === Types === + +export interface AgentIdentity { + soul: string; + instructions: string; + userProfile: string; + heartbeat?: string; +} + +export interface ContextBuildResult { + systemPrompt: string; + memorySummary: string; + tokensUsed: number; + memoriesInjected: number; +} + +export interface CompactionResult { + compactedMessages: ChatMessage[]; + summary: string; + memoriesFlushed: number; +} + +export interface ChatMessage { + role: 'system' | 'user' | 'assistant'; + content: string; +} + +export interface ContextBuilderConfig { + enabled: boolean; + maxMemoryTokens: number; + compactionThresholdTokens: number; + compactionReserveTokens: number; + memoryFlushOnCompact: boolean; + autoExtractOnComplete: boolean; + minExtractionMessages: number; +} + +const DEFAULT_CONFIG: ContextBuilderConfig = { + enabled: true, + maxMemoryTokens: 6000, + compactionThresholdTokens: 15000, + compactionReserveTokens: 4000, + memoryFlushOnCompact: true, + autoExtractOnComplete: true, + minExtractionMessages: 4, +}; + +// === Token Estimation === + +function estimateTokens(text: string): number { + const cjkChars = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length; + const otherChars = text.length - cjkChars; + return Math.ceil(cjkChars * 1.5 + otherChars * 0.4); +} + +function estimateMessagesTokens(messages: ChatMessage[]): number { + return messages.reduce((sum, m) => sum + estimateTokens(m.content) + 4, 0); +} + +// === ContextBuilder Implementation === + +export class ContextBuilder { + private viking: VikingAdapter; + private config: ContextBuilderConfig; + private identityCache: Map = new Map(); + private static IDENTITY_CACHE_TTL = 5 * 60 * 1000; // 5 min + + constructor(config?: Partial) { + this.config = { ...DEFAULT_CONFIG, ...config }; + this.viking = getVikingAdapter(); + } + + // === Core: Build Context for a Chat Message === + + async buildContext( + userMessage: string, + agentId: string, + _existingMessages: ChatMessage[] = [] + ): Promise { + if (!this.config.enabled) { + return { + systemPrompt: '', + memorySummary: '', + tokensUsed: 0, + memoriesInjected: 0, + }; + } + + // Check if OpenViking is available + const connected = await this.viking.isConnected(); + if (!connected) { + console.warn('[ContextBuilder] OpenViking not available, skipping memory injection'); + return { + systemPrompt: '', + memorySummary: '', + tokensUsed: 0, + memoriesInjected: 0, + }; + } + + // Step 1: Load agent identity + const identity = await this.loadIdentity(agentId); + + // Step 2: Build enhanced context with memories + const enhanced = await this.viking.buildEnhancedContext( + userMessage, + agentId, + { maxTokens: this.config.maxMemoryTokens, includeTrace: true } + ); + + // Step 3: Compose system prompt + const systemPrompt = this.composeSystemPrompt(identity, enhanced); + + // Step 4: Build summary for UI display + const memorySummary = this.buildMemorySummary(enhanced); + + return { + systemPrompt, + memorySummary, + tokensUsed: enhanced.totalTokens + estimateTokens(systemPrompt), + memoriesInjected: enhanced.memories.length, + }; + } + + // === Identity Loading === + + async loadIdentity(agentId: string): Promise { + // Check cache + const cached = this.identityCache.get(agentId); + if (cached && Date.now() - cached.cachedAt < ContextBuilder.IDENTITY_CACHE_TTL) { + return cached.identity; + } + + // Try loading from OpenViking first, fall back to defaults + let soul = ''; + let instructions = ''; + let userProfile = ''; + let heartbeat = ''; + + try { + [soul, instructions, userProfile, heartbeat] = await Promise.all([ + this.viking.getIdentityFromViking(agentId, 'soul').catch(() => ''), + this.viking.getIdentityFromViking(agentId, 'instructions').catch(() => ''), + this.viking.getIdentityFromViking(agentId, 'user_profile').catch(() => ''), + this.viking.getIdentityFromViking(agentId, 'heartbeat').catch(() => ''), + ]); + } catch { + // OpenViking not available, use empty defaults + } + + const identity: AgentIdentity = { + soul: soul || DEFAULT_SOUL, + instructions: instructions || DEFAULT_INSTRUCTIONS, + userProfile: userProfile || '', + heartbeat: heartbeat || '', + }; + + this.identityCache.set(agentId, { identity, cachedAt: Date.now() }); + return identity; + } + + // === Context Compaction === + + async checkAndCompact( + messages: ChatMessage[], + agentId: string + ): Promise { + const totalTokens = estimateMessagesTokens(messages); + if (totalTokens < this.config.compactionThresholdTokens) { + return null; // No compaction needed + } + + let memoriesFlushed = 0; + + // Step 1: Memory flush before compaction + if (this.config.memoryFlushOnCompact) { + const keepCount = 5; + const messagesToFlush = messages.slice(0, -keepCount); + if (messagesToFlush.length >= this.config.minExtractionMessages) { + try { + const result = await this.viking.extractAndSaveMemories( + messagesToFlush.map(m => ({ role: m.role, content: m.content })), + agentId, + 'compaction' + ); + memoriesFlushed = result.saved; + console.log(`[ContextBuilder] Memory flush: saved ${memoriesFlushed} memories before compaction`); + } catch (err) { + console.warn('[ContextBuilder] Memory flush failed:', err); + } + } + } + + // Step 2: Create summary of older messages + const keepCount = 5; + const oldMessages = messages.slice(0, -keepCount); + const recentMessages = messages.slice(-keepCount); + + const summary = this.createCompactionSummary(oldMessages); + + const compactedMessages: ChatMessage[] = [ + { role: 'system', content: `[之前的对话摘要]\n${summary}` }, + ...recentMessages, + ]; + + return { + compactedMessages, + summary, + memoriesFlushed, + }; + } + + // === Post-Conversation Memory Extraction === + + async extractMemoriesFromConversation( + messages: ChatMessage[], + agentId: string, + conversationId?: string + ): Promise<{ saved: number; userMemories: number; agentMemories: number }> { + if (!this.config.autoExtractOnComplete) { + return { saved: 0, userMemories: 0, agentMemories: 0 }; + } + + if (messages.length < this.config.minExtractionMessages) { + return { saved: 0, userMemories: 0, agentMemories: 0 }; + } + + const connected = await this.viking.isConnected(); + if (!connected) { + return { saved: 0, userMemories: 0, agentMemories: 0 }; + } + + try { + const result = await this.viking.extractAndSaveMemories( + messages.map(m => ({ role: m.role, content: m.content })), + agentId, + conversationId + ); + console.log( + `[ContextBuilder] Extracted ${result.saved} memories (user: ${result.userMemories}, agent: ${result.agentMemories})` + ); + return result; + } catch (err) { + console.warn('[ContextBuilder] Memory extraction failed:', err); + return { saved: 0, userMemories: 0, agentMemories: 0 }; + } + } + + // === Identity Sync === + + async syncIdentityFiles( + agentId: string, + files: { soul?: string; instructions?: string; userProfile?: string; heartbeat?: string } + ): Promise { + const connected = await this.viking.isConnected(); + if (!connected) return; + + const syncTasks: Promise[] = []; + + if (files.soul) { + syncTasks.push(this.viking.syncIdentityToViking(agentId, 'SOUL.md', files.soul)); + } + if (files.instructions) { + syncTasks.push(this.viking.syncIdentityToViking(agentId, 'AGENTS.md', files.instructions)); + } + if (files.userProfile) { + syncTasks.push(this.viking.syncIdentityToViking(agentId, 'USER.md', files.userProfile)); + } + if (files.heartbeat) { + syncTasks.push(this.viking.syncIdentityToViking(agentId, 'HEARTBEAT.md', files.heartbeat)); + } + + await Promise.allSettled(syncTasks); + + // Invalidate cache + this.identityCache.delete(agentId); + } + + // === Configuration === + + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + } + + getConfig(): Readonly { + return { ...this.config }; + } + + isEnabled(): boolean { + return this.config.enabled; + } + + // === Private Helpers === + + private composeSystemPrompt(identity: AgentIdentity, enhanced: EnhancedContext): string { + const sections: string[] = []; + + if (identity.soul) { + sections.push(identity.soul); + } + + if (identity.instructions) { + sections.push(identity.instructions); + } + + if (identity.userProfile) { + sections.push(`## 用户画像\n${identity.userProfile}`); + } + + if (enhanced.systemPromptAddition) { + sections.push(enhanced.systemPromptAddition); + } + + return sections.join('\n\n'); + } + + private buildMemorySummary(enhanced: EnhancedContext): string { + if (enhanced.memories.length === 0) { + return '无相关记忆'; + } + + const parts: string[] = [ + `已注入 ${enhanced.memories.length} 条相关记忆`, + `Token 消耗: L0=${enhanced.tokensByLevel.L0} L1=${enhanced.tokensByLevel.L1} L2=${enhanced.tokensByLevel.L2}`, + ]; + + return parts.join(' | '); + } + + private createCompactionSummary(messages: ChatMessage[]): string { + // Create a concise summary of compacted messages + const userMessages = messages.filter(m => m.role === 'user'); + const assistantMessages = messages.filter(m => m.role === 'assistant'); + + const topics = userMessages + .map(m => { + const text = m.content.trim(); + return text.length > 50 ? text.slice(0, 50) + '...' : text; + }) + .slice(0, 5); + + const summary = [ + `对话包含 ${messages.length} 条消息(${userMessages.length} 条用户消息,${assistantMessages.length} 条助手回复)`, + topics.length > 0 ? `讨论主题:${topics.join(';')}` : '', + ].filter(Boolean).join('\n'); + + return summary; + } +} + +// === Default Identity Content === + +const DEFAULT_SOUL = `# ZCLAW 人格 + +你是 ZCLAW(小龙虾),一个基于 OpenClaw 定制的中文 AI 助手。 + +## 核心特质 + +- **高效执行**: 你不只是出主意,你会真正动手完成任务 +- **中文优先**: 默认使用中文交流,必要时切换英文 +- **专业可靠**: 对技术问题给出精确答案,不确定时坦诚说明 +- **主动服务**: 定期检查任务清单,主动推进未完成的工作 + +## 语气 + +简洁、专业、友好。避免过度客套,直接给出有用信息。`; + +const DEFAULT_INSTRUCTIONS = `# Agent 指令 + +## 操作规范 + +1. 执行文件操作前,先确认目标路径 +2. 执行 Shell 命令前,评估安全风险 +3. 长时间任务需定期汇报进度 + +## 记忆管理 + +- 重要的用户偏好自动记录 +- 项目上下文保存到工作区 +- 对话结束时总结关键信息`; + +// === Singleton === + +let _instance: ContextBuilder | null = null; + +export function getContextBuilder(config?: Partial): ContextBuilder { + if (!_instance || config) { + _instance = new ContextBuilder(config); + } + return _instance; +} + +export function resetContextBuilder(): void { + _instance = null; +} diff --git a/desktop/src/lib/viking-client.ts b/desktop/src/lib/viking-client.ts new file mode 100644 index 0000000..7a2db32 --- /dev/null +++ b/desktop/src/lib/viking-client.ts @@ -0,0 +1,329 @@ +/** + * OpenViking HTTP API Client + * + * TypeScript client for communicating with the OpenViking Server. + * OpenViking is an open-source context database for AI agents by Volcengine. + * + * API Reference: https://github.com/volcengine/OpenViking + * Default server port: 1933 + */ + +// === Types === + +export interface VikingStatus { + status: 'ok' | 'error'; + version?: string; + uptime?: number; + workspace?: string; +} + +export interface VikingEntry { + uri: string; + name: string; + type: 'file' | 'directory'; + size?: number; + modifiedAt?: string; + abstract?: string; +} + +export interface VikingTreeNode { + uri: string; + name: string; + type: 'file' | 'directory'; + children?: VikingTreeNode[]; +} + +export type ContextLevel = 'L0' | 'L1' | 'L2'; + +export interface FindOptions { + scope?: string; + level?: ContextLevel; + limit?: number; + minScore?: number; +} + +export interface FindResult { + uri: string; + score: number; + content: string; + level: ContextLevel; + abstract?: string; + overview?: string; +} + +export interface GrepOptions { + uri?: string; + caseSensitive?: boolean; + limit?: number; +} + +export interface GrepResult { + uri: string; + line: number; + content: string; + matchStart: number; + matchEnd: number; +} + +export interface AddResourceOptions { + metadata?: Record; + wait?: boolean; +} + +export interface ExtractedMemory { + category: 'user_preference' | 'user_fact' | 'agent_lesson' | 'agent_pattern' | 'task'; + content: string; + tags: string[]; + importance: number; + suggestedUri: string; +} + +export interface SessionExtractionResult { + memories: ExtractedMemory[]; + summary: string; + tokensSaved?: number; +} + +export interface RetrievalTraceStep { + uri: string; + score: number; + action: 'entered' | 'skipped' | 'matched'; + level: ContextLevel; + childrenExplored?: number; +} + +export interface RetrievalTrace { + query: string; + steps: RetrievalTraceStep[]; + totalTokensUsed: number; + tokensByLevel: { L0: number; L1: number; L2: number }; + duration: number; +} + +// === Client Implementation === + +export class VikingHttpClient { + private baseUrl: string; + private timeout: number; + + constructor(baseUrl: string = 'http://localhost:1933', timeout: number = 30000) { + this.baseUrl = baseUrl.replace(/\/$/, ''); + this.timeout = timeout; + } + + // === Health & Status === + + async status(): Promise { + return this.get('/api/status'); + } + + async isAvailable(): Promise { + try { + const result = await this.status(); + return result.status === 'ok'; + } catch { + return false; + } + } + + // === Resource Management === + + async addResource( + uri: string, + content: string, + options?: AddResourceOptions + ): Promise<{ uri: string; status: string }> { + return this.post('/api/resources', { + uri, + content, + metadata: options?.metadata, + wait: options?.wait ?? false, + }); + } + + async removeResource(uri: string): Promise { + await this.delete(`/api/resources`, { uri }); + } + + async ls(path: string): Promise { + const result = await this.get<{ entries: VikingEntry[] }>('/api/ls', { path }); + return result.entries || []; + } + + async tree(path: string, depth: number = 2): Promise { + return this.get('/api/tree', { path, depth: String(depth) }); + } + + // === Retrieval === + + async find(query: string, options?: FindOptions): Promise { + const result = await this.post<{ results: FindResult[]; trace?: RetrievalTrace }>( + '/api/find', + { + query, + scope: options?.scope, + level: options?.level || 'L1', + limit: options?.limit || 10, + min_score: options?.minScore, + } + ); + return result.results || []; + } + + async findWithTrace( + query: string, + options?: FindOptions + ): Promise<{ results: FindResult[]; trace: RetrievalTrace }> { + return this.post('/api/find', { + query, + scope: options?.scope, + level: options?.level || 'L1', + limit: options?.limit || 10, + min_score: options?.minScore, + include_trace: true, + }); + } + + async grep( + pattern: string, + options?: GrepOptions + ): Promise { + const result = await this.post<{ results: GrepResult[] }>('/api/grep', { + pattern, + uri: options?.uri, + case_sensitive: options?.caseSensitive ?? false, + limit: options?.limit || 20, + }); + return result.results || []; + } + + // === Memory Operations === + + async readContent(uri: string, level: ContextLevel = 'L1'): Promise { + const result = await this.get<{ content: string }>('/api/read', { uri, level }); + return result.content || ''; + } + + // === Session Management === + + async extractMemories( + sessionContent: string, + agentId?: string + ): Promise { + return this.post('/api/session/extract', { + content: sessionContent, + agent_id: agentId, + }); + } + + async compactSession( + messages: Array<{ role: string; content: string }>, + ): Promise { + const result = await this.post<{ summary: string }>('/api/session/compact', { + messages, + }); + return result.summary; + } + + // === Internal HTTP Methods === + + private async get(path: string, params?: Record): Promise { + const url = new URL(`${this.baseUrl}${path}`); + if (params) { + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null) { + url.searchParams.set(key, value); + } + } + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(url.toString(), { + method: 'GET', + headers: { 'Accept': 'application/json' }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new VikingError( + `Viking API error: ${response.status} ${response.statusText}`, + response.status + ); + } + + return await response.json() as T; + } finally { + clearTimeout(timeoutId); + } + } + + private async post(path: string, body: unknown): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: JSON.stringify(body), + signal: controller.signal, + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ''); + throw new VikingError( + `Viking API error: ${response.status} ${response.statusText} - ${errorBody}`, + response.status + ); + } + + return await response.json() as T; + } finally { + clearTimeout(timeoutId); + } + } + + private async delete(path: string, body?: unknown): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + const response = await fetch(`${this.baseUrl}${path}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, + body: body ? JSON.stringify(body) : undefined, + signal: controller.signal, + }); + + if (!response.ok) { + throw new VikingError( + `Viking API error: ${response.status} ${response.statusText}`, + response.status + ); + } + } finally { + clearTimeout(timeoutId); + } + } +} + +// === Error Class === + +export class VikingError extends Error { + constructor( + message: string, + public readonly statusCode?: number + ) { + super(message); + this.name = 'VikingError'; + } +} diff --git a/desktop/src/lib/viking-local.ts b/desktop/src/lib/viking-local.ts new file mode 100644 index 0000000..5c7bed1 --- /dev/null +++ b/desktop/src/lib/viking-local.ts @@ -0,0 +1,144 @@ +/** + * Viking Local Adapter - Tauri Sidecar Integration + * + * Provides local memory operations through the OpenViking CLI sidecar. + * This eliminates the need for a Python server dependency. + */ + +import { invoke } from '@tauri-apps/api/core'; + +// === Types === + +export interface LocalVikingStatus { + available: boolean; + version?: string; + dataDir?: string; + error?: string; +} + +export interface LocalVikingResource { + uri: string; + name: string; + type: string; + size?: number; + modifiedAt?: string; +} + +export interface LocalVikingFindResult { + uri: string; + score: number; + content: string; + level: string; + overview?: string; +} + +export interface LocalVikingGrepResult { + uri: string; + line: number; + content: string; + matchStart: number; + matchEnd: number; +} + +export interface LocalVikingAddResult { + uri: string; + status: string; +} + +// === Local Viking Client === + +export class VikingLocalClient { + private available: boolean | null = null; + + async isAvailable(): Promise { + if (this.available !== null) { + return this.available; + } + + try { + const status = await this.status(); + this.available = status.available; + return status.available; + } catch { + this.available = false; + return false; + } + } + + async status(): Promise { + return await invoke('viking_status'); + } + + async addResource( + uri: string, + content: string + ): Promise { + // For small content, use inline; for large content. use file-based + if (content.length < 10000) { + return await invoke('viking_add_inline', { uri, content }); + } else { + return await invoke('viking_add', { uri, content }); + } + } + + async find( + query: string, + options?: { + scope?: string; + limit?: number; + } + ): Promise { + return await invoke('viking_find', { + query, + scope: options?.scope, + limit: options?.limit, + }); + } + + async grep( + pattern: string, + options?: { + uri?: string; + caseSensitive?: boolean; + limit?: number; + } + ): Promise { + return await invoke('viking_grep', { + pattern, + uri: options?.uri, + caseSensitive: options?.caseSensitive, + limit: options?.limit, + }); + } + + async ls(path: string): Promise { + return await invoke('viking_ls', { path }); + } + + async readContent(uri: string, level?: string): Promise { + return await invoke('viking_read', { uri, level }); + } + + async removeResource(uri: string): Promise { + await invoke('viking_remove', { uri }); + } + + async tree(path: string, depth?: number): Promise { + return await invoke('viking_tree', { path, depth }); + } +} + +// === Singleton === + +let _localClient: VikingLocalClient | null; + +export function getVikingLocalClient(): VikingLocalClient { + if (!_localClient) { + _localClient = new VikingLocalClient(); + } + return _localClient; +} + +export function resetVikingLocalClient(): void { + _localClient = null; +} diff --git a/desktop/src/lib/viking-memory-adapter.ts b/desktop/src/lib/viking-memory-adapter.ts new file mode 100644 index 0000000..b94cca6 --- /dev/null +++ b/desktop/src/lib/viking-memory-adapter.ts @@ -0,0 +1,408 @@ +/** + * VikingMemoryAdapter - Bridges VikingAdapter to MemoryManager Interface + * + * This adapter allows the existing MemoryPanel to use OpenViking as a backend + * while maintaining compatibility with the existing MemoryManager interface. + * + * Features: + * - Implements MemoryManager interface + * - Falls back to local MemoryManager when OpenViking unavailable + * - Supports both sidecar and remote modes + */ + +import { + getMemoryManager, + type MemoryEntry, + type MemoryType, + type MemorySource, + type MemorySearchOptions, + type MemoryStats, +} from './agent-memory'; + +import { + getVikingAdapter, + type MemoryResult, + type VikingMode, +} from './viking-adapter'; + +// === Types === + +export interface VikingMemoryConfig { + enabled: boolean; + mode: VikingMode | 'auto'; + fallbackToLocal: boolean; +} + +const DEFAULT_CONFIG: VikingMemoryConfig = { + enabled: true, + mode: 'auto', + fallbackToLocal: true, +}; + +// === VikingMemoryAdapter Implementation === + +/** + * VikingMemoryAdapter implements the MemoryManager interface + * using OpenViking as the backend with optional fallback to localStorage. + */ +export class VikingMemoryAdapter { + private config: VikingMemoryConfig; + private vikingAvailable: boolean | null = null; + private lastCheckTime: number = 0; + private static CHECK_INTERVAL = 30000; // 30 seconds + + constructor(config?: Partial) { + this.config = { ...DEFAULT_CONFIG, ...config }; + } + + // === Availability Check === + + private async isVikingAvailable(): Promise { + const now = Date.now(); + if (this.vikingAvailable !== null && now - this.lastCheckTime < VikingMemoryAdapter.CHECK_INTERVAL) { + return this.vikingAvailable; + } + + try { + const viking = getVikingAdapter(); + const connected = await viking.isConnected(); + this.vikingAvailable = connected; + this.lastCheckTime = now; + return connected; + } catch { + this.vikingAvailable = false; + this.lastCheckTime = now; + return false; + } + } + + private async getBackend(): Promise<'viking' | 'local'> { + if (!this.config.enabled) { + return 'local'; + } + + const available = await this.isVikingAvailable(); + if (available) { + return 'viking'; + } + + if (this.config.fallbackToLocal) { + console.log('[VikingMemoryAdapter] OpenViking unavailable, using local fallback'); + return 'local'; + } + + throw new Error('OpenViking unavailable and fallback disabled'); + } + + // === MemoryManager Interface Implementation === + + async save( + entry: Omit + ): Promise { + const backend = await this.getBackend(); + + if (backend === 'viking') { + const viking = getVikingAdapter(); + const result = await this.saveToViking(viking, entry); + return result; + } + + return getMemoryManager().save(entry); + } + + private async saveToViking( + viking: ReturnType, + entry: Omit + ): Promise { + const now = new Date().toISOString(); + + let result; + const tags = entry.tags.join(','); + + switch (entry.type) { + case 'fact': + result = await viking.saveUserFact('general', entry.content, entry.tags); + break; + case 'preference': + result = await viking.saveUserPreference(tags || 'preference', entry.content); + break; + case 'lesson': + result = await viking.saveAgentLesson(entry.agentId, entry.content, entry.tags); + break; + case 'context': + result = await viking.saveAgentPattern(entry.agentId, `[Context] ${entry.content}`, entry.tags); + break; + case 'task': + result = await viking.saveAgentPattern(entry.agentId, `[Task] ${entry.content}`, entry.tags); + break; + default: + result = await viking.saveUserFact('general', entry.content, entry.tags); + } + + return { + id: result.uri, + agentId: entry.agentId, + content: entry.content, + type: entry.type, + importance: entry.importance, + source: entry.source, + tags: entry.tags, + createdAt: now, + lastAccessedAt: now, + accessCount: 0, + }; + } + + async search(query: string, options?: MemorySearchOptions): Promise { + const backend = await this.getBackend(); + + if (backend === 'viking') { + const viking = getVikingAdapter(); + return this.searchViking(viking, query, options); + } + + return getMemoryManager().search(query, options); + } + + private async searchViking( + viking: ReturnType, + query: string, + options?: MemorySearchOptions + ): Promise { + const results: MemoryEntry[] = []; + const agentId = options?.agentId || 'zclaw-main'; + + // Search user memories + const userResults = await viking.searchUserMemories(query, options?.limit || 10); + for (const r of userResults) { + results.push(this.memoryResultToEntry(r, agentId)); + } + + // Search agent memories + const agentResults = await viking.searchAgentMemories(agentId, query, options?.limit || 10); + for (const r of agentResults) { + results.push(this.memoryResultToEntry(r, agentId)); + } + + // Filter by type if specified + if (options?.type) { + return results.filter(r => r.type === options.type); + } + + // Sort by score (desc) and limit + return results.slice(0, options?.limit || 10); + } + + private memoryResultToEntry(result: MemoryResult, agentId: string): MemoryEntry { + const type = this.mapCategoryToType(result.category); + return { + id: result.uri, + agentId, + content: result.content, + type, + importance: Math.round(result.score * 10), + source: 'auto' as MemorySource, + tags: result.tags || [], + createdAt: new Date().toISOString(), + lastAccessedAt: new Date().toISOString(), + accessCount: 0, + }; + } + + private mapCategoryToType(category: string): MemoryType { + const categoryLower = category.toLowerCase(); + if (categoryLower.includes('prefer') || categoryLower.includes('偏好')) { + return 'preference'; + } + if (categoryLower.includes('fact') || categoryLower.includes('事实')) { + return 'fact'; + } + if (categoryLower.includes('lesson') || categoryLower.includes('经验')) { + return 'lesson'; + } + if (categoryLower.includes('context') || categoryLower.includes('上下文')) { + return 'context'; + } + if (categoryLower.includes('task') || categoryLower.includes('任务')) { + return 'task'; + } + return 'fact'; + } + + async getAll(agentId: string, options?: { type?: MemoryType; limit?: number }): Promise { + const backend = await this.getBackend(); + + if (backend === 'viking') { + const viking = getVikingAdapter(); + const entries = await viking.browseMemories(`viking://agent/${agentId}/memories`); + + return entries + .filter(_e => !options?.type || true) // TODO: filter by type + .slice(0, options?.limit || 50) + .map(e => ({ + id: e.uri, + agentId, + content: e.name, // Placeholder - would need to fetch full content + type: 'fact' as MemoryType, + importance: 5, + source: 'auto' as MemorySource, + tags: [], + createdAt: e.modifiedAt || new Date().toISOString(), + lastAccessedAt: new Date().toISOString(), + accessCount: 0, + })); + } + + return getMemoryManager().getAll(agentId, options); + } + + async get(id: string): Promise { + const backend = await this.getBackend(); + + if (backend === 'viking') { + const viking = getVikingAdapter(); + try { + const content = await viking.getIdentityFromViking('zclaw-main', id); + return { + id, + agentId: 'zclaw-main', + content, + type: 'fact', + importance: 5, + source: 'auto', + tags: [], + createdAt: new Date().toISOString(), + lastAccessedAt: new Date().toISOString(), + accessCount: 0, + }; + } catch { + return null; + } + } + + return getMemoryManager().get(id); + } + + async forget(id: string): Promise { + const backend = await this.getBackend(); + + if (backend === 'viking') { + const viking = getVikingAdapter(); + await viking.deleteMemory(id); + return; + } + + return getMemoryManager().forget(id); + } + + async prune(options: { + maxAgeDays?: number; + minImportance?: number; + agentId?: string; + }): Promise { + const backend = await this.getBackend(); + + if (backend === 'viking') { + // OpenViking handles pruning internally + // For now, return 0 (no items pruned) + console.log('[VikingMemoryAdapter] Pruning delegated to OpenViking'); + return 0; + } + + return getMemoryManager().prune(options); + } + + async exportToMarkdown(agentId: string): Promise { + const backend = await this.getBackend(); + + if (backend === 'viking') { + const entries = await this.getAll(agentId, { limit: 100 }); + // Generate markdown from entries + const lines = [ + `# Agent Memory Export (OpenViking)`, + '', + `> Agent: ${agentId}`, + `> Exported: ${new Date().toISOString()}`, + `> Total entries: ${entries.length}`, + '', + ]; + + for (const entry of entries) { + lines.push(`- [${entry.type}] ${entry.content}`); + } + + return lines.join('\n'); + } + + return getMemoryManager().exportToMarkdown(agentId); + } + + async stats(agentId?: string): Promise { + const backend = await this.getBackend(); + + if (backend === 'viking') { + const viking = getVikingAdapter(); + try { + const vikingStats = await viking.getMemoryStats(agentId || 'zclaw-main'); + return { + totalEntries: vikingStats.totalEntries, + byType: vikingStats.categories, + byAgent: { [agentId || 'zclaw-main']: vikingStats.agentMemories }, + oldestEntry: null, + newestEntry: null, + }; + } catch { + // Fall back to local stats + return getMemoryManager().stats(agentId); + } + } + + return getMemoryManager().stats(agentId); + } + + async updateImportance(id: string, importance: number): Promise { + const backend = await this.getBackend(); + + if (backend === 'viking') { + // OpenViking handles importance internally via access patterns + console.log(`[VikingMemoryAdapter] Importance update for ${id}: ${importance}`); + return; + } + + return getMemoryManager().updateImportance(id, importance); + } + + // === Configuration === + + updateConfig(config: Partial): void { + this.config = { ...this.config, ...config }; + // Reset availability check when config changes + this.vikingAvailable = null; + } + + getConfig(): Readonly { + return { ...this.config }; + } + + getMode(): 'viking' | 'local' | 'unavailable' { + if (!this.config.enabled) return 'local'; + if (this.vikingAvailable === true) return 'viking'; + if (this.vikingAvailable === false && this.config.fallbackToLocal) return 'local'; + return 'unavailable'; + } +} + +// === Singleton === + +let _instance: VikingMemoryAdapter | null = null; + +export function getVikingMemoryAdapter(config?: Partial): VikingMemoryAdapter { + if (!_instance || config) { + _instance = new VikingMemoryAdapter(config); + } + return _instance; +} + +export function resetVikingMemoryAdapter(): void { + _instance = null; +} diff --git a/tests/desktop/viking-adapter.test.ts b/tests/desktop/viking-adapter.test.ts new file mode 100644 index 0000000..4f4a617 --- /dev/null +++ b/tests/desktop/viking-adapter.test.ts @@ -0,0 +1,446 @@ +/** + * Tests for VikingAdapter and ContextBuilder + * + * Tests the ZCLAW ↔ OpenViking integration layer with mocked HTTP responses. + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { VikingHttpClient, VikingError } from '../../desktop/src/lib/viking-client'; +import { + VikingAdapter, + VIKING_NS, + resetVikingAdapter, +} from '../../desktop/src/lib/viking-adapter'; +import { + ContextBuilder, + resetContextBuilder, +} from '../../desktop/src/lib/context-builder'; + +// === Mock fetch globally === + +const mockFetch = vi.fn(); +vi.stubGlobal('fetch', mockFetch); + +function mockJsonResponse(data: unknown, status = 200) { + return { + ok: status >= 200 && status < 300, + status, + statusText: status === 200 ? 'OK' : 'Error', + json: () => Promise.resolve(data), + text: () => Promise.resolve(JSON.stringify(data)), + }; +} + +// === VikingHttpClient Tests === + +describe('VikingHttpClient', () => { + let client: VikingHttpClient; + + beforeEach(() => { + client = new VikingHttpClient('http://localhost:1933'); + mockFetch.mockReset(); + }); + + describe('status', () => { + it('returns server status on success', async () => { + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ status: 'ok', version: '0.1.18' }) + ); + + const result = await client.status(); + expect(result.status).toBe('ok'); + expect(result.version).toBe('0.1.18'); + }); + + it('throws VikingError on server error', async () => { + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ error: 'internal' }, 500) + ); + + await expect(client.status()).rejects.toThrow(VikingError); + }); + }); + + describe('isAvailable', () => { + it('returns true when server responds ok', async () => { + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ status: 'ok' }) + ); + + expect(await client.isAvailable()).toBe(true); + }); + + it('returns false when server is down', async () => { + mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED')); + + expect(await client.isAvailable()).toBe(false); + }); + }); + + describe('find', () => { + it('sends correct find request', async () => { + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ + results: [ + { uri: 'viking://user/memories/preferences/lang', score: 0.9, content: '中文', level: 'L1' }, + ], + }) + ); + + const results = await client.find('language preference', { + scope: 'viking://user/memories/', + level: 'L1', + limit: 10, + }); + + expect(results).toHaveLength(1); + expect(results[0].score).toBe(0.9); + expect(results[0].content).toBe('中文'); + + // Verify request body + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.query).toBe('language preference'); + expect(callBody.scope).toBe('viking://user/memories/'); + expect(callBody.level).toBe('L1'); + }); + }); + + describe('addResource', () => { + it('sends correct add request', async () => { + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ uri: 'viking://user/memories/preferences/lang', status: 'ok' }) + ); + + const result = await client.addResource( + 'viking://user/memories/preferences/lang', + '用户偏好中文回复', + { metadata: { type: 'preference' }, wait: true } + ); + + expect(result.status).toBe('ok'); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.uri).toBe('viking://user/memories/preferences/lang'); + expect(callBody.content).toBe('用户偏好中文回复'); + expect(callBody.wait).toBe(true); + }); + }); + + describe('extractMemories', () => { + it('sends session content for extraction', async () => { + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ + memories: [ + { + category: 'user_preference', + content: '用户喜欢简洁回答', + tags: ['communication'], + importance: 8, + suggestedUri: 'viking://user/memories/preferences/communication', + }, + { + category: 'agent_lesson', + content: '使用飞书API前需验证token', + tags: ['feishu', 'api'], + importance: 7, + suggestedUri: 'viking://agent/zclaw/memories/lessons/feishu_token', + }, + ], + summary: '讨论了飞书集成和回复风格偏好', + }) + ); + + const result = await client.extractMemories( + '[user]: 帮我集成飞书API\n[assistant]: 好的,我来...', + 'zclaw-main' + ); + + expect(result.memories).toHaveLength(2); + expect(result.memories[0].category).toBe('user_preference'); + expect(result.memories[1].category).toBe('agent_lesson'); + expect(result.summary).toContain('飞书'); + }); + }); +}); + +// === VikingAdapter Tests === + +describe('VikingAdapter', () => { + let adapter: VikingAdapter; + + beforeEach(() => { + resetVikingAdapter(); + adapter = new VikingAdapter({ serverUrl: 'http://localhost:1933' }); + mockFetch.mockReset(); + }); + + describe('saveUserPreference', () => { + it('saves to correct viking:// URI', async () => { + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ uri: 'viking://user/memories/preferences/language', status: 'ok' }) + ); + + await adapter.saveUserPreference('language', '中文优先'); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.uri).toBe('viking://user/memories/preferences/language'); + expect(callBody.content).toBe('中文优先'); + expect(callBody.metadata.type).toBe('preference'); + }); + }); + + describe('saveAgentLesson', () => { + it('saves to agent-specific lessons URI', async () => { + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ uri: 'viking://agent/zclaw-main/memories/lessons_learned/123', status: 'ok' }) + ); + + await adapter.saveAgentLesson('zclaw-main', '飞书API需要先验证token', ['feishu', 'auth']); + + const callBody = JSON.parse(mockFetch.mock.calls[0][1].body); + expect(callBody.uri).toContain('viking://agent/zclaw-main/memories/lessons_learned/'); + expect(callBody.content).toBe('飞书API需要先验证token'); + expect(callBody.metadata.tags).toBe('feishu,auth'); + }); + }); + + describe('buildEnhancedContext', () => { + it('performs L0 scan then L1 load', async () => { + // Mock L0 user memories search + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ + results: [ + { uri: 'viking://user/memories/preferences/lang', score: 0.85, content: '中文', level: 'L0' }, + { uri: 'viking://user/memories/facts/project', score: 0.7, content: '飞书集成', level: 'L0' }, + ], + }) + ); + // Mock L0 agent memories search + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ + results: [ + { uri: 'viking://agent/zclaw/memories/lessons/feishu', score: 0.8, content: 'API认证', level: 'L0' }, + ], + }) + ); + // Mock L1 reads for relevant items (score >= 0.5) + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ content: '用户偏好中文回复,简洁风格' }) + ); + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ content: '飞书API需要先验证app_id和app_secret' }) + ); + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ content: '调用飞书API前确保token未过期' }) + ); + + const result = await adapter.buildEnhancedContext('帮我处理飞书集成', 'zclaw'); + + expect(result.memories.length).toBeGreaterThan(0); + expect(result.totalTokens).toBeGreaterThan(0); + expect(result.systemPromptAddition).toContain('记忆'); + }); + + it('returns empty context when Viking is unavailable', async () => { + // Both L0 searches fail + mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await adapter.buildEnhancedContext('test message', 'zclaw'); + + expect(result.memories).toHaveLength(0); + expect(result.totalTokens).toBe(0); + }); + }); + + describe('extractAndSaveMemories', () => { + it('extracts and saves memories to correct categories', async () => { + // Mock extraction call + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ + memories: [ + { + category: 'user_preference', + content: '用户喜欢TypeScript', + tags: ['coding'], + importance: 8, + suggestedUri: 'viking://user/memories/preferences/coding', + }, + { + category: 'agent_lesson', + content: 'Vitest配置需要在tsconfig中设置paths', + tags: ['testing', 'vitest'], + importance: 7, + suggestedUri: 'viking://agent/zclaw/memories/lessons/vitest_config', + }, + ], + summary: '讨论了TypeScript测试配置', + }) + ); + // Mock save calls (2 saves) + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ uri: 'viking://user/memories/preferences/coding', status: 'ok' }) + ); + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ uri: 'viking://agent/zclaw/memories/lessons/123', status: 'ok' }) + ); + + const result = await adapter.extractAndSaveMemories( + [ + { role: 'user', content: '帮我配置Vitest' }, + { role: 'assistant', content: '好的,需要在tsconfig中...' }, + ], + 'zclaw' + ); + + expect(result.saved).toBe(2); + expect(result.userMemories).toBe(1); + expect(result.agentMemories).toBe(1); + }); + + it('handles extraction failure gracefully', async () => { + mockFetch.mockRejectedValueOnce(new Error('Server error')); + + const result = await adapter.extractAndSaveMemories( + [{ role: 'user', content: 'test' }], + 'zclaw' + ); + + expect(result.saved).toBe(0); + }); + }); + + describe('VIKING_NS', () => { + it('generates correct namespace URIs', () => { + expect(VIKING_NS.userPreferences).toBe('viking://user/memories/preferences'); + expect(VIKING_NS.agentLessons('zclaw')).toBe('viking://agent/zclaw/memories/lessons_learned'); + expect(VIKING_NS.agentIdentity('zclaw')).toBe('viking://agent/zclaw/identity'); + }); + }); +}); + +// === ContextBuilder Tests === + +describe('ContextBuilder', () => { + let builder: ContextBuilder; + + beforeEach(() => { + resetContextBuilder(); + resetVikingAdapter(); + builder = new ContextBuilder({ enabled: true }); + mockFetch.mockReset(); + }); + + describe('buildContext', () => { + it('returns empty prompt when disabled', async () => { + builder.updateConfig({ enabled: false }); + + const result = await builder.buildContext('test', 'zclaw'); + + expect(result.systemPrompt).toBe(''); + expect(result.tokensUsed).toBe(0); + }); + + it('returns empty prompt when Viking is unavailable', async () => { + // isAvailable check fails + mockFetch.mockRejectedValue(new Error('ECONNREFUSED')); + + const result = await builder.buildContext('test', 'zclaw'); + + expect(result.memoriesInjected).toBe(0); + }); + }); + + describe('checkAndCompact', () => { + it('returns null when under threshold', async () => { + const messages = [ + { role: 'user' as const, content: '你好' }, + { role: 'assistant' as const, content: '你好!有什么可以帮你?' }, + ]; + + const result = await builder.checkAndCompact(messages, 'zclaw'); + expect(result).toBeNull(); + }); + + it('compacts and flushes memory when over threshold', async () => { + // Create messages that exceed the threshold + const longContent = '这是一段很长的对话内容。'.repeat(500); + const messages = [ + { role: 'user' as const, content: longContent }, + { role: 'assistant' as const, content: longContent }, + { role: 'user' as const, content: longContent }, + { role: 'assistant' as const, content: longContent }, + { role: 'user' as const, content: '最近的消息1' }, + { role: 'assistant' as const, content: '最近的消息2' }, + { role: 'user' as const, content: '最近的消息3' }, + { role: 'assistant' as const, content: '最近的消息4' }, + { role: 'user' as const, content: '最近的消息5' }, + { role: 'assistant' as const, content: '最近的回复5' }, + ]; + + // Mock memory flush extraction call + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ + memories: [ + { + category: 'user_fact', + content: '讨论了长文本处理', + tags: ['text'], + importance: 5, + suggestedUri: 'viking://user/memories/facts/text', + }, + ], + summary: '长文本处理讨论', + }) + ); + // Mock save call for flushed memory + mockFetch.mockResolvedValueOnce( + mockJsonResponse({ uri: 'viking://user/memories/facts/text/123', status: 'ok' }) + ); + + builder.updateConfig({ compactionThresholdTokens: 100 }); // Low threshold for test + const result = await builder.checkAndCompact(messages, 'zclaw'); + + expect(result).not.toBeNull(); + expect(result!.compactedMessages.length).toBeLessThan(messages.length); + expect(result!.compactedMessages[0].content).toContain('对话摘要'); + // Recent messages preserved + expect(result!.compactedMessages.some(m => m.content === '最近的回复5')).toBe(true); + }); + }); + + describe('extractMemoriesFromConversation', () => { + it('skips extraction when disabled', async () => { + builder.updateConfig({ autoExtractOnComplete: false }); + + const result = await builder.extractMemoriesFromConversation( + [ + { role: 'user', content: '你好' }, + { role: 'assistant', content: '你好!' }, + ], + 'zclaw' + ); + + expect(result.saved).toBe(0); + }); + + it('skips extraction for short conversations', async () => { + const result = await builder.extractMemoriesFromConversation( + [{ role: 'user', content: '你好' }], + 'zclaw' + ); + + expect(result.saved).toBe(0); + }); + }); + + describe('configuration', () => { + it('can update and read config', () => { + builder.updateConfig({ maxMemoryTokens: 4000, enabled: false }); + + const config = builder.getConfig(); + expect(config.maxMemoryTokens).toBe(4000); + expect(config.enabled).toBe(false); + expect(builder.isEnabled()).toBe(false); + }); + }); +});