/** * 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 };