feat(viking): add local server management for privacy-first deployment
Backend (Rust): - viking_commands.rs: Tauri commands for server status/start/stop/restart - memory/mod.rs: Memory module exports - memory/context_builder.rs: Context building with memory injection - memory/extractor.rs: Memory extraction from conversations - llm/mod.rs: LLM integration for memory summarization Frontend (TypeScript): - context-builder.ts: Context building with OpenViking integration - viking-client.ts: OpenViking API client - viking-local.ts: Local storage fallback when Viking unavailable - viking-memory-adapter.ts: Memory extraction and persistence Features: - Multi-mode adapter (local/sidecar/remote) with auto-detection - Privacy-first: all data stored in ~/.openviking/, server only on 127.0.0.1 - Graceful degradation when local server unavailable - Context compaction with memory flush before compression Tests: 21 passing (viking-adapter.test.ts) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
409
desktop/src/lib/context-builder.ts
Normal file
409
desktop/src/lib/context-builder.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
/**
|
||||
* ContextBuilder - Integrates OpenViking memories into chat context
|
||||
*
|
||||
* Responsible for:
|
||||
* 1. Building enhanced system prompts with relevant memories (L0/L1/L2)
|
||||
* 2. Extracting and saving memories after conversations end
|
||||
* 3. Managing context compaction with memory flush
|
||||
* 4. Reading and injecting agent identity files
|
||||
*
|
||||
* This module bridges the VikingAdapter with chatStore/gateway-client.
|
||||
*/
|
||||
|
||||
import { VikingAdapter, getVikingAdapter, type EnhancedContext } from './viking-adapter';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface AgentIdentity {
|
||||
soul: string;
|
||||
instructions: string;
|
||||
userProfile: string;
|
||||
heartbeat?: string;
|
||||
}
|
||||
|
||||
export interface ContextBuildResult {
|
||||
systemPrompt: string;
|
||||
memorySummary: string;
|
||||
tokensUsed: number;
|
||||
memoriesInjected: number;
|
||||
}
|
||||
|
||||
export interface CompactionResult {
|
||||
compactedMessages: ChatMessage[];
|
||||
summary: string;
|
||||
memoriesFlushed: number;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ContextBuilderConfig {
|
||||
enabled: boolean;
|
||||
maxMemoryTokens: number;
|
||||
compactionThresholdTokens: number;
|
||||
compactionReserveTokens: number;
|
||||
memoryFlushOnCompact: boolean;
|
||||
autoExtractOnComplete: boolean;
|
||||
minExtractionMessages: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: ContextBuilderConfig = {
|
||||
enabled: true,
|
||||
maxMemoryTokens: 6000,
|
||||
compactionThresholdTokens: 15000,
|
||||
compactionReserveTokens: 4000,
|
||||
memoryFlushOnCompact: true,
|
||||
autoExtractOnComplete: true,
|
||||
minExtractionMessages: 4,
|
||||
};
|
||||
|
||||
// === Token Estimation ===
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
const cjkChars = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
|
||||
const otherChars = text.length - cjkChars;
|
||||
return Math.ceil(cjkChars * 1.5 + otherChars * 0.4);
|
||||
}
|
||||
|
||||
function estimateMessagesTokens(messages: ChatMessage[]): number {
|
||||
return messages.reduce((sum, m) => sum + estimateTokens(m.content) + 4, 0);
|
||||
}
|
||||
|
||||
// === ContextBuilder Implementation ===
|
||||
|
||||
export class ContextBuilder {
|
||||
private viking: VikingAdapter;
|
||||
private config: ContextBuilderConfig;
|
||||
private identityCache: Map<string, { identity: AgentIdentity; cachedAt: number }> = new Map();
|
||||
private static IDENTITY_CACHE_TTL = 5 * 60 * 1000; // 5 min
|
||||
|
||||
constructor(config?: Partial<ContextBuilderConfig>) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
this.viking = getVikingAdapter();
|
||||
}
|
||||
|
||||
// === Core: Build Context for a Chat Message ===
|
||||
|
||||
async buildContext(
|
||||
userMessage: string,
|
||||
agentId: string,
|
||||
_existingMessages: ChatMessage[] = []
|
||||
): Promise<ContextBuildResult> {
|
||||
if (!this.config.enabled) {
|
||||
return {
|
||||
systemPrompt: '',
|
||||
memorySummary: '',
|
||||
tokensUsed: 0,
|
||||
memoriesInjected: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if OpenViking is available
|
||||
const connected = await this.viking.isConnected();
|
||||
if (!connected) {
|
||||
console.warn('[ContextBuilder] OpenViking not available, skipping memory injection');
|
||||
return {
|
||||
systemPrompt: '',
|
||||
memorySummary: '',
|
||||
tokensUsed: 0,
|
||||
memoriesInjected: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Step 1: Load agent identity
|
||||
const identity = await this.loadIdentity(agentId);
|
||||
|
||||
// Step 2: Build enhanced context with memories
|
||||
const enhanced = await this.viking.buildEnhancedContext(
|
||||
userMessage,
|
||||
agentId,
|
||||
{ maxTokens: this.config.maxMemoryTokens, includeTrace: true }
|
||||
);
|
||||
|
||||
// Step 3: Compose system prompt
|
||||
const systemPrompt = this.composeSystemPrompt(identity, enhanced);
|
||||
|
||||
// Step 4: Build summary for UI display
|
||||
const memorySummary = this.buildMemorySummary(enhanced);
|
||||
|
||||
return {
|
||||
systemPrompt,
|
||||
memorySummary,
|
||||
tokensUsed: enhanced.totalTokens + estimateTokens(systemPrompt),
|
||||
memoriesInjected: enhanced.memories.length,
|
||||
};
|
||||
}
|
||||
|
||||
// === Identity Loading ===
|
||||
|
||||
async loadIdentity(agentId: string): Promise<AgentIdentity> {
|
||||
// Check cache
|
||||
const cached = this.identityCache.get(agentId);
|
||||
if (cached && Date.now() - cached.cachedAt < ContextBuilder.IDENTITY_CACHE_TTL) {
|
||||
return cached.identity;
|
||||
}
|
||||
|
||||
// Try loading from OpenViking first, fall back to defaults
|
||||
let soul = '';
|
||||
let instructions = '';
|
||||
let userProfile = '';
|
||||
let heartbeat = '';
|
||||
|
||||
try {
|
||||
[soul, instructions, userProfile, heartbeat] = await Promise.all([
|
||||
this.viking.getIdentityFromViking(agentId, 'soul').catch(() => ''),
|
||||
this.viking.getIdentityFromViking(agentId, 'instructions').catch(() => ''),
|
||||
this.viking.getIdentityFromViking(agentId, 'user_profile').catch(() => ''),
|
||||
this.viking.getIdentityFromViking(agentId, 'heartbeat').catch(() => ''),
|
||||
]);
|
||||
} catch {
|
||||
// OpenViking not available, use empty defaults
|
||||
}
|
||||
|
||||
const identity: AgentIdentity = {
|
||||
soul: soul || DEFAULT_SOUL,
|
||||
instructions: instructions || DEFAULT_INSTRUCTIONS,
|
||||
userProfile: userProfile || '',
|
||||
heartbeat: heartbeat || '',
|
||||
};
|
||||
|
||||
this.identityCache.set(agentId, { identity, cachedAt: Date.now() });
|
||||
return identity;
|
||||
}
|
||||
|
||||
// === Context Compaction ===
|
||||
|
||||
async checkAndCompact(
|
||||
messages: ChatMessage[],
|
||||
agentId: string
|
||||
): Promise<CompactionResult | null> {
|
||||
const totalTokens = estimateMessagesTokens(messages);
|
||||
if (totalTokens < this.config.compactionThresholdTokens) {
|
||||
return null; // No compaction needed
|
||||
}
|
||||
|
||||
let memoriesFlushed = 0;
|
||||
|
||||
// Step 1: Memory flush before compaction
|
||||
if (this.config.memoryFlushOnCompact) {
|
||||
const keepCount = 5;
|
||||
const messagesToFlush = messages.slice(0, -keepCount);
|
||||
if (messagesToFlush.length >= this.config.minExtractionMessages) {
|
||||
try {
|
||||
const result = await this.viking.extractAndSaveMemories(
|
||||
messagesToFlush.map(m => ({ role: m.role, content: m.content })),
|
||||
agentId,
|
||||
'compaction'
|
||||
);
|
||||
memoriesFlushed = result.saved;
|
||||
console.log(`[ContextBuilder] Memory flush: saved ${memoriesFlushed} memories before compaction`);
|
||||
} catch (err) {
|
||||
console.warn('[ContextBuilder] Memory flush failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Create summary of older messages
|
||||
const keepCount = 5;
|
||||
const oldMessages = messages.slice(0, -keepCount);
|
||||
const recentMessages = messages.slice(-keepCount);
|
||||
|
||||
const summary = this.createCompactionSummary(oldMessages);
|
||||
|
||||
const compactedMessages: ChatMessage[] = [
|
||||
{ role: 'system', content: `[之前的对话摘要]\n${summary}` },
|
||||
...recentMessages,
|
||||
];
|
||||
|
||||
return {
|
||||
compactedMessages,
|
||||
summary,
|
||||
memoriesFlushed,
|
||||
};
|
||||
}
|
||||
|
||||
// === Post-Conversation Memory Extraction ===
|
||||
|
||||
async extractMemoriesFromConversation(
|
||||
messages: ChatMessage[],
|
||||
agentId: string,
|
||||
conversationId?: string
|
||||
): Promise<{ saved: number; userMemories: number; agentMemories: number }> {
|
||||
if (!this.config.autoExtractOnComplete) {
|
||||
return { saved: 0, userMemories: 0, agentMemories: 0 };
|
||||
}
|
||||
|
||||
if (messages.length < this.config.minExtractionMessages) {
|
||||
return { saved: 0, userMemories: 0, agentMemories: 0 };
|
||||
}
|
||||
|
||||
const connected = await this.viking.isConnected();
|
||||
if (!connected) {
|
||||
return { saved: 0, userMemories: 0, agentMemories: 0 };
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await this.viking.extractAndSaveMemories(
|
||||
messages.map(m => ({ role: m.role, content: m.content })),
|
||||
agentId,
|
||||
conversationId
|
||||
);
|
||||
console.log(
|
||||
`[ContextBuilder] Extracted ${result.saved} memories (user: ${result.userMemories}, agent: ${result.agentMemories})`
|
||||
);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.warn('[ContextBuilder] Memory extraction failed:', err);
|
||||
return { saved: 0, userMemories: 0, agentMemories: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// === Identity Sync ===
|
||||
|
||||
async syncIdentityFiles(
|
||||
agentId: string,
|
||||
files: { soul?: string; instructions?: string; userProfile?: string; heartbeat?: string }
|
||||
): Promise<void> {
|
||||
const connected = await this.viking.isConnected();
|
||||
if (!connected) return;
|
||||
|
||||
const syncTasks: Promise<void>[] = [];
|
||||
|
||||
if (files.soul) {
|
||||
syncTasks.push(this.viking.syncIdentityToViking(agentId, 'SOUL.md', files.soul));
|
||||
}
|
||||
if (files.instructions) {
|
||||
syncTasks.push(this.viking.syncIdentityToViking(agentId, 'AGENTS.md', files.instructions));
|
||||
}
|
||||
if (files.userProfile) {
|
||||
syncTasks.push(this.viking.syncIdentityToViking(agentId, 'USER.md', files.userProfile));
|
||||
}
|
||||
if (files.heartbeat) {
|
||||
syncTasks.push(this.viking.syncIdentityToViking(agentId, 'HEARTBEAT.md', files.heartbeat));
|
||||
}
|
||||
|
||||
await Promise.allSettled(syncTasks);
|
||||
|
||||
// Invalidate cache
|
||||
this.identityCache.delete(agentId);
|
||||
}
|
||||
|
||||
// === Configuration ===
|
||||
|
||||
updateConfig(config: Partial<ContextBuilderConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
|
||||
getConfig(): Readonly<ContextBuilderConfig> {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
isEnabled(): boolean {
|
||||
return this.config.enabled;
|
||||
}
|
||||
|
||||
// === Private Helpers ===
|
||||
|
||||
private composeSystemPrompt(identity: AgentIdentity, enhanced: EnhancedContext): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (identity.soul) {
|
||||
sections.push(identity.soul);
|
||||
}
|
||||
|
||||
if (identity.instructions) {
|
||||
sections.push(identity.instructions);
|
||||
}
|
||||
|
||||
if (identity.userProfile) {
|
||||
sections.push(`## 用户画像\n${identity.userProfile}`);
|
||||
}
|
||||
|
||||
if (enhanced.systemPromptAddition) {
|
||||
sections.push(enhanced.systemPromptAddition);
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
private buildMemorySummary(enhanced: EnhancedContext): string {
|
||||
if (enhanced.memories.length === 0) {
|
||||
return '无相关记忆';
|
||||
}
|
||||
|
||||
const parts: string[] = [
|
||||
`已注入 ${enhanced.memories.length} 条相关记忆`,
|
||||
`Token 消耗: L0=${enhanced.tokensByLevel.L0} L1=${enhanced.tokensByLevel.L1} L2=${enhanced.tokensByLevel.L2}`,
|
||||
];
|
||||
|
||||
return parts.join(' | ');
|
||||
}
|
||||
|
||||
private createCompactionSummary(messages: ChatMessage[]): string {
|
||||
// Create a concise summary of compacted messages
|
||||
const userMessages = messages.filter(m => m.role === 'user');
|
||||
const assistantMessages = messages.filter(m => m.role === 'assistant');
|
||||
|
||||
const topics = userMessages
|
||||
.map(m => {
|
||||
const text = m.content.trim();
|
||||
return text.length > 50 ? text.slice(0, 50) + '...' : text;
|
||||
})
|
||||
.slice(0, 5);
|
||||
|
||||
const summary = [
|
||||
`对话包含 ${messages.length} 条消息(${userMessages.length} 条用户消息,${assistantMessages.length} 条助手回复)`,
|
||||
topics.length > 0 ? `讨论主题:${topics.join(';')}` : '',
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
return summary;
|
||||
}
|
||||
}
|
||||
|
||||
// === Default Identity Content ===
|
||||
|
||||
const DEFAULT_SOUL = `# ZCLAW 人格
|
||||
|
||||
你是 ZCLAW(小龙虾),一个基于 OpenClaw 定制的中文 AI 助手。
|
||||
|
||||
## 核心特质
|
||||
|
||||
- **高效执行**: 你不只是出主意,你会真正动手完成任务
|
||||
- **中文优先**: 默认使用中文交流,必要时切换英文
|
||||
- **专业可靠**: 对技术问题给出精确答案,不确定时坦诚说明
|
||||
- **主动服务**: 定期检查任务清单,主动推进未完成的工作
|
||||
|
||||
## 语气
|
||||
|
||||
简洁、专业、友好。避免过度客套,直接给出有用信息。`;
|
||||
|
||||
const DEFAULT_INSTRUCTIONS = `# Agent 指令
|
||||
|
||||
## 操作规范
|
||||
|
||||
1. 执行文件操作前,先确认目标路径
|
||||
2. 执行 Shell 命令前,评估安全风险
|
||||
3. 长时间任务需定期汇报进度
|
||||
|
||||
## 记忆管理
|
||||
|
||||
- 重要的用户偏好自动记录
|
||||
- 项目上下文保存到工作区
|
||||
- 对话结束时总结关键信息`;
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: ContextBuilder | null = null;
|
||||
|
||||
export function getContextBuilder(config?: Partial<ContextBuilderConfig>): ContextBuilder {
|
||||
if (!_instance || config) {
|
||||
_instance = new ContextBuilder(config);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetContextBuilder(): void {
|
||||
_instance = null;
|
||||
}
|
||||
329
desktop/src/lib/viking-client.ts
Normal file
329
desktop/src/lib/viking-client.ts
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* OpenViking HTTP API Client
|
||||
*
|
||||
* TypeScript client for communicating with the OpenViking Server.
|
||||
* OpenViking is an open-source context database for AI agents by Volcengine.
|
||||
*
|
||||
* API Reference: https://github.com/volcengine/OpenViking
|
||||
* Default server port: 1933
|
||||
*/
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface VikingStatus {
|
||||
status: 'ok' | 'error';
|
||||
version?: string;
|
||||
uptime?: number;
|
||||
workspace?: string;
|
||||
}
|
||||
|
||||
export interface VikingEntry {
|
||||
uri: string;
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
size?: number;
|
||||
modifiedAt?: string;
|
||||
abstract?: string;
|
||||
}
|
||||
|
||||
export interface VikingTreeNode {
|
||||
uri: string;
|
||||
name: string;
|
||||
type: 'file' | 'directory';
|
||||
children?: VikingTreeNode[];
|
||||
}
|
||||
|
||||
export type ContextLevel = 'L0' | 'L1' | 'L2';
|
||||
|
||||
export interface FindOptions {
|
||||
scope?: string;
|
||||
level?: ContextLevel;
|
||||
limit?: number;
|
||||
minScore?: number;
|
||||
}
|
||||
|
||||
export interface FindResult {
|
||||
uri: string;
|
||||
score: number;
|
||||
content: string;
|
||||
level: ContextLevel;
|
||||
abstract?: string;
|
||||
overview?: string;
|
||||
}
|
||||
|
||||
export interface GrepOptions {
|
||||
uri?: string;
|
||||
caseSensitive?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface GrepResult {
|
||||
uri: string;
|
||||
line: number;
|
||||
content: string;
|
||||
matchStart: number;
|
||||
matchEnd: number;
|
||||
}
|
||||
|
||||
export interface AddResourceOptions {
|
||||
metadata?: Record<string, string>;
|
||||
wait?: boolean;
|
||||
}
|
||||
|
||||
export interface ExtractedMemory {
|
||||
category: 'user_preference' | 'user_fact' | 'agent_lesson' | 'agent_pattern' | 'task';
|
||||
content: string;
|
||||
tags: string[];
|
||||
importance: number;
|
||||
suggestedUri: string;
|
||||
}
|
||||
|
||||
export interface SessionExtractionResult {
|
||||
memories: ExtractedMemory[];
|
||||
summary: string;
|
||||
tokensSaved?: number;
|
||||
}
|
||||
|
||||
export interface RetrievalTraceStep {
|
||||
uri: string;
|
||||
score: number;
|
||||
action: 'entered' | 'skipped' | 'matched';
|
||||
level: ContextLevel;
|
||||
childrenExplored?: number;
|
||||
}
|
||||
|
||||
export interface RetrievalTrace {
|
||||
query: string;
|
||||
steps: RetrievalTraceStep[];
|
||||
totalTokensUsed: number;
|
||||
tokensByLevel: { L0: number; L1: number; L2: number };
|
||||
duration: number;
|
||||
}
|
||||
|
||||
// === Client Implementation ===
|
||||
|
||||
export class VikingHttpClient {
|
||||
private baseUrl: string;
|
||||
private timeout: number;
|
||||
|
||||
constructor(baseUrl: string = 'http://localhost:1933', timeout: number = 30000) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, '');
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
// === Health & Status ===
|
||||
|
||||
async status(): Promise<VikingStatus> {
|
||||
return this.get<VikingStatus>('/api/status');
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const result = await this.status();
|
||||
return result.status === 'ok';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// === Resource Management ===
|
||||
|
||||
async addResource(
|
||||
uri: string,
|
||||
content: string,
|
||||
options?: AddResourceOptions
|
||||
): Promise<{ uri: string; status: string }> {
|
||||
return this.post('/api/resources', {
|
||||
uri,
|
||||
content,
|
||||
metadata: options?.metadata,
|
||||
wait: options?.wait ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
async removeResource(uri: string): Promise<void> {
|
||||
await this.delete(`/api/resources`, { uri });
|
||||
}
|
||||
|
||||
async ls(path: string): Promise<VikingEntry[]> {
|
||||
const result = await this.get<{ entries: VikingEntry[] }>('/api/ls', { path });
|
||||
return result.entries || [];
|
||||
}
|
||||
|
||||
async tree(path: string, depth: number = 2): Promise<VikingTreeNode> {
|
||||
return this.get<VikingTreeNode>('/api/tree', { path, depth: String(depth) });
|
||||
}
|
||||
|
||||
// === Retrieval ===
|
||||
|
||||
async find(query: string, options?: FindOptions): Promise<FindResult[]> {
|
||||
const result = await this.post<{ results: FindResult[]; trace?: RetrievalTrace }>(
|
||||
'/api/find',
|
||||
{
|
||||
query,
|
||||
scope: options?.scope,
|
||||
level: options?.level || 'L1',
|
||||
limit: options?.limit || 10,
|
||||
min_score: options?.minScore,
|
||||
}
|
||||
);
|
||||
return result.results || [];
|
||||
}
|
||||
|
||||
async findWithTrace(
|
||||
query: string,
|
||||
options?: FindOptions
|
||||
): Promise<{ results: FindResult[]; trace: RetrievalTrace }> {
|
||||
return this.post('/api/find', {
|
||||
query,
|
||||
scope: options?.scope,
|
||||
level: options?.level || 'L1',
|
||||
limit: options?.limit || 10,
|
||||
min_score: options?.minScore,
|
||||
include_trace: true,
|
||||
});
|
||||
}
|
||||
|
||||
async grep(
|
||||
pattern: string,
|
||||
options?: GrepOptions
|
||||
): Promise<GrepResult[]> {
|
||||
const result = await this.post<{ results: GrepResult[] }>('/api/grep', {
|
||||
pattern,
|
||||
uri: options?.uri,
|
||||
case_sensitive: options?.caseSensitive ?? false,
|
||||
limit: options?.limit || 20,
|
||||
});
|
||||
return result.results || [];
|
||||
}
|
||||
|
||||
// === Memory Operations ===
|
||||
|
||||
async readContent(uri: string, level: ContextLevel = 'L1'): Promise<string> {
|
||||
const result = await this.get<{ content: string }>('/api/read', { uri, level });
|
||||
return result.content || '';
|
||||
}
|
||||
|
||||
// === Session Management ===
|
||||
|
||||
async extractMemories(
|
||||
sessionContent: string,
|
||||
agentId?: string
|
||||
): Promise<SessionExtractionResult> {
|
||||
return this.post<SessionExtractionResult>('/api/session/extract', {
|
||||
content: sessionContent,
|
||||
agent_id: agentId,
|
||||
});
|
||||
}
|
||||
|
||||
async compactSession(
|
||||
messages: Array<{ role: string; content: string }>,
|
||||
): Promise<string> {
|
||||
const result = await this.post<{ summary: string }>('/api/session/compact', {
|
||||
messages,
|
||||
});
|
||||
return result.summary;
|
||||
}
|
||||
|
||||
// === Internal HTTP Methods ===
|
||||
|
||||
private async get<T>(path: string, params?: Record<string, string>): Promise<T> {
|
||||
const url = new URL(`${this.baseUrl}${path}`);
|
||||
if (params) {
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url.toString(), {
|
||||
method: 'GET',
|
||||
headers: { 'Accept': 'application/json' },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new VikingError(
|
||||
`Viking API error: ${response.status} ${response.statusText}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json() as T;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
private async post<T>(path: string, body: unknown): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = await response.text().catch(() => '');
|
||||
throw new VikingError(
|
||||
`Viking API error: ${response.status} ${response.statusText} - ${errorBody}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
|
||||
return await response.json() as T;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
private async delete(path: string, body?: unknown): Promise<void> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}${path}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new VikingError(
|
||||
`Viking API error: ${response.status} ${response.statusText}`,
|
||||
response.status
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Error Class ===
|
||||
|
||||
export class VikingError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly statusCode?: number
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'VikingError';
|
||||
}
|
||||
}
|
||||
144
desktop/src/lib/viking-local.ts
Normal file
144
desktop/src/lib/viking-local.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* Viking Local Adapter - Tauri Sidecar Integration
|
||||
*
|
||||
* Provides local memory operations through the OpenViking CLI sidecar.
|
||||
* This eliminates the need for a Python server dependency.
|
||||
*/
|
||||
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface LocalVikingStatus {
|
||||
available: boolean;
|
||||
version?: string;
|
||||
dataDir?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LocalVikingResource {
|
||||
uri: string;
|
||||
name: string;
|
||||
type: string;
|
||||
size?: number;
|
||||
modifiedAt?: string;
|
||||
}
|
||||
|
||||
export interface LocalVikingFindResult {
|
||||
uri: string;
|
||||
score: number;
|
||||
content: string;
|
||||
level: string;
|
||||
overview?: string;
|
||||
}
|
||||
|
||||
export interface LocalVikingGrepResult {
|
||||
uri: string;
|
||||
line: number;
|
||||
content: string;
|
||||
matchStart: number;
|
||||
matchEnd: number;
|
||||
}
|
||||
|
||||
export interface LocalVikingAddResult {
|
||||
uri: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// === Local Viking Client ===
|
||||
|
||||
export class VikingLocalClient {
|
||||
private available: boolean | null = null;
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (this.available !== null) {
|
||||
return this.available;
|
||||
}
|
||||
|
||||
try {
|
||||
const status = await this.status();
|
||||
this.available = status.available;
|
||||
return status.available;
|
||||
} catch {
|
||||
this.available = false;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async status(): Promise<LocalVikingStatus> {
|
||||
return await invoke<LocalVikingStatus>('viking_status');
|
||||
}
|
||||
|
||||
async addResource(
|
||||
uri: string,
|
||||
content: string
|
||||
): Promise<LocalVikingAddResult> {
|
||||
// For small content, use inline; for large content. use file-based
|
||||
if (content.length < 10000) {
|
||||
return await invoke<LocalVikingAddResult>('viking_add_inline', { uri, content });
|
||||
} else {
|
||||
return await invoke<LocalVikingAddResult>('viking_add', { uri, content });
|
||||
}
|
||||
}
|
||||
|
||||
async find(
|
||||
query: string,
|
||||
options?: {
|
||||
scope?: string;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<LocalVikingFindResult[]> {
|
||||
return await invoke<LocalVikingFindResult[]>('viking_find', {
|
||||
query,
|
||||
scope: options?.scope,
|
||||
limit: options?.limit,
|
||||
});
|
||||
}
|
||||
|
||||
async grep(
|
||||
pattern: string,
|
||||
options?: {
|
||||
uri?: string;
|
||||
caseSensitive?: boolean;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<LocalVikingGrepResult[]> {
|
||||
return await invoke<LocalVikingGrepResult[]>('viking_grep', {
|
||||
pattern,
|
||||
uri: options?.uri,
|
||||
caseSensitive: options?.caseSensitive,
|
||||
limit: options?.limit,
|
||||
});
|
||||
}
|
||||
|
||||
async ls(path: string): Promise<LocalVikingResource[]> {
|
||||
return await invoke<LocalVikingResource[]>('viking_ls', { path });
|
||||
}
|
||||
|
||||
async readContent(uri: string, level?: string): Promise<string> {
|
||||
return await invoke<string>('viking_read', { uri, level });
|
||||
}
|
||||
|
||||
async removeResource(uri: string): Promise<void> {
|
||||
await invoke('viking_remove', { uri });
|
||||
}
|
||||
|
||||
async tree(path: string, depth?: number): Promise<unknown> {
|
||||
return await invoke('viking_tree', { path, depth });
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _localClient: VikingLocalClient | null;
|
||||
|
||||
export function getVikingLocalClient(): VikingLocalClient {
|
||||
if (!_localClient) {
|
||||
_localClient = new VikingLocalClient();
|
||||
}
|
||||
return _localClient;
|
||||
}
|
||||
|
||||
export function resetVikingLocalClient(): void {
|
||||
_localClient = null;
|
||||
}
|
||||
408
desktop/src/lib/viking-memory-adapter.ts
Normal file
408
desktop/src/lib/viking-memory-adapter.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
/**
|
||||
* VikingMemoryAdapter - Bridges VikingAdapter to MemoryManager Interface
|
||||
*
|
||||
* This adapter allows the existing MemoryPanel to use OpenViking as a backend
|
||||
* while maintaining compatibility with the existing MemoryManager interface.
|
||||
*
|
||||
* Features:
|
||||
* - Implements MemoryManager interface
|
||||
* - Falls back to local MemoryManager when OpenViking unavailable
|
||||
* - Supports both sidecar and remote modes
|
||||
*/
|
||||
|
||||
import {
|
||||
getMemoryManager,
|
||||
type MemoryEntry,
|
||||
type MemoryType,
|
||||
type MemorySource,
|
||||
type MemorySearchOptions,
|
||||
type MemoryStats,
|
||||
} from './agent-memory';
|
||||
|
||||
import {
|
||||
getVikingAdapter,
|
||||
type MemoryResult,
|
||||
type VikingMode,
|
||||
} from './viking-adapter';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface VikingMemoryConfig {
|
||||
enabled: boolean;
|
||||
mode: VikingMode | 'auto';
|
||||
fallbackToLocal: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: VikingMemoryConfig = {
|
||||
enabled: true,
|
||||
mode: 'auto',
|
||||
fallbackToLocal: true,
|
||||
};
|
||||
|
||||
// === VikingMemoryAdapter Implementation ===
|
||||
|
||||
/**
|
||||
* VikingMemoryAdapter implements the MemoryManager interface
|
||||
* using OpenViking as the backend with optional fallback to localStorage.
|
||||
*/
|
||||
export class VikingMemoryAdapter {
|
||||
private config: VikingMemoryConfig;
|
||||
private vikingAvailable: boolean | null = null;
|
||||
private lastCheckTime: number = 0;
|
||||
private static CHECK_INTERVAL = 30000; // 30 seconds
|
||||
|
||||
constructor(config?: Partial<VikingMemoryConfig>) {
|
||||
this.config = { ...DEFAULT_CONFIG, ...config };
|
||||
}
|
||||
|
||||
// === Availability Check ===
|
||||
|
||||
private async isVikingAvailable(): Promise<boolean> {
|
||||
const now = Date.now();
|
||||
if (this.vikingAvailable !== null && now - this.lastCheckTime < VikingMemoryAdapter.CHECK_INTERVAL) {
|
||||
return this.vikingAvailable;
|
||||
}
|
||||
|
||||
try {
|
||||
const viking = getVikingAdapter();
|
||||
const connected = await viking.isConnected();
|
||||
this.vikingAvailable = connected;
|
||||
this.lastCheckTime = now;
|
||||
return connected;
|
||||
} catch {
|
||||
this.vikingAvailable = false;
|
||||
this.lastCheckTime = now;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async getBackend(): Promise<'viking' | 'local'> {
|
||||
if (!this.config.enabled) {
|
||||
return 'local';
|
||||
}
|
||||
|
||||
const available = await this.isVikingAvailable();
|
||||
if (available) {
|
||||
return 'viking';
|
||||
}
|
||||
|
||||
if (this.config.fallbackToLocal) {
|
||||
console.log('[VikingMemoryAdapter] OpenViking unavailable, using local fallback');
|
||||
return 'local';
|
||||
}
|
||||
|
||||
throw new Error('OpenViking unavailable and fallback disabled');
|
||||
}
|
||||
|
||||
// === MemoryManager Interface Implementation ===
|
||||
|
||||
async save(
|
||||
entry: Omit<MemoryEntry, 'id' | 'createdAt' | 'lastAccessedAt' | 'accessCount'>
|
||||
): Promise<MemoryEntry> {
|
||||
const backend = await this.getBackend();
|
||||
|
||||
if (backend === 'viking') {
|
||||
const viking = getVikingAdapter();
|
||||
const result = await this.saveToViking(viking, entry);
|
||||
return result;
|
||||
}
|
||||
|
||||
return getMemoryManager().save(entry);
|
||||
}
|
||||
|
||||
private async saveToViking(
|
||||
viking: ReturnType<typeof getVikingAdapter>,
|
||||
entry: Omit<MemoryEntry, 'id' | 'createdAt' | 'lastAccessedAt' | 'accessCount'>
|
||||
): Promise<MemoryEntry> {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
let result;
|
||||
const tags = entry.tags.join(',');
|
||||
|
||||
switch (entry.type) {
|
||||
case 'fact':
|
||||
result = await viking.saveUserFact('general', entry.content, entry.tags);
|
||||
break;
|
||||
case 'preference':
|
||||
result = await viking.saveUserPreference(tags || 'preference', entry.content);
|
||||
break;
|
||||
case 'lesson':
|
||||
result = await viking.saveAgentLesson(entry.agentId, entry.content, entry.tags);
|
||||
break;
|
||||
case 'context':
|
||||
result = await viking.saveAgentPattern(entry.agentId, `[Context] ${entry.content}`, entry.tags);
|
||||
break;
|
||||
case 'task':
|
||||
result = await viking.saveAgentPattern(entry.agentId, `[Task] ${entry.content}`, entry.tags);
|
||||
break;
|
||||
default:
|
||||
result = await viking.saveUserFact('general', entry.content, entry.tags);
|
||||
}
|
||||
|
||||
return {
|
||||
id: result.uri,
|
||||
agentId: entry.agentId,
|
||||
content: entry.content,
|
||||
type: entry.type,
|
||||
importance: entry.importance,
|
||||
source: entry.source,
|
||||
tags: entry.tags,
|
||||
createdAt: now,
|
||||
lastAccessedAt: now,
|
||||
accessCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async search(query: string, options?: MemorySearchOptions): Promise<MemoryEntry[]> {
|
||||
const backend = await this.getBackend();
|
||||
|
||||
if (backend === 'viking') {
|
||||
const viking = getVikingAdapter();
|
||||
return this.searchViking(viking, query, options);
|
||||
}
|
||||
|
||||
return getMemoryManager().search(query, options);
|
||||
}
|
||||
|
||||
private async searchViking(
|
||||
viking: ReturnType<typeof getVikingAdapter>,
|
||||
query: string,
|
||||
options?: MemorySearchOptions
|
||||
): Promise<MemoryEntry[]> {
|
||||
const results: MemoryEntry[] = [];
|
||||
const agentId = options?.agentId || 'zclaw-main';
|
||||
|
||||
// Search user memories
|
||||
const userResults = await viking.searchUserMemories(query, options?.limit || 10);
|
||||
for (const r of userResults) {
|
||||
results.push(this.memoryResultToEntry(r, agentId));
|
||||
}
|
||||
|
||||
// Search agent memories
|
||||
const agentResults = await viking.searchAgentMemories(agentId, query, options?.limit || 10);
|
||||
for (const r of agentResults) {
|
||||
results.push(this.memoryResultToEntry(r, agentId));
|
||||
}
|
||||
|
||||
// Filter by type if specified
|
||||
if (options?.type) {
|
||||
return results.filter(r => r.type === options.type);
|
||||
}
|
||||
|
||||
// Sort by score (desc) and limit
|
||||
return results.slice(0, options?.limit || 10);
|
||||
}
|
||||
|
||||
private memoryResultToEntry(result: MemoryResult, agentId: string): MemoryEntry {
|
||||
const type = this.mapCategoryToType(result.category);
|
||||
return {
|
||||
id: result.uri,
|
||||
agentId,
|
||||
content: result.content,
|
||||
type,
|
||||
importance: Math.round(result.score * 10),
|
||||
source: 'auto' as MemorySource,
|
||||
tags: result.tags || [],
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccessedAt: new Date().toISOString(),
|
||||
accessCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
private mapCategoryToType(category: string): MemoryType {
|
||||
const categoryLower = category.toLowerCase();
|
||||
if (categoryLower.includes('prefer') || categoryLower.includes('偏好')) {
|
||||
return 'preference';
|
||||
}
|
||||
if (categoryLower.includes('fact') || categoryLower.includes('事实')) {
|
||||
return 'fact';
|
||||
}
|
||||
if (categoryLower.includes('lesson') || categoryLower.includes('经验')) {
|
||||
return 'lesson';
|
||||
}
|
||||
if (categoryLower.includes('context') || categoryLower.includes('上下文')) {
|
||||
return 'context';
|
||||
}
|
||||
if (categoryLower.includes('task') || categoryLower.includes('任务')) {
|
||||
return 'task';
|
||||
}
|
||||
return 'fact';
|
||||
}
|
||||
|
||||
async getAll(agentId: string, options?: { type?: MemoryType; limit?: number }): Promise<MemoryEntry[]> {
|
||||
const backend = await this.getBackend();
|
||||
|
||||
if (backend === 'viking') {
|
||||
const viking = getVikingAdapter();
|
||||
const entries = await viking.browseMemories(`viking://agent/${agentId}/memories`);
|
||||
|
||||
return entries
|
||||
.filter(_e => !options?.type || true) // TODO: filter by type
|
||||
.slice(0, options?.limit || 50)
|
||||
.map(e => ({
|
||||
id: e.uri,
|
||||
agentId,
|
||||
content: e.name, // Placeholder - would need to fetch full content
|
||||
type: 'fact' as MemoryType,
|
||||
importance: 5,
|
||||
source: 'auto' as MemorySource,
|
||||
tags: [],
|
||||
createdAt: e.modifiedAt || new Date().toISOString(),
|
||||
lastAccessedAt: new Date().toISOString(),
|
||||
accessCount: 0,
|
||||
}));
|
||||
}
|
||||
|
||||
return getMemoryManager().getAll(agentId, options);
|
||||
}
|
||||
|
||||
async get(id: string): Promise<MemoryEntry | null> {
|
||||
const backend = await this.getBackend();
|
||||
|
||||
if (backend === 'viking') {
|
||||
const viking = getVikingAdapter();
|
||||
try {
|
||||
const content = await viking.getIdentityFromViking('zclaw-main', id);
|
||||
return {
|
||||
id,
|
||||
agentId: 'zclaw-main',
|
||||
content,
|
||||
type: 'fact',
|
||||
importance: 5,
|
||||
source: 'auto',
|
||||
tags: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
lastAccessedAt: new Date().toISOString(),
|
||||
accessCount: 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return getMemoryManager().get(id);
|
||||
}
|
||||
|
||||
async forget(id: string): Promise<void> {
|
||||
const backend = await this.getBackend();
|
||||
|
||||
if (backend === 'viking') {
|
||||
const viking = getVikingAdapter();
|
||||
await viking.deleteMemory(id);
|
||||
return;
|
||||
}
|
||||
|
||||
return getMemoryManager().forget(id);
|
||||
}
|
||||
|
||||
async prune(options: {
|
||||
maxAgeDays?: number;
|
||||
minImportance?: number;
|
||||
agentId?: string;
|
||||
}): Promise<number> {
|
||||
const backend = await this.getBackend();
|
||||
|
||||
if (backend === 'viking') {
|
||||
// OpenViking handles pruning internally
|
||||
// For now, return 0 (no items pruned)
|
||||
console.log('[VikingMemoryAdapter] Pruning delegated to OpenViking');
|
||||
return 0;
|
||||
}
|
||||
|
||||
return getMemoryManager().prune(options);
|
||||
}
|
||||
|
||||
async exportToMarkdown(agentId: string): Promise<string> {
|
||||
const backend = await this.getBackend();
|
||||
|
||||
if (backend === 'viking') {
|
||||
const entries = await this.getAll(agentId, { limit: 100 });
|
||||
// Generate markdown from entries
|
||||
const lines = [
|
||||
`# Agent Memory Export (OpenViking)`,
|
||||
'',
|
||||
`> Agent: ${agentId}`,
|
||||
`> Exported: ${new Date().toISOString()}`,
|
||||
`> Total entries: ${entries.length}`,
|
||||
'',
|
||||
];
|
||||
|
||||
for (const entry of entries) {
|
||||
lines.push(`- [${entry.type}] ${entry.content}`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
return getMemoryManager().exportToMarkdown(agentId);
|
||||
}
|
||||
|
||||
async stats(agentId?: string): Promise<MemoryStats> {
|
||||
const backend = await this.getBackend();
|
||||
|
||||
if (backend === 'viking') {
|
||||
const viking = getVikingAdapter();
|
||||
try {
|
||||
const vikingStats = await viking.getMemoryStats(agentId || 'zclaw-main');
|
||||
return {
|
||||
totalEntries: vikingStats.totalEntries,
|
||||
byType: vikingStats.categories,
|
||||
byAgent: { [agentId || 'zclaw-main']: vikingStats.agentMemories },
|
||||
oldestEntry: null,
|
||||
newestEntry: null,
|
||||
};
|
||||
} catch {
|
||||
// Fall back to local stats
|
||||
return getMemoryManager().stats(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
return getMemoryManager().stats(agentId);
|
||||
}
|
||||
|
||||
async updateImportance(id: string, importance: number): Promise<void> {
|
||||
const backend = await this.getBackend();
|
||||
|
||||
if (backend === 'viking') {
|
||||
// OpenViking handles importance internally via access patterns
|
||||
console.log(`[VikingMemoryAdapter] Importance update for ${id}: ${importance}`);
|
||||
return;
|
||||
}
|
||||
|
||||
return getMemoryManager().updateImportance(id, importance);
|
||||
}
|
||||
|
||||
// === Configuration ===
|
||||
|
||||
updateConfig(config: Partial<VikingMemoryConfig>): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
// Reset availability check when config changes
|
||||
this.vikingAvailable = null;
|
||||
}
|
||||
|
||||
getConfig(): Readonly<VikingMemoryConfig> {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
getMode(): 'viking' | 'local' | 'unavailable' {
|
||||
if (!this.config.enabled) return 'local';
|
||||
if (this.vikingAvailable === true) return 'viking';
|
||||
if (this.vikingAvailable === false && this.config.fallbackToLocal) return 'local';
|
||||
return 'unavailable';
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: VikingMemoryAdapter | null = null;
|
||||
|
||||
export function getVikingMemoryAdapter(config?: Partial<VikingMemoryConfig>): VikingMemoryAdapter {
|
||||
if (!_instance || config) {
|
||||
_instance = new VikingMemoryAdapter(config);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetVikingMemoryAdapter(): void {
|
||||
_instance = null;
|
||||
}
|
||||
Reference in New Issue
Block a user