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 <noreply@anthropic.com>
This commit is contained in:
iven
2026-03-16 08:14:44 +08:00
parent 137f1a32fa
commit c8202d04e0
6 changed files with 1721 additions and 1 deletions

View File

@@ -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"

View File

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

View File

@@ -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<u32>,
pub data_dir: Option<String>,
pub version: Option<String>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ServerConfig {
pub port: u16,
pub data_dir: String,
pub config_file: Option<String>,
}
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<Option<Child>> = 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<String, String> {
// 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<ServerStatus, String> {
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<ServerConfig>) -> Result<ServerStatus, String> {
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<ServerConfig>) -> Result<ServerStatus, String> {
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);
}
}

View File

@@ -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<string, unknown>) => Promise<unknown>) | 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<VikingAdapterConfig>) {
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<boolean> {
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<VikingServerStatus> {
return this.serverManager.getStatus();
}
/**
* Start the local server (for local mode)
*/
async startLocalServer(): Promise<VikingServerStatus> {
return this.serverManager.start();
}
/**
* Stop the local server (for local mode)
*/
async stopLocalServer(): Promise<void> {
return this.serverManager.stop();
}
getLastTrace(): RetrievalTrace | null {
return this.lastTrace;
}
// === User Memory Operations ===
async saveUserPreference(
key: string,
value: string
): Promise<MemorySaveResult> {
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<MemorySaveResult> {
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<MemoryResult[]> {
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<VikingEntry[]> {
try {
return await this.client.ls(VIKING_NS.userPreferences);
} catch {
return [];
}
}
// === Agent Memory Operations ===
async saveAgentLesson(
agentId: string,
lesson: string,
tags?: string[]
): Promise<MemorySaveResult> {
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<MemorySaveResult> {
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<MemorySaveResult> {
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<MemoryResult[]> {
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<void> {
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<string> {
const uri = `${VIKING_NS.agentIdentity(agentId)}/${sanitizeKey(fileName.replace('.md', ''))}`;
return this.client.readContent(uri, 'L2');
}
async proposeIdentityChange(
agentId: string,
proposal: IdentityChangeProposal
): Promise<MemorySaveResult> {
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<EnhancedContext> {
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<ExtractionResult> {
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<VikingEntry[]> {
try {
return await this.client.ls(path);
} catch {
return [];
}
}
async getMemoryTree(
agentId: string,
depth: number = 2
): Promise<VikingTreeNode | null> {
try {
return await this.client.tree(VIKING_NS.agentBase(agentId), depth);
} catch {
return null;
}
}
async deleteMemory(uri: string): Promise<void> {
await this.client.removeResource(uri);
}
// === Memory Statistics ===
async getMemoryStats(agentId: string): Promise<{
totalEntries: number;
userMemories: number;
agentMemories: number;
categories: Record<string, number>;
}> {
const [userEntries, agentEntries] = await Promise.all([
this.client.ls(VIKING_NS.userMemories).catch(() => []),
this.client.ls(VIKING_NS.agentMemories(agentId)).catch(() => []),
]);
const categories: Record<string, number> = {};
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<VikingAdapterConfig>): VikingAdapter {
if (!_instance || config) {
_instance = new VikingAdapter(config);
}
return _instance;
}
export function resetVikingAdapter(): void {
_instance = null;
}
export { VIKING_NS };

View File

@@ -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<VikingServerConfig> = {
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<VikingServerStatus> | null = null;
/**
* Get current server status
*/
async getStatus(): Promise<VikingServerStatus> {
try {
this.status = await invoke<VikingServerStatus>('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<VikingServerStatus> {
// 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<VikingServerStatus> {
const fullConfig = { ...DEFAULT_CONFIG, ...config };
console.log('[VikingServerManager] Starting local server on port', fullConfig.port);
try {
const status = await invoke<VikingServerStatus>('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<void> {
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<VikingServerStatus> {
console.log('[VikingServerManager] Restarting server');
try {
const status = await invoke<VikingServerStatus>('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<boolean> {
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;
}

View File

@@ -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<VikingServerStatus>('viking_server_status');
// 启动服务器
await invoke<VikingServerStatus>('viking_server_start', {
config: {
port: 1933,
dataDir: '', // 使用默认 ~/.openviking/workspace
configFile: '' // 使用默认 ~/.openviking/ov.conf
}
});
// 停止服务器
await invoke('viking_server_stop');
// 重启服务器
await invoke<VikingServerStatus>('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)