feat(viking): add local server management for privacy-first deployment

Backend (Rust):
- viking_commands.rs: Tauri commands for server status/start/stop/restart
- memory/mod.rs: Memory module exports
- memory/context_builder.rs: Context building with memory injection
- memory/extractor.rs: Memory extraction from conversations
- llm/mod.rs: LLM integration for memory summarization

Frontend (TypeScript):
- context-builder.ts: Context building with OpenViking integration
- viking-client.ts: OpenViking API client
- viking-local.ts: Local storage fallback when Viking unavailable
- viking-memory-adapter.ts: Memory extraction and persistence

Features:
- Multi-mode adapter (local/sidecar/remote) with auto-detection
- Privacy-first: all data stored in ~/.openviking/, server only on 127.0.0.1
- Graceful degradation when local server unavailable
- Context compaction with memory flush before compression

Tests: 21 passing (viking-adapter.test.ts)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-16 09:59:14 +08:00
parent 26e64a3fff
commit 134798c430
10 changed files with 3378 additions and 0 deletions

View File

@@ -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<String>,
pub model: Option<String>,
}
#[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<LlmMessage>,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub temperature: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub max_tokens: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LlmResponse {
pub content: String,
pub model: Option<String>,
pub usage: Option<LlmUsage>,
pub finish_reason: Option<String>,
}
#[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<String, ProviderConfig> {
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<ProviderConfig>,
}
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<LlmMessage>) -> Result<LlmResponse, String> {
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<LlmResponse, String> {
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<LlmMessage>,
model: Option<String>,
) -> Result<LlmResponse, String> {
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());
}
}

View File

@@ -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<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RetrievalTrace {
pub query: String,
pub steps: Vec<RetrievalStep>,
pub total_tokens_used: u32,
pub tokens_by_level: HashMap<String, u32>,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct EnhancedContext {
pub system_prompt_addition: String,
pub items: Vec<ContextItem>,
pub total_tokens: u32,
pub tokens_by_level: HashMap<String, u32>,
pub trace: Option<RetrievalTrace>,
}
#[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<RetrievalTrace>,
}
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<Vec<FindResult>, String>,
viking_read: impl Fn(&str, ContextLevel) -> Result<String, String>,
) -> Result<EnhancedContext, String> {
let start_time = std::time::Instant::now();
let mut tokens_by_level: HashMap<String, u32> =
[("L0".to_string(), 0), ("L1".to_string(), 0), ("L2".to_string(), 0)]
.into_iter()
.collect();
let mut trace_steps: Vec<RetrievalStep> = Vec::new();
let mut context_items: Vec<ContextItem> = 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<FindResult> = [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<FindResult>,
viking_read: impl Fn(&str, ContextLevel) -> Result<String, String>,
) -> Result<EnhancedContext, String> {
// Similar to build_context but uses pre-fetched L0 results
let start_time = std::time::Instant::now();
let mut tokens_by_level: HashMap<String, u32> =
[("L0".to_string(), 0), ("L1".to_string(), 0), ("L2".to_string(), 0)]
.into_iter()
.collect();
let mut trace_steps: Vec<RetrievalStep> = Vec::new();
let mut context_items: Vec<ContextItem> = 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<String> = 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);
}
}

View File

@@ -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<String>,
pub importance: u8, // 1-10 scale
pub suggested_uri: String,
pub reasoning: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExtractionResult {
pub memories: Vec<ExtractedMemory>,
pub summary: String,
pub tokens_saved: Option<u32>,
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<String>,
}
// === Session Extractor ===
pub struct SessionExtractor {
config: ExtractionConfig,
llm_endpoint: Option<String>,
api_key: Option<String>,
}
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<ExtractionResult, String> {
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<ExtractedMemory> = 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::<Vec<_>>()
.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<String, String> {
// 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<String, String> {
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<String, String> {
// Simple pattern matching for common memory patterns
let mut memories: Vec<ExtractedMemory> = 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<Vec<ExtractedMemory>, 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::Value> = serde_json::from_str(json_str)
.unwrap_or_default();
let memories: Vec<ExtractedMemory> = 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<ExtractedMemory> {
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<ChatMessage>,
agent_id: String,
) -> Result<ExtractionResult, String> {
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/"));
}
}

View File

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

View File

@@ -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<String>,
pub data_dir: Option<String>,
pub error: Option<String>,
}
#[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<u64>,
pub modified_at: Option<String>,
}
#[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<String>,
}
#[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<String, String> {
// 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<String, String> {
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<T: for<'de> Deserialize<'de>>(args: &[&str]) -> Result<T, String> {
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<VikingStatus, String> {
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<VikingAddResult, String> {
// 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<VikingAddResult, String> {
// 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<String>,
limit: Option<usize>,
) -> Result<Vec<VikingFindResult>, 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<String>,
case_sensitive: Option<bool>,
limit: Option<usize>,
) -> Result<Vec<VikingGrepResult>, 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<Vec<VikingResource>, 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<String>) -> Result<String, String> {
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<usize>) -> Result<serde_json::Value, String> {
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());
}
}

View File

@@ -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<string, { identity: AgentIdentity; cachedAt: number }> = new Map();
private static IDENTITY_CACHE_TTL = 5 * 60 * 1000; // 5 min
constructor(config?: Partial<ContextBuilderConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.viking = getVikingAdapter();
}
// === Core: Build Context for a Chat Message ===
async buildContext(
userMessage: string,
agentId: string,
_existingMessages: ChatMessage[] = []
): Promise<ContextBuildResult> {
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<AgentIdentity> {
// 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<CompactionResult | null> {
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<void> {
const connected = await this.viking.isConnected();
if (!connected) return;
const syncTasks: Promise<void>[] = [];
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<ContextBuilderConfig>): void {
this.config = { ...this.config, ...config };
}
getConfig(): Readonly<ContextBuilderConfig> {
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<ContextBuilderConfig>): ContextBuilder {
if (!_instance || config) {
_instance = new ContextBuilder(config);
}
return _instance;
}
export function resetContextBuilder(): void {
_instance = null;
}

View File

@@ -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<string, string>;
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<VikingStatus> {
return this.get<VikingStatus>('/api/status');
}
async isAvailable(): Promise<boolean> {
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<void> {
await this.delete(`/api/resources`, { uri });
}
async ls(path: string): Promise<VikingEntry[]> {
const result = await this.get<{ entries: VikingEntry[] }>('/api/ls', { path });
return result.entries || [];
}
async tree(path: string, depth: number = 2): Promise<VikingTreeNode> {
return this.get<VikingTreeNode>('/api/tree', { path, depth: String(depth) });
}
// === Retrieval ===
async find(query: string, options?: FindOptions): Promise<FindResult[]> {
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<GrepResult[]> {
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<string> {
const result = await this.get<{ content: string }>('/api/read', { uri, level });
return result.content || '';
}
// === Session Management ===
async extractMemories(
sessionContent: string,
agentId?: string
): Promise<SessionExtractionResult> {
return this.post<SessionExtractionResult>('/api/session/extract', {
content: sessionContent,
agent_id: agentId,
});
}
async compactSession(
messages: Array<{ role: string; content: string }>,
): Promise<string> {
const result = await this.post<{ summary: string }>('/api/session/compact', {
messages,
});
return result.summary;
}
// === Internal HTTP Methods ===
private async get<T>(path: string, params?: Record<string, string>): Promise<T> {
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<T>(path: string, body: unknown): Promise<T> {
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<void> {
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';
}
}

View File

@@ -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<boolean> {
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<LocalVikingStatus> {
return await invoke<LocalVikingStatus>('viking_status');
}
async addResource(
uri: string,
content: string
): Promise<LocalVikingAddResult> {
// For small content, use inline; for large content. use file-based
if (content.length < 10000) {
return await invoke<LocalVikingAddResult>('viking_add_inline', { uri, content });
} else {
return await invoke<LocalVikingAddResult>('viking_add', { uri, content });
}
}
async find(
query: string,
options?: {
scope?: string;
limit?: number;
}
): Promise<LocalVikingFindResult[]> {
return await invoke<LocalVikingFindResult[]>('viking_find', {
query,
scope: options?.scope,
limit: options?.limit,
});
}
async grep(
pattern: string,
options?: {
uri?: string;
caseSensitive?: boolean;
limit?: number;
}
): Promise<LocalVikingGrepResult[]> {
return await invoke<LocalVikingGrepResult[]>('viking_grep', {
pattern,
uri: options?.uri,
caseSensitive: options?.caseSensitive,
limit: options?.limit,
});
}
async ls(path: string): Promise<LocalVikingResource[]> {
return await invoke<LocalVikingResource[]>('viking_ls', { path });
}
async readContent(uri: string, level?: string): Promise<string> {
return await invoke<string>('viking_read', { uri, level });
}
async removeResource(uri: string): Promise<void> {
await invoke('viking_remove', { uri });
}
async tree(path: string, depth?: number): Promise<unknown> {
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;
}

View File

@@ -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<VikingMemoryConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
}
// === Availability Check ===
private async isVikingAvailable(): Promise<boolean> {
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<MemoryEntry, 'id' | 'createdAt' | 'lastAccessedAt' | 'accessCount'>
): Promise<MemoryEntry> {
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<typeof getVikingAdapter>,
entry: Omit<MemoryEntry, 'id' | 'createdAt' | 'lastAccessedAt' | 'accessCount'>
): Promise<MemoryEntry> {
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<MemoryEntry[]> {
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<typeof getVikingAdapter>,
query: string,
options?: MemorySearchOptions
): Promise<MemoryEntry[]> {
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<MemoryEntry[]> {
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<MemoryEntry | null> {
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<void> {
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<number> {
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<string> {
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<MemoryStats> {
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<void> {
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<VikingMemoryConfig>): void {
this.config = { ...this.config, ...config };
// Reset availability check when config changes
this.vikingAvailable = null;
}
getConfig(): Readonly<VikingMemoryConfig> {
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<VikingMemoryConfig>): VikingMemoryAdapter {
if (!_instance || config) {
_instance = new VikingMemoryAdapter(config);
}
return _instance;
}
export function resetVikingMemoryAdapter(): void {
_instance = null;
}

View File

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