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:
iven
2026-03-20 22:14:13 +08:00
parent 6f72442531
commit 1cf3f585d3
43 changed files with 2826 additions and 3103 deletions

View File

@@ -14,7 +14,8 @@ import { AgentOnboardingWizard } from './components/AgentOnboardingWizard';
import { HandApprovalModal } from './components/HandApprovalModal';
import { TopBar } from './components/TopBar';
import { DetailDrawer } from './components/DetailDrawer';
import { useGatewayStore, type HandRun } from './store/gatewayStore';
import { useConnectionStore } from './store/connectionStore';
import { useHandStore, type HandRun } from './store/handStore';
import { useTeamStore } from './store/teamStore';
import { useChatStore } from './store/chatStore';
import { getStoredGatewayToken } from './lib/gateway-client';
@@ -53,7 +54,10 @@ function App() {
const [showApprovalModal, setShowApprovalModal] = useState(false);
const [teamViewMode, setTeamViewMode] = useState<'collaboration' | 'orchestrator'>('collaboration');
const { connect, hands, approveHand, loadHands } = useGatewayStore();
const connect = useConnectionStore((s) => s.connect);
const hands = useHandStore((s) => s.hands);
const approveHand = useHandStore((s) => s.approveHand);
const loadHands = useHandStore((s) => s.loadHands);
const { activeTeam, setActiveTeam, teams } = useTeamStore();
const { setCurrentAgent, newConversation } = useChatStore();
const { isNeeded: onboardingNeeded, isLoading: onboardingLoading, markCompleted } = useOnboarding();

View File

@@ -8,11 +8,8 @@
*/
import { useState, useEffect, useCallback } from 'react';
import {
useGatewayStore,
type Approval,
type ApprovalStatus,
} from '../store/gatewayStore';
import { useHandStore } from '../store/handStore';
import type { Approval, ApprovalStatus } from '../store/handStore';
import {
CheckCircle,
XCircle,
@@ -297,8 +294,10 @@ function EmptyState({ filter }: { filter: FilterStatus }) {
// === Main ApprovalsPanel Component ===
export function ApprovalsPanel() {
const { approvals, loadApprovals, respondToApproval, isLoading } =
useGatewayStore();
const approvals = useHandStore((s) => s.approvals);
const loadApprovals = useHandStore((s) => s.loadApprovals);
const respondToApproval = useHandStore((s) => s.respondToApproval);
const isLoading = useHandStore((s) => s.isLoading);
const [filter, setFilter] = useState<FilterStatus>('all');
const [processingId, setProcessingId] = useState<string | null>(null);

View File

@@ -1,12 +1,18 @@
import { useState, useEffect } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import { useAgentStore } from '../store/agentStore';
import { useConnectionStore } from '../store/connectionStore';
import { useConfigStore } from '../store/configStore';
import { toChatAgent, useChatStore } from '../store/chatStore';
import { Bot, Plus, X, Globe, Cat, Search, BarChart2, Sparkles } from 'lucide-react';
import { AgentOnboardingWizard } from './AgentOnboardingWizard';
import type { Clone } from '../store/agentStore';
export function CloneManager() {
const { clones, loadClones, deleteClone, connectionState, quickConfig } = useGatewayStore();
const clones = useAgentStore((s) => s.clones);
const loadClones = useAgentStore((s) => s.loadClones);
const deleteClone = useAgentStore((s) => s.deleteClone);
const connectionState = useConnectionStore((s) => s.connectionState);
const quickConfig = useConfigStore((s) => s.quickConfig);
const { agents, currentAgent, setCurrentAgent } = useChatStore();
const [showWizard, setShowWizard] = useState(false);

View File

@@ -6,7 +6,7 @@
*/
import { useEffect } from 'react';
import { useGatewayStore, type Hand } from '../store/gatewayStore';
import { useHandStore, type Hand } from '../store/handStore';
import { Zap, Loader2, RefreshCw, CheckCircle, XCircle, AlertTriangle } from 'lucide-react';
interface HandListProps {
@@ -42,7 +42,9 @@ const STATUS_LABELS: Record<Hand['status'], string> = {
};
export function HandList({ selectedHandId, onSelectHand }: HandListProps) {
const { hands, loadHands, isLoading } = useGatewayStore();
const hands = useHandStore((s) => s.hands);
const loadHands = useHandStore((s) => s.loadHands);
const isLoading = useHandStore((s) => s.isLoading);
useEffect(() => {
loadHands();

View File

@@ -6,7 +6,7 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore, type Hand, type HandRun } from '../store/gatewayStore';
import { useHandStore, type Hand, type HandRun } from '../store/handStore';
import {
Zap,
Loader2,
@@ -39,7 +39,12 @@ const RUN_STATUS_CONFIG: Record<string, { label: string; className: string; icon
};
export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
const { hands, handRuns, loadHands, loadHandRuns, triggerHand, isLoading } = useGatewayStore();
const hands = useHandStore((s) => s.hands);
const handRuns = useHandStore((s) => s.handRuns);
const loadHands = useHandStore((s) => s.loadHands);
const loadHandRuns = useHandStore((s) => s.loadHandRuns);
const triggerHand = useHandStore((s) => s.triggerHand);
const isLoading = useHandStore((s) => s.isLoading);
const { toast } = useToast();
const [selectedHand, setSelectedHand] = useState<Hand | null>(null);
const [isActivating, setIsActivating] = useState(false);
@@ -103,7 +108,7 @@ export function HandTaskPanel({ handId, onBack }: HandTaskPanelProps) {
]);
} else {
// Check for specific error in store
const storeError = useGatewayStore.getState().error;
const storeError = useHandStore.getState().error;
if (storeError?.includes('already active')) {
toast(`Hand "${selectedHand.name}" 已在运行中`, 'warning');
} else {

View File

@@ -1,9 +1,10 @@
import { FileText, Globe } from 'lucide-react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useConfigStore } from '../../store/configStore';
import { silentErrorHandler } from '../../lib/error-utils';
export function MCPServices() {
const { quickConfig, saveQuickConfig } = useGatewayStore();
const quickConfig = useConfigStore((s) => s.quickConfig);
const saveQuickConfig = useConfigStore((s) => s.saveQuickConfig);
const services = quickConfig.mcpServices || [];

View File

@@ -1,10 +1,13 @@
import { useEffect } from 'react';
import { ExternalLink } from 'lucide-react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useConfigStore } from '../../store/configStore';
import { silentErrorHandler } from '../../lib/error-utils';
export function Privacy() {
const { quickConfig, workspaceInfo, loadWorkspaceInfo, saveQuickConfig } = useGatewayStore();
const quickConfig = useConfigStore((s) => s.quickConfig);
const workspaceInfo = useConfigStore((s) => s.workspaceInfo);
const loadWorkspaceInfo = useConfigStore((s) => s.loadWorkspaceInfo);
const saveQuickConfig = useConfigStore((s) => s.saveQuickConfig);
useEffect(() => {
loadWorkspaceInfo().catch(silentErrorHandler('Privacy'));

View File

@@ -1,9 +1,12 @@
import { useEffect, useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useAgentStore } from '../../store/agentStore';
import { useConnectionStore } from '../../store/connectionStore';
import { BarChart3, TrendingUp, Clock, Zap } from 'lucide-react';
export function UsageStats() {
const { usageStats, loadUsageStats, connectionState } = useGatewayStore();
const usageStats = useAgentStore((s) => s.usageStats);
const loadUsageStats = useAgentStore((s) => s.loadUsageStats);
const connectionState = useConnectionStore((s) => s.connectionState);
const [timeRange, setTimeRange] = useState<'7d' | '30d' | 'all'>('7d');
useEffect(() => {

View File

@@ -1,14 +1,12 @@
import { useEffect, useState } from 'react';
import { useGatewayStore } from '../../store/gatewayStore';
import { useConfigStore } from '../../store/configStore';
import { silentErrorHandler } from '../../lib/error-utils';
export function Workspace() {
const {
quickConfig,
workspaceInfo,
loadWorkspaceInfo,
saveQuickConfig,
} = useGatewayStore();
const quickConfig = useConfigStore((s) => s.quickConfig);
const workspaceInfo = useConfigStore((s) => s.workspaceInfo);
const loadWorkspaceInfo = useConfigStore((s) => s.loadWorkspaceInfo);
const saveQuickConfig = useConfigStore((s) => s.saveQuickConfig);
const [projectDir, setProjectDir] = useState('~/.openfang/zclaw-workspace');
useEffect(() => {

View File

@@ -6,7 +6,7 @@ import {
} from 'lucide-react';
import { CloneManager } from './CloneManager';
import { TeamList } from './TeamList';
import { useGatewayStore } from '../store/gatewayStore';
import { useConfigStore } from '../store/configStore';
import { containerVariants, defaultTransition } from '../lib/animations';
export type MainViewType = 'chat' | 'automation' | 'team' | 'swarm' | 'skills';
@@ -44,7 +44,7 @@ export function Sidebar({
}: SidebarProps) {
const [activeTab, setActiveTab] = useState<Tab>('clones');
const [searchQuery, setSearchQuery] = useState('');
const userName = useGatewayStore((state) => state.quickConfig.userName) || '用户7141';
const userName = useConfigStore((state) => state.quickConfig?.userName) || '用户7141';
const handleNavClick = (key: Tab, mainView?: MainViewType) => {
setActiveTab(key);

View File

@@ -5,8 +5,8 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import type { Trigger } from '../store/gatewayStore';
import { useHandStore } from '../store/handStore';
import type { Trigger } from '../store/handStore';
import { CreateTriggerModal } from './CreateTriggerModal';
import {
Zap,
@@ -105,7 +105,11 @@ function TriggerCard({ trigger, onToggle, onDelete, isToggling, isDeleting }: Tr
}
export function TriggersPanel() {
const { triggers, loadTriggers, isLoading, client, deleteTrigger } = useGatewayStore();
const triggers = useHandStore((s) => s.triggers);
const loadTriggers = useHandStore((s) => s.loadTriggers);
const deleteTrigger = useHandStore((s) => s.deleteTrigger);
const isLoading = useHandStore((s) => s.isLoading);
const client = useHandStore((s) => s.client);
const [togglingTrigger, setTogglingTrigger] = useState<string | null>(null);
const [deletingTrigger, setDeletingTrigger] = useState<string | null>(null);
const [refreshing, setRefreshing] = useState(false);

View File

@@ -8,7 +8,7 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore, type Workflow, type WorkflowRun } from '../store/gatewayStore';
import { useWorkflowStore, type Workflow, type WorkflowRun } from '../store/workflowStore';
import {
ArrowLeft,
Clock,
@@ -113,7 +113,9 @@ function RunCard({ run, index }: RunCardProps) {
}
export function WorkflowHistory({ workflow, onBack }: WorkflowHistoryProps) {
const { loadWorkflowRuns, cancelWorkflow, isLoading } = useGatewayStore();
const loadWorkflowRuns = useWorkflowStore((s) => s.loadWorkflowRuns);
const cancelWorkflow = useWorkflowStore((s) => s.cancelWorkflow);
const isLoading = useWorkflowStore((s) => s.isLoading);
const [runs, setRuns] = useState<WorkflowRun[]>([]);
const [isRefreshing, setIsRefreshing] = useState(false);
const [cancellingRunId, setCancellingRunId] = useState<string | null>(null);

View File

@@ -7,8 +7,7 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { useGatewayStore } from '../store/gatewayStore';
import type { Workflow } from '../store/gatewayStore';
import { useWorkflowStore, type Workflow } from '../store/workflowStore';
import { WorkflowEditor } from './WorkflowEditor';
import { WorkflowHistory } from './WorkflowHistory';
import {
@@ -236,7 +235,13 @@ function WorkflowRow({ workflow, onExecute, onEdit, onDelete, onHistory, isExecu
// === Main WorkflowList Component ===
export function WorkflowList() {
const { workflows, loadWorkflows, executeWorkflow, deleteWorkflow, createWorkflow, updateWorkflow, isLoading } = useGatewayStore();
const workflows = useWorkflowStore((s) => s.workflows);
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
const triggerWorkflow = useWorkflowStore((s) => s.triggerWorkflow);
const deleteWorkflow = useWorkflowStore((s) => s.deleteWorkflow);
const createWorkflow = useWorkflowStore((s) => s.createWorkflow);
const updateWorkflow = useWorkflowStore((s) => s.updateWorkflow);
const isLoading = useWorkflowStore((s) => s.isLoading);
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [executingWorkflowId, setExecutingWorkflowId] = useState<string | null>(null);
const [deletingWorkflowId, setDeletingWorkflowId] = useState<string | null>(null);
@@ -254,11 +259,11 @@ export function WorkflowList() {
const handleExecute = useCallback(async (id: string, input?: Record<string, unknown>) => {
setExecutingWorkflowId(id);
try {
await executeWorkflow(id, input);
await triggerWorkflow(id, input);
} finally {
setExecutingWorkflowId(null);
}
}, [executeWorkflow]);
}, [triggerWorkflow]);
const handleExecuteClick = useCallback((workflow: Workflow) => {
setSelectedWorkflow(workflow);

View File

@@ -257,7 +257,7 @@ export class AutonomyManager {
identity_update: 'identityAutoUpdate',
identity_rollback: null,
skill_install: 'skillAutoInstall',
skill_uninstall: null,
skill_uninstall: 'skillAutoInstall',
config_change: null,
workflow_trigger: 'autoCompaction',
hand_trigger: null,

View File

@@ -1,409 +0,0 @@
/**
* 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;
}

View File

@@ -0,0 +1,674 @@
/**
* gateway-api.ts - Gateway REST API Methods
*
* Extracted from gateway-client.ts for modularity.
* Contains all REST API method implementations grouped by domain:
* - Health / Status
* - Agents (Clones)
* - Stats & Workspace
* - Config (Quick Config, Channels, Skills, Scheduler, Models)
* - Hands (OpenFang)
* - Workflows (OpenFang)
* - Sessions (OpenFang)
* - Triggers (OpenFang)
* - Audit (OpenFang)
* - Security (OpenFang)
* - Approvals (OpenFang)
*
* These methods are installed onto GatewayClient.prototype via installApiMethods().
* The GatewayClient core class exposes restGet/restPost/restPut/restDelete/restPatch
* as public methods for this purpose.
*/
import type { GatewayClient } from './gateway-client';
import type { GatewayConfigSnapshot, GatewayModelChoice } from './gateway-config';
import { tomlUtils } from './toml-utils';
import {
getQuickConfigFallback,
getWorkspaceInfoFallback,
getUsageStatsFallback,
getPluginStatusFallback,
getScheduledTasksFallback,
getSecurityStatusFallback,
isNotFoundError,
} from './api-fallbacks';
// === Install all API methods onto GatewayClient prototype ===
export function installApiMethods(ClientClass: { prototype: GatewayClient }): void {
const proto = ClientClass.prototype as any;
// ─── Health / Status ───
proto.health = async function (this: GatewayClient): Promise<any> {
return this.request('health');
};
proto.status = async function (this: GatewayClient): Promise<any> {
return this.request('status');
};
// ─── Agents (Clones) ───
proto.listClones = async function (this: GatewayClient): Promise<any> {
return this.restGet('/api/agents');
};
proto.createClone = async function (this: GatewayClient, opts: {
name: string;
role?: string;
nickname?: string;
scenarios?: string[];
model?: string;
workspaceDir?: string;
restrictFiles?: boolean;
privacyOptIn?: boolean;
userName?: string;
userRole?: string;
emoji?: string;
personality?: string;
communicationStyle?: string;
notes?: string;
}): Promise<any> {
// Build manifest config object, then serialize via tomlUtils
const manifest: Record<string, unknown> = {
name: opts.nickname || opts.name,
model_provider: 'bailian',
model_name: opts.model || 'qwen3.5-plus',
};
// Identity section
const identity: Record<string, string> = {};
if (opts.emoji) identity.emoji = opts.emoji;
if (opts.personality) identity.personality = opts.personality;
if (opts.communicationStyle) identity.communication_style = opts.communicationStyle;
if (Object.keys(identity).length > 0) manifest.identity = identity;
// Scenarios
if (opts.scenarios && opts.scenarios.length > 0) {
manifest.scenarios = opts.scenarios;
}
// User context
const userContext: Record<string, string> = {};
if (opts.userName) userContext.name = opts.userName;
if (opts.userRole) userContext.role = opts.userRole;
if (Object.keys(userContext).length > 0) manifest.user_context = userContext;
const manifestToml = tomlUtils.stringify(manifest);
return this.restPost('/api/agents', {
manifest_toml: manifestToml,
});
};
proto.updateClone = async function (this: GatewayClient, id: string, updates: Record<string, any>): Promise<any> {
return this.restPut(`/api/agents/${id}`, updates);
};
proto.deleteClone = async function (this: GatewayClient, id: string): Promise<any> {
return this.restDelete(`/api/agents/${id}`);
};
// ─── Stats & Workspace ───
proto.getUsageStats = async function (this: GatewayClient): Promise<any> {
try {
return await this.restGet('/api/stats/usage');
} catch (error) {
if (isNotFoundError(error)) {
return getUsageStatsFallback([]);
}
return {
totalMessages: 0,
totalTokens: 0,
sessionsCount: 0,
agentsCount: 0,
};
}
};
proto.getSessionStats = async function (this: GatewayClient): Promise<any> {
try {
return await this.restGet('/api/stats/sessions');
} catch {
return { sessions: [] };
}
};
proto.getWorkspaceInfo = async function (this: GatewayClient): Promise<any> {
try {
return await this.restGet('/api/workspace');
} catch (error) {
if (isNotFoundError(error)) {
return getWorkspaceInfoFallback();
}
return {
rootDir: typeof process !== 'undefined' ? (process.env.HOME || process.env.USERPROFILE || '~') : '~',
skillsDir: null,
handsDir: null,
configDir: null,
};
}
};
proto.getPluginStatus = async function (this: GatewayClient): Promise<any> {
try {
return await this.restGet('/api/plugins/status');
} catch (error) {
if (isNotFoundError(error)) {
const plugins = getPluginStatusFallback([]);
return { plugins, loaded: plugins.length, total: plugins.length };
}
return { plugins: [], loaded: 0, total: 0 };
}
};
// ─── Quick Config ───
proto.getQuickConfig = async function (this: GatewayClient): Promise<any> {
try {
const config = await this.restGet<{
data_dir?: string;
home_dir?: string;
default_model?: { model?: string; provider?: string };
}>('/api/config');
// 从 localStorage 读取前端特定配置
const storedTheme = localStorage.getItem('zclaw-theme') as 'light' | 'dark' | null;
const storedAutoStart = localStorage.getItem('zclaw-autoStart');
const storedShowToolCalls = localStorage.getItem('zclaw-showToolCalls');
// Map OpenFang config to frontend expected format
return {
quickConfig: {
agentName: 'ZCLAW',
agentRole: 'AI 助手',
userName: '用户',
userRole: '用户',
agentNickname: 'ZCLAW',
scenarios: ['通用对话', '代码助手', '文档编写'],
workspaceDir: config.data_dir || config.home_dir,
gatewayUrl: this.getRestBaseUrl(),
defaultModel: config.default_model?.model,
defaultProvider: config.default_model?.provider,
theme: storedTheme || 'light',
autoStart: storedAutoStart === 'true',
showToolCalls: storedShowToolCalls !== 'false',
autoSaveContext: true,
fileWatching: true,
privacyOptIn: false,
}
};
} catch (error) {
if (isNotFoundError(error)) {
return { quickConfig: getQuickConfigFallback() };
}
return {};
}
};
proto.saveQuickConfig = async function (this: GatewayClient, config: Record<string, any>): Promise<any> {
// 保存前端特定配置到 localStorage
if (config.theme !== undefined) {
localStorage.setItem('zclaw-theme', config.theme);
}
if (config.autoStart !== undefined) {
localStorage.setItem('zclaw-autoStart', String(config.autoStart));
}
if (config.showToolCalls !== undefined) {
localStorage.setItem('zclaw-showToolCalls', String(config.showToolCalls));
}
// Map frontend config back to OpenFang format
const openfangConfig = {
data_dir: config.workspaceDir,
default_model: config.defaultModel ? {
model: config.defaultModel,
provider: config.defaultProvider || 'bailian',
} : undefined,
};
return this.restPut('/api/config', openfangConfig);
};
// ─── Skills ───
proto.listSkills = async function (this: GatewayClient): Promise<any> {
return this.restGet('/api/skills');
};
proto.getSkill = async function (this: GatewayClient, id: string): Promise<any> {
return this.restGet(`/api/skills/${id}`);
};
proto.createSkill = async function (this: GatewayClient, skill: {
name: string;
description?: string;
triggers: Array<{ type: string; pattern?: string }>;
actions: Array<{ type: string; params?: Record<string, unknown> }>;
enabled?: boolean;
}): Promise<any> {
return this.restPost('/api/skills', skill);
};
proto.updateSkill = async function (this: GatewayClient, id: string, updates: {
name?: string;
description?: string;
triggers?: Array<{ type: string; pattern?: string }>;
actions?: Array<{ type: string; params?: Record<string, unknown> }>;
enabled?: boolean;
}): Promise<any> {
return this.restPut(`/api/skills/${id}`, updates);
};
proto.deleteSkill = async function (this: GatewayClient, id: string): Promise<any> {
return this.restDelete(`/api/skills/${id}`);
};
// ─── Channels ───
proto.listChannels = async function (this: GatewayClient): Promise<any> {
return this.restGet('/api/channels');
};
proto.getChannel = async function (this: GatewayClient, id: string): Promise<any> {
return this.restGet(`/api/channels/${id}`);
};
proto.createChannel = async function (this: GatewayClient, channel: {
type: string;
name: string;
config: Record<string, unknown>;
enabled?: boolean;
}): Promise<any> {
return this.restPost('/api/channels', channel);
};
proto.updateChannel = async function (this: GatewayClient, id: string, updates: {
name?: string;
config?: Record<string, unknown>;
enabled?: boolean;
}): Promise<any> {
return this.restPut(`/api/channels/${id}`, updates);
};
proto.deleteChannel = async function (this: GatewayClient, id: string): Promise<any> {
return this.restDelete(`/api/channels/${id}`);
};
proto.getFeishuStatus = async function (this: GatewayClient): Promise<any> {
return this.restGet('/api/channels/feishu/status');
};
// ─── Scheduler ───
proto.listScheduledTasks = async function (this: GatewayClient): Promise<any> {
try {
return await this.restGet('/api/scheduler/tasks');
} catch (error) {
if (isNotFoundError(error)) {
const tasks = getScheduledTasksFallback([]);
return { tasks, total: tasks.length };
}
return { tasks: [], total: 0 };
}
};
proto.createScheduledTask = async function (this: GatewayClient, task: {
name: string;
schedule: string;
scheduleType: 'cron' | 'interval' | 'once';
target?: { type: 'agent' | 'hand' | 'workflow'; id: string };
description?: string;
enabled?: boolean;
}): Promise<{ id: string; name: string; schedule: string; status: string }> {
return this.restPost('/api/scheduler/tasks', task);
};
proto.deleteScheduledTask = async function (this: GatewayClient, id: string): Promise<void> {
return this.restDelete(`/api/scheduler/tasks/${id}`);
};
proto.toggleScheduledTask = async function (this: GatewayClient, id: string, enabled: boolean): Promise<{ id: string; enabled: boolean }> {
return this.restPatch(`/api/scheduler/tasks/${id}`, { enabled });
};
// ─── OpenFang Hands API ───
proto.listHands = async function (this: GatewayClient): Promise<{
hands: {
id?: string;
name: string;
description?: string;
status?: string;
requirements_met?: boolean;
category?: string;
icon?: string;
tool_count?: number;
tools?: string[];
metric_count?: number;
metrics?: string[];
}[]
}> {
return this.restGet('/api/hands');
};
proto.getHand = async function (this: GatewayClient, name: string): Promise<{
id?: string;
name?: string;
description?: string;
status?: string;
requirements_met?: boolean;
category?: string;
icon?: string;
provider?: string;
model?: string;
requirements?: { description?: string; name?: string; met?: boolean; satisfied?: boolean; details?: string; hint?: string }[];
tools?: string[];
metrics?: string[];
config?: Record<string, unknown>;
tool_count?: number;
metric_count?: number;
}> {
return this.restGet(`/api/hands/${name}`);
};
proto.triggerHand = async function (this: GatewayClient, name: string, params?: Record<string, unknown>): Promise<{ runId: string; status: string }> {
console.log(`[GatewayClient] Triggering hand: ${name}`, params);
try {
const result = await this.restPost<{
instance_id: string;
status: string;
}>(`/api/hands/${name}/activate`, params || {});
console.log(`[GatewayClient] Hand trigger response:`, result);
return { runId: result.instance_id, status: result.status };
} catch (err) {
console.error(`[GatewayClient] Hand trigger failed for ${name}:`, err);
throw err;
}
};
proto.getHandStatus = async function (this: GatewayClient, name: string, runId: string): Promise<{ status: string; result?: unknown }> {
return this.restGet(`/api/hands/${name}/runs/${runId}`);
};
proto.approveHand = async function (this: GatewayClient, name: string, runId: string, approved: boolean, reason?: string): Promise<{ status: string }> {
return this.restPost(`/api/hands/${name}/runs/${runId}/approve`, { approved, reason });
};
proto.cancelHand = async function (this: GatewayClient, name: string, runId: string): Promise<{ status: string }> {
return this.restPost(`/api/hands/${name}/runs/${runId}/cancel`, {});
};
proto.listHandRuns = async function (this: GatewayClient, name: string, opts?: { limit?: number; offset?: number }): Promise<{ runs: { runId: string; status: string; startedAt: string }[] }> {
const params = new URLSearchParams();
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
return this.restGet(`/api/hands/${name}/runs?${params}`);
};
// ─── OpenFang Workflows API ───
proto.listWorkflows = async function (this: GatewayClient): Promise<{ workflows: { id: string; name: string; steps: number }[] }> {
return this.restGet('/api/workflows');
};
proto.getWorkflow = async function (this: GatewayClient, id: string): Promise<{ id: string; name: string; steps: unknown[] }> {
return this.restGet(`/api/workflows/${id}`);
};
proto.executeWorkflow = async function (this: GatewayClient, id: string, input?: Record<string, unknown>): Promise<{ runId: string; status: string }> {
return this.restPost(`/api/workflows/${id}/execute`, input);
};
proto.getWorkflowRun = async function (this: GatewayClient, workflowId: string, runId: string): Promise<{ status: string; step: string; result?: unknown }> {
return this.restGet(`/api/workflows/${workflowId}/runs/${runId}`);
};
proto.listWorkflowRuns = async function (this: GatewayClient, workflowId: string, opts?: { limit?: number; offset?: number }): Promise<{
runs: Array<{
runId: string;
status: string;
startedAt: string;
completedAt?: string;
step?: string;
result?: unknown;
error?: string;
}>;
}> {
const params = new URLSearchParams();
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
return this.restGet(`/api/workflows/${workflowId}/runs?${params}`);
};
proto.cancelWorkflow = async function (this: GatewayClient, workflowId: string, runId: string): Promise<{ status: string }> {
return this.restPost(`/api/workflows/${workflowId}/runs/${runId}/cancel`, {});
};
proto.createWorkflow = async function (this: GatewayClient, workflow: {
name: string;
description?: string;
steps: Array<{
handName: string;
name?: string;
params?: Record<string, unknown>;
condition?: string;
}>;
}): Promise<{ id: string; name: string }> {
return this.restPost('/api/workflows', workflow);
};
proto.updateWorkflow = async function (this: GatewayClient, id: string, updates: {
name?: string;
description?: string;
steps?: Array<{
handName: string;
name?: string;
params?: Record<string, unknown>;
condition?: string;
}>;
}): Promise<{ id: string; name: string }> {
return this.restPut(`/api/workflows/${id}`, updates);
};
proto.deleteWorkflow = async function (this: GatewayClient, id: string): Promise<{ status: string }> {
return this.restDelete(`/api/workflows/${id}`);
};
// ─── OpenFang Session API ───
proto.listSessions = async function (this: GatewayClient, opts?: { limit?: number; offset?: number }): Promise<{
sessions: Array<{
id: string;
agent_id: string;
created_at: string;
updated_at?: string;
message_count?: number;
status?: 'active' | 'archived' | 'expired';
}>;
}> {
const params = new URLSearchParams();
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
return this.restGet(`/api/sessions?${params}`);
};
proto.getSession = async function (this: GatewayClient, sessionId: string): Promise<{
id: string;
agent_id: string;
created_at: string;
updated_at?: string;
message_count?: number;
status?: 'active' | 'archived' | 'expired';
metadata?: Record<string, unknown>;
}> {
return this.restGet(`/api/sessions/${sessionId}`);
};
proto.createSession = async function (this: GatewayClient, opts: {
agent_id: string;
metadata?: Record<string, unknown>;
}): Promise<{
id: string;
agent_id: string;
created_at: string;
}> {
return this.restPost('/api/sessions', opts);
};
proto.deleteSession = async function (this: GatewayClient, sessionId: string): Promise<{ status: string }> {
return this.restDelete(`/api/sessions/${sessionId}`);
};
proto.getSessionMessages = async function (this: GatewayClient, sessionId: string, opts?: {
limit?: number;
offset?: number;
}): Promise<{
messages: Array<{
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
created_at: string;
tokens?: { input?: number; output?: number };
}>;
}> {
const params = new URLSearchParams();
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
return this.restGet(`/api/sessions/${sessionId}/messages?${params}`);
};
// ─── OpenFang Triggers API ───
proto.listTriggers = async function (this: GatewayClient): Promise<{ triggers: { id: string; type: string; enabled: boolean }[] }> {
return this.restGet('/api/triggers');
};
proto.getTrigger = async function (this: GatewayClient, id: string): Promise<{
id: string;
type: string;
name?: string;
enabled: boolean;
config?: Record<string, unknown>;
}> {
return this.restGet(`/api/triggers/${id}`);
};
proto.createTrigger = async function (this: GatewayClient, trigger: {
type: string;
name?: string;
enabled?: boolean;
config?: Record<string, unknown>;
handName?: string;
workflowId?: string;
}): Promise<{ id: string }> {
return this.restPost('/api/triggers', trigger);
};
proto.updateTrigger = async function (this: GatewayClient, id: string, updates: {
name?: string;
enabled?: boolean;
config?: Record<string, unknown>;
handName?: string;
workflowId?: string;
}): Promise<{ id: string }> {
return this.restPut(`/api/triggers/${id}`, updates);
};
proto.deleteTrigger = async function (this: GatewayClient, id: string): Promise<{ status: string }> {
return this.restDelete(`/api/triggers/${id}`);
};
// ─── OpenFang Audit API ───
proto.getAuditLogs = async function (this: GatewayClient, opts?: { limit?: number; offset?: number }): Promise<{ logs: unknown[] }> {
const params = new URLSearchParams();
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.offset) params.set('offset', String(opts.offset));
return this.restGet(`/api/audit/logs?${params}`);
};
proto.verifyAuditLogChain = async function (this: GatewayClient, logId: string): Promise<{
valid: boolean;
chain_depth?: number;
root_hash?: string;
broken_at_index?: number;
}> {
return this.restGet(`/api/audit/verify/${logId}`);
};
// ─── OpenFang Security API ───
proto.getSecurityStatus = async function (this: GatewayClient): Promise<{ layers: { name: string; enabled: boolean }[] }> {
try {
return await this.restGet('/api/security/status');
} catch (error) {
if (isNotFoundError(error)) {
const status = getSecurityStatusFallback();
return { layers: status.layers };
}
return {
layers: [
{ name: 'device_auth', enabled: true },
{ name: 'rbac', enabled: true },
{ name: 'audit_log', enabled: true },
],
};
}
};
proto.getCapabilities = async function (this: GatewayClient): Promise<{ capabilities: string[] }> {
try {
return await this.restGet('/api/capabilities');
} catch {
return { capabilities: ['chat', 'agents', 'hands', 'workflows'] };
}
};
// ─── OpenFang Approvals API ───
proto.listApprovals = async function (this: GatewayClient, status?: string): Promise<{
approvals: {
id: string;
hand_name: string;
run_id: string;
status: string;
requested_at: string;
requested_by?: string;
reason?: string;
action?: string;
params?: Record<string, unknown>;
responded_at?: string;
responded_by?: string;
response_reason?: string;
}[];
}> {
const params = status ? `?status=${status}` : '';
return this.restGet(`/api/approvals${params}`);
};
proto.respondToApproval = async function (this: GatewayClient, approvalId: string, approved: boolean, reason?: string): Promise<{ status: string }> {
return this.restPost(`/api/approvals/${approvalId}/respond`, { approved, reason });
};
// ─── Models & Config ───
proto.listModels = async function (this: GatewayClient): Promise<{ models: GatewayModelChoice[] }> {
return this.restGet('/api/models');
};
proto.getConfig = async function (this: GatewayClient): Promise<GatewayConfigSnapshot | Record<string, any>> {
return this.restGet('/api/config');
};
proto.applyConfig = async function (this: GatewayClient, raw: string, baseHash?: string, opts?: { sessionKey?: string; note?: string; restartDelayMs?: number }): Promise<any> {
return this.request('config.apply', {
raw,
baseHash,
sessionKey: opts?.sessionKey,
note: opts?.note,
restartDelayMs: opts?.restartDelayMs,
});
};
}

View File

@@ -0,0 +1,175 @@
/**
* gateway-auth.ts - Device Authentication Module
*
* Extracted from gateway-client.ts for modularity.
* Handles Ed25519 device key generation, loading, signing,
* and device identity management using OS keyring or localStorage.
*/
import nacl from 'tweetnacl';
import {
storeDeviceKeys,
getDeviceKeys,
deleteDeviceKeys,
} from './secure-storage';
// === Types ===
export interface DeviceKeys {
deviceId: string;
publicKey: Uint8Array;
secretKey: Uint8Array;
publicKeyBase64: string;
}
export interface LocalDeviceIdentity {
deviceId: string;
publicKeyBase64: string;
}
// === Base64 Encoding ===
export function b64Encode(bytes: Uint8Array): string {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// === Key Derivation ===
async function deriveDeviceId(publicKey: Uint8Array): Promise<string> {
const stableBytes = Uint8Array.from(publicKey);
const digest = await crypto.subtle.digest('SHA-256', stableBytes.buffer);
return Array.from(new Uint8Array(digest))
.map((byte) => byte.toString(16).padStart(2, '0'))
.join('');
}
// === Key Generation ===
async function generateDeviceKeys(): Promise<DeviceKeys> {
const keyPair = nacl.sign.keyPair();
const deviceId = await deriveDeviceId(keyPair.publicKey);
return {
deviceId,
publicKey: keyPair.publicKey,
secretKey: keyPair.secretKey,
publicKeyBase64: b64Encode(keyPair.publicKey),
};
}
// === Key Loading ===
/**
* Load device keys from secure storage.
* Uses OS keyring when available, falls back to localStorage.
*/
export async function loadDeviceKeys(): Promise<DeviceKeys> {
// Try to load from secure storage (keyring or localStorage fallback)
const storedKeys = await getDeviceKeys();
if (storedKeys) {
try {
const deviceId = await deriveDeviceId(storedKeys.publicKey);
return {
deviceId,
publicKey: storedKeys.publicKey,
secretKey: storedKeys.secretKey,
publicKeyBase64: b64Encode(storedKeys.publicKey),
};
} catch (e) {
console.warn('[GatewayClient] Failed to load stored keys:', e);
// Invalid stored keys, clear and regenerate
await deleteDeviceKeys();
}
}
// Generate new keys
const keys = await generateDeviceKeys();
// Store in secure storage (keyring when available, localStorage fallback)
await storeDeviceKeys(keys.publicKey, keys.secretKey);
return keys;
}
// === Public Identity ===
export async function getLocalDeviceIdentity(): Promise<LocalDeviceIdentity> {
const keys = await loadDeviceKeys();
return {
deviceId: keys.deviceId,
publicKeyBase64: keys.publicKeyBase64,
};
}
/**
* Clear cached device keys to force regeneration on next connect.
* Useful when device signature validation fails repeatedly.
*/
export async function clearDeviceKeys(): Promise<void> {
try {
await deleteDeviceKeys();
console.log('[GatewayClient] Device keys cleared');
} catch (e) {
console.warn('[GatewayClient] Failed to clear device keys:', e);
}
}
// === Device Auth Signing ===
export function buildDeviceAuthPayload(params: {
clientId: string;
clientMode: string;
deviceId: string;
nonce: string;
role: string;
scopes: string[];
signedAt: number;
token?: string;
}): string {
return [
'v2',
params.deviceId,
params.clientId,
params.clientMode,
params.role,
params.scopes.join(','),
String(params.signedAt),
params.token || '',
params.nonce,
].join('|');
}
export function signDeviceAuth(params: {
clientId: string;
clientMode: string;
deviceId: string;
nonce: string;
role: string;
scopes: string[];
secretKey: Uint8Array;
token?: string;
}): { signature: string; signedAt: number } {
const signedAt = Date.now();
const message = buildDeviceAuthPayload({
clientId: params.clientId,
clientMode: params.clientMode,
deviceId: params.deviceId,
nonce: params.nonce,
role: params.role,
scopes: params.scopes,
signedAt,
token: params.token,
});
const messageBytes = new TextEncoder().encode(message);
const signature = nacl.sign.detached(messageBytes, params.secretKey);
return {
signature: b64Encode(signature),
signedAt,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,118 @@
/**
* gateway-storage.ts - Gateway URL/Token Storage & Normalization
*
* Extracted from gateway-client.ts for modularity.
* Manages WSS configuration, URL normalization, and
* localStorage persistence for gateway URL and token.
*/
// === WSS Configuration ===
/**
* Whether to use WSS (WebSocket Secure) instead of WS.
* - Production: defaults to WSS for security
* - Development: defaults to WS for convenience
* - Override: set VITE_USE_WSS=false to force WS in production
*/
const USE_WSS = import.meta.env.VITE_USE_WSS !== 'false' && import.meta.env.PROD;
/**
* Default protocol based on WSS configuration.
*/
const DEFAULT_WS_PROTOCOL = USE_WSS ? 'wss://' : 'ws://';
/**
* Check if a URL points to localhost.
*/
export function isLocalhost(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.hostname === 'localhost' ||
parsed.hostname === '127.0.0.1' ||
parsed.hostname === '[::1]';
} catch {
return false;
}
}
// === URL Constants ===
// OpenFang endpoints (port 50051 - actual running port)
// Note: REST API uses relative path to leverage Vite proxy for CORS bypass
export const DEFAULT_GATEWAY_URL = `${DEFAULT_WS_PROTOCOL}127.0.0.1:50051/ws`;
export const REST_API_URL = ''; // Empty = use relative path (Vite proxy)
export const FALLBACK_GATEWAY_URLS = [
DEFAULT_GATEWAY_URL,
`${DEFAULT_WS_PROTOCOL}127.0.0.1:4200/ws`,
];
const GATEWAY_URL_STORAGE_KEY = 'zclaw_gateway_url';
const GATEWAY_TOKEN_STORAGE_KEY = 'zclaw_gateway_token';
// === URL Normalization ===
/**
* Normalize a gateway URL to ensure correct protocol and path.
* - Ensures ws:// or wss:// protocol based on configuration
* - Ensures /ws path suffix
* - Handles both localhost and IP addresses
*/
export function normalizeGatewayUrl(url: string): string {
let normalized = url.trim();
// Remove trailing slashes except for protocol
normalized = normalized.replace(/\/+$/, '');
// Ensure protocol
if (!normalized.startsWith('ws://') && !normalized.startsWith('wss://')) {
normalized = USE_WSS ? `wss://${normalized}` : `ws://${normalized}`;
}
// Ensure /ws path
if (!normalized.endsWith('/ws')) {
normalized = `${normalized}/ws`;
}
return normalized;
}
// === LocalStorage Helpers ===
export function getStoredGatewayUrl(): string {
try {
const stored = localStorage.getItem(GATEWAY_URL_STORAGE_KEY);
return normalizeGatewayUrl(stored || DEFAULT_GATEWAY_URL);
} catch {
return DEFAULT_GATEWAY_URL;
}
}
export function setStoredGatewayUrl(url: string): string {
const normalized = normalizeGatewayUrl(url || DEFAULT_GATEWAY_URL);
try {
localStorage.setItem(GATEWAY_URL_STORAGE_KEY, normalized);
} catch { /* ignore localStorage failures */ }
return normalized;
}
export function getStoredGatewayToken(): string {
try {
return localStorage.getItem(GATEWAY_TOKEN_STORAGE_KEY) || '';
} catch {
return '';
}
}
export function setStoredGatewayToken(token: string): string {
const normalized = token.trim();
try {
if (normalized) {
localStorage.setItem(GATEWAY_TOKEN_STORAGE_KEY, normalized);
} else {
localStorage.removeItem(GATEWAY_TOKEN_STORAGE_KEY);
}
} catch {
/* ignore localStorage failures */
}
return normalized;
}

View File

@@ -0,0 +1,96 @@
/**
* gateway-types.ts - Gateway Protocol Types
*
* Extracted from gateway-client.ts for modularity.
* Contains all WebSocket protocol types, stream event types,
* and connection state definitions.
*/
// === Protocol Types ===
export interface GatewayRequest {
type: 'req';
id: string;
method: string;
params?: Record<string, unknown>;
}
export interface GatewayError {
code?: string;
message?: string;
details?: unknown;
}
export interface GatewayResponse {
type: 'res';
id: string;
ok: boolean;
payload?: unknown;
error?: GatewayError;
}
export interface GatewayEvent {
type: 'event';
event: string;
payload?: unknown;
seq?: number;
}
export interface GatewayPong {
type: 'pong';
timestamp?: number;
}
export type GatewayFrame = GatewayRequest | GatewayResponse | GatewayEvent | GatewayPong;
// === Stream Types ===
export interface AgentStreamDelta {
stream: 'assistant' | 'tool' | 'lifecycle' | 'hand' | 'workflow';
delta?: string;
content?: string;
tool?: string;
toolInput?: string;
toolOutput?: string;
phase?: 'start' | 'end' | 'error';
runId?: string;
error?: string;
// Hand event fields
handName?: string;
handStatus?: string;
handResult?: unknown;
// Workflow event fields
workflowId?: string;
workflowStep?: string;
workflowStatus?: string;
workflowResult?: unknown;
}
/** OpenFang WebSocket stream event types */
export interface OpenFangStreamEvent {
type: 'text_delta' | 'phase' | 'response' | 'typing' | 'tool_call' | 'tool_result' | 'hand' | 'workflow' | 'error' | 'connected' | 'agents_updated';
content?: string;
phase?: 'streaming' | 'done';
state?: 'start' | 'stop';
tool?: string;
input?: unknown;
output?: string;
result?: unknown;
hand_name?: string;
hand_status?: string;
hand_result?: unknown;
workflow_id?: string;
workflow_step?: string;
workflow_status?: string;
workflow_result?: unknown;
message?: string;
code?: string;
agent_id?: string;
agents?: Array<{ id: string; name: string; status: string }>;
}
// === Connection State ===
export type ConnectionState = 'disconnected' | 'connecting' | 'handshaking' | 'connected' | 'reconnecting';
export type EventCallback = (payload: unknown) => void;

View File

@@ -1,655 +0,0 @@
/**
* 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();
}

View File

@@ -1,385 +0,0 @@
/**
* 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();
}

View File

@@ -1,734 +0,0 @@
/**
* Viking Adapter - ZCLAW ↔ OpenViking Integration Layer
*
* Maps ZCLAW agent concepts (memories, identity, skills) to OpenViking's
* viking:// URI namespace. Provides high-level operations for:
* - User memory management (preferences, facts, history)
* - Agent memory management (lessons, patterns, tool tips)
* - L0/L1/L2 layered context building (token-efficient)
* - Session memory extraction (auto-learning)
* - Identity file synchronization
* - Retrieval trace capture (debuggability)
*
* Supports three modes:
* - local: Manages a local OpenViking server (privacy-first, data stays local)
* - sidecar: Uses OpenViking CLI via Tauri commands (direct CLI integration)
* - remote: Uses OpenViking HTTP Server (connects to external server)
*
* For privacy-conscious users, use 'local' mode which ensures all data
* stays on the local machine in ~/.openviking/
*/
import {
VikingHttpClient,
type FindResult,
type RetrievalTrace,
type ExtractedMemory,
type SessionExtractionResult,
type ContextLevel,
type VikingEntry,
type VikingTreeNode,
} from './viking-client';
import {
getVikingServerManager,
type VikingServerStatus,
} from './viking-server-manager';
// Tauri invoke import (safe to import even if not in Tauri context)
let invoke: ((cmd: string, args?: Record<string, unknown>) => Promise<unknown>) | null = null;
try {
// Dynamic import for Tauri API
// eslint-disable-next-line @typescript-eslint/no-var-requires
invoke = require('@tauri-apps/api/core').invoke;
} catch {
// Not in Tauri context, invoke will be null
console.log('[VikingAdapter] Not in Tauri context, sidecar mode unavailable');
}
// === Types ===
export interface MemoryResult {
uri: string;
content: string;
score: number;
level: ContextLevel;
category: string;
tags?: string[];
}
export interface EnhancedContext {
systemPromptAddition: string;
memories: MemoryResult[];
totalTokens: number;
tokensByLevel: { L0: number; L1: number; L2: number };
trace?: RetrievalTrace;
}
export interface MemorySaveResult {
uri: string;
status: string;
}
export interface ExtractionResult {
saved: number;
userMemories: number;
agentMemories: number;
details: ExtractedMemory[];
}
export interface IdentityFile {
name: string;
content: string;
lastModified?: string;
}
export interface IdentityChangeProposal {
file: string;
currentContent: string;
suggestedContent: string;
reason: string;
timestamp: string;
}
export interface VikingAdapterConfig {
serverUrl: string;
defaultAgentId: string;
maxContextTokens: number;
l0Limit: number;
l1Limit: number;
minRelevanceScore: number;
enableTrace: boolean;
mode?: VikingMode;
}
const DEFAULT_CONFIG: VikingAdapterConfig = {
serverUrl: 'http://localhost:1933',
defaultAgentId: 'zclaw-main',
maxContextTokens: 8000,
l0Limit: 30,
l1Limit: 15,
minRelevanceScore: 0.5,
enableTrace: true,
};
// === URI Helpers ===
const VIKING_NS = {
userMemories: 'viking://user/memories',
userPreferences: 'viking://user/memories/preferences',
userFacts: 'viking://user/memories/facts',
userHistory: 'viking://user/memories/history',
agentBase: (agentId: string) => `viking://agent/${agentId}`,
agentIdentity: (agentId: string) => `viking://agent/${agentId}/identity`,
agentMemories: (agentId: string) => `viking://agent/${agentId}/memories`,
agentLessons: (agentId: string) => `viking://agent/${agentId}/memories/lessons_learned`,
agentPatterns: (agentId: string) => `viking://agent/${agentId}/memories/task_patterns`,
agentToolTips: (agentId: string) => `viking://agent/${agentId}/memories/tool_tips`,
agentSkills: (agentId: string) => `viking://agent/${agentId}/skills`,
sharedKnowledge: 'viking://agent/shared/common_knowledge',
resources: 'viking://resources',
} as const;
// === Rough Token Estimator ===
function estimateTokens(text: string): number {
// ~1.5 tokens per CJK character, ~0.75 tokens per English word
const cjkChars = (text.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length;
const otherChars = text.length - cjkChars;
return Math.ceil(cjkChars * 1.5 + otherChars * 0.4);
}
// === Mode Type ===
export type VikingMode = 'local' | 'sidecar' | 'remote' | 'auto';
// === Adapter Implementation ===
export class VikingAdapter {
private client: VikingHttpClient;
private config: VikingAdapterConfig;
private lastTrace: RetrievalTrace | null = null;
private mode: VikingMode;
private resolvedMode: 'local' | 'sidecar' | 'remote' | null = null;
private serverManager = getVikingServerManager();
constructor(config?: Partial<VikingAdapterConfig>) {
this.config = { ...DEFAULT_CONFIG, ...config };
this.client = new VikingHttpClient(this.config.serverUrl);
this.mode = config?.mode ?? 'auto';
}
// === Mode Detection ===
private async detectMode(): Promise<'local' | 'sidecar' | 'remote'> {
if (this.resolvedMode) {
return this.resolvedMode;
}
if (this.mode === 'local') {
this.resolvedMode = 'local';
return 'local';
}
if (this.mode === 'sidecar') {
this.resolvedMode = 'sidecar';
return 'sidecar';
}
if (this.mode === 'remote') {
this.resolvedMode = 'remote';
return 'remote';
}
// Auto mode: try local server first (privacy-first), then sidecar, then remote
// 1. Check if local server is already running or can be started
if (invoke) {
try {
const status = await this.serverManager.getStatus();
if (status.running) {
console.log('[VikingAdapter] Using local mode (OpenViking local server already running)');
this.resolvedMode = 'local';
return 'local';
}
// Try to start local server
const started = await this.serverManager.ensureRunning();
if (started) {
console.log('[VikingAdapter] Using local mode (OpenViking local server started)');
this.resolvedMode = 'local';
return 'local';
}
} catch {
console.log('[VikingAdapter] Local server not available, trying sidecar');
}
}
// 2. Try sidecar mode
if (invoke) {
try {
const status = await invoke('viking_status') as { available: boolean };
if (status.available) {
console.log('[VikingAdapter] Using sidecar mode (OpenViking CLI)');
this.resolvedMode = 'sidecar';
return 'sidecar';
}
} catch {
console.log('[VikingAdapter] Sidecar mode not available, trying remote');
}
}
// 3. Try remote mode
if (await this.client.isAvailable()) {
console.log('[VikingAdapter] Using remote mode (OpenViking Server)');
this.resolvedMode = 'remote';
return 'remote';
}
console.warn('[VikingAdapter] No Viking backend available');
return 'remote'; // Default fallback
}
getMode(): 'local' | 'sidecar' | 'remote' | null {
return this.resolvedMode;
}
// === Connection ===
async isConnected(): Promise<boolean> {
const mode = await this.detectMode();
if (mode === 'local') {
const status = await this.serverManager.getStatus();
return status.running;
}
if (mode === 'sidecar') {
try {
if (!invoke) return false;
const status = await invoke('viking_status') as { available: boolean };
return status.available;
} catch {
return false;
}
}
return this.client.isAvailable();
}
// === Server Management (for local mode) ===
/**
* Get the local server status (for local mode)
*/
async getLocalServerStatus(): Promise<VikingServerStatus> {
return this.serverManager.getStatus();
}
/**
* Start the local server (for local mode)
*/
async startLocalServer(): Promise<VikingServerStatus> {
return this.serverManager.start();
}
/**
* Stop the local server (for local mode)
*/
async stopLocalServer(): Promise<void> {
return this.serverManager.stop();
}
getLastTrace(): RetrievalTrace | null {
return this.lastTrace;
}
// === User Memory Operations ===
async saveUserPreference(
key: string,
value: string
): Promise<MemorySaveResult> {
const uri = `${VIKING_NS.userPreferences}/${sanitizeKey(key)}`;
return this.client.addResource(uri, value, {
metadata: { type: 'preference', key, updated_at: new Date().toISOString() },
wait: true,
});
}
async saveUserFact(
category: string,
content: string,
tags?: string[]
): Promise<MemorySaveResult> {
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const uri = `${VIKING_NS.userFacts}/${sanitizeKey(category)}/${id}`;
return this.client.addResource(uri, content, {
metadata: {
type: 'fact',
category,
tags: (tags || []).join(','),
created_at: new Date().toISOString(),
},
wait: true,
});
}
async searchUserMemories(
query: string,
limit: number = 10
): Promise<MemoryResult[]> {
const results = await this.client.find(query, {
scope: VIKING_NS.userMemories,
limit,
level: 'L1',
minScore: this.config.minRelevanceScore,
});
return results.map(toMemoryResult);
}
async getUserPreferences(): Promise<VikingEntry[]> {
try {
return await this.client.ls(VIKING_NS.userPreferences);
} catch {
return [];
}
}
// === Agent Memory Operations ===
async saveAgentLesson(
agentId: string,
lesson: string,
tags?: string[]
): Promise<MemorySaveResult> {
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const uri = `${VIKING_NS.agentLessons(agentId)}/${id}`;
return this.client.addResource(uri, lesson, {
metadata: {
type: 'lesson',
tags: (tags || []).join(','),
agent_id: agentId,
created_at: new Date().toISOString(),
},
wait: true,
});
}
async saveAgentPattern(
agentId: string,
pattern: string,
tags?: string[]
): Promise<MemorySaveResult> {
const id = `${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
const uri = `${VIKING_NS.agentPatterns(agentId)}/${id}`;
return this.client.addResource(uri, pattern, {
metadata: {
type: 'pattern',
tags: (tags || []).join(','),
agent_id: agentId,
created_at: new Date().toISOString(),
},
wait: true,
});
}
async saveAgentToolTip(
agentId: string,
tip: string,
toolName: string
): Promise<MemorySaveResult> {
const uri = `${VIKING_NS.agentToolTips(agentId)}/${sanitizeKey(toolName)}`;
return this.client.addResource(uri, tip, {
metadata: {
type: 'tool_tip',
tool: toolName,
agent_id: agentId,
updated_at: new Date().toISOString(),
},
wait: true,
});
}
async searchAgentMemories(
agentId: string,
query: string,
limit: number = 10
): Promise<MemoryResult[]> {
const results = await this.client.find(query, {
scope: VIKING_NS.agentMemories(agentId),
limit,
level: 'L1',
minScore: this.config.minRelevanceScore,
});
return results.map(toMemoryResult);
}
// === Identity File Management ===
async syncIdentityToViking(
agentId: string,
fileName: string,
content: string
): Promise<void> {
const uri = `${VIKING_NS.agentIdentity(agentId)}/${sanitizeKey(fileName.replace('.md', ''))}`;
await this.client.addResource(uri, content, {
metadata: {
type: 'identity',
file: fileName,
agent_id: agentId,
synced_at: new Date().toISOString(),
},
wait: true,
});
}
async getIdentityFromViking(
agentId: string,
fileName: string
): Promise<string> {
const uri = `${VIKING_NS.agentIdentity(agentId)}/${sanitizeKey(fileName.replace('.md', ''))}`;
return this.client.readContent(uri, 'L2');
}
async proposeIdentityChange(
agentId: string,
proposal: IdentityChangeProposal
): Promise<MemorySaveResult> {
const id = `${Date.now()}`;
const uri = `${VIKING_NS.agentIdentity(agentId)}/changelog/${id}`;
const content = [
`# Identity Change Proposal`,
`**File**: ${proposal.file}`,
`**Reason**: ${proposal.reason}`,
`**Timestamp**: ${proposal.timestamp}`,
'',
'## Current Content',
'```',
proposal.currentContent,
'```',
'',
'## Suggested Content',
'```',
proposal.suggestedContent,
'```',
].join('\n');
return this.client.addResource(uri, content, {
metadata: {
type: 'identity_change_proposal',
file: proposal.file,
status: 'pending',
agent_id: agentId,
},
wait: true,
});
}
// === Core: Context Building (L0/L1/L2 layered loading) ===
async buildEnhancedContext(
userMessage: string,
agentId: string,
options?: { maxTokens?: number; includeTrace?: boolean }
): Promise<EnhancedContext> {
const maxTokens = options?.maxTokens ?? this.config.maxContextTokens;
const includeTrace = options?.includeTrace ?? this.config.enableTrace;
const tokensByLevel = { L0: 0, L1: 0, L2: 0 };
// Step 1: L0 fast scan across user + agent memories
const [userL0, agentL0] = await Promise.all([
this.client.find(userMessage, {
scope: VIKING_NS.userMemories,
level: 'L0',
limit: this.config.l0Limit,
}).catch(() => [] as FindResult[]),
this.client.find(userMessage, {
scope: VIKING_NS.agentMemories(agentId),
level: 'L0',
limit: this.config.l0Limit,
}).catch(() => [] as FindResult[]),
]);
const allL0 = [...userL0, ...agentL0];
for (const r of allL0) {
tokensByLevel.L0 += estimateTokens(r.content);
}
// Step 2: Filter high-relevance items, load L1
const relevant = allL0
.filter(r => r.score >= this.config.minRelevanceScore)
.sort((a, b) => b.score - a.score)
.slice(0, this.config.l1Limit);
const l1Results: MemoryResult[] = [];
let tokenBudget = maxTokens;
for (const item of relevant) {
try {
const l1Content = await this.client.readContent(item.uri, 'L1');
const tokens = estimateTokens(l1Content);
if (tokenBudget - tokens < 500) break; // Keep 500 token reserve
l1Results.push({
uri: item.uri,
content: l1Content,
score: item.score,
level: 'L1',
category: extractCategory(item.uri),
});
tokenBudget -= tokens;
tokensByLevel.L1 += tokens;
} catch {
// Skip items that fail to load
}
}
// Step 3: Build retrieval trace (if enabled)
let trace: RetrievalTrace | undefined;
if (includeTrace) {
trace = {
query: userMessage,
steps: allL0.map(r => ({
uri: r.uri,
score: r.score,
action: r.score >= this.config.minRelevanceScore ? 'entered' as const : 'skipped' as const,
level: 'L0' as ContextLevel,
})),
totalTokensUsed: maxTokens - tokenBudget,
tokensByLevel,
duration: 0, // filled by caller if timing
};
this.lastTrace = trace;
}
// Step 4: Format as system prompt addition
const systemPromptAddition = formatMemoriesForPrompt(l1Results);
return {
systemPromptAddition,
memories: l1Results,
totalTokens: maxTokens - tokenBudget,
tokensByLevel,
trace,
};
}
// === Session Memory Extraction ===
async extractAndSaveMemories(
messages: Array<{ role: string; content: string }>,
agentId: string,
_conversationId?: string
): Promise<ExtractionResult> {
const sessionContent = messages
.map(m => `[${m.role}]: ${m.content}`)
.join('\n\n');
let extraction: SessionExtractionResult;
try {
extraction = await this.client.extractMemories(sessionContent, agentId);
} catch (err) {
// If OpenViking extraction API is not available, use fallback
console.warn('[VikingAdapter] Session extraction failed, using fallback:', err);
return { saved: 0, userMemories: 0, agentMemories: 0, details: [] };
}
let userCount = 0;
let agentCount = 0;
for (const memory of extraction.memories) {
try {
if (memory.category === 'user_preference') {
const key = memory.tags[0] || `pref_${Date.now()}`;
await this.saveUserPreference(key, memory.content);
userCount++;
} else if (memory.category === 'user_fact') {
const category = memory.tags[0] || 'general';
await this.saveUserFact(category, memory.content, memory.tags);
userCount++;
} else if (memory.category === 'agent_lesson') {
await this.saveAgentLesson(agentId, memory.content, memory.tags);
agentCount++;
} else if (memory.category === 'agent_pattern') {
await this.saveAgentPattern(agentId, memory.content, memory.tags);
agentCount++;
}
} catch (err) {
console.warn('[VikingAdapter] Failed to save memory:', memory.suggestedUri, err);
}
}
return {
saved: userCount + agentCount,
userMemories: userCount,
agentMemories: agentCount,
details: extraction.memories,
};
}
// === Memory Browsing ===
async browseMemories(
path: string = 'viking://'
): Promise<VikingEntry[]> {
try {
return await this.client.ls(path);
} catch {
return [];
}
}
async getMemoryTree(
agentId: string,
depth: number = 2
): Promise<VikingTreeNode | null> {
try {
return await this.client.tree(VIKING_NS.agentBase(agentId), depth);
} catch {
return null;
}
}
async deleteMemory(uri: string): Promise<void> {
await this.client.removeResource(uri);
}
// === Memory Statistics ===
async getMemoryStats(agentId: string): Promise<{
totalEntries: number;
userMemories: number;
agentMemories: number;
categories: Record<string, number>;
}> {
const [userEntries, agentEntries] = await Promise.all([
this.client.ls(VIKING_NS.userMemories).catch(() => []),
this.client.ls(VIKING_NS.agentMemories(agentId)).catch(() => []),
]);
const categories: Record<string, number> = {};
for (const entry of [...userEntries, ...agentEntries]) {
const cat = extractCategory(entry.uri);
categories[cat] = (categories[cat] || 0) + 1;
}
return {
totalEntries: userEntries.length + agentEntries.length,
userMemories: userEntries.length,
agentMemories: agentEntries.length,
categories,
};
}
}
// === Utility Functions ===
function sanitizeKey(key: string): string {
return key
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fff_-]/g, '_')
.replace(/_+/g, '_')
.replace(/^_|_$/g, '');
}
function extractCategory(uri: string): string {
const parts = uri.replace('viking://', '').split('/');
// Return the 3rd segment as category (e.g., "preferences" from viking://user/memories/preferences/...)
return parts[2] || parts[1] || 'unknown';
}
function toMemoryResult(result: FindResult): MemoryResult {
return {
uri: result.uri,
content: result.content,
score: result.score,
level: result.level,
category: extractCategory(result.uri),
};
}
function formatMemoriesForPrompt(memories: MemoryResult[]): string {
if (memories.length === 0) return '';
const userMemories = memories.filter(m => m.uri.startsWith('viking://user/'));
const agentMemories = memories.filter(m => m.uri.startsWith('viking://agent/'));
const sections: string[] = [];
if (userMemories.length > 0) {
sections.push('## 用户记忆');
for (const m of userMemories) {
sections.push(`- [${m.category}] ${m.content}`);
}
}
if (agentMemories.length > 0) {
sections.push('## Agent 经验');
for (const m of agentMemories) {
sections.push(`- [${m.category}] ${m.content}`);
}
}
return sections.join('\n');
}
// === Singleton factory ===
let _instance: VikingAdapter | null = null;
export function getVikingAdapter(config?: Partial<VikingAdapterConfig>): VikingAdapter {
if (!_instance || config) {
_instance = new VikingAdapter(config);
}
return _instance;
}
export function resetVikingAdapter(): void {
_instance = null;
}
export { VIKING_NS };

View File

@@ -1,353 +0,0 @@
/**
* 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;
}

View File

@@ -1,144 +0,0 @@
/**
* 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;
}

View File

@@ -1,408 +0,0 @@
/**
* 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;
}

View File

@@ -1,231 +0,0 @@
/**
* Viking Server Manager - Local OpenViking Server Management
*
* Manages a local OpenViking server instance for privacy-first deployment.
* All data is stored locally in ~/.openviking/ - nothing is uploaded to remote servers.
*
* Usage:
* const manager = getVikingServerManager();
*
* // Check server status
* const status = await manager.getStatus();
*
* // Start server if not running
* if (!status.running) {
* await manager.start();
* }
*
* // Server is now available at http://127.0.0.1:1933
*/
import { invoke } from '@tauri-apps/api/core';
// === Types ===
export interface VikingServerStatus {
running: boolean;
port: number;
pid?: number;
dataDir?: string;
version?: string;
error?: string;
}
export interface VikingServerConfig {
port?: number;
dataDir?: string;
configFile?: string;
}
// === Default Configuration ===
const DEFAULT_CONFIG: Required<VikingServerConfig> = {
port: 1933,
dataDir: '', // Will use default ~/.openviking/workspace
configFile: '', // Will use default ~/.openviking/ov.conf
};
// === Server Manager Class ===
export class VikingServerManager {
private status: VikingServerStatus | null = null;
private startPromise: Promise<VikingServerStatus> | null = null;
/**
* Get current server status
*/
async getStatus(): Promise<VikingServerStatus> {
try {
this.status = await invoke<VikingServerStatus>('viking_server_status');
return this.status;
} catch (err) {
console.error('[VikingServerManager] Failed to get status:', err);
return {
running: false,
port: DEFAULT_CONFIG.port,
error: err instanceof Error ? err.message : String(err),
};
}
}
/**
* Start local OpenViking server
* If server is already running, returns current status
*/
async start(config?: VikingServerConfig): Promise<VikingServerStatus> {
// Prevent concurrent start attempts
if (this.startPromise) {
return this.startPromise;
}
// Check if already running
const currentStatus = await this.getStatus();
if (currentStatus.running) {
console.log('[VikingServerManager] Server already running on port', currentStatus.port);
return currentStatus;
}
this.startPromise = this.doStart(config);
try {
const result = await this.startPromise;
return result;
} finally {
this.startPromise = null;
}
}
private async doStart(config?: VikingServerConfig): Promise<VikingServerStatus> {
const fullConfig = { ...DEFAULT_CONFIG, ...config };
console.log('[VikingServerManager] Starting local server on port', fullConfig.port);
try {
const status = await invoke<VikingServerStatus>('viking_server_start', {
config: {
port: fullConfig.port,
dataDir: fullConfig.dataDir || undefined,
configFile: fullConfig.configFile || undefined,
},
});
this.status = status;
console.log('[VikingServerManager] Server started:', status);
return status;
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error('[VikingServerManager] Failed to start server:', errorMsg);
this.status = {
running: false,
port: fullConfig.port,
error: errorMsg,
};
return this.status;
}
}
/**
* Stop local OpenViking server
*/
async stop(): Promise<void> {
console.log('[VikingServerManager] Stopping server');
try {
await invoke('viking_server_stop');
this.status = {
running: false,
port: DEFAULT_CONFIG.port,
};
console.log('[VikingServerManager] Server stopped');
} catch (err) {
console.error('[VikingServerManager] Failed to stop server:', err);
throw err;
}
}
/**
* Restart local OpenViking server
*/
async restart(config?: VikingServerConfig): Promise<VikingServerStatus> {
console.log('[VikingServerManager] Restarting server');
try {
const status = await invoke<VikingServerStatus>('viking_server_restart', {
config: config ? {
port: config.port,
dataDir: config.dataDir,
configFile: config.configFile,
} : undefined,
});
this.status = status;
console.log('[VikingServerManager] Server restarted:', status);
return status;
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
console.error('[VikingServerManager] Failed to restart server:', errorMsg);
this.status = {
running: false,
port: config?.port || DEFAULT_CONFIG.port,
error: errorMsg,
};
return this.status;
}
}
/**
* Ensure server is running, starting if necessary
* This is the main entry point for ensuring availability
*/
async ensureRunning(config?: VikingServerConfig): Promise<boolean> {
const status = await this.getStatus();
if (status.running) {
return true;
}
const startResult = await this.start(config);
return startResult.running;
}
/**
* Get the server URL for HTTP client connections
*/
getServerUrl(port?: number): string {
const actualPort = port || this.status?.port || DEFAULT_CONFIG.port;
return `http://127.0.0.1:${actualPort}`;
}
/**
* Check if server is available (cached status)
*/
isRunning(): boolean {
return this.status?.running ?? false;
}
/**
* Clear cached status (force refresh on next call)
*/
clearCache(): void {
this.status = null;
}
}
// === Singleton ===
let _instance: VikingServerManager | null = null;
export function getVikingServerManager(): VikingServerManager {
if (!_instance) {
_instance = new VikingServerManager();
}
return _instance;
}
export function resetVikingServerManager(): void {
_instance = null;
}

View File

@@ -6,6 +6,7 @@
*/
import { create } from 'zustand';
import type { GatewayModelChoice } from '../lib/gateway-config';
import { setStoredGatewayUrl, setStoredGatewayToken } from '../lib/gateway-client';
import type { GatewayClient } from '../lib/gateway-client';
// === Types ===
@@ -233,6 +234,13 @@ export const useConfigStore = create<ConfigStateSlice & ConfigActionsSlice>((set
try {
const nextConfig = { ...get().quickConfig, ...updates };
// Persist gateway URL/token to localStorage for reconnection
if (nextConfig.gatewayUrl) {
setStoredGatewayUrl(nextConfig.gatewayUrl);
}
if (Object.prototype.hasOwnProperty.call(updates, 'gatewayToken')) {
setStoredGatewayToken(nextConfig.gatewayToken || '');
}
const result = await client.saveQuickConfig(nextConfig);
set({ quickConfig: result?.quickConfig || nextConfig });
} catch (err: unknown) {
@@ -278,12 +286,12 @@ export const useConfigStore = create<ConfigStateSlice & ConfigActionsSlice>((set
channels.push({
id: 'feishu',
type: 'feishu',
label: 'Feishu',
label: '飞书 (Feishu)',
status: feishu?.configured ? 'active' : 'inactive',
accounts: feishu?.accounts || 0,
});
} catch {
channels.push({ id: 'feishu', type: 'feishu', label: 'Feishu', status: 'inactive' });
channels.push({ id: 'feishu', type: 'feishu', label: '飞书 (Feishu)', status: 'inactive' });
}
set({ channels });

File diff suppressed because it is too large Load Diff

View File

@@ -6,8 +6,7 @@
*
* The coordinator:
* 1. Injects the shared client into all stores
* 2. Provides a composite hook that combines all store slices
* 3. Re-exports all individual stores for direct access
* 2. Re-exports all individual stores for direct access
*/
// === Re-export Individual Stores ===
@@ -26,6 +25,12 @@ export type { WorkflowStore, WorkflowStateSlice, WorkflowActionsSlice, Workflow,
export { useConfigStore, setConfigStoreClient } from './configStore';
export type { ConfigStore, ConfigStateSlice, ConfigActionsSlice, QuickConfig, WorkspaceInfo, ChannelInfo, ScheduledTask, SkillInfo } from './configStore';
export { useSecurityStore, setSecurityStoreClient } from './securityStore';
export type { SecurityStore, SecurityStateSlice, SecurityActionsSlice, SecurityLayer, SecurityStatus, AuditLogEntry } from './securityStore';
export { useSessionStore, setSessionStoreClient } from './sessionStore';
export type { SessionStore, SessionStateSlice, SessionActionsSlice, Session, SessionMessage } from './sessionStore';
// === New Stores ===
export { useMemoryGraphStore } from './memoryGraphStore';
export type { MemoryGraphStore, GraphNode, GraphEdge, GraphFilter, GraphLayout } from './memoryGraphStore';
@@ -49,14 +54,15 @@ export type {
SessionOptions,
} from '../components/BrowserHand/templates/types';
// === Composite Store Hook ===
// === Store Initialization ===
import { useMemo } from 'react';
import { useConnectionStore, getClient } from './connectionStore';
import { useAgentStore, setAgentStoreClient } from './agentStore';
import { useHandStore, setHandStoreClient } from './handStore';
import { useWorkflowStore, setWorkflowStoreClient } from './workflowStore';
import { useConfigStore, setConfigStoreClient } from './configStore';
import { getClient } from './connectionStore';
import { setAgentStoreClient } from './agentStore';
import { setHandStoreClient } from './handStore';
import { setWorkflowStoreClient } from './workflowStore';
import { setConfigStoreClient } from './configStore';
import { setSecurityStoreClient } from './securityStore';
import { setSessionStoreClient } from './sessionStore';
/**
* Initialize all stores with the shared client.
@@ -70,207 +76,8 @@ export function initializeStores(): void {
setHandStoreClient(client);
setWorkflowStoreClient(client);
setConfigStoreClient(client);
}
/**
* Hook that provides a composite view of all stores.
* Use this for components that need access to multiple store slices.
*
* For components that only need specific slices, import the individual
* store hooks directly (e.g., useConnectionStore, useAgentStore).
*/
export function useCompositeStore() {
// Subscribe to all stores
const connectionState = useConnectionStore((s) => s.connectionState);
const gatewayVersion = useConnectionStore((s) => s.gatewayVersion);
const connectionError = useConnectionStore((s) => s.error);
const logs = useConnectionStore((s) => s.logs);
const localGateway = useConnectionStore((s) => s.localGateway);
const localGatewayBusy = useConnectionStore((s) => s.localGatewayBusy);
const isLoading = useConnectionStore((s) => s.isLoading);
const client = useConnectionStore((s) => s.client);
const clones = useAgentStore((s) => s.clones);
const usageStats = useAgentStore((s) => s.usageStats);
const pluginStatus = useAgentStore((s) => s.pluginStatus);
const hands = useHandStore((s) => s.hands);
const handRuns = useHandStore((s) => s.handRuns);
const triggers = useHandStore((s) => s.triggers);
const approvals = useHandStore((s) => s.approvals);
const workflows = useWorkflowStore((s) => s.workflows);
const workflowRuns = useWorkflowStore((s) => s.workflowRuns);
const quickConfig = useConfigStore((s) => s.quickConfig);
const workspaceInfo = useConfigStore((s) => s.workspaceInfo);
const channels = useConfigStore((s) => s.channels);
const scheduledTasks = useConfigStore((s) => s.scheduledTasks);
const skillsCatalog = useConfigStore((s) => s.skillsCatalog);
const models = useConfigStore((s) => s.models);
const modelsLoading = useConfigStore((s) => s.modelsLoading);
const modelsError = useConfigStore((s) => s.modelsError);
// Get all actions
const connect = useConnectionStore((s) => s.connect);
const disconnect = useConnectionStore((s) => s.disconnect);
const clearLogs = useConnectionStore((s) => s.clearLogs);
const refreshLocalGateway = useConnectionStore((s) => s.refreshLocalGateway);
const startLocalGateway = useConnectionStore((s) => s.startLocalGateway);
const stopLocalGateway = useConnectionStore((s) => s.stopLocalGateway);
const restartLocalGateway = useConnectionStore((s) => s.restartLocalGateway);
const loadClones = useAgentStore((s) => s.loadClones);
const createClone = useAgentStore((s) => s.createClone);
const updateClone = useAgentStore((s) => s.updateClone);
const deleteClone = useAgentStore((s) => s.deleteClone);
const loadUsageStats = useAgentStore((s) => s.loadUsageStats);
const loadPluginStatus = useAgentStore((s) => s.loadPluginStatus);
const loadHands = useHandStore((s) => s.loadHands);
const getHandDetails = useHandStore((s) => s.getHandDetails);
const triggerHand = useHandStore((s) => s.triggerHand);
const loadHandRuns = useHandStore((s) => s.loadHandRuns);
const loadTriggers = useHandStore((s) => s.loadTriggers);
const createTrigger = useHandStore((s) => s.createTrigger);
const deleteTrigger = useHandStore((s) => s.deleteTrigger);
const loadApprovals = useHandStore((s) => s.loadApprovals);
const respondToApproval = useHandStore((s) => s.respondToApproval);
const loadWorkflows = useWorkflowStore((s) => s.loadWorkflows);
const getWorkflow = useWorkflowStore((s) => s.getWorkflow);
const createWorkflow = useWorkflowStore((s) => s.createWorkflow);
const updateWorkflow = useWorkflowStore((s) => s.updateWorkflow);
const deleteWorkflow = useWorkflowStore((s) => s.deleteWorkflow);
const triggerWorkflow = useWorkflowStore((s) => s.triggerWorkflow);
const loadWorkflowRuns = useWorkflowStore((s) => s.loadWorkflowRuns);
const loadQuickConfig = useConfigStore((s) => s.loadQuickConfig);
const saveQuickConfig = useConfigStore((s) => s.saveQuickConfig);
const loadWorkspaceInfo = useConfigStore((s) => s.loadWorkspaceInfo);
const loadChannels = useConfigStore((s) => s.loadChannels);
const getChannel = useConfigStore((s) => s.getChannel);
const createChannel = useConfigStore((s) => s.createChannel);
const updateChannel = useConfigStore((s) => s.updateChannel);
const deleteChannel = useConfigStore((s) => s.deleteChannel);
const loadScheduledTasks = useConfigStore((s) => s.loadScheduledTasks);
const createScheduledTask = useConfigStore((s) => s.createScheduledTask);
const loadSkillsCatalog = useConfigStore((s) => s.loadSkillsCatalog);
const getSkill = useConfigStore((s) => s.getSkill);
const createSkill = useConfigStore((s) => s.createSkill);
const updateSkill = useConfigStore((s) => s.updateSkill);
const deleteSkill = useConfigStore((s) => s.deleteSkill);
const loadModels = useConfigStore((s) => s.loadModels);
// Memoize the composite store to prevent unnecessary re-renders
return useMemo(() => ({
// Connection state
connectionState,
gatewayVersion,
error: connectionError,
logs,
localGateway,
localGatewayBusy,
isLoading,
client,
// Agent state
clones,
usageStats,
pluginStatus,
// Hand state
hands,
handRuns,
triggers,
approvals,
// Workflow state
workflows,
workflowRuns,
// Config state
quickConfig,
workspaceInfo,
channels,
scheduledTasks,
skillsCatalog,
models,
modelsLoading,
modelsError,
// Connection actions
connect,
disconnect,
clearLogs,
refreshLocalGateway,
startLocalGateway,
stopLocalGateway,
restartLocalGateway,
// Agent actions
loadClones,
createClone,
updateClone,
deleteClone,
loadUsageStats,
loadPluginStatus,
// Hand actions
loadHands,
getHandDetails,
triggerHand,
loadHandRuns,
loadTriggers,
createTrigger,
deleteTrigger,
loadApprovals,
respondToApproval,
// Workflow actions
loadWorkflows,
getWorkflow,
createWorkflow,
updateWorkflow,
deleteWorkflow,
triggerWorkflow,
loadWorkflowRuns,
// Config actions
loadQuickConfig,
saveQuickConfig,
loadWorkspaceInfo,
loadChannels,
getChannel,
createChannel,
updateChannel,
deleteChannel,
loadScheduledTasks,
createScheduledTask,
loadSkillsCatalog,
getSkill,
createSkill,
updateSkill,
deleteSkill,
loadModels,
// Legacy sendMessage (delegates to client)
sendMessage: async (message: string, sessionKey?: string) => {
return client.chat(message, { sessionKey });
},
}), [
connectionState, gatewayVersion, connectionError, logs, localGateway, localGatewayBusy, isLoading, client,
clones, usageStats, pluginStatus,
hands, handRuns, triggers, approvals,
workflows, workflowRuns,
quickConfig, workspaceInfo, channels, scheduledTasks, skillsCatalog, models, modelsLoading, modelsError,
connect, disconnect, clearLogs, refreshLocalGateway, startLocalGateway, stopLocalGateway, restartLocalGateway,
loadClones, createClone, updateClone, deleteClone, loadUsageStats, loadPluginStatus,
loadHands, getHandDetails, triggerHand, loadHandRuns, loadTriggers, createTrigger, deleteTrigger, loadApprovals, respondToApproval,
loadWorkflows, getWorkflow, createWorkflow, updateWorkflow, deleteWorkflow, triggerWorkflow, loadWorkflowRuns,
loadQuickConfig, saveQuickConfig, loadWorkspaceInfo, loadChannels, getChannel, createChannel, updateChannel, deleteChannel,
loadScheduledTasks, createScheduledTask, loadSkillsCatalog, getSkill, createSkill, updateSkill, deleteSkill, loadModels,
]);
setSecurityStoreClient(client);
setSessionStoreClient(client);
}
/**

View File

@@ -0,0 +1,141 @@
/**
* securityStore.ts - Security Status and Audit Log Management
*
* Extracted from gatewayStore.ts for Store Refactoring.
* Manages OpenFang security layers, security status, and audit logs.
*/
import { create } from 'zustand';
import type { GatewayClient } from '../lib/gateway-client';
// === Types ===
export interface SecurityLayer {
name: string;
enabled: boolean;
description?: string;
}
export interface SecurityStatus {
layers: SecurityLayer[];
enabledCount: number;
totalCount: number;
securityLevel: 'critical' | 'high' | 'medium' | 'low';
}
export interface AuditLogEntry {
id: string;
timestamp: string;
action: string;
actor?: string;
result?: 'success' | 'failure';
details?: Record<string, unknown>;
// Merkle hash chain fields (OpenFang)
hash?: string;
previousHash?: string;
}
// === Helpers ===
function calculateSecurityLevel(enabledCount: number, totalCount: number): 'critical' | 'high' | 'medium' | 'low' {
if (totalCount === 0) return 'low';
const ratio = enabledCount / totalCount;
if (ratio >= 0.875) return 'critical'; // 14-16 layers
if (ratio >= 0.625) return 'high'; // 10-13 layers
if (ratio >= 0.375) return 'medium'; // 6-9 layers
return 'low'; // 0-5 layers
}
// === Client Interface ===
interface SecurityClient {
getSecurityStatus(): Promise<{ layers?: SecurityLayer[] } | null>;
getAuditLogs(opts?: { limit?: number; offset?: number }): Promise<{ logs?: AuditLogEntry[] } | null>;
}
// === Store Interface ===
export interface SecurityStateSlice {
securityStatus: SecurityStatus | null;
securityStatusLoading: boolean;
securityStatusError: string | null;
auditLogs: AuditLogEntry[];
auditLogsLoading: boolean;
}
export interface SecurityActionsSlice {
loadSecurityStatus: () => Promise<void>;
loadAuditLogs: (opts?: { limit?: number; offset?: number }) => Promise<void>;
}
export type SecurityStore = SecurityStateSlice & SecurityActionsSlice & { client: SecurityClient | null };
// === Store Implementation ===
export const useSecurityStore = create<SecurityStore>((set, get) => ({
// Initial state
securityStatus: null,
securityStatusLoading: false,
securityStatusError: null,
auditLogs: [],
auditLogsLoading: false,
client: null,
loadSecurityStatus: async () => {
const client = get().client;
if (!client) return;
set({ securityStatusLoading: true, securityStatusError: null });
try {
const result = await client.getSecurityStatus();
if (result?.layers) {
const layers = result.layers as SecurityLayer[];
const enabledCount = layers.filter(l => l.enabled).length;
const totalCount = layers.length;
const securityLevel = calculateSecurityLevel(enabledCount, totalCount);
set({
securityStatus: { layers, enabledCount, totalCount, securityLevel },
securityStatusLoading: false,
securityStatusError: null,
});
} else {
set({
securityStatusLoading: false,
securityStatusError: 'API returned no data',
});
}
} catch (err: unknown) {
set({
securityStatusLoading: false,
securityStatusError: (err instanceof Error ? err.message : String(err)) || 'Security API not available',
});
}
},
loadAuditLogs: async (opts?: { limit?: number; offset?: number }) => {
const client = get().client;
if (!client) return;
set({ auditLogsLoading: true });
try {
const result = await client.getAuditLogs(opts);
set({ auditLogs: (result?.logs || []) as AuditLogEntry[], auditLogsLoading: false });
} catch {
set({ auditLogsLoading: false });
/* ignore if audit API not available */
}
},
}));
// === Client Injection ===
function createSecurityClientFromGateway(client: GatewayClient): SecurityClient {
return {
getSecurityStatus: () => client.getSecurityStatus() as Promise<{ layers?: SecurityLayer[] } | null>,
getAuditLogs: (opts) => client.getAuditLogs(opts) as Promise<{ logs?: AuditLogEntry[] } | null>,
};
}
export function setSecurityStoreClient(client: unknown): void {
const securityClient = createSecurityClientFromGateway(client as GatewayClient);
useSecurityStore.setState({ client: securityClient });
}

View File

@@ -0,0 +1,228 @@
/**
* sessionStore.ts - Session Management Store
*
* Extracted from gatewayStore.ts for Store Refactoring.
* Manages Gateway sessions and session messages.
*/
import { create } from 'zustand';
import type { GatewayClient } from '../lib/gateway-client';
// === Types ===
export interface Session {
id: string;
agentId: string;
createdAt: string;
updatedAt?: string;
messageCount?: number;
status?: 'active' | 'archived' | 'expired';
metadata?: Record<string, unknown>;
}
export interface SessionMessage {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
createdAt: string;
tokens?: { input?: number; output?: number };
}
// === Raw API Response Types ===
interface RawSession {
id?: string;
sessionId?: string;
session_id?: string;
agentId?: string;
agent_id?: string;
model?: string;
status?: string;
createdAt?: string;
created_at?: string;
updatedAt?: string;
updated_at?: string;
messageCount?: number;
message_count?: number;
metadata?: Record<string, unknown>;
}
interface RawSessionMessage {
id?: string;
messageId?: string;
message_id?: string;
role?: string;
content?: string;
createdAt?: string;
created_at?: string;
metadata?: Record<string, unknown>;
tokens?: { input?: number; output?: number };
}
// === Client Interface ===
interface SessionClient {
listSessions(opts?: { limit?: number; offset?: number }): Promise<{ sessions?: RawSession[] } | null>;
getSession(sessionId: string): Promise<Record<string, unknown> | null>;
createSession(params: { agent_id: string; metadata?: Record<string, unknown> }): Promise<Record<string, unknown> | null>;
deleteSession(sessionId: string): Promise<void>;
getSessionMessages(sessionId: string, opts?: { limit?: number; offset?: number }): Promise<{ messages?: RawSessionMessage[] } | null>;
}
// === Store Interface ===
export interface SessionStateSlice {
sessions: Session[];
sessionMessages: Record<string, SessionMessage[]>;
isLoading: boolean;
error: string | null;
}
export interface SessionActionsSlice {
loadSessions: (opts?: { limit?: number; offset?: number }) => Promise<void>;
getSession: (sessionId: string) => Promise<Session | undefined>;
createSession: (agentId: string, metadata?: Record<string, unknown>) => Promise<Session | undefined>;
deleteSession: (sessionId: string) => Promise<void>;
loadSessionMessages: (sessionId: string, opts?: { limit?: number; offset?: number }) => Promise<SessionMessage[]>;
}
export type SessionStore = SessionStateSlice & SessionActionsSlice & { client: SessionClient | null };
// === Store Implementation ===
export const useSessionStore = create<SessionStore>((set, get) => ({
// Initial state
sessions: [],
sessionMessages: {},
isLoading: false,
error: null,
client: null,
loadSessions: async (opts?: { limit?: number; offset?: number }) => {
const client = get().client;
if (!client) return;
try {
const result = await client.listSessions(opts);
const sessions: Session[] = (result?.sessions || [])
.filter((s: RawSession) => s.id || s.session_id)
.map((s: RawSession) => ({
id: s.id || s.session_id || '',
agentId: s.agent_id || s.agentId || '',
createdAt: s.created_at || s.createdAt || new Date().toISOString(),
updatedAt: s.updated_at || s.updatedAt,
messageCount: s.message_count || s.messageCount,
status: s.status as Session['status'],
metadata: s.metadata,
}));
set({ sessions });
} catch {
/* ignore if sessions API not available */
}
},
getSession: async (sessionId: string) => {
const client = get().client;
if (!client) return undefined;
try {
const result = await client.getSession(sessionId);
if (!result) return undefined;
const session: Session = {
id: result.id as string,
agentId: result.agent_id as string,
createdAt: result.created_at as string,
updatedAt: result.updated_at as string | undefined,
messageCount: result.message_count as number | undefined,
status: result.status as Session['status'],
metadata: result.metadata as Record<string, unknown> | undefined,
};
set(state => ({
sessions: state.sessions.some(s => s.id === sessionId)
? state.sessions.map(s => s.id === sessionId ? session : s)
: [...state.sessions, session],
}));
return session;
} catch {
return undefined;
}
},
createSession: async (agentId: string, metadata?: Record<string, unknown>) => {
const client = get().client;
if (!client) return undefined;
try {
const result = await client.createSession({ agent_id: agentId, metadata });
if (!result) return undefined;
const session: Session = {
id: result.id as string,
agentId: result.agent_id as string,
createdAt: result.created_at as string,
status: 'active',
metadata,
};
set(state => ({ sessions: [...state.sessions, session] }));
return session;
} catch (err: unknown) {
set({ error: err instanceof Error ? err.message : String(err) });
return undefined;
}
},
deleteSession: async (sessionId: string) => {
const client = get().client;
if (!client) return;
try {
await client.deleteSession(sessionId);
set(state => ({
sessions: state.sessions.filter(s => s.id !== sessionId),
sessionMessages: Object.fromEntries(
Object.entries(state.sessionMessages).filter(([id]) => id !== sessionId)
),
}));
} catch (err: unknown) {
set({ error: err instanceof Error ? err.message : String(err) });
throw err;
}
},
loadSessionMessages: async (sessionId: string, opts?: { limit?: number; offset?: number }) => {
const client = get().client;
if (!client) return [];
try {
const result = await client.getSessionMessages(sessionId, opts);
const messages: SessionMessage[] = (result?.messages || []).map((m: RawSessionMessage) => ({
id: m.id || m.message_id || '',
role: (m.role || 'user') as 'user' | 'assistant' | 'system',
content: m.content || '',
createdAt: m.created_at || m.createdAt || new Date().toISOString(),
tokens: m.tokens,
}));
set(state => ({
sessionMessages: { ...state.sessionMessages, [sessionId]: messages },
}));
return messages;
} catch {
return [];
}
},
}));
// === Client Injection ===
function createSessionClientFromGateway(client: GatewayClient): SessionClient {
return {
listSessions: (opts) => client.listSessions(opts),
getSession: (sessionId) => client.getSession(sessionId),
createSession: (params) => client.createSession(params),
deleteSession: async (sessionId) => { await client.deleteSession(sessionId); },
getSessionMessages: (sessionId, opts) => client.getSessionMessages(sessionId, opts),
};
}
export function setSessionStoreClient(client: unknown): void {
const sessionClient = createSessionClientFromGateway(client as GatewayClient);
useSessionStore.setState({ client: sessionClient });
}

View File

@@ -24,8 +24,6 @@ import type {
ReviewFeedback,
TaskDeliverable,
} from '../types/team';
import { parseJsonOrDefault } from '../lib/json-utils';
// === Store State ===
interface TeamStoreState {

View File

@@ -1,7 +1,23 @@
import { create } from 'zustand';
import { Workflow, WorkflowRun } from './gatewayStore';
import type { GatewayClient } from '../lib/gateway-client';
// === Core Types (previously imported from gatewayStore) ===
export interface Workflow {
id: string;
name: string;
steps: number;
description?: string;
createdAt?: string;
}
export interface WorkflowRun {
runId: string;
status: string;
step?: string;
result?: unknown;
}
// === Types ===
interface RawWorkflowRun {
@@ -256,8 +272,7 @@ export const useWorkflowStore = create<WorkflowStateSlice & WorkflowActionsSlice
},
}));
// Re-export types from gatewayStore for convenience
export type { Workflow, WorkflowRun };
// Types are now defined locally in this file (no longer imported from gatewayStore)
// === Client Injection ===