refactor(store): split gatewayStore into specialized domain stores
Major restructuring: - Split monolithic gatewayStore into 5 focused stores: - connectionStore: WebSocket connection and gateway lifecycle - configStore: quickConfig, workspaceInfo, MCP services - agentStore: clones, usage stats, agent management - handStore: hands, approvals, triggers, hand runs - workflowStore: workflows, workflow runs, execution - Update all components to use new stores with selector pattern - Remove
This commit is contained in:
27
docs/archive/v1-viking-dead-code/README.md
Normal file
27
docs/archive/v1-viking-dead-code/README.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# V1 Viking Dead Code Archive
|
||||
|
||||
Archived on 2026-03-20 during gateway-client refactoring.
|
||||
|
||||
These files formed an isolated dependency island with **zero external consumers** in the active codebase. They implemented a Viking vector database integration that was never wired into the application's import graph.
|
||||
|
||||
## Archived Files
|
||||
|
||||
### lib/ (8 files)
|
||||
- `viking-local.ts` — Local Viking server wrapper
|
||||
- `viking-client.ts` — Viking HTTP client
|
||||
- `viking-adapter.ts` — Viking adapter (bridge to memory system)
|
||||
- `viking-server-manager.ts` — Viking server lifecycle management
|
||||
- `viking-memory-adapter.ts` — Viking ↔ memory adapter
|
||||
- `context-builder.ts` — Context builder using Viking
|
||||
- `vector-memory.ts` — Vector memory using Viking
|
||||
- `session-persistence.ts` — Session persistence using Viking
|
||||
|
||||
### tests/ (3 files)
|
||||
- `viking-adapter.test.ts`
|
||||
- `vector-memory.test.ts`
|
||||
- `session-persistence.test.ts`
|
||||
|
||||
## Reason for Archival
|
||||
- No file in `desktop/src/` imports any of these modules
|
||||
- The entire chain is self-referential (only imports each other)
|
||||
- Functionality has been superseded by OpenFang's native memory/session APIs
|
||||
409
docs/archive/v1-viking-dead-code/lib/context-builder.ts
Normal file
409
docs/archive/v1-viking-dead-code/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;
|
||||
}
|
||||
655
docs/archive/v1-viking-dead-code/lib/session-persistence.ts
Normal file
655
docs/archive/v1-viking-dead-code/lib/session-persistence.ts
Normal file
@@ -0,0 +1,655 @@
|
||||
/**
|
||||
* Session Persistence - Automatic session data persistence for L4 self-evolution
|
||||
*
|
||||
* Provides automatic persistence of conversation sessions:
|
||||
* - Periodic auto-save of session state
|
||||
* - Memory extraction at session end
|
||||
* - Context compaction for long sessions
|
||||
* - Session history and recovery
|
||||
*
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.4
|
||||
*/
|
||||
|
||||
import { getVikingClient, type VikingHttpClient } from './viking-client';
|
||||
import { getMemoryExtractor } from './memory-extractor';
|
||||
import { canAutoExecute } from './autonomy-manager';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface SessionMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface SessionState {
|
||||
id: string;
|
||||
agentId: string;
|
||||
startedAt: string;
|
||||
lastActivityAt: string;
|
||||
messageCount: number;
|
||||
status: 'active' | 'paused' | 'ended';
|
||||
messages: SessionMessage[];
|
||||
metadata: {
|
||||
model?: string;
|
||||
workspaceId?: string;
|
||||
conversationId?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SessionPersistenceConfig {
|
||||
enabled: boolean;
|
||||
autoSaveIntervalMs: number; // Auto-save interval (default: 60s)
|
||||
maxMessagesBeforeCompact: number; // Trigger compaction at this count
|
||||
extractMemoriesOnEnd: boolean; // Extract memories when session ends
|
||||
persistToViking: boolean; // Use OpenViking for persistence
|
||||
fallbackToLocal: boolean; // Fall back to localStorage
|
||||
maxSessionHistory: number; // Max sessions to keep in history
|
||||
sessionTimeoutMs: number; // Session timeout (default: 30min)
|
||||
}
|
||||
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
agentId: string;
|
||||
startedAt: string;
|
||||
endedAt: string;
|
||||
messageCount: number;
|
||||
topicsDiscussed: string[];
|
||||
memoriesExtracted: number;
|
||||
compacted: boolean;
|
||||
}
|
||||
|
||||
export interface PersistenceResult {
|
||||
saved: boolean;
|
||||
sessionId: string;
|
||||
messageCount: number;
|
||||
extractedMemories: number;
|
||||
compacted: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// === Default Config ===
|
||||
|
||||
export const DEFAULT_SESSION_CONFIG: SessionPersistenceConfig = {
|
||||
enabled: true,
|
||||
autoSaveIntervalMs: 60000, // 1 minute
|
||||
maxMessagesBeforeCompact: 100, // Compact after 100 messages
|
||||
extractMemoriesOnEnd: true,
|
||||
persistToViking: true,
|
||||
fallbackToLocal: true,
|
||||
maxSessionHistory: 50,
|
||||
sessionTimeoutMs: 1800000, // 30 minutes
|
||||
};
|
||||
|
||||
// === Storage Keys ===
|
||||
|
||||
const SESSION_STORAGE_KEY = 'zclaw-sessions';
|
||||
const CURRENT_SESSION_KEY = 'zclaw-current-session';
|
||||
|
||||
// === Session Persistence Service ===
|
||||
|
||||
export class SessionPersistenceService {
|
||||
private config: SessionPersistenceConfig;
|
||||
private currentSession: SessionState | null = null;
|
||||
private vikingClient: VikingHttpClient | null = null;
|
||||
private autoSaveTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private sessionHistory: SessionSummary[] = [];
|
||||
|
||||
constructor(config?: Partial<SessionPersistenceConfig>) {
|
||||
this.config = { ...DEFAULT_SESSION_CONFIG, ...config };
|
||||
this.loadSessionHistory();
|
||||
this.initializeVikingClient();
|
||||
}
|
||||
|
||||
private async initializeVikingClient(): Promise<void> {
|
||||
try {
|
||||
this.vikingClient = getVikingClient();
|
||||
} catch (error) {
|
||||
console.warn('[SessionPersistence] Viking client initialization failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// === Session Lifecycle ===
|
||||
|
||||
/**
|
||||
* Start a new session.
|
||||
*/
|
||||
startSession(agentId: string, metadata?: Record<string, unknown>): SessionState {
|
||||
// End any existing session first
|
||||
if (this.currentSession && this.currentSession.status === 'active') {
|
||||
this.endSession();
|
||||
}
|
||||
|
||||
const sessionId = `session_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
this.currentSession = {
|
||||
id: sessionId,
|
||||
agentId,
|
||||
startedAt: new Date().toISOString(),
|
||||
lastActivityAt: new Date().toISOString(),
|
||||
messageCount: 0,
|
||||
status: 'active',
|
||||
messages: [],
|
||||
metadata: metadata || {},
|
||||
};
|
||||
|
||||
this.saveCurrentSession();
|
||||
this.startAutoSave();
|
||||
|
||||
console.log(`[SessionPersistence] Started session: ${sessionId}`);
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a message to the current session.
|
||||
*/
|
||||
addMessage(message: Omit<SessionMessage, 'id' | 'timestamp'>): SessionMessage | null {
|
||||
if (!this.currentSession || this.currentSession.status !== 'active') {
|
||||
console.warn('[SessionPersistence] No active session');
|
||||
return null;
|
||||
}
|
||||
|
||||
const fullMessage: SessionMessage = {
|
||||
id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
...message,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.currentSession.messages.push(fullMessage);
|
||||
this.currentSession.messageCount++;
|
||||
this.currentSession.lastActivityAt = fullMessage.timestamp;
|
||||
|
||||
// Check if compaction is needed
|
||||
if (this.currentSession.messageCount >= this.config.maxMessagesBeforeCompact) {
|
||||
this.compactSession();
|
||||
}
|
||||
|
||||
return fullMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pause the current session.
|
||||
*/
|
||||
pauseSession(): void {
|
||||
if (!this.currentSession) return;
|
||||
|
||||
this.currentSession.status = 'paused';
|
||||
this.stopAutoSave();
|
||||
this.saveCurrentSession();
|
||||
|
||||
console.log(`[SessionPersistence] Paused session: ${this.currentSession.id}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a paused session.
|
||||
*/
|
||||
resumeSession(): SessionState | null {
|
||||
if (!this.currentSession || this.currentSession.status !== 'paused') {
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
this.currentSession.status = 'active';
|
||||
this.currentSession.lastActivityAt = new Date().toISOString();
|
||||
this.startAutoSave();
|
||||
this.saveCurrentSession();
|
||||
|
||||
console.log(`[SessionPersistence] Resumed session: ${this.currentSession.id}`);
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* End the current session and extract memories.
|
||||
*/
|
||||
async endSession(): Promise<PersistenceResult> {
|
||||
if (!this.currentSession) {
|
||||
return {
|
||||
saved: false,
|
||||
sessionId: '',
|
||||
messageCount: 0,
|
||||
extractedMemories: 0,
|
||||
compacted: false,
|
||||
error: 'No active session',
|
||||
};
|
||||
}
|
||||
|
||||
const session = this.currentSession;
|
||||
session.status = 'ended';
|
||||
this.stopAutoSave();
|
||||
|
||||
let extractedMemories = 0;
|
||||
let compacted = false;
|
||||
|
||||
try {
|
||||
// Extract memories from the session
|
||||
if (this.config.extractMemoriesOnEnd && session.messageCount >= 4) {
|
||||
extractedMemories = await this.extractMemories(session);
|
||||
}
|
||||
|
||||
// Persist to OpenViking if available
|
||||
if (this.config.persistToViking && this.vikingClient) {
|
||||
await this.persistToViking(session);
|
||||
}
|
||||
|
||||
// Save to local storage
|
||||
this.saveToLocalStorage(session);
|
||||
|
||||
// Add to history
|
||||
this.addToHistory(session, extractedMemories, compacted);
|
||||
|
||||
console.log(`[SessionPersistence] Ended session: ${session.id}, extracted ${extractedMemories} memories`);
|
||||
|
||||
return {
|
||||
saved: true,
|
||||
sessionId: session.id,
|
||||
messageCount: session.messageCount,
|
||||
extractedMemories,
|
||||
compacted,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[SessionPersistence] Error ending session:', error);
|
||||
return {
|
||||
saved: false,
|
||||
sessionId: session.id,
|
||||
messageCount: session.messageCount,
|
||||
extractedMemories: 0,
|
||||
compacted: false,
|
||||
error: String(error),
|
||||
};
|
||||
} finally {
|
||||
this.clearCurrentSession();
|
||||
}
|
||||
}
|
||||
|
||||
// === Memory Extraction ===
|
||||
|
||||
private async extractMemories(session: SessionState): Promise<number> {
|
||||
const extractor = getMemoryExtractor();
|
||||
|
||||
// Check if we can auto-extract
|
||||
const { canProceed } = canAutoExecute('memory_save', 5);
|
||||
|
||||
if (!canProceed) {
|
||||
console.log('[SessionPersistence] Memory extraction requires approval');
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = session.messages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
const result = await extractor.extractFromConversation(
|
||||
messages,
|
||||
session.agentId,
|
||||
session.id
|
||||
);
|
||||
|
||||
return result.saved;
|
||||
} catch (error) {
|
||||
console.error('[SessionPersistence] Memory extraction failed:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// === Session Compaction ===
|
||||
|
||||
private async compactSession(): Promise<void> {
|
||||
if (!this.currentSession || !this.vikingClient) return;
|
||||
|
||||
try {
|
||||
const messages = this.currentSession.messages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
}));
|
||||
|
||||
// Use OpenViking to compact the session
|
||||
const summary = await this.vikingClient.compactSession(messages);
|
||||
|
||||
// Keep recent messages, replace older ones with summary
|
||||
const recentMessages = this.currentSession.messages.slice(-20);
|
||||
|
||||
// Create a summary message
|
||||
const summaryMessage: SessionMessage = {
|
||||
id: `summary_${Date.now()}`,
|
||||
role: 'system',
|
||||
content: `[会话摘要]\n${summary}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
metadata: { type: 'compaction-summary' },
|
||||
};
|
||||
|
||||
this.currentSession.messages = [summaryMessage, ...recentMessages];
|
||||
this.currentSession.messageCount = this.currentSession.messages.length;
|
||||
|
||||
console.log(`[SessionPersistence] Compacted session: ${this.currentSession.id}`);
|
||||
} catch (error) {
|
||||
console.error('[SessionPersistence] Compaction failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// === Persistence ===
|
||||
|
||||
private async persistToViking(session: SessionState): Promise<void> {
|
||||
if (!this.vikingClient) return;
|
||||
|
||||
try {
|
||||
const sessionContent = session.messages
|
||||
.map(m => `[${m.role}]: ${m.content}`)
|
||||
.join('\n\n');
|
||||
|
||||
await this.vikingClient.addResource(
|
||||
`viking://sessions/${session.agentId}/${session.id}`,
|
||||
sessionContent,
|
||||
{
|
||||
metadata: {
|
||||
startedAt: session.startedAt,
|
||||
endedAt: new Date().toISOString(),
|
||||
messageCount: String(session.messageCount || 0),
|
||||
agentId: session.agentId || 'default',
|
||||
},
|
||||
wait: false,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[SessionPersistence] Viking persistence failed:', error);
|
||||
if (!this.config.fallbackToLocal) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private saveToLocalStorage(session: SessionState): void {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
`${SESSION_STORAGE_KEY}/${session.id}`,
|
||||
JSON.stringify(session)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[SessionPersistence] Local storage failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private saveCurrentSession(): void {
|
||||
if (!this.currentSession) return;
|
||||
|
||||
try {
|
||||
localStorage.setItem(CURRENT_SESSION_KEY, JSON.stringify(this.currentSession));
|
||||
} catch (error) {
|
||||
console.error('[SessionPersistence] Failed to save current session:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private loadCurrentSession(): SessionState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(CURRENT_SESSION_KEY);
|
||||
if (raw) {
|
||||
return JSON.parse(raw);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SessionPersistence] Failed to load current session:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private clearCurrentSession(): void {
|
||||
this.currentSession = null;
|
||||
try {
|
||||
localStorage.removeItem(CURRENT_SESSION_KEY);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// === Auto-save ===
|
||||
|
||||
private startAutoSave(): void {
|
||||
if (this.autoSaveTimer) {
|
||||
clearInterval(this.autoSaveTimer);
|
||||
}
|
||||
|
||||
this.autoSaveTimer = setInterval(() => {
|
||||
if (this.currentSession && this.currentSession.status === 'active') {
|
||||
this.saveCurrentSession();
|
||||
}
|
||||
}, this.config.autoSaveIntervalMs);
|
||||
}
|
||||
|
||||
private stopAutoSave(): void {
|
||||
if (this.autoSaveTimer) {
|
||||
clearInterval(this.autoSaveTimer);
|
||||
this.autoSaveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// === Session History ===
|
||||
|
||||
private loadSessionHistory(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (raw) {
|
||||
this.sessionHistory = JSON.parse(raw);
|
||||
}
|
||||
} catch {
|
||||
this.sessionHistory = [];
|
||||
}
|
||||
}
|
||||
|
||||
private saveSessionHistory(): void {
|
||||
try {
|
||||
localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(this.sessionHistory));
|
||||
} catch (error) {
|
||||
console.error('[SessionPersistence] Failed to save session history:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private addToHistory(session: SessionState, extractedMemories: number, compacted: boolean): void {
|
||||
const summary: SessionSummary = {
|
||||
id: session.id,
|
||||
agentId: session.agentId,
|
||||
startedAt: session.startedAt,
|
||||
endedAt: new Date().toISOString(),
|
||||
messageCount: session.messageCount,
|
||||
topicsDiscussed: this.extractTopics(session),
|
||||
memoriesExtracted: extractedMemories,
|
||||
compacted,
|
||||
};
|
||||
|
||||
this.sessionHistory.unshift(summary);
|
||||
|
||||
// Trim to max size
|
||||
if (this.sessionHistory.length > this.config.maxSessionHistory) {
|
||||
this.sessionHistory = this.sessionHistory.slice(0, this.config.maxSessionHistory);
|
||||
}
|
||||
|
||||
this.saveSessionHistory();
|
||||
}
|
||||
|
||||
private extractTopics(session: SessionState): string[] {
|
||||
// Simple topic extraction from user messages
|
||||
const userMessages = session.messages
|
||||
.filter(m => m.role === 'user')
|
||||
.map(m => m.content);
|
||||
|
||||
// Look for common patterns
|
||||
const topics: string[] = [];
|
||||
const patterns = [
|
||||
/(?:帮我|请|能否)(.{2,10})/g,
|
||||
/(?:问题|bug|错误|报错)(.{2,20})/g,
|
||||
/(?:实现|添加|开发)(.{2,15})/g,
|
||||
];
|
||||
|
||||
for (const msg of userMessages) {
|
||||
for (const pattern of patterns) {
|
||||
const matches = msg.matchAll(pattern);
|
||||
for (const match of matches) {
|
||||
if (match[1] && match[1].length > 2) {
|
||||
topics.push(match[1].trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(topics)].slice(0, 10);
|
||||
}
|
||||
|
||||
// === Public API ===
|
||||
|
||||
/**
|
||||
* Get the current session.
|
||||
*/
|
||||
getCurrentSession(): SessionState | null {
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session history.
|
||||
*/
|
||||
getSessionHistory(limit: number = 20): SessionSummary[] {
|
||||
return this.sessionHistory.slice(0, limit);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a previous session.
|
||||
*/
|
||||
restoreSession(sessionId: string): SessionState | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(`${SESSION_STORAGE_KEY}/${sessionId}`);
|
||||
if (raw) {
|
||||
const session = JSON.parse(raw) as SessionState;
|
||||
session.status = 'active';
|
||||
session.lastActivityAt = new Date().toISOString();
|
||||
this.currentSession = session;
|
||||
this.startAutoSave();
|
||||
this.saveCurrentSession();
|
||||
return session;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[SessionPersistence] Failed to restore session:', error);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a session from history.
|
||||
*/
|
||||
deleteSession(sessionId: string): boolean {
|
||||
try {
|
||||
localStorage.removeItem(`${SESSION_STORAGE_KEY}/${sessionId}`);
|
||||
this.sessionHistory = this.sessionHistory.filter(s => s.id !== sessionId);
|
||||
this.saveSessionHistory();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration.
|
||||
*/
|
||||
getConfig(): SessionPersistenceConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration.
|
||||
*/
|
||||
updateConfig(updates: Partial<SessionPersistenceConfig>): void {
|
||||
this.config = { ...this.config, ...updates };
|
||||
|
||||
// Restart auto-save if interval changed
|
||||
if (updates.autoSaveIntervalMs && this.currentSession?.status === 'active') {
|
||||
this.stopAutoSave();
|
||||
this.startAutoSave();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if session persistence is available.
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (!this.config.enabled) return false;
|
||||
|
||||
if (this.config.persistToViking && this.vikingClient) {
|
||||
return this.vikingClient.isAvailable();
|
||||
}
|
||||
|
||||
return this.config.fallbackToLocal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover from crash - restore last session if valid.
|
||||
*/
|
||||
recoverFromCrash(): SessionState | null {
|
||||
const lastSession = this.loadCurrentSession();
|
||||
|
||||
if (!lastSession) return null;
|
||||
|
||||
// Check if session timed out
|
||||
const lastActivity = new Date(lastSession.lastActivityAt).getTime();
|
||||
const now = Date.now();
|
||||
|
||||
if (now - lastActivity > this.config.sessionTimeoutMs) {
|
||||
console.log('[SessionPersistence] Last session timed out, not recovering');
|
||||
this.clearCurrentSession();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Recover the session
|
||||
lastSession.status = 'active';
|
||||
lastSession.lastActivityAt = new Date().toISOString();
|
||||
this.currentSession = lastSession;
|
||||
this.startAutoSave();
|
||||
this.saveCurrentSession();
|
||||
|
||||
console.log(`[SessionPersistence] Recovered session: ${lastSession.id}`);
|
||||
return lastSession;
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: SessionPersistenceService | null = null;
|
||||
|
||||
export function getSessionPersistence(config?: Partial<SessionPersistenceConfig>): SessionPersistenceService {
|
||||
if (!_instance || config) {
|
||||
_instance = new SessionPersistenceService(config);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetSessionPersistence(): void {
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
/**
|
||||
* Quick start a session.
|
||||
*/
|
||||
export function startSession(agentId: string, metadata?: Record<string, unknown>): SessionState {
|
||||
return getSessionPersistence().startSession(agentId, metadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick add a message.
|
||||
*/
|
||||
export function addSessionMessage(message: Omit<SessionMessage, 'id' | 'timestamp'>): SessionMessage | null {
|
||||
return getSessionPersistence().addMessage(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Quick end session.
|
||||
*/
|
||||
export async function endCurrentSession(): Promise<PersistenceResult> {
|
||||
return getSessionPersistence().endSession();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current session.
|
||||
*/
|
||||
export function getCurrentSession(): SessionState | null {
|
||||
return getSessionPersistence().getCurrentSession();
|
||||
}
|
||||
385
docs/archive/v1-viking-dead-code/lib/vector-memory.ts
Normal file
385
docs/archive/v1-viking-dead-code/lib/vector-memory.ts
Normal file
@@ -0,0 +1,385 @@
|
||||
/**
|
||||
* Vector Memory - Semantic search wrapper for L4 self-evolution
|
||||
*
|
||||
* Provides vector-based semantic search over agent memories using OpenViking.
|
||||
* This enables finding conceptually similar memories rather than just keyword matches.
|
||||
*
|
||||
* Key capabilities:
|
||||
* - Semantic search: Find memories by meaning, not just keywords
|
||||
* - Relevance scoring: Get similarity scores for search results
|
||||
* - Context-aware: Search at different context levels (L0/L1/L2)
|
||||
*
|
||||
* Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.4.2
|
||||
*/
|
||||
|
||||
import { getVikingClient, type VikingHttpClient } from './viking-client';
|
||||
import { getMemoryManager, type MemoryEntry, type MemoryType } from './agent-memory';
|
||||
|
||||
// === Types ===
|
||||
|
||||
export interface VectorSearchResult {
|
||||
memory: MemoryEntry;
|
||||
score: number;
|
||||
uri: string;
|
||||
highlights?: string[];
|
||||
}
|
||||
|
||||
export interface VectorSearchOptions {
|
||||
topK?: number; // Number of results to return (default: 10)
|
||||
minScore?: number; // Minimum relevance score (default: 0.5)
|
||||
types?: MemoryType[]; // Filter by memory types
|
||||
agentId?: string; // Filter by agent
|
||||
level?: 'L0' | 'L1' | 'L2'; // Context level to search
|
||||
}
|
||||
|
||||
export interface VectorEmbedding {
|
||||
id: string;
|
||||
vector: number[];
|
||||
dimension: number;
|
||||
model: string;
|
||||
}
|
||||
|
||||
export interface VectorMemoryConfig {
|
||||
enabled: boolean;
|
||||
defaultTopK: number;
|
||||
defaultMinScore: number;
|
||||
defaultLevel: 'L0' | 'L1' | 'L2';
|
||||
embeddingModel: string;
|
||||
cacheEmbeddings: boolean;
|
||||
}
|
||||
|
||||
// === Default Config ===
|
||||
|
||||
export const DEFAULT_VECTOR_CONFIG: VectorMemoryConfig = {
|
||||
enabled: true,
|
||||
defaultTopK: 10,
|
||||
defaultMinScore: 0.3,
|
||||
defaultLevel: 'L1',
|
||||
embeddingModel: 'text-embedding-ada-002',
|
||||
cacheEmbeddings: true,
|
||||
};
|
||||
|
||||
// === Vector Memory Service ===
|
||||
|
||||
export class VectorMemoryService {
|
||||
private config: VectorMemoryConfig;
|
||||
private vikingClient: VikingHttpClient | null = null;
|
||||
private embeddingCache: Map<string, VectorEmbedding> = new Map();
|
||||
|
||||
constructor(config?: Partial<VectorMemoryConfig>) {
|
||||
this.config = { ...DEFAULT_VECTOR_CONFIG, ...config };
|
||||
this.initializeClient();
|
||||
}
|
||||
|
||||
private async initializeClient(): Promise<void> {
|
||||
try {
|
||||
this.vikingClient = getVikingClient();
|
||||
} catch (error) {
|
||||
console.warn('[VectorMemory] Failed to initialize Viking client:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// === Semantic Search ===
|
||||
|
||||
/**
|
||||
* Perform semantic search over memories.
|
||||
* Uses OpenViking's built-in vector search capabilities.
|
||||
*/
|
||||
async semanticSearch(
|
||||
query: string,
|
||||
options?: VectorSearchOptions
|
||||
): Promise<VectorSearchResult[]> {
|
||||
if (!this.config.enabled) {
|
||||
console.warn('[VectorMemory] Semantic search is disabled');
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!this.vikingClient) {
|
||||
await this.initializeClient();
|
||||
if (!this.vikingClient) {
|
||||
console.warn('[VectorMemory] Viking client not available');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const results = await this.vikingClient.find(query, {
|
||||
limit: options?.topK ?? this.config.defaultTopK,
|
||||
minScore: options?.minScore ?? this.config.defaultMinScore,
|
||||
level: options?.level ?? this.config.defaultLevel,
|
||||
scope: options?.agentId ? `memories/${options.agentId}` : undefined,
|
||||
});
|
||||
|
||||
// Convert FindResult to VectorSearchResult
|
||||
const searchResults: VectorSearchResult[] = [];
|
||||
|
||||
for (const result of results) {
|
||||
// Convert Viking result to MemoryEntry format
|
||||
const memory: MemoryEntry = {
|
||||
id: this.extractMemoryId(result.uri),
|
||||
agentId: options?.agentId ?? 'unknown',
|
||||
content: result.content,
|
||||
type: this.inferMemoryType(result.uri),
|
||||
importance: Math.round((1 - result.score) * 10), // Invert score to importance
|
||||
createdAt: new Date().toISOString(),
|
||||
source: 'auto',
|
||||
tags: Array.isArray((result.metadata as Record<string, unknown>)?.tags)
|
||||
? (result.metadata as Record<string, unknown>).tags as string[]
|
||||
: [],
|
||||
lastAccessedAt: new Date().toISOString(),
|
||||
accessCount: 0,
|
||||
};
|
||||
|
||||
searchResults.push({
|
||||
memory,
|
||||
score: result.score,
|
||||
uri: result.uri,
|
||||
highlights: Array.isArray((result.metadata as Record<string, unknown>)?.highlights)
|
||||
? (result.metadata as Record<string, unknown>).highlights as string[]
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Apply type filter if specified
|
||||
if (options?.types && options.types.length > 0) {
|
||||
return searchResults.filter(r => options.types!.includes(r.memory.type));
|
||||
}
|
||||
|
||||
return searchResults;
|
||||
} catch (error) {
|
||||
console.error('[VectorMemory] Semantic search failed:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar memories to a given memory.
|
||||
*/
|
||||
async findSimilar(
|
||||
memoryId: string,
|
||||
options?: Omit<VectorSearchOptions, 'types'>
|
||||
): Promise<VectorSearchResult[]> {
|
||||
// Get the memory content first
|
||||
const memoryManager = getMemoryManager();
|
||||
const memories = await memoryManager.getAll(options?.agentId ?? 'default');
|
||||
const memory = memories.find((m: MemoryEntry) => m.id === memoryId);
|
||||
|
||||
if (!memory) {
|
||||
console.warn(`[VectorMemory] Memory not found: ${memoryId}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
// Use the memory content as query for semantic search
|
||||
const results = await this.semanticSearch(memory.content, {
|
||||
...options,
|
||||
topK: (options?.topK ?? 10) + 1, // +1 to account for the memory itself
|
||||
});
|
||||
|
||||
// Filter out the original memory from results
|
||||
return results.filter(r => r.memory.id !== memoryId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find memories related to a topic/concept.
|
||||
*/
|
||||
async findByConcept(
|
||||
concept: string,
|
||||
options?: VectorSearchOptions
|
||||
): Promise<VectorSearchResult[]> {
|
||||
return this.semanticSearch(concept, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cluster memories by semantic similarity.
|
||||
* Returns groups of related memories.
|
||||
*/
|
||||
async clusterMemories(
|
||||
agentId: string,
|
||||
clusterCount: number = 5
|
||||
): Promise<VectorSearchResult[][]> {
|
||||
const memoryManager = getMemoryManager();
|
||||
const memories = await memoryManager.getAll(agentId);
|
||||
|
||||
if (memories.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Simple clustering: use each memory as a seed and find similar ones
|
||||
const clusters: VectorSearchResult[][] = [];
|
||||
const usedIds = new Set<string>();
|
||||
|
||||
for (const memory of memories) {
|
||||
if (usedIds.has(memory.id)) continue;
|
||||
|
||||
const similar = await this.findSimilar(memory.id, { agentId, topK: clusterCount });
|
||||
|
||||
if (similar.length > 0) {
|
||||
const cluster: VectorSearchResult[] = [
|
||||
{ memory, score: 1.0, uri: `memory://${memory.id}` },
|
||||
...similar.filter(r => !usedIds.has(r.memory.id)),
|
||||
];
|
||||
|
||||
cluster.forEach(r => usedIds.add(r.memory.id));
|
||||
clusters.push(cluster);
|
||||
|
||||
if (clusters.length >= clusterCount) break;
|
||||
}
|
||||
}
|
||||
|
||||
return clusters;
|
||||
}
|
||||
|
||||
// === Embedding Operations ===
|
||||
|
||||
/**
|
||||
* Get or compute embedding for a text.
|
||||
* Note: OpenViking handles embeddings internally, this is for advanced use.
|
||||
*/
|
||||
async getEmbedding(text: string): Promise<VectorEmbedding | null> {
|
||||
if (!this.config.enabled) return null;
|
||||
|
||||
// Check cache first
|
||||
const cacheKey = this.hashText(text);
|
||||
if (this.config.cacheEmbeddings && this.embeddingCache.has(cacheKey)) {
|
||||
return this.embeddingCache.get(cacheKey)!;
|
||||
}
|
||||
|
||||
// OpenViking handles embeddings internally via /api/find
|
||||
// This method is provided for future extensibility
|
||||
console.warn('[VectorMemory] Direct embedding computation not available - OpenViking handles this internally');
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute similarity between two texts.
|
||||
*/
|
||||
async computeSimilarity(text1: string, text2: string): Promise<number> {
|
||||
if (!this.config.enabled || !this.vikingClient) return 0;
|
||||
|
||||
try {
|
||||
// Use OpenViking to find text1, then check if text2 is in results
|
||||
const results = await this.vikingClient.find(text1, { limit: 20 });
|
||||
|
||||
// If we find text2 in results, return its score
|
||||
for (const result of results) {
|
||||
if (result.content.includes(text2) || text2.includes(result.content)) {
|
||||
return result.score;
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, return 0 (no similarity found)
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// === Utility Methods ===
|
||||
|
||||
/**
|
||||
* Check if vector search is available.
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
if (!this.config.enabled) return false;
|
||||
|
||||
if (!this.vikingClient) {
|
||||
await this.initializeClient();
|
||||
}
|
||||
|
||||
return this.vikingClient?.isAvailable() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration.
|
||||
*/
|
||||
getConfig(): VectorMemoryConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update configuration.
|
||||
*/
|
||||
updateConfig(updates: Partial<VectorMemoryConfig>): void {
|
||||
this.config = { ...this.config, ...updates };
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear embedding cache.
|
||||
*/
|
||||
clearCache(): void {
|
||||
this.embeddingCache.clear();
|
||||
}
|
||||
|
||||
// === Private Helpers ===
|
||||
|
||||
private extractMemoryId(uri: string): string {
|
||||
// Extract memory ID from Viking URI
|
||||
// Format: memories/agent-id/memory-id or similar
|
||||
const parts = uri.split('/');
|
||||
return parts[parts.length - 1] || uri;
|
||||
}
|
||||
|
||||
private inferMemoryType(uri: string): MemoryType {
|
||||
// Infer memory type from URI or metadata
|
||||
if (uri.includes('preference')) return 'preference';
|
||||
if (uri.includes('fact')) return 'fact';
|
||||
if (uri.includes('task')) return 'task';
|
||||
if (uri.includes('lesson')) return 'lesson';
|
||||
return 'fact'; // Default
|
||||
}
|
||||
|
||||
private hashText(text: string): string {
|
||||
// Simple hash for cache key
|
||||
let hash = 0;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const char = text.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash;
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: VectorMemoryService | null = null;
|
||||
|
||||
export function getVectorMemory(): VectorMemoryService {
|
||||
if (!_instance) {
|
||||
_instance = new VectorMemoryService();
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
export function resetVectorMemory(): void {
|
||||
_instance = null;
|
||||
}
|
||||
|
||||
// === Helper Functions ===
|
||||
|
||||
/**
|
||||
* Quick semantic search helper.
|
||||
*/
|
||||
export async function semanticSearch(
|
||||
query: string,
|
||||
options?: VectorSearchOptions
|
||||
): Promise<VectorSearchResult[]> {
|
||||
return getVectorMemory().semanticSearch(query, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar memories helper.
|
||||
*/
|
||||
export async function findSimilarMemories(
|
||||
memoryId: string,
|
||||
agentId?: string
|
||||
): Promise<VectorSearchResult[]> {
|
||||
return getVectorMemory().findSimilar(memoryId, { agentId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if vector search is available.
|
||||
*/
|
||||
export async function isVectorSearchAvailable(): Promise<boolean> {
|
||||
return getVectorMemory().isAvailable();
|
||||
}
|
||||
734
docs/archive/v1-viking-dead-code/lib/viking-adapter.ts
Normal file
734
docs/archive/v1-viking-dead-code/lib/viking-adapter.ts
Normal 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 };
|
||||
353
docs/archive/v1-viking-dead-code/lib/viking-client.ts
Normal file
353
docs/archive/v1-viking-dead-code/lib/viking-client.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* 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;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
// === Singleton ===
|
||||
|
||||
let _instance: VikingHttpClient | null = null;
|
||||
|
||||
/**
|
||||
* Get the singleton VikingHttpClient instance.
|
||||
* Uses default configuration (localhost:1933).
|
||||
*/
|
||||
export function getVikingClient(baseUrl?: string): VikingHttpClient {
|
||||
if (!_instance) {
|
||||
_instance = new VikingHttpClient(baseUrl);
|
||||
}
|
||||
return _instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset the singleton instance.
|
||||
* Useful for testing or reconfiguration.
|
||||
*/
|
||||
export function resetVikingClient(): void {
|
||||
_instance = null;
|
||||
}
|
||||
144
docs/archive/v1-viking-dead-code/lib/viking-local.ts
Normal file
144
docs/archive/v1-viking-dead-code/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
docs/archive/v1-viking-dead-code/lib/viking-memory-adapter.ts
Normal file
408
docs/archive/v1-viking-dead-code/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;
|
||||
}
|
||||
231
docs/archive/v1-viking-dead-code/lib/viking-server-manager.ts
Normal file
231
docs/archive/v1-viking-dead-code/lib/viking-server-manager.ts
Normal 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;
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
/**
|
||||
* Session Persistence Tests - Phase 4.3
|
||||
*
|
||||
* Tests for automatic session data persistence:
|
||||
* - Session lifecycle (start/add/end)
|
||||
* - Auto-save functionality
|
||||
* - Memory extraction
|
||||
* - Session compaction
|
||||
* - Crash recovery
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
SessionPersistenceService,
|
||||
getSessionPersistence,
|
||||
resetSessionPersistence,
|
||||
startSession,
|
||||
addSessionMessage,
|
||||
endCurrentSession,
|
||||
getCurrentSession,
|
||||
DEFAULT_SESSION_CONFIG,
|
||||
type SessionState,
|
||||
type PersistenceResult,
|
||||
} from '../../desktop/src/lib/session-persistence';
|
||||
|
||||
// === Mock Dependencies ===
|
||||
|
||||
const mockVikingClient = {
|
||||
isAvailable: vi.fn(async () => true),
|
||||
addResource: vi.fn(async () => ({ uri: 'test-uri', status: 'ok' })),
|
||||
removeResource: vi.fn(async () => undefined),
|
||||
compactSession: vi.fn(async () => '[会话摘要]\n讨论主题: 代码优化\n关键决策: 使用缓存策略'),
|
||||
extractMemories: vi.fn(async () => ({
|
||||
memories: [
|
||||
{ content: '用户偏好简洁的回答', type: 'preference', importance: 7 },
|
||||
],
|
||||
summary: 'Extracted 1 memory',
|
||||
tokensSaved: 100,
|
||||
})),
|
||||
};
|
||||
|
||||
vi.mock('../../desktop/src/lib/viking-client', () => ({
|
||||
getVikingClient: vi.fn(() => mockVikingClient),
|
||||
resetVikingClient: vi.fn(),
|
||||
VikingHttpClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockMemoryExtractor = {
|
||||
extractFromConversation: vi.fn(async () => ({
|
||||
items: [{ content: 'Test memory', type: 'fact', importance: 5, tags: [] }],
|
||||
saved: 1,
|
||||
skipped: 0,
|
||||
userProfileUpdated: false,
|
||||
})),
|
||||
};
|
||||
|
||||
vi.mock('../../desktop/src/lib/memory-extractor', () => ({
|
||||
getMemoryExtractor: vi.fn(() => mockMemoryExtractor),
|
||||
resetMemoryExtractor: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockAutonomyManager = {
|
||||
evaluate: vi.fn(() => ({
|
||||
action: 'memory_save',
|
||||
allowed: true,
|
||||
requiresApproval: false,
|
||||
reason: 'Auto-approved',
|
||||
riskLevel: 'low',
|
||||
importance: 5,
|
||||
timestamp: new Date().toISOString(),
|
||||
})),
|
||||
};
|
||||
|
||||
vi.mock('../../desktop/src/lib/autonomy-manager', () => ({
|
||||
canAutoExecute: vi.fn(() => ({ canProceed: true, decision: mockAutonomyManager.evaluate() })),
|
||||
executeWithAutonomy: vi.fn(async (_action: string, _importance: number, executor: () => unknown) => {
|
||||
const result = await executor();
|
||||
return { executed: true, result };
|
||||
}),
|
||||
getAutonomyManager: vi.fn(() => mockAutonomyManager),
|
||||
}));
|
||||
|
||||
// === Session Persistence Tests ===
|
||||
|
||||
describe('SessionPersistenceService', () => {
|
||||
let service: SessionPersistenceService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
resetSessionPersistence();
|
||||
service = new SessionPersistenceService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
service.stopAutoSave();
|
||||
resetSessionPersistence();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with default config', () => {
|
||||
const config = service.getConfig();
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.autoSaveIntervalMs).toBe(60000);
|
||||
expect(config.maxMessagesBeforeCompact).toBe(100);
|
||||
expect(config.extractMemoriesOnEnd).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept custom config', () => {
|
||||
const customService = new SessionPersistenceService({
|
||||
autoSaveIntervalMs: 30000,
|
||||
maxMessagesBeforeCompact: 50,
|
||||
});
|
||||
const config = customService.getConfig();
|
||||
expect(config.autoSaveIntervalMs).toBe(30000);
|
||||
expect(config.maxMessagesBeforeCompact).toBe(50);
|
||||
});
|
||||
|
||||
it('should update config', () => {
|
||||
service.updateConfig({ autoSaveIntervalMs: 120000 });
|
||||
const config = service.getConfig();
|
||||
expect(config.autoSaveIntervalMs).toBe(120000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Lifecycle', () => {
|
||||
it('should start a new session', () => {
|
||||
const session = service.startSession('agent1', { model: 'gpt-4' });
|
||||
|
||||
expect(session.id).toBeDefined();
|
||||
expect(session.agentId).toBe('agent1');
|
||||
expect(session.status).toBe('active');
|
||||
expect(session.messageCount).toBe(0);
|
||||
expect(session.metadata.model).toBe('gpt-4');
|
||||
});
|
||||
|
||||
it('should end previous session when starting new one', () => {
|
||||
service.startSession('agent1');
|
||||
const session2 = service.startSession('agent2');
|
||||
|
||||
expect(session2.agentId).toBe('agent2');
|
||||
});
|
||||
|
||||
it('should add messages to session', () => {
|
||||
service.startSession('agent1');
|
||||
|
||||
const msg1 = service.addMessage({ role: 'user', content: 'Hello' });
|
||||
const msg2 = service.addMessage({ role: 'assistant', content: 'Hi there!' });
|
||||
|
||||
expect(msg1).not.toBeNull();
|
||||
expect(msg2).not.toBeNull();
|
||||
expect(msg1?.role).toBe('user');
|
||||
expect(msg2?.role).toBe('assistant');
|
||||
|
||||
const current = service.getCurrentSession();
|
||||
expect(current?.messageCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should return null when adding message without session', () => {
|
||||
const msg = service.addMessage({ role: 'user', content: 'Hello' });
|
||||
expect(msg).toBeNull();
|
||||
});
|
||||
|
||||
it('should end session and return result', async () => {
|
||||
service.startSession('agent1');
|
||||
service.addMessage({ role: 'user', content: 'Hello' });
|
||||
service.addMessage({ role: 'assistant', content: 'Hi!' });
|
||||
|
||||
const result = await service.endSession();
|
||||
|
||||
expect(result.saved).toBe(true);
|
||||
expect(result.messageCount).toBe(2);
|
||||
expect(service.getCurrentSession()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return empty result when no session', async () => {
|
||||
const result = await service.endSession();
|
||||
|
||||
expect(result.saved).toBe(false);
|
||||
expect(result.error).toBe('No active session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Compaction', () => {
|
||||
it('should trigger compaction when threshold reached', async () => {
|
||||
const customService = new SessionPersistenceService({
|
||||
maxMessagesBeforeCompact: 5,
|
||||
});
|
||||
|
||||
customService.startSession('agent1');
|
||||
|
||||
// Add more messages than threshold
|
||||
for (let i = 0; i < 7; i++) {
|
||||
customService.addMessage({ role: 'user', content: `Message ${i}` });
|
||||
customService.addMessage({ role: 'assistant', content: `Response ${i}` });
|
||||
}
|
||||
|
||||
// Wait for async compaction to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Compaction should have been triggered
|
||||
// Since compaction is async and creates a summary, we verify it was attempted
|
||||
const session = customService.getCurrentSession();
|
||||
// Compaction may or may not complete in time, but session should still be valid
|
||||
expect(session).not.toBeNull();
|
||||
expect(session!.messages.length).toBeGreaterThan(0);
|
||||
|
||||
customService.stopAutoSave();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Memory Extraction', () => {
|
||||
it('should extract memories on session end', async () => {
|
||||
service.startSession('agent1');
|
||||
|
||||
// Add enough messages for extraction
|
||||
for (let i = 0; i < 5; i++) {
|
||||
service.addMessage({ role: 'user', content: `User message ${i}` });
|
||||
service.addMessage({ role: 'assistant', content: `Assistant response ${i}` });
|
||||
}
|
||||
|
||||
const result = await service.endSession();
|
||||
|
||||
expect(result.extractedMemories).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should skip extraction for short sessions', async () => {
|
||||
service.startSession('agent1');
|
||||
service.addMessage({ role: 'user', content: 'Hi' });
|
||||
|
||||
const result = await service.endSession();
|
||||
|
||||
// Should not extract memories for sessions with < 4 messages
|
||||
expect(mockMemoryExtractor.extractFromConversation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session History', () => {
|
||||
it('should track session history', async () => {
|
||||
service.startSession('agent1');
|
||||
service.addMessage({ role: 'user', content: 'Hello' });
|
||||
await service.endSession();
|
||||
|
||||
const history = service.getSessionHistory();
|
||||
expect(history.length).toBe(1);
|
||||
expect(history[0].agentId).toBe('agent1');
|
||||
});
|
||||
|
||||
it('should limit history size', async () => {
|
||||
const customService = new SessionPersistenceService({
|
||||
maxSessionHistory: 3,
|
||||
});
|
||||
|
||||
// Create 5 sessions
|
||||
for (let i = 0; i < 5; i++) {
|
||||
customService.startSession(`agent${i}`);
|
||||
customService.addMessage({ role: 'user', content: 'Test' });
|
||||
await customService.endSession();
|
||||
}
|
||||
|
||||
const history = customService.getSessionHistory();
|
||||
expect(history.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should delete session from history', async () => {
|
||||
service.startSession('agent1');
|
||||
service.addMessage({ role: 'user', content: 'Test' });
|
||||
const result = await service.endSession();
|
||||
|
||||
const deleted = service.deleteSession(result.sessionId);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const history = service.getSessionHistory();
|
||||
expect(history.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Crash Recovery', () => {
|
||||
it('should recover from crash', () => {
|
||||
// Start a session
|
||||
const session = service.startSession('agent1');
|
||||
service.addMessage({ role: 'user', content: 'Before crash' });
|
||||
|
||||
// Simulate crash by not ending session
|
||||
const savedSession = service.getCurrentSession();
|
||||
expect(savedSession).not.toBeNull();
|
||||
|
||||
// Reset service (simulates restart)
|
||||
resetSessionPersistence();
|
||||
service = new SessionPersistenceService();
|
||||
|
||||
// Recover
|
||||
const recovered = service.recoverFromCrash();
|
||||
|
||||
expect(recovered).not.toBeNull();
|
||||
expect(recovered?.agentId).toBe('agent1');
|
||||
expect(recovered?.status).toBe('active');
|
||||
});
|
||||
|
||||
it('should not recover timed-out sessions', async () => {
|
||||
const customService = new SessionPersistenceService({
|
||||
sessionTimeoutMs: 1000, // 1 second
|
||||
});
|
||||
|
||||
customService.startSession('agent1');
|
||||
customService.addMessage({ role: 'user', content: 'Test' });
|
||||
|
||||
// Manually set lastActivityAt to past and save to localStorage
|
||||
const session = customService.getCurrentSession();
|
||||
if (session) {
|
||||
session.lastActivityAt = new Date(Date.now() - 5000).toISOString();
|
||||
// Force save to localStorage so recovery can find it
|
||||
localStorage.setItem('zclaw-current-session', JSON.stringify(session));
|
||||
}
|
||||
|
||||
// Stop auto-save to prevent overwriting
|
||||
customService.stopAutoSave();
|
||||
|
||||
// Reset and try to recover
|
||||
resetSessionPersistence();
|
||||
const newService = new SessionPersistenceService({ sessionTimeoutMs: 1000 });
|
||||
const recovered = newService.recoverFromCrash();
|
||||
|
||||
expect(recovered).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Availability', () => {
|
||||
it('should check availability', async () => {
|
||||
const available = await service.isAvailable();
|
||||
expect(available).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when disabled', async () => {
|
||||
service.updateConfig({ enabled: false });
|
||||
const available = await service.isAvailable();
|
||||
expect(available).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// === Helper Function Tests ===
|
||||
|
||||
describe('Helper Functions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
resetSessionPersistence();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetSessionPersistence();
|
||||
});
|
||||
|
||||
it('should start session via helper', () => {
|
||||
const session = startSession('agent1');
|
||||
expect(session.agentId).toBe('agent1');
|
||||
});
|
||||
|
||||
it('should add message via helper', () => {
|
||||
startSession('agent1');
|
||||
const msg = addSessionMessage({ role: 'user', content: 'Test' });
|
||||
expect(msg?.content).toBe('Test');
|
||||
});
|
||||
|
||||
it('should end session via helper', async () => {
|
||||
startSession('agent1');
|
||||
addSessionMessage({ role: 'user', content: 'Test' });
|
||||
|
||||
const result = await endCurrentSession();
|
||||
expect(result.saved).toBe(true);
|
||||
});
|
||||
|
||||
it('should get current session via helper', () => {
|
||||
startSession('agent1');
|
||||
const session = getCurrentSession();
|
||||
expect(session?.agentId).toBe('agent1');
|
||||
});
|
||||
});
|
||||
|
||||
// === Integration Tests ===
|
||||
|
||||
describe('Session Persistence Integration', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
resetSessionPersistence();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetSessionPersistence();
|
||||
});
|
||||
|
||||
it('should handle Viking client errors gracefully', async () => {
|
||||
mockVikingClient.addResource.mockRejectedValueOnce(new Error('Viking error'));
|
||||
|
||||
const service = new SessionPersistenceService({ fallbackToLocal: true });
|
||||
service.startSession('agent1');
|
||||
service.addMessage({ role: 'user', content: 'Test' });
|
||||
|
||||
const result = await service.endSession();
|
||||
|
||||
// Should still save to local storage
|
||||
expect(result.saved).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle memory extractor errors gracefully', async () => {
|
||||
mockMemoryExtractor.extractFromConversation.mockRejectedValueOnce(new Error('Extraction failed'));
|
||||
|
||||
const service = new SessionPersistenceService();
|
||||
service.startSession('agent1');
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
service.addMessage({ role: 'user', content: `Message ${i}` });
|
||||
service.addMessage({ role: 'assistant', content: `Response ${i}` });
|
||||
}
|
||||
|
||||
const result = await service.endSession();
|
||||
|
||||
// Should still complete session even if extraction fails
|
||||
expect(result.saved).toBe(true);
|
||||
expect(result.extractedMemories).toBe(0);
|
||||
});
|
||||
});
|
||||
299
docs/archive/v1-viking-dead-code/tests/vector-memory.test.ts
Normal file
299
docs/archive/v1-viking-dead-code/tests/vector-memory.test.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
/**
|
||||
* Vector Memory Tests - Phase 4.2 Semantic Search
|
||||
*
|
||||
* Tests for vector-based semantic memory search:
|
||||
* - VectorMemoryService initialization
|
||||
* - Semantic search with OpenViking
|
||||
* - Similar memory finding
|
||||
* - Clustering functionality
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import {
|
||||
VectorMemoryService,
|
||||
getVectorMemory,
|
||||
resetVectorMemory,
|
||||
semanticSearch,
|
||||
findSimilarMemories,
|
||||
isVectorSearchAvailable,
|
||||
DEFAULT_VECTOR_CONFIG,
|
||||
type VectorSearchOptions,
|
||||
type VectorSearchResult,
|
||||
} from '../../desktop/src/lib/vector-memory';
|
||||
import { getVikingClient, resetVikingClient } from '../../desktop/src/lib/viking-client';
|
||||
import { getMemoryManager, resetMemoryManager } from '../../desktop/src/lib/agent-memory';
|
||||
|
||||
// === Mock Dependencies ===
|
||||
|
||||
const mockVikingClient = {
|
||||
isAvailable: vi.fn(async () => true),
|
||||
find: vi.fn(async () => [
|
||||
{ uri: 'memories/agent1/memory1', content: '用户偏好简洁的回答', score: 0.9, metadata: { tags: ['preference'] } },
|
||||
{ uri: 'memories/agent1/memory2', content: '项目使用 TypeScript', score: 0.7, metadata: { tags: ['fact'] } },
|
||||
{ uri: 'memories/agent1/memory3', content: '需要完成性能测试', score: 0.5, metadata: { tags: ['task'] } },
|
||||
]),
|
||||
addResource: vi.fn(async () => ({ uri: 'test', status: 'ok' })),
|
||||
removeResource: vi.fn(async () => undefined),
|
||||
};
|
||||
|
||||
vi.mock('../../desktop/src/lib/viking-client', () => ({
|
||||
getVikingClient: vi.fn(() => mockVikingClient),
|
||||
resetVikingClient: vi.fn(),
|
||||
VikingHttpClient: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockMemoryManager = {
|
||||
getByAgent: vi.fn(() => [
|
||||
{ id: 'memory1', agentId: 'agent1', content: '用户偏好简洁的回答', type: 'preference', importance: 7, createdAt: new Date().toISOString(), source: 'auto', tags: ['style'], lastAccessedAt: new Date().toISOString(), accessCount: 0 },
|
||||
{ id: 'memory2', agentId: 'agent1', content: '项目使用 TypeScript', type: 'fact', importance: 6, createdAt: new Date().toISOString(), source: 'auto', tags: ['tech'], lastAccessedAt: new Date().toISOString(), accessCount: 0 },
|
||||
{ id: 'memory3', agentId: 'agent1', content: '需要完成性能测试', type: 'task', importance: 8, createdAt: new Date().toISOString(), source: 'auto', tags: ['todo'], lastAccessedAt: new Date().toISOString(), accessCount: 0 },
|
||||
]),
|
||||
getAll: vi.fn(async () => [
|
||||
{ id: 'memory1', agentId: 'agent1', content: '用户偏好简洁的回答', type: 'preference', importance: 7, createdAt: new Date().toISOString(), source: 'auto', tags: ['style'], lastAccessedAt: new Date().toISOString(), accessCount: 0 },
|
||||
{ id: 'memory2', agentId: 'agent1', content: '项目使用 TypeScript', type: 'fact', importance: 6, createdAt: new Date().toISOString(), source: 'auto', tags: ['tech'], lastAccessedAt: new Date().toISOString(), accessCount: 0 },
|
||||
{ id: 'memory3', agentId: 'agent1', content: '需要完成性能测试', type: 'task', importance: 8, createdAt: new Date().toISOString(), source: 'auto', tags: ['todo'], lastAccessedAt: new Date().toISOString(), accessCount: 0 },
|
||||
]),
|
||||
save: vi.fn(async () => 'memory-id'),
|
||||
};
|
||||
|
||||
vi.mock('../../desktop/src/lib/agent-memory', () => ({
|
||||
getMemoryManager: vi.fn(() => mockMemoryManager),
|
||||
resetMemoryManager: vi.fn(),
|
||||
}));
|
||||
|
||||
// === VectorMemoryService Tests ===
|
||||
|
||||
describe('VectorMemoryService', () => {
|
||||
let service: VectorMemoryService;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetVectorMemory();
|
||||
resetVikingClient();
|
||||
service = new VectorMemoryService();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetVectorMemory();
|
||||
});
|
||||
|
||||
describe('Initialization', () => {
|
||||
it('should initialize with default config', () => {
|
||||
const config = service.getConfig();
|
||||
expect(config.enabled).toBe(true);
|
||||
expect(config.defaultTopK).toBe(10);
|
||||
expect(config.defaultMinScore).toBe(0.3);
|
||||
expect(config.defaultLevel).toBe('L1');
|
||||
});
|
||||
|
||||
it('should accept custom config', () => {
|
||||
const customService = new VectorMemoryService({
|
||||
defaultTopK: 20,
|
||||
defaultMinScore: 0.5,
|
||||
});
|
||||
const config = customService.getConfig();
|
||||
expect(config.defaultTopK).toBe(20);
|
||||
expect(config.defaultMinScore).toBe(0.5);
|
||||
});
|
||||
|
||||
it('should update config', () => {
|
||||
service.updateConfig({ defaultTopK: 15 });
|
||||
const config = service.getConfig();
|
||||
expect(config.defaultTopK).toBe(15);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Semantic Search', () => {
|
||||
it('should perform semantic search', async () => {
|
||||
const results = await service.semanticSearch('用户偏好');
|
||||
|
||||
expect(mockVikingClient.find).toHaveBeenCalled();
|
||||
expect(results.length).toBeGreaterThan(0);
|
||||
expect(results[0].score).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should respect topK option', async () => {
|
||||
await service.semanticSearch('测试', { topK: 5 });
|
||||
|
||||
expect(mockVikingClient.find).toHaveBeenCalledWith(
|
||||
'测试',
|
||||
expect.objectContaining({ limit: 5 })
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect minScore option', async () => {
|
||||
await service.semanticSearch('测试', { minScore: 0.8 });
|
||||
|
||||
expect(mockVikingClient.find).toHaveBeenCalledWith(
|
||||
'测试',
|
||||
expect.objectContaining({ minScore: 0.8 })
|
||||
);
|
||||
});
|
||||
|
||||
it('should respect level option', async () => {
|
||||
await service.semanticSearch('测试', { level: 'L2' });
|
||||
|
||||
expect(mockVikingClient.find).toHaveBeenCalledWith(
|
||||
'测试',
|
||||
expect.objectContaining({ level: 'L2' })
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when disabled', async () => {
|
||||
service.updateConfig({ enabled: false });
|
||||
const results = await service.semanticSearch('测试');
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter by types when specified', async () => {
|
||||
const results = await service.semanticSearch('用户偏好', { types: ['preference'] });
|
||||
|
||||
// Should only return preference type memories
|
||||
for (const result of results) {
|
||||
expect(result.memory.type).toBe('preference');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Find Similar', () => {
|
||||
it('should find similar memories', async () => {
|
||||
const results = await service.findSimilar('memory1', { agentId: 'agent1' });
|
||||
|
||||
expect(mockMemoryManager.getAll).toHaveBeenCalledWith('agent1');
|
||||
expect(mockVikingClient.find).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return empty array for non-existent memory', async () => {
|
||||
mockMemoryManager.getAll.mockResolvedValueOnce([]);
|
||||
|
||||
const results = await service.findSimilar('non-existent', { agentId: 'agent1' });
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Find By Concept', () => {
|
||||
it('should find memories by concept', async () => {
|
||||
const results = await service.findByConcept('代码优化');
|
||||
|
||||
expect(mockVikingClient.find).toHaveBeenCalledWith(
|
||||
'代码优化',
|
||||
expect.any(Object)
|
||||
);
|
||||
expect(results.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Clustering', () => {
|
||||
it('should cluster memories', async () => {
|
||||
const clusters = await service.clusterMemories('agent1', 3);
|
||||
|
||||
expect(mockMemoryManager.getAll).toHaveBeenCalledWith('agent1');
|
||||
expect(Array.isArray(clusters)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return empty array for agent with no memories', async () => {
|
||||
mockMemoryManager.getAll.mockResolvedValueOnce([]);
|
||||
|
||||
const clusters = await service.clusterMemories('empty-agent');
|
||||
|
||||
expect(clusters).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Availability', () => {
|
||||
it('should check availability', async () => {
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when disabled', async () => {
|
||||
service.updateConfig({ enabled: false });
|
||||
const available = await service.isAvailable();
|
||||
|
||||
expect(available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cache', () => {
|
||||
it('should clear cache', () => {
|
||||
service.clearCache();
|
||||
// No error means success
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// === Helper Function Tests ===
|
||||
|
||||
describe('Helper Functions', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetVectorMemory();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetVectorMemory();
|
||||
});
|
||||
|
||||
describe('getVectorMemory', () => {
|
||||
it('should return singleton instance', () => {
|
||||
const instance1 = getVectorMemory();
|
||||
const instance2 = getVectorMemory();
|
||||
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('semanticSearch helper', () => {
|
||||
it('should call service.semanticSearch', async () => {
|
||||
const results = await semanticSearch('测试查询');
|
||||
|
||||
expect(mockVikingClient.find).toHaveBeenCalled();
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSimilarMemories helper', () => {
|
||||
it('should call service.findSimilar', async () => {
|
||||
const results = await findSimilarMemories('memory1', 'agent1');
|
||||
|
||||
expect(mockMemoryManager.getAll).toHaveBeenCalled();
|
||||
expect(Array.isArray(results)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isVectorSearchAvailable helper', () => {
|
||||
it('should call service.isAvailable', async () => {
|
||||
const available = await isVectorSearchAvailable();
|
||||
|
||||
expect(typeof available).toBe('boolean');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// === Integration Tests ===
|
||||
|
||||
describe('VectorMemoryService Integration', () => {
|
||||
it('should handle Viking client errors gracefully', async () => {
|
||||
mockVikingClient.find.mockRejectedValueOnce(new Error('Connection failed'));
|
||||
|
||||
const service = new VectorMemoryService();
|
||||
const results = await service.semanticSearch('测试');
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing Viking client gracefully', async () => {
|
||||
vi.mocked(getVikingClient).mockImplementation(() => {
|
||||
throw new Error('Viking not available');
|
||||
});
|
||||
|
||||
const service = new VectorMemoryService();
|
||||
const results = await service.semanticSearch('测试');
|
||||
|
||||
expect(results).toEqual([]);
|
||||
});
|
||||
});
|
||||
446
docs/archive/v1-viking-dead-code/tests/viking-adapter.test.ts
Normal file
446
docs/archive/v1-viking-dead-code/tests/viking-adapter.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
/**
|
||||
* Tests for VikingAdapter and ContextBuilder
|
||||
*
|
||||
* Tests the ZCLAW ↔ OpenViking integration layer with mocked HTTP responses.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { VikingHttpClient, VikingError } from '../../desktop/src/lib/viking-client';
|
||||
import {
|
||||
VikingAdapter,
|
||||
VIKING_NS,
|
||||
resetVikingAdapter,
|
||||
} from '../../desktop/src/lib/viking-adapter';
|
||||
import {
|
||||
ContextBuilder,
|
||||
resetContextBuilder,
|
||||
} from '../../desktop/src/lib/context-builder';
|
||||
|
||||
// === Mock fetch globally ===
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
vi.stubGlobal('fetch', mockFetch);
|
||||
|
||||
function mockJsonResponse(data: unknown, status = 200) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: status === 200 ? 'OK' : 'Error',
|
||||
json: () => Promise.resolve(data),
|
||||
text: () => Promise.resolve(JSON.stringify(data)),
|
||||
};
|
||||
}
|
||||
|
||||
// === VikingHttpClient Tests ===
|
||||
|
||||
describe('VikingHttpClient', () => {
|
||||
let client: VikingHttpClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new VikingHttpClient('http://localhost:1933');
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
it('returns server status on success', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({ status: 'ok', version: '0.1.18' })
|
||||
);
|
||||
|
||||
const result = await client.status();
|
||||
expect(result.status).toBe('ok');
|
||||
expect(result.version).toBe('0.1.18');
|
||||
});
|
||||
|
||||
it('throws VikingError on server error', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({ error: 'internal' }, 500)
|
||||
);
|
||||
|
||||
await expect(client.status()).rejects.toThrow(VikingError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('returns true when server responds ok', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({ status: 'ok' })
|
||||
);
|
||||
|
||||
expect(await client.isAvailable()).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when server is down', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('ECONNREFUSED'));
|
||||
|
||||
expect(await client.isAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('find', () => {
|
||||
it('sends correct find request', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
results: [
|
||||
{ uri: 'viking://user/memories/preferences/lang', score: 0.9, content: '中文', level: 'L1' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const results = await client.find('language preference', {
|
||||
scope: 'viking://user/memories/',
|
||||
level: 'L1',
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].score).toBe(0.9);
|
||||
expect(results[0].content).toBe('中文');
|
||||
|
||||
// Verify request body
|
||||
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(callBody.query).toBe('language preference');
|
||||
expect(callBody.scope).toBe('viking://user/memories/');
|
||||
expect(callBody.level).toBe('L1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addResource', () => {
|
||||
it('sends correct add request', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({ uri: 'viking://user/memories/preferences/lang', status: 'ok' })
|
||||
);
|
||||
|
||||
const result = await client.addResource(
|
||||
'viking://user/memories/preferences/lang',
|
||||
'用户偏好中文回复',
|
||||
{ metadata: { type: 'preference' }, wait: true }
|
||||
);
|
||||
|
||||
expect(result.status).toBe('ok');
|
||||
|
||||
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(callBody.uri).toBe('viking://user/memories/preferences/lang');
|
||||
expect(callBody.content).toBe('用户偏好中文回复');
|
||||
expect(callBody.wait).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractMemories', () => {
|
||||
it('sends session content for extraction', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
memories: [
|
||||
{
|
||||
category: 'user_preference',
|
||||
content: '用户喜欢简洁回答',
|
||||
tags: ['communication'],
|
||||
importance: 8,
|
||||
suggestedUri: 'viking://user/memories/preferences/communication',
|
||||
},
|
||||
{
|
||||
category: 'agent_lesson',
|
||||
content: '使用飞书API前需验证token',
|
||||
tags: ['feishu', 'api'],
|
||||
importance: 7,
|
||||
suggestedUri: 'viking://agent/zclaw/memories/lessons/feishu_token',
|
||||
},
|
||||
],
|
||||
summary: '讨论了飞书集成和回复风格偏好',
|
||||
})
|
||||
);
|
||||
|
||||
const result = await client.extractMemories(
|
||||
'[user]: 帮我集成飞书API\n[assistant]: 好的,我来...',
|
||||
'zclaw-main'
|
||||
);
|
||||
|
||||
expect(result.memories).toHaveLength(2);
|
||||
expect(result.memories[0].category).toBe('user_preference');
|
||||
expect(result.memories[1].category).toBe('agent_lesson');
|
||||
expect(result.summary).toContain('飞书');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// === VikingAdapter Tests ===
|
||||
|
||||
describe('VikingAdapter', () => {
|
||||
let adapter: VikingAdapter;
|
||||
|
||||
beforeEach(() => {
|
||||
resetVikingAdapter();
|
||||
adapter = new VikingAdapter({ serverUrl: 'http://localhost:1933' });
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe('saveUserPreference', () => {
|
||||
it('saves to correct viking:// URI', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({ uri: 'viking://user/memories/preferences/language', status: 'ok' })
|
||||
);
|
||||
|
||||
await adapter.saveUserPreference('language', '中文优先');
|
||||
|
||||
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(callBody.uri).toBe('viking://user/memories/preferences/language');
|
||||
expect(callBody.content).toBe('中文优先');
|
||||
expect(callBody.metadata.type).toBe('preference');
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveAgentLesson', () => {
|
||||
it('saves to agent-specific lessons URI', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({ uri: 'viking://agent/zclaw-main/memories/lessons_learned/123', status: 'ok' })
|
||||
);
|
||||
|
||||
await adapter.saveAgentLesson('zclaw-main', '飞书API需要先验证token', ['feishu', 'auth']);
|
||||
|
||||
const callBody = JSON.parse(mockFetch.mock.calls[0][1].body);
|
||||
expect(callBody.uri).toContain('viking://agent/zclaw-main/memories/lessons_learned/');
|
||||
expect(callBody.content).toBe('飞书API需要先验证token');
|
||||
expect(callBody.metadata.tags).toBe('feishu,auth');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildEnhancedContext', () => {
|
||||
it('performs L0 scan then L1 load', async () => {
|
||||
// Mock L0 user memories search
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
results: [
|
||||
{ uri: 'viking://user/memories/preferences/lang', score: 0.85, content: '中文', level: 'L0' },
|
||||
{ uri: 'viking://user/memories/facts/project', score: 0.7, content: '飞书集成', level: 'L0' },
|
||||
],
|
||||
})
|
||||
);
|
||||
// Mock L0 agent memories search
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
results: [
|
||||
{ uri: 'viking://agent/zclaw/memories/lessons/feishu', score: 0.8, content: 'API认证', level: 'L0' },
|
||||
],
|
||||
})
|
||||
);
|
||||
// Mock L1 reads for relevant items (score >= 0.5)
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({ content: '用户偏好中文回复,简洁风格' })
|
||||
);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({ content: '飞书API需要先验证app_id和app_secret' })
|
||||
);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({ content: '调用飞书API前确保token未过期' })
|
||||
);
|
||||
|
||||
const result = await adapter.buildEnhancedContext('帮我处理飞书集成', 'zclaw');
|
||||
|
||||
expect(result.memories.length).toBeGreaterThan(0);
|
||||
expect(result.totalTokens).toBeGreaterThan(0);
|
||||
expect(result.systemPromptAddition).toContain('记忆');
|
||||
});
|
||||
|
||||
it('returns empty context when Viking is unavailable', async () => {
|
||||
// Both L0 searches fail
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const result = await adapter.buildEnhancedContext('test message', 'zclaw');
|
||||
|
||||
expect(result.memories).toHaveLength(0);
|
||||
expect(result.totalTokens).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractAndSaveMemories', () => {
|
||||
it('extracts and saves memories to correct categories', async () => {
|
||||
// Mock extraction call
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
memories: [
|
||||
{
|
||||
category: 'user_preference',
|
||||
content: '用户喜欢TypeScript',
|
||||
tags: ['coding'],
|
||||
importance: 8,
|
||||
suggestedUri: 'viking://user/memories/preferences/coding',
|
||||
},
|
||||
{
|
||||
category: 'agent_lesson',
|
||||
content: 'Vitest配置需要在tsconfig中设置paths',
|
||||
tags: ['testing', 'vitest'],
|
||||
importance: 7,
|
||||
suggestedUri: 'viking://agent/zclaw/memories/lessons/vitest_config',
|
||||
},
|
||||
],
|
||||
summary: '讨论了TypeScript测试配置',
|
||||
})
|
||||
);
|
||||
// Mock save calls (2 saves)
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({ uri: 'viking://user/memories/preferences/coding', status: 'ok' })
|
||||
);
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({ uri: 'viking://agent/zclaw/memories/lessons/123', status: 'ok' })
|
||||
);
|
||||
|
||||
const result = await adapter.extractAndSaveMemories(
|
||||
[
|
||||
{ role: 'user', content: '帮我配置Vitest' },
|
||||
{ role: 'assistant', content: '好的,需要在tsconfig中...' },
|
||||
],
|
||||
'zclaw'
|
||||
);
|
||||
|
||||
expect(result.saved).toBe(2);
|
||||
expect(result.userMemories).toBe(1);
|
||||
expect(result.agentMemories).toBe(1);
|
||||
});
|
||||
|
||||
it('handles extraction failure gracefully', async () => {
|
||||
mockFetch.mockRejectedValueOnce(new Error('Server error'));
|
||||
|
||||
const result = await adapter.extractAndSaveMemories(
|
||||
[{ role: 'user', content: 'test' }],
|
||||
'zclaw'
|
||||
);
|
||||
|
||||
expect(result.saved).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('VIKING_NS', () => {
|
||||
it('generates correct namespace URIs', () => {
|
||||
expect(VIKING_NS.userPreferences).toBe('viking://user/memories/preferences');
|
||||
expect(VIKING_NS.agentLessons('zclaw')).toBe('viking://agent/zclaw/memories/lessons_learned');
|
||||
expect(VIKING_NS.agentIdentity('zclaw')).toBe('viking://agent/zclaw/identity');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// === ContextBuilder Tests ===
|
||||
|
||||
describe('ContextBuilder', () => {
|
||||
let builder: ContextBuilder;
|
||||
|
||||
beforeEach(() => {
|
||||
resetContextBuilder();
|
||||
resetVikingAdapter();
|
||||
builder = new ContextBuilder({ enabled: true });
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe('buildContext', () => {
|
||||
it('returns empty prompt when disabled', async () => {
|
||||
builder.updateConfig({ enabled: false });
|
||||
|
||||
const result = await builder.buildContext('test', 'zclaw');
|
||||
|
||||
expect(result.systemPrompt).toBe('');
|
||||
expect(result.tokensUsed).toBe(0);
|
||||
});
|
||||
|
||||
it('returns empty prompt when Viking is unavailable', async () => {
|
||||
// isAvailable check fails
|
||||
mockFetch.mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
|
||||
const result = await builder.buildContext('test', 'zclaw');
|
||||
|
||||
expect(result.memoriesInjected).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkAndCompact', () => {
|
||||
it('returns null when under threshold', async () => {
|
||||
const messages = [
|
||||
{ role: 'user' as const, content: '你好' },
|
||||
{ role: 'assistant' as const, content: '你好!有什么可以帮你?' },
|
||||
];
|
||||
|
||||
const result = await builder.checkAndCompact(messages, 'zclaw');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('compacts and flushes memory when over threshold', async () => {
|
||||
// Create messages that exceed the threshold
|
||||
const longContent = '这是一段很长的对话内容。'.repeat(500);
|
||||
const messages = [
|
||||
{ role: 'user' as const, content: longContent },
|
||||
{ role: 'assistant' as const, content: longContent },
|
||||
{ role: 'user' as const, content: longContent },
|
||||
{ role: 'assistant' as const, content: longContent },
|
||||
{ role: 'user' as const, content: '最近的消息1' },
|
||||
{ role: 'assistant' as const, content: '最近的消息2' },
|
||||
{ role: 'user' as const, content: '最近的消息3' },
|
||||
{ role: 'assistant' as const, content: '最近的消息4' },
|
||||
{ role: 'user' as const, content: '最近的消息5' },
|
||||
{ role: 'assistant' as const, content: '最近的回复5' },
|
||||
];
|
||||
|
||||
// Mock memory flush extraction call
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({
|
||||
memories: [
|
||||
{
|
||||
category: 'user_fact',
|
||||
content: '讨论了长文本处理',
|
||||
tags: ['text'],
|
||||
importance: 5,
|
||||
suggestedUri: 'viking://user/memories/facts/text',
|
||||
},
|
||||
],
|
||||
summary: '长文本处理讨论',
|
||||
})
|
||||
);
|
||||
// Mock save call for flushed memory
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
mockJsonResponse({ uri: 'viking://user/memories/facts/text/123', status: 'ok' })
|
||||
);
|
||||
|
||||
builder.updateConfig({ compactionThresholdTokens: 100 }); // Low threshold for test
|
||||
const result = await builder.checkAndCompact(messages, 'zclaw');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.compactedMessages.length).toBeLessThan(messages.length);
|
||||
expect(result!.compactedMessages[0].content).toContain('对话摘要');
|
||||
// Recent messages preserved
|
||||
expect(result!.compactedMessages.some(m => m.content === '最近的回复5')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractMemoriesFromConversation', () => {
|
||||
it('skips extraction when disabled', async () => {
|
||||
builder.updateConfig({ autoExtractOnComplete: false });
|
||||
|
||||
const result = await builder.extractMemoriesFromConversation(
|
||||
[
|
||||
{ role: 'user', content: '你好' },
|
||||
{ role: 'assistant', content: '你好!' },
|
||||
],
|
||||
'zclaw'
|
||||
);
|
||||
|
||||
expect(result.saved).toBe(0);
|
||||
});
|
||||
|
||||
it('skips extraction for short conversations', async () => {
|
||||
const result = await builder.extractMemoriesFromConversation(
|
||||
[{ role: 'user', content: '你好' }],
|
||||
'zclaw'
|
||||
);
|
||||
|
||||
expect(result.saved).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('configuration', () => {
|
||||
it('can update and read config', () => {
|
||||
builder.updateConfig({ maxMemoryTokens: 4000, enabled: false });
|
||||
|
||||
const config = builder.getConfig();
|
||||
expect(config.maxMemoryTokens).toBe(4000);
|
||||
expect(config.enabled).toBe(false);
|
||||
expect(builder.isEnabled()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user