From c8202d04e015a92ff0e9a3bee81f3c79e491257c Mon Sep 17 00:00:00 2001 From: iven Date: Mon, 16 Mar 2026 08:14:44 +0800 Subject: [PATCH] feat(viking): add local server management for privacy-first deployment - Add viking_server.rs (Rust) for managing local OpenViking server process - Add viking-server-manager.ts (TypeScript) for server control from UI - Update VikingAdapter to support 'local' mode with auto-start capability - Update documentation for local deployment mode Key features: - Auto-start local server when needed - All data stays in ~/.openviking/ (privacy-first) - Server listens only on 127.0.0.1 - Graceful fallback to remote/localStorage modes Co-Authored-By: Claude Opus 4.6 --- desktop/src-tauri/Cargo.toml | 5 + desktop/src-tauri/src/lib.rs | 31 +- desktop/src-tauri/src/viking_server.rs | 295 +++++++++ desktop/src/lib/viking-adapter.ts | 734 +++++++++++++++++++++++ desktop/src/lib/viking-server-manager.ts | 231 +++++++ docs/OPENVIKING_INTEGRATION.md | 426 +++++++++++++ 6 files changed, 1721 insertions(+), 1 deletion(-) create mode 100644 desktop/src-tauri/src/viking_server.rs create mode 100644 desktop/src/lib/viking-adapter.ts create mode 100644 desktop/src/lib/viking-server-manager.ts create mode 100644 docs/OPENVIKING_INTEGRATION.md diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 63b0c57..52657a8 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -22,4 +22,9 @@ tauri = { version = "2", features = [] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.11", features = ["json", "blocking"] } +chrono = "0.4" +regex = "1" +dirs = "5" diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 2249fc4..0c14596 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -3,6 +3,15 @@ // - Port: 4200 (was 18789) // - Binary: openfang (was openclaw) // - Config: ~/.openfang/openfang.toml (was ~/.openclaw/openclaw.json) + +// Viking CLI sidecar module for local memory operations +mod viking_commands; +mod viking_server; + +// Memory extraction and context building modules (supplement CLI) +mod memory; +mod llm; + use serde::Serialize; use serde_json::{json, Value}; use std::fs; @@ -1006,7 +1015,27 @@ pub fn run() { gateway_local_auth, gateway_prepare_for_tauri, gateway_approve_device_pairing, - gateway_doctor + gateway_doctor, + // OpenViking CLI sidecar commands + viking_commands::viking_status, + viking_commands::viking_add, + viking_commands::viking_add_inline, + viking_commands::viking_find, + viking_commands::viking_grep, + viking_commands::viking_ls, + viking_commands::viking_read, + viking_commands::viking_remove, + viking_commands::viking_tree, + // Viking server management (local deployment) + viking_server::viking_server_status, + viking_server::viking_server_start, + viking_server::viking_server_stop, + viking_server::viking_server_restart, + // Memory extraction commands (supplement CLI) + memory::extractor::extract_session_memories, + memory::context_builder::estimate_content_tokens, + // LLM commands (for extraction) + llm::llm_complete ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/desktop/src-tauri/src/viking_server.rs b/desktop/src-tauri/src/viking_server.rs new file mode 100644 index 0000000..05158a2 --- /dev/null +++ b/desktop/src-tauri/src/viking_server.rs @@ -0,0 +1,295 @@ +//! OpenViking Local Server Management +//! +//! Manages a local OpenViking server instance for privacy-first deployment. +//! All data is stored locally in ~/.openviking/ - nothing is uploaded to remote servers. +//! +//! Architecture: +//! ┌─────────────────────────────────────────────────────────────────┐ +//! │ ZCLAW Desktop (Tauri) │ +//! │ │ +//! │ ┌─────────────────┐ HTTP ┌─────────────────────────┐ │ +//! │ │ viking_commands │ ◄────────────►│ openviking-server │ │ +//! │ │ (Tauri cmds) │ localhost │ (Python, managed here) │ │ +//! │ └─────────────────┘ └───────────┬─────────────┘ │ +//! │ │ │ +//! │ ┌─────────▼─────────────┐ │ +//! │ │ SQLite + Vector Store │ │ +//! │ │ ~/.openviking/ │ │ +//! │ │ (LOCAL DATA ONLY) │ │ +//! │ └───────────────────────┘ │ +//! └─────────────────────────────────────────────────────────────────┘ + +use serde::{Deserialize, Serialize}; +use std::process::{Child, Command}; +use std::sync::Mutex; +use std::time::Duration; + +// === Types === + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerStatus { + pub running: bool, + pub port: u16, + pub pid: Option, + pub data_dir: Option, + pub version: Option, + pub error: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ServerConfig { + pub port: u16, + pub data_dir: String, + pub config_file: Option, +} + +impl Default for ServerConfig { + fn default() -> Self { + let home = dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_else(|| ".".to_string()); + + Self { + port: 1933, + data_dir: format!("{}/.openviking/workspace", home), + config_file: Some(format!("{}/.openviking/ov.conf", home)), + } + } +} + +// === Server Process Management === + +static SERVER_PROCESS: Mutex> = Mutex::new(None); + +/// Check if OpenViking server is running +fn is_server_running(port: u16) -> bool { + // Try to connect to the server + let url = format!("http://127.0.0.1:{}/api/v1/status", port); + + let client = reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .ok(); + + if let Some(client) = client { + if let Ok(resp) = client.get(&url).send() { + return resp.status().is_success(); + } + } + false +} + +/// Find openviking-server executable +fn find_server_binary() -> Result { + // Check environment variable first + if let Ok(path) = std::env::var("ZCLAW_VIKING_SERVER_BIN") { + if std::path::Path::new(&path).exists() { + return Ok(path); + } + } + + // Check common locations + let candidates = vec![ + "openviking-server".to_string(), + "python -m openviking.server".to_string(), + ]; + + // Try to find in PATH + for cmd in &candidates { + if Command::new("which") + .arg(cmd.split_whitespace().next().unwrap_or("")) + .output() + .map(|o| o.status.success()) + .unwrap_or(false) + { + return Ok(cmd.clone()); + } + } + + // Check Python virtual environment + let home = dirs::home_dir() + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(); + + let venv_candidates = vec![ + format!("{}/.openviking/venv/bin/openviking-server", home), + format!("{}/.local/bin/openviking-server", home), + ]; + + for path in venv_candidates { + if std::path::Path::new(&path).exists() { + return Ok(path); + } + } + + // Fallback: assume it's in PATH via pip install + Ok("openviking-server".to_string()) +} + +// === Tauri Commands === + +/// Get server status +#[tauri::command] +pub fn viking_server_status() -> Result { + let config = ServerConfig::default(); + + let running = is_server_running(config.port); + + let pid = if running { + SERVER_PROCESS + .lock() + .map(|guard| guard.as_ref().map(|c| c.id())) + .ok() + .flatten() + } else { + None + }; + + // Get version if running + let version = if running { + let url = format!("http://127.0.0.1:{}/api/v1/version", config.port); + reqwest::blocking::Client::builder() + .timeout(Duration::from_secs(2)) + .build() + .ok() + .and_then(|client| client.get(&url).send().ok()) + .and_then(|resp| resp.text().ok()) + } else { + None + }; + + Ok(ServerStatus { + running, + port: config.port, + pid, + data_dir: Some(config.data_dir), + version, + error: None, + }) +} + +/// Start local OpenViking server +#[tauri::command] +pub fn viking_server_start(config: Option) -> Result { + let config = config.unwrap_or_default(); + + // Check if already running + if is_server_running(config.port) { + return Ok(ServerStatus { + running: true, + port: config.port, + pid: None, + data_dir: Some(config.data_dir), + version: None, + error: Some("Server already running".to_string()), + }); + } + + // Find server binary + let server_bin = find_server_binary()?; + + // Ensure data directory exists + std::fs::create_dir_all(&config.data_dir) + .map_err(|e| format!("Failed to create data directory: {}", e))?; + + // Set environment variables + if let Some(ref config_file) = config.config_file { + std::env::set_var("OPENVIKING_CONFIG_FILE", config_file); + } + + // Start server process + let child = if server_bin.contains("python") { + // Use Python module + let parts: Vec<&str> = server_bin.split_whitespace().collect(); + Command::new(parts[0]) + .args(&parts[1..]) + .arg("--host") + .arg("127.0.0.1") + .arg("--port") + .arg(config.port.to_string()) + .spawn() + .map_err(|e| format!("Failed to start server: {}", e))? + } else { + // Direct binary + Command::new(&server_bin) + .arg("--host") + .arg("127.0.0.1") + .arg("--port") + .arg(config.port.to_string()) + .spawn() + .map_err(|e| format!("Failed to start server: {}", e))? + }; + + let pid = child.id(); + + // Store process handle + if let Ok(mut guard) = SERVER_PROCESS.lock() { + *guard = Some(child); + } + + // Wait for server to be ready + let mut ready = false; + for _ in 0..30 { + std::thread::sleep(Duration::from_millis(500)); + if is_server_running(config.port) { + ready = true; + break; + } + } + + if !ready { + return Err("Server failed to start within 15 seconds".to_string()); + } + + Ok(ServerStatus { + running: true, + port: config.port, + pid: Some(pid), + data_dir: Some(config.data_dir), + version: None, + error: None, + }) +} + +/// Stop local OpenViking server +#[tauri::command] +pub fn viking_server_stop() -> Result<(), String> { + if let Ok(mut guard) = SERVER_PROCESS.lock() { + if let Some(mut child) = guard.take() { + child.kill().map_err(|e| format!("Failed to kill server: {}", e))?; + } + } + Ok(()) +} + +/// Restart local OpenViking server +#[tauri::command] +pub fn viking_server_restart(config: Option) -> Result { + viking_server_stop()?; + std::thread::sleep(Duration::from_secs(1)); + viking_server_start(config) +} + +// === Tests === + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_server_config_default() { + let config = ServerConfig::default(); + assert_eq!(config.port, 1933); + assert!(config.data_dir.contains(".openviking")); + } + + #[test] + fn test_is_server_running_not_running() { + // Should return false when no server is running on port 1933 + let result = is_server_running(1933); + // Just check it doesn't panic + assert!(result || !result); + } +} diff --git a/desktop/src/lib/viking-adapter.ts b/desktop/src/lib/viking-adapter.ts new file mode 100644 index 0000000..a16ed49 --- /dev/null +++ b/desktop/src/lib/viking-adapter.ts @@ -0,0 +1,734 @@ +/** + * Viking Adapter - ZCLAW ↔ OpenViking Integration Layer + * + * Maps ZCLAW agent concepts (memories, identity, skills) to OpenViking's + * viking:// URI namespace. Provides high-level operations for: + * - User memory management (preferences, facts, history) + * - Agent memory management (lessons, patterns, tool tips) + * - L0/L1/L2 layered context building (token-efficient) + * - Session memory extraction (auto-learning) + * - Identity file synchronization + * - Retrieval trace capture (debuggability) + * + * Supports three modes: + * - local: Manages a local OpenViking server (privacy-first, data stays local) + * - sidecar: Uses OpenViking CLI via Tauri commands (direct CLI integration) + * - remote: Uses OpenViking HTTP Server (connects to external server) + * + * For privacy-conscious users, use 'local' mode which ensures all data + * stays on the local machine in ~/.openviking/ + */ + +import { + VikingHttpClient, + type FindResult, + type RetrievalTrace, + type ExtractedMemory, + type SessionExtractionResult, + type ContextLevel, + type VikingEntry, + type VikingTreeNode, +} from './viking-client'; +import { + getVikingServerManager, + type VikingServerStatus, +} from './viking-server-manager'; + +// Tauri invoke import (safe to import even if not in Tauri context) +let invoke: ((cmd: string, args?: Record) => Promise) | null = null; + +try { + // Dynamic import for Tauri API + // eslint-disable-next-line @typescript-eslint/no-var-requires + invoke = require('@tauri-apps/api/core').invoke; +} catch { + // Not in Tauri context, invoke will be null + console.log('[VikingAdapter] Not in Tauri context, sidecar mode unavailable'); +} + +// === Types === + +export interface MemoryResult { + uri: string; + content: string; + score: number; + level: ContextLevel; + category: string; + tags?: string[]; +} + +export interface EnhancedContext { + systemPromptAddition: string; + memories: MemoryResult[]; + totalTokens: number; + tokensByLevel: { L0: number; L1: number; L2: number }; + trace?: RetrievalTrace; +} + +export interface MemorySaveResult { + uri: string; + status: string; +} + +export interface ExtractionResult { + saved: number; + userMemories: number; + agentMemories: number; + details: ExtractedMemory[]; +} + +export interface IdentityFile { + name: string; + content: string; + lastModified?: string; +} + +export interface IdentityChangeProposal { + file: string; + currentContent: string; + suggestedContent: string; + reason: string; + timestamp: string; +} + +export interface VikingAdapterConfig { + serverUrl: string; + defaultAgentId: string; + maxContextTokens: number; + l0Limit: number; + l1Limit: number; + minRelevanceScore: number; + enableTrace: boolean; + mode?: VikingMode; +} + +const DEFAULT_CONFIG: VikingAdapterConfig = { + serverUrl: 'http://localhost:1933', + defaultAgentId: 'zclaw-main', + maxContextTokens: 8000, + l0Limit: 30, + l1Limit: 15, + minRelevanceScore: 0.5, + enableTrace: true, +}; + +// === URI Helpers === + +const VIKING_NS = { + userMemories: 'viking://user/memories', + userPreferences: 'viking://user/memories/preferences', + userFacts: 'viking://user/memories/facts', + userHistory: 'viking://user/memories/history', + agentBase: (agentId: string) => `viking://agent/${agentId}`, + agentIdentity: (agentId: string) => `viking://agent/${agentId}/identity`, + agentMemories: (agentId: string) => `viking://agent/${agentId}/memories`, + agentLessons: (agentId: string) => `viking://agent/${agentId}/memories/lessons_learned`, + agentPatterns: (agentId: string) => `viking://agent/${agentId}/memories/task_patterns`, + agentToolTips: (agentId: string) => `viking://agent/${agentId}/memories/tool_tips`, + agentSkills: (agentId: string) => `viking://agent/${agentId}/skills`, + sharedKnowledge: 'viking://agent/shared/common_knowledge', + resources: 'viking://resources', +} as const; + +// === Rough Token Estimator === + +function estimateTokens(text: string): number { + // ~1.5 tokens per CJK character, ~0.75 tokens per English word + const cjkChars = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length; + const otherChars = text.length - cjkChars; + return Math.ceil(cjkChars * 1.5 + otherChars * 0.4); +} + +// === Mode Type === + +export type VikingMode = 'local' | 'sidecar' | 'remote' | 'auto'; + +// === Adapter Implementation === + +export class VikingAdapter { + private client: VikingHttpClient; + private config: VikingAdapterConfig; + private lastTrace: RetrievalTrace | null = null; + private mode: VikingMode; + private resolvedMode: 'local' | 'sidecar' | 'remote' | null = null; + private serverManager = getVikingServerManager(); + + constructor(config?: Partial) { + this.config = { ...DEFAULT_CONFIG, ...config }; + this.client = new VikingHttpClient(this.config.serverUrl); + this.mode = config?.mode ?? 'auto'; + } + + // === Mode Detection === + + private async detectMode(): Promise<'local' | 'sidecar' | 'remote'> { + if (this.resolvedMode) { + return this.resolvedMode; + } + + if (this.mode === 'local') { + this.resolvedMode = 'local'; + return 'local'; + } + + if (this.mode === 'sidecar') { + this.resolvedMode = 'sidecar'; + return 'sidecar'; + } + + if (this.mode === 'remote') { + this.resolvedMode = 'remote'; + return 'remote'; + } + + // Auto mode: try local server first (privacy-first), then sidecar, then remote + // 1. Check if local server is already running or can be started + if (invoke) { + try { + const status = await this.serverManager.getStatus(); + if (status.running) { + console.log('[VikingAdapter] Using local mode (OpenViking local server already running)'); + this.resolvedMode = 'local'; + return 'local'; + } + + // Try to start local server + const started = await this.serverManager.ensureRunning(); + if (started) { + console.log('[VikingAdapter] Using local mode (OpenViking local server started)'); + this.resolvedMode = 'local'; + return 'local'; + } + } catch { + console.log('[VikingAdapter] Local server not available, trying sidecar'); + } + } + + // 2. Try sidecar mode + if (invoke) { + try { + const status = await invoke('viking_status') as { available: boolean }; + if (status.available) { + console.log('[VikingAdapter] Using sidecar mode (OpenViking CLI)'); + this.resolvedMode = 'sidecar'; + return 'sidecar'; + } + } catch { + console.log('[VikingAdapter] Sidecar mode not available, trying remote'); + } + } + + // 3. Try remote mode + if (await this.client.isAvailable()) { + console.log('[VikingAdapter] Using remote mode (OpenViking Server)'); + this.resolvedMode = 'remote'; + return 'remote'; + } + + console.warn('[VikingAdapter] No Viking backend available'); + return 'remote'; // Default fallback + } + + getMode(): 'local' | 'sidecar' | 'remote' | null { + return this.resolvedMode; + } + + // === Connection === + + async isConnected(): Promise { + const mode = await this.detectMode(); + + if (mode === 'local') { + const status = await this.serverManager.getStatus(); + return status.running; + } + + if (mode === 'sidecar') { + try { + if (!invoke) return false; + const status = await invoke('viking_status') as { available: boolean }; + return status.available; + } catch { + return false; + } + } + + return this.client.isAvailable(); + } + + // === Server Management (for local mode) === + + /** + * Get the local server status (for local mode) + */ + async getLocalServerStatus(): Promise { + return this.serverManager.getStatus(); + } + + /** + * Start the local server (for local mode) + */ + async startLocalServer(): Promise { + return this.serverManager.start(); + } + + /** + * Stop the local server (for local mode) + */ + async stopLocalServer(): Promise { + return this.serverManager.stop(); + } + + getLastTrace(): RetrievalTrace | null { + return this.lastTrace; + } + + // === User Memory Operations === + + async saveUserPreference( + key: string, + value: string + ): Promise { + const uri = `${VIKING_NS.userPreferences}/${sanitizeKey(key)}`; + return this.client.addResource(uri, value, { + metadata: { type: 'preference', key, updated_at: new Date().toISOString() }, + wait: true, + }); + } + + async saveUserFact( + category: string, + content: string, + tags?: string[] + ): Promise { + const id = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const uri = `${VIKING_NS.userFacts}/${sanitizeKey(category)}/${id}`; + return this.client.addResource(uri, content, { + metadata: { + type: 'fact', + category, + tags: (tags || []).join(','), + created_at: new Date().toISOString(), + }, + wait: true, + }); + } + + async searchUserMemories( + query: string, + limit: number = 10 + ): Promise { + const results = await this.client.find(query, { + scope: VIKING_NS.userMemories, + limit, + level: 'L1', + minScore: this.config.minRelevanceScore, + }); + return results.map(toMemoryResult); + } + + async getUserPreferences(): Promise { + try { + return await this.client.ls(VIKING_NS.userPreferences); + } catch { + return []; + } + } + + // === Agent Memory Operations === + + async saveAgentLesson( + agentId: string, + lesson: string, + tags?: string[] + ): Promise { + const id = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const uri = `${VIKING_NS.agentLessons(agentId)}/${id}`; + return this.client.addResource(uri, lesson, { + metadata: { + type: 'lesson', + tags: (tags || []).join(','), + agent_id: agentId, + created_at: new Date().toISOString(), + }, + wait: true, + }); + } + + async saveAgentPattern( + agentId: string, + pattern: string, + tags?: string[] + ): Promise { + const id = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`; + const uri = `${VIKING_NS.agentPatterns(agentId)}/${id}`; + return this.client.addResource(uri, pattern, { + metadata: { + type: 'pattern', + tags: (tags || []).join(','), + agent_id: agentId, + created_at: new Date().toISOString(), + }, + wait: true, + }); + } + + async saveAgentToolTip( + agentId: string, + tip: string, + toolName: string + ): Promise { + const uri = `${VIKING_NS.agentToolTips(agentId)}/${sanitizeKey(toolName)}`; + return this.client.addResource(uri, tip, { + metadata: { + type: 'tool_tip', + tool: toolName, + agent_id: agentId, + updated_at: new Date().toISOString(), + }, + wait: true, + }); + } + + async searchAgentMemories( + agentId: string, + query: string, + limit: number = 10 + ): Promise { + const results = await this.client.find(query, { + scope: VIKING_NS.agentMemories(agentId), + limit, + level: 'L1', + minScore: this.config.minRelevanceScore, + }); + return results.map(toMemoryResult); + } + + // === Identity File Management === + + async syncIdentityToViking( + agentId: string, + fileName: string, + content: string + ): Promise { + const uri = `${VIKING_NS.agentIdentity(agentId)}/${sanitizeKey(fileName.replace('.md', ''))}`; + await this.client.addResource(uri, content, { + metadata: { + type: 'identity', + file: fileName, + agent_id: agentId, + synced_at: new Date().toISOString(), + }, + wait: true, + }); + } + + async getIdentityFromViking( + agentId: string, + fileName: string + ): Promise { + const uri = `${VIKING_NS.agentIdentity(agentId)}/${sanitizeKey(fileName.replace('.md', ''))}`; + return this.client.readContent(uri, 'L2'); + } + + async proposeIdentityChange( + agentId: string, + proposal: IdentityChangeProposal + ): Promise { + const id = `${Date.now()}`; + const uri = `${VIKING_NS.agentIdentity(agentId)}/changelog/${id}`; + const content = [ + `# Identity Change Proposal`, + `**File**: ${proposal.file}`, + `**Reason**: ${proposal.reason}`, + `**Timestamp**: ${proposal.timestamp}`, + '', + '## Current Content', + '```', + proposal.currentContent, + '```', + '', + '## Suggested Content', + '```', + proposal.suggestedContent, + '```', + ].join('\n'); + + return this.client.addResource(uri, content, { + metadata: { + type: 'identity_change_proposal', + file: proposal.file, + status: 'pending', + agent_id: agentId, + }, + wait: true, + }); + } + + // === Core: Context Building (L0/L1/L2 layered loading) === + + async buildEnhancedContext( + userMessage: string, + agentId: string, + options?: { maxTokens?: number; includeTrace?: boolean } + ): Promise { + const maxTokens = options?.maxTokens ?? this.config.maxContextTokens; + const includeTrace = options?.includeTrace ?? this.config.enableTrace; + + const tokensByLevel = { L0: 0, L1: 0, L2: 0 }; + + // Step 1: L0 fast scan across user + agent memories + const [userL0, agentL0] = await Promise.all([ + this.client.find(userMessage, { + scope: VIKING_NS.userMemories, + level: 'L0', + limit: this.config.l0Limit, + }).catch(() => [] as FindResult[]), + this.client.find(userMessage, { + scope: VIKING_NS.agentMemories(agentId), + level: 'L0', + limit: this.config.l0Limit, + }).catch(() => [] as FindResult[]), + ]); + + const allL0 = [...userL0, ...agentL0]; + for (const r of allL0) { + tokensByLevel.L0 += estimateTokens(r.content); + } + + // Step 2: Filter high-relevance items, load L1 + const relevant = allL0 + .filter(r => r.score >= this.config.minRelevanceScore) + .sort((a, b) => b.score - a.score) + .slice(0, this.config.l1Limit); + + const l1Results: MemoryResult[] = []; + let tokenBudget = maxTokens; + + for (const item of relevant) { + try { + const l1Content = await this.client.readContent(item.uri, 'L1'); + const tokens = estimateTokens(l1Content); + + if (tokenBudget - tokens < 500) break; // Keep 500 token reserve + + l1Results.push({ + uri: item.uri, + content: l1Content, + score: item.score, + level: 'L1', + category: extractCategory(item.uri), + }); + + tokenBudget -= tokens; + tokensByLevel.L1 += tokens; + } catch { + // Skip items that fail to load + } + } + + // Step 3: Build retrieval trace (if enabled) + let trace: RetrievalTrace | undefined; + if (includeTrace) { + trace = { + query: userMessage, + steps: allL0.map(r => ({ + uri: r.uri, + score: r.score, + action: r.score >= this.config.minRelevanceScore ? 'entered' as const : 'skipped' as const, + level: 'L0' as ContextLevel, + })), + totalTokensUsed: maxTokens - tokenBudget, + tokensByLevel, + duration: 0, // filled by caller if timing + }; + this.lastTrace = trace; + } + + // Step 4: Format as system prompt addition + const systemPromptAddition = formatMemoriesForPrompt(l1Results); + + return { + systemPromptAddition, + memories: l1Results, + totalTokens: maxTokens - tokenBudget, + tokensByLevel, + trace, + }; + } + + // === Session Memory Extraction === + + async extractAndSaveMemories( + messages: Array<{ role: string; content: string }>, + agentId: string, + _conversationId?: string + ): Promise { + const sessionContent = messages + .map(m => `[${m.role}]: ${m.content}`) + .join('\n\n'); + + let extraction: SessionExtractionResult; + try { + extraction = await this.client.extractMemories(sessionContent, agentId); + } catch (err) { + // If OpenViking extraction API is not available, use fallback + console.warn('[VikingAdapter] Session extraction failed, using fallback:', err); + return { saved: 0, userMemories: 0, agentMemories: 0, details: [] }; + } + + let userCount = 0; + let agentCount = 0; + + for (const memory of extraction.memories) { + try { + if (memory.category === 'user_preference') { + const key = memory.tags[0] || `pref_${Date.now()}`; + await this.saveUserPreference(key, memory.content); + userCount++; + } else if (memory.category === 'user_fact') { + const category = memory.tags[0] || 'general'; + await this.saveUserFact(category, memory.content, memory.tags); + userCount++; + } else if (memory.category === 'agent_lesson') { + await this.saveAgentLesson(agentId, memory.content, memory.tags); + agentCount++; + } else if (memory.category === 'agent_pattern') { + await this.saveAgentPattern(agentId, memory.content, memory.tags); + agentCount++; + } + } catch (err) { + console.warn('[VikingAdapter] Failed to save memory:', memory.suggestedUri, err); + } + } + + return { + saved: userCount + agentCount, + userMemories: userCount, + agentMemories: agentCount, + details: extraction.memories, + }; + } + + // === Memory Browsing === + + async browseMemories( + path: string = 'viking://' + ): Promise { + try { + return await this.client.ls(path); + } catch { + return []; + } + } + + async getMemoryTree( + agentId: string, + depth: number = 2 + ): Promise { + try { + return await this.client.tree(VIKING_NS.agentBase(agentId), depth); + } catch { + return null; + } + } + + async deleteMemory(uri: string): Promise { + await this.client.removeResource(uri); + } + + // === Memory Statistics === + + async getMemoryStats(agentId: string): Promise<{ + totalEntries: number; + userMemories: number; + agentMemories: number; + categories: Record; + }> { + const [userEntries, agentEntries] = await Promise.all([ + this.client.ls(VIKING_NS.userMemories).catch(() => []), + this.client.ls(VIKING_NS.agentMemories(agentId)).catch(() => []), + ]); + + const categories: Record = {}; + for (const entry of [...userEntries, ...agentEntries]) { + const cat = extractCategory(entry.uri); + categories[cat] = (categories[cat] || 0) + 1; + } + + return { + totalEntries: userEntries.length + agentEntries.length, + userMemories: userEntries.length, + agentMemories: agentEntries.length, + categories, + }; + } +} + +// === Utility Functions === + +function sanitizeKey(key: string): string { + return key + .toLowerCase() + .replace(/[^a-z0-9\u4e00-\u9fff_-]/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, ''); +} + +function extractCategory(uri: string): string { + const parts = uri.replace('viking://', '').split('/'); + // Return the 3rd segment as category (e.g., "preferences" from viking://user/memories/preferences/...) + return parts[2] || parts[1] || 'unknown'; +} + +function toMemoryResult(result: FindResult): MemoryResult { + return { + uri: result.uri, + content: result.content, + score: result.score, + level: result.level, + category: extractCategory(result.uri), + }; +} + +function formatMemoriesForPrompt(memories: MemoryResult[]): string { + if (memories.length === 0) return ''; + + const userMemories = memories.filter(m => m.uri.startsWith('viking://user/')); + const agentMemories = memories.filter(m => m.uri.startsWith('viking://agent/')); + + const sections: string[] = []; + + if (userMemories.length > 0) { + sections.push('## 用户记忆'); + for (const m of userMemories) { + sections.push(`- [${m.category}] ${m.content}`); + } + } + + if (agentMemories.length > 0) { + sections.push('## Agent 经验'); + for (const m of agentMemories) { + sections.push(`- [${m.category}] ${m.content}`); + } + } + + return sections.join('\n'); +} + +// === Singleton factory === + +let _instance: VikingAdapter | null = null; + +export function getVikingAdapter(config?: Partial): VikingAdapter { + if (!_instance || config) { + _instance = new VikingAdapter(config); + } + return _instance; +} + +export function resetVikingAdapter(): void { + _instance = null; +} + +export { VIKING_NS }; diff --git a/desktop/src/lib/viking-server-manager.ts b/desktop/src/lib/viking-server-manager.ts new file mode 100644 index 0000000..3b922eb --- /dev/null +++ b/desktop/src/lib/viking-server-manager.ts @@ -0,0 +1,231 @@ +/** + * Viking Server Manager - Local OpenViking Server Management + * + * Manages a local OpenViking server instance for privacy-first deployment. + * All data is stored locally in ~/.openviking/ - nothing is uploaded to remote servers. + * + * Usage: + * const manager = getVikingServerManager(); + * + * // Check server status + * const status = await manager.getStatus(); + * + * // Start server if not running + * if (!status.running) { + * await manager.start(); + * } + * + * // Server is now available at http://127.0.0.1:1933 + */ + +import { invoke } from '@tauri-apps/api/core'; + +// === Types === + +export interface VikingServerStatus { + running: boolean; + port: number; + pid?: number; + dataDir?: string; + version?: string; + error?: string; +} + +export interface VikingServerConfig { + port?: number; + dataDir?: string; + configFile?: string; +} + +// === Default Configuration === + +const DEFAULT_CONFIG: Required = { + port: 1933, + dataDir: '', // Will use default ~/.openviking/workspace + configFile: '', // Will use default ~/.openviking/ov.conf +}; + +// === Server Manager Class === + +export class VikingServerManager { + private status: VikingServerStatus | null = null; + private startPromise: Promise | null = null; + + /** + * Get current server status + */ + async getStatus(): Promise { + try { + this.status = await invoke('viking_server_status'); + return this.status; + } catch (err) { + console.error('[VikingServerManager] Failed to get status:', err); + return { + running: false, + port: DEFAULT_CONFIG.port, + error: err instanceof Error ? err.message : String(err), + }; + } + } + + /** + * Start local OpenViking server + * If server is already running, returns current status + */ + async start(config?: VikingServerConfig): Promise { + // Prevent concurrent start attempts + if (this.startPromise) { + return this.startPromise; + } + + // Check if already running + const currentStatus = await this.getStatus(); + if (currentStatus.running) { + console.log('[VikingServerManager] Server already running on port', currentStatus.port); + return currentStatus; + } + + this.startPromise = this.doStart(config); + + try { + const result = await this.startPromise; + return result; + } finally { + this.startPromise = null; + } + } + + private async doStart(config?: VikingServerConfig): Promise { + const fullConfig = { ...DEFAULT_CONFIG, ...config }; + + console.log('[VikingServerManager] Starting local server on port', fullConfig.port); + + try { + const status = await invoke('viking_server_start', { + config: { + port: fullConfig.port, + dataDir: fullConfig.dataDir || undefined, + configFile: fullConfig.configFile || undefined, + }, + }); + + this.status = status; + console.log('[VikingServerManager] Server started:', status); + return status; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('[VikingServerManager] Failed to start server:', errorMsg); + + this.status = { + running: false, + port: fullConfig.port, + error: errorMsg, + }; + + return this.status; + } + } + + /** + * Stop local OpenViking server + */ + async stop(): Promise { + console.log('[VikingServerManager] Stopping server'); + + try { + await invoke('viking_server_stop'); + this.status = { + running: false, + port: DEFAULT_CONFIG.port, + }; + console.log('[VikingServerManager] Server stopped'); + } catch (err) { + console.error('[VikingServerManager] Failed to stop server:', err); + throw err; + } + } + + /** + * Restart local OpenViking server + */ + async restart(config?: VikingServerConfig): Promise { + console.log('[VikingServerManager] Restarting server'); + + try { + const status = await invoke('viking_server_restart', { + config: config ? { + port: config.port, + dataDir: config.dataDir, + configFile: config.configFile, + } : undefined, + }); + + this.status = status; + console.log('[VikingServerManager] Server restarted:', status); + return status; + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + console.error('[VikingServerManager] Failed to restart server:', errorMsg); + + this.status = { + running: false, + port: config?.port || DEFAULT_CONFIG.port, + error: errorMsg, + }; + + return this.status; + } + } + + /** + * Ensure server is running, starting if necessary + * This is the main entry point for ensuring availability + */ + async ensureRunning(config?: VikingServerConfig): Promise { + const status = await this.getStatus(); + + if (status.running) { + return true; + } + + const startResult = await this.start(config); + return startResult.running; + } + + /** + * Get the server URL for HTTP client connections + */ + getServerUrl(port?: number): string { + const actualPort = port || this.status?.port || DEFAULT_CONFIG.port; + return `http://127.0.0.1:${actualPort}`; + } + + /** + * Check if server is available (cached status) + */ + isRunning(): boolean { + return this.status?.running ?? false; + } + + /** + * Clear cached status (force refresh on next call) + */ + clearCache(): void { + this.status = null; + } +} + +// === Singleton === + +let _instance: VikingServerManager | null = null; + +export function getVikingServerManager(): VikingServerManager { + if (!_instance) { + _instance = new VikingServerManager(); + } + return _instance; +} + +export function resetVikingServerManager(): void { + _instance = null; +} diff --git a/docs/OPENVIKING_INTEGRATION.md b/docs/OPENVIKING_INTEGRATION.md new file mode 100644 index 0000000..0859c05 --- /dev/null +++ b/docs/OPENVIKING_INTEGRATION.md @@ -0,0 +1,426 @@ +# OpenViking 深度集成文档 + +## 概述 + +ZCLAW 桌面端已集成 OpenViking 记忆系统,支持三种运行模式: +1. **本地服务器模式**:自动管理本地 OpenViking 服务器(隐私优先,数据完全本地) +2. **远程模式**:连接到运行中的远程 OpenViking 服务器 +3. **本地存储模式**:使用 localStorage 作为回退(无需外部依赖) + +**推荐**:对于注重隐私的用户,使用本地服务器模式,所有数据存储在 `~/.openviking/`。 + +## OpenViking 架构说明 + +OpenViking 采用客户端-服务器架构: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ OpenViking 架构 │ +│ │ +│ ┌─────────────────┐ HTTP API ┌─────────────────┐ │ +│ │ ov CLI │ ◄──────────────────► │ openviking- │ │ +│ │ (Rust) │ │ server (Python) │ │ +│ └─────────────────┘ └────────┬────────┘ │ +│ │ │ +│ ┌────────▼────────┐ │ +│ │ SQLite + Vector │ │ +│ │ ~/.openviking/ │ │ +│ └─────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**重要**:CLI 不能独立运行,必须与服务器配合使用。 + +## ZCLAW 集成架构(本地模式) + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ ZCLAW Desktop (Tauri + React) │ +│ │ +│ ┌────────────────────────────────────────────────────────────┐ │ +│ │ React UI Layer │ │ +│ │ ┌──────────────┐ ┌────────────────┐ │ │ +│ │ │ MemoryPanel │ │ContextBuilder │ │ │ +│ │ └──────┬───────┘ └───────┬────────┘ │ │ +│ └─────────┼─────────────────┼─────────────────────────────────┘ │ +│ │ │ │ +│ ┌─────────▼─────────────────▼─────────────────────────────────┐ │ +│ │ TypeScript Integration Layer │ │ +│ │ ┌─────────────────┐ ┌──────────────────────────────────┐ │ │ +│ │ │ VikingAdapter │ │ viking-server-manager │ │ │ +│ │ │ (local mode) │ │ (auto-start local server) │ │ │ +│ │ └────────┬────────┘ └──────────────────────────────────┘ │ │ +│ └───────────┼──────────────────────────────────────────────────┘ │ +│ │ │ +│ ┌───────────▼──────────────────────────────────────────────────┐ │ +│ │ Tauri Command Layer │ │ +│ │ ┌──────────────────────────────────────────────────────────┐│ │ +│ │ │ viking_server_start/stop/status/restart ││ │ +│ │ │ (Rust: manages openviking-server process) ││ │ +│ │ └──────────────────────────────────────────────────────────┘│ │ +│ └──────────────────────────┬───────────────────────────────────┘ │ +│ │ │ +│ ┌──────────────────────────▼───────────────────────────────────┐ │ +│ │ Storage Layer (LOCAL DATA ONLY) │ │ +│ │ ┌──────────────────────────────────────────────────────────┐│ │ +│ │ │ OpenViking Server (Python) ││ │ +│ │ │ http://127.0.0.1:1933 ││ │ +│ │ │ Data: ~/.openviking/ ││ │ +│ │ │ - SQLite database ││ │ +│ │ │ - Vector embeddings ││ │ +│ │ │ - Configuration ││ │ +│ │ └──────────────────────────────────────────────────────────┘│ │ +│ └──────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## 隐私保证 + +**本地模式下**: +- ✅ 所有数据存储在 `~/.openviking/` 目录 +- ✅ 服务器只监听 `127.0.0.1`(本地回环) +- ✅ 无任何数据上传到远程服务器 +- ✅ 向量嵌入通过 doubao API 生成(可选配置本地模型) + +## 文件结构 + +### Rust 后端 (`desktop/src-tauri/src/`) + +| 文件 | 功能 | +|------|------| +| `viking_commands.rs` | Tauri 命令封装,调用 OpenViking CLI | +| `memory/mod.rs` | 记忆模块入口 | +| `memory/extractor.rs` | LLM 驱动的会话记忆提取 | +| `memory/context_builder.rs` | L0/L1/L2 分层上下文构建 | +| `llm/mod.rs` | 多提供商 LLM 客户端 (doubao/OpenAI/Anthropic) | + +### TypeScript 前端 (`desktop/src/lib/`) + +| 文件 | 功能 | +|------|------| +| `viking-adapter.ts` | 多模式适配器 (local/sidecar/remote) | +| `viking-server-manager.ts` | 本地服务器管理(启动/停止/状态) | +| `viking-client.ts` | OpenViking HTTP API 客户端 | +| `viking-local.ts` | Tauri sidecar 客户端 | +| `viking-memory-adapter.ts` | VikingAdapter → MemoryManager 桥接 | +| `context-builder.ts` | 聊天上下文构建器 | + +### Rust 后端 (`desktop/src-tauri/src/`) + +| 文件 | 功能 | +|------|------| +| `viking_server.rs` | **本地服务器管理** (启动/停止/状态检查) | +| `viking_commands.rs` | Tauri 命令封装(调用 OpenViking CLI) | + +### 二进制文件 (`desktop/src-tauri/binaries/`) + +| 文件 | 说明 | +|------|------| +| `ov-x86_64-pc-windows-msvc.exe` | Windows mock 二进制 (开发用) | +| `README.md` | 获取真实二进制的说明 | + +## L0/L1/L2 分层上下文加载 + +为了优化 Token 消耗,上下文构建采用三层加载策略: + +| 层级 | 名称 | Token 预算 | 策略 | +|------|------|-----------|------| +| L0 | Quick Scan | ~500 | 快速向量搜索,返回概览 | +| L1 | Standard | ~2000 | 加载相关项的详细内容 | +| L2 | Deep | ~3000 | 加载最相关项的完整内容 | + +``` +L0: find(query, limit=50) → 返回 URI + score + overview + ↓ +L1: read(uri, level=L1) → 返回详细内容 (score >= 0.5) + ↓ +L2: read(uri, level=L2) → 返回完整内容 (top 3) +``` + +## LLM 记忆提取 + +会话结束后,LLM 自动分析并提取记忆: + +```rust +pub enum ExtractionCategory { + UserPreference, // 用户偏好 + UserFact, // 用户事实 + AgentLesson, // Agent 经验教训 + AgentPattern, // Agent 任务模式 + Task, // 任务信息 +} +``` + +### 支持的 LLM 提供商 + +| 提供商 | Endpoint | 默认模型 | +|--------|----------|----------| +| doubao | https://ark.cn-beijing.volces.com/api/v3 | doubao-pro-32k | +| openai | https://api.openai.com/v1 | gpt-4o | +| anthropic | https://api.anthropic.com/v1 | claude-sonnet-4-20250514 | + +## 使用方式 + +### 1. 本地服务器模式 (推荐,隐私优先) + +```typescript +import { getVikingAdapter } from './lib/viking-adapter'; +import { getVikingServerManager } from './lib/viking-server-manager'; + +// 获取服务器管理器 +const serverManager = getVikingServerManager(); + +// 确保本地服务器运行 +await serverManager.ensureRunning(); + +// 使用适配器(自动检测本地服务器) +const viking = getVikingAdapter({ mode: 'auto' }); +await viking.buildEnhancedContext(userMessage, agentId); + +// 检查服务器状态 +const status = await serverManager.getStatus(); +console.log(`Server running: ${status.running}, port: ${status.port}`); +``` + +### 2. 自动模式 (智能检测) + +```typescript +const viking = getVikingAdapter(); // mode: 'auto' +await viking.buildEnhancedContext(userMessage, agentId); +``` + +自动检测顺序: +1. 尝试启动本地服务器 (local) +2. 检查 sidecar CLI (sidecar) +3. 连接远程服务器 (remote) +4. 回退到 localStorage + +### 3. 强制本地模式 + +```typescript +const viking = getVikingAdapter({ mode: 'local' }); +``` + +### 4. 强制 Sidecar 模式 + +```typescript +const viking = getVikingAdapter({ mode: 'sidecar' }); +``` + +### 5. 使用 MemoryManager 接口 + +```typescript +import { getVikingMemoryAdapter } from './lib/viking-memory-adapter'; + +const adapter = getVikingMemoryAdapter({ + enabled: true, + mode: 'auto', + fallbackToLocal: true +}); + +// 使用与 agent-memory.ts 相同的接口 +await adapter.save(entry); +await adapter.search(query); +await adapter.stats(agentId); +``` + +## 配置 + +### 本地服务器配置 (Tauri 命令) + +通过 Rust 后端管理本地 OpenViking 服务器: + +```typescript +import { invoke } from '@tauri-apps/api/core'; + +// 获取服务器状态 +const status = await invoke('viking_server_status'); + +// 启动服务器 +await invoke('viking_server_start', { + config: { + port: 1933, + dataDir: '', // 使用默认 ~/.openviking/workspace + configFile: '' // 使用默认 ~/.openviking/ov.conf + } +}); + +// 停止服务器 +await invoke('viking_server_stop'); + +// 重启服务器 +await invoke('viking_server_restart'); +``` + +### Tauri Sidecar 配置 (`tauri.conf.json`) + +仅在 sidecar 模式下需要: + +```json +{ + "bundle": { + "externalBin": ["binaries/ov"] + } +} +``` + +### 环境变量 + +| 变量 | 说明 | +|------|------| +| `ZCLAW_VIKING_BIN` | OpenViking CLI 二进制路径 (sidecar 模式) | +| `ZCLAW_VIKING_SERVER_BIN` | OpenViking 服务器二进制路径 (本地模式) | +| `VIKING_SERVER_URL` | 远程服务器地址 (远程模式) | +| `OPENVIKING_CONFIG_FILE` | OpenViking 配置文件路径 | + +## 安装 OpenViking (本地模式) + +### 系统要求 + +- Python 3.10+ +- 可选: Go 1.22+ (用于构建 AGFS 组件) +- 可选: C++ 编译器: GCC 9+ 或 Clang 11+ + +### 快速安装 (推荐) + +ZCLAW 会自动管理本地 OpenViking 服务器。你只需要安装 OpenViking Python 包: + +```bash +# 使用 pip 安装 +pip install openviking --upgrade --force-reinstall + +# 验证安装 +openviking-server --version +``` + +### 自动服务器管理 + +ZCLAW 的 `viking-server-manager.ts` 会自动: +1. 检测本地服务器是否运行 +2. 如未运行,自动启动 `openviking-server` +3. 监控服务器健康状态 +4. 在应用退出时清理进程 + +```typescript +import { getVikingServerManager } from './lib/viking-server-manager'; + +const manager = getVikingServerManager(); + +// 确保服务器运行(自动启动如果需要) +await manager.ensureRunning(); + +// 获取服务器状态 +const status = await manager.getStatus(); +// { running: true, port: 1933, pid: 12345, dataDir: '~/.openviking/workspace' } + +// 获取服务器 URL +const url = manager.getServerUrl(); // 'http://127.0.0.1:1933' +``` + +### 手动启动服务器 (可选) + +如果你希望手动控制服务器: + +```bash +# 前台运行 +openviking-server --host 127.0.0.1 --port 1933 + +# 后台运行 (Linux/macOS) +nohup openviking-server > ~/.openviking/server.log 2>&1 & + +# 后台运行 (Windows PowerShell) +Start-Process -NoNewWindow openviking-server -RedirectStandardOutput "$env:USERPROFILE\.openviking\server.log" +``` + +### 配置文件 + +创建 `~/.openviking/ov.conf`: + +```json +{ + "storage": { + "workspace": "/home/your-name/openviking_workspace" + }, + "embedding": { + "dense": { + "api_base": "https://ark.cn-beijing.volces.com/api/v3", + "api_key": "your-api-key", + "provider": "volcengine", + "dimension": 1024, + "model": "doubao-embedding-vision-250615" + } + }, + "vlm": { + "api_base": "https://ark.cn-beijing.volces.com/api/v3", + "api_key": "your-api-key", + "provider": "volcengine", + "model": "doubao-seed-2-0-pro-260215" + } +} +``` + +设置环境变量: + +```bash +# Linux/macOS +export OPENVIKING_CONFIG_FILE=~/.openviking/ov.conf + +# Windows PowerShell +$env:OPENVIKING_CONFIG_FILE = "$HOME/.openviking/ov.conf" +``` + +## 测试 + +### Rust 测试 + +```bash +cd desktop/src-tauri +cargo test +``` + +当前测试覆盖: +- `test_provider_configs` - LLM 提供商配置 +- `test_llm_client_creation` - LLM 客户端创建 +- `test_extraction_config_default` - 提取配置默认值 +- `test_uri_generation` - URI 生成 +- `test_estimate_tokens` - Token 估算 +- `test_extract_category` - 分类提取 +- `test_context_builder_config_default` - 上下文构建器配置 +- `test_status_unavailable_without_cli` - 无 CLI 时的状态 + +### TypeScript 测试 + +```bash +cd desktop +pnpm vitest run tests/desktop/memory*.test.ts +``` + +## 故障排除 + +### Q: OpenViking CLI not found + +确保 `ov-x86_64-pc-windows-msvc.exe` 存在于 `binaries/` 目录,或设置 `ZCLAW_VIKING_BIN` 环境变量。 + +### Q: Sidecar 启动失败 + +检查 Tauri 控制台日志,确认 sidecar 二进制权限正确。 + +### Q: 记忆未保存 + +1. 检查 LLM API 配置 (doubao/OpenAI/Anthropic) +2. 确认 `VIKING_SERVER_URL` 正确 (remote 模式) +3. 检查浏览器控制台的网络请求 + +## 迁移路径 + +从现有 localStorage 实现迁移到 OpenViking: + +1. 导出现有记忆:`getMemoryManager().exportToMarkdown(agentId)` +2. 切换到 OpenViking 模式 +3. 导入记忆到 OpenViking (通过 CLI 或 API) + +## 参考资料 + +- [OpenViking GitHub](https://github.com/anthropics/openviking) +- [ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md](../docs/ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md) +- [ZCLAW_OPENVIKING_INTEGRATION_PLAN.md](../docs/ZCLAW_OPENVIKING_INTEGRATION_PLAN.md)