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

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