/** * ZCLAW UI Extensions Plugin * * Registers custom Gateway RPC methods that the ZCLAW Tauri desktop UI * calls for features beyond standard OpenClaw protocol. * * Custom methods: * - zclaw.clones.list → list all agent clones (分身) * - zclaw.clones.create → create a new clone * - zclaw.clones.update → update clone config * - zclaw.clones.delete → delete a clone * - zclaw.stats.usage → token usage statistics * - zclaw.stats.sessions → session statistics * - zclaw.config.quick → quick configuration (name/role/scenarios) * - zclaw.workspace.info → workspace information * - zclaw.plugins.status → all ZCLAW plugin statuses */ import * as fs from 'fs'; import * as path from 'path'; interface PluginAPI { config: Record; registerGatewayMethod(method: string, handler: (ctx: RpcContext) => void): void; registerHook(event: string, handler: (...args: any[]) => any, meta?: Record): void; } interface RpcContext { params: Record; respond(ok: boolean, payload: any): void; } interface SkillInfo { id: string; name: string; path: string; source: 'builtin' | 'extra'; } // Clone (分身) management - stored in ZCLAW config interface CloneConfig { id: string; name: string; role?: string; nickname?: string; scenarios?: string[]; model?: string; workspaceDir?: string; workspaceResolvedPath?: string; restrictFiles?: boolean; privacyOptIn?: boolean; userName?: string; userRole?: string; bootstrapReady?: boolean; bootstrapFiles?: Array<{ name: string; path: string; exists: boolean }>; createdAt: string; updatedAt?: string; } export default function register(api: PluginAPI) { const configDir = process.env.OPENCLAW_HOME || path.join(process.env.HOME || process.env.USERPROFILE || '', '.openclaw'); const zclawDataPath = path.join(configDir, 'zclaw-data.json'); const templateRoot = path.resolve(__dirname, '../../config'); const bootstrapFiles = ['SOUL.md', 'AGENTS.md', 'IDENTITY.md', 'USER.md'] as const; // Helper: read/write ZCLAW data function readZclawData(): { clones: CloneConfig[]; quickConfig?: Record } { try { if (fs.existsSync(zclawDataPath)) { return JSON.parse(fs.readFileSync(zclawDataPath, 'utf-8')); } } catch { /* ignore */ } return { clones: [] }; } function writeZclawData(data: any) { fs.mkdirSync(path.dirname(zclawDataPath), { recursive: true }); fs.writeFileSync(zclawDataPath, JSON.stringify(data, null, 2), 'utf-8'); } function uniquePaths(paths: string[]) { return Array.from(new Set(paths.filter(Boolean).map((item) => path.resolve(item)))); } function sanitizePathSegment(value: string) { return value .trim() .replace(/[<>:"/\\|?*\u0000-\u001F]/g, '-') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') .toLowerCase(); } function resolveWorkspacePath(workspaceDir: string | undefined, clone: Pick) { const baseWorkspace = workspaceDir || api.config?.agents?.defaults?.workspace || '~/.openclaw/zclaw-workspace'; const expandedBase = baseWorkspace.replace('~', process.env.HOME || process.env.USERPROFILE || ''); const resolvedBase = path.resolve(expandedBase); const cloneDirName = sanitizePathSegment(clone.name) || clone.id; return path.join(resolvedBase, 'agents', cloneDirName); } function readTemplate(fileName: typeof bootstrapFiles[number], fallback: string) { const filePath = path.join(templateRoot, fileName); try { if (fs.existsSync(filePath)) { return fs.readFileSync(filePath, 'utf-8'); } } catch { /* ignore */ } return fallback; } function renderIdentity(clone: CloneConfig) { const nickname = clone.nickname || clone.name; return `# ${clone.name} 身份 - **名字**: ${clone.name} - **昵称**: ${nickname} - **Emoji**: ${nickname.slice(0, 1) || '🦞'} - **描述**: ${clone.role || 'ZCLAW Agent'} - **模型**: ${clone.model || '继承当前默认模型'} - **创建时间**: ${clone.createdAt} `; } function renderSoul(clone: CloneConfig) { const scenarios = clone.scenarios?.length ? clone.scenarios.join('、') : '通用协作'; return `# ${clone.name} 人格 你是 ${clone.name},一个运行在 ZCLAW / OpenClaw 体系中的 Agent。 ## 角色定位 - **角色**: ${clone.role || '未设置'} - **擅长场景**: ${scenarios} - **工作方式**: 高执行力、中文优先、面向交付 ## 边界 - 文件访问限制: ${clone.restrictFiles ? '已开启' : '未开启'} - 优化计划: ${clone.privacyOptIn ? '已加入' : '未加入'} - 工作目录: ${clone.workspaceResolvedPath || clone.workspaceDir || '继承系统默认'} `; } function renderUser(clone: CloneConfig) { return `# 用户配置 - **名字**: ${clone.userName || '未设置'} - **角色**: ${clone.userRole || '未设置'} - **语言**: 中文 (zh-CN) - **时区**: Asia/Shanghai (UTC+8) ## 协作说明 - 默认按照用户当前任务优先级推进工作 - 在风险操作前先确认 - 输出以可执行结果为导向 `; } function renderAgents(clone: CloneConfig) { const scenarios = clone.scenarios?.length ? clone.scenarios.map((item) => `- ${item}`).join('\n') : '- 通用协作'; return `# ${clone.name} Agent 指令 ## 当前 Agent - **名称**: ${clone.name} - **角色**: ${clone.role || '未设置'} - **昵称**: ${clone.nickname || clone.name} - **工作目录**: ${clone.workspaceResolvedPath || clone.workspaceDir || '继承系统默认'} ## 专注场景 ${scenarios} ## 运行要求 1. 优先完成当前明确目标 2. 长任务分阶段汇报 3. 风险操作先确认 4. 保持产出可复现、可审计 `; } function ensureCloneWorkspace(clone: CloneConfig) { const workspaceResolvedPath = resolveWorkspacePath(clone.workspaceDir, clone); fs.mkdirSync(workspaceResolvedPath, { recursive: true }); const contents: Record = { 'IDENTITY.md': renderIdentity(clone), 'SOUL.md': renderSoul(clone), 'USER.md': renderUser(clone), 'AGENTS.md': renderAgents(clone), }; const files = bootstrapFiles.map((fileName) => { const filePath = path.join(workspaceResolvedPath, fileName); const content = contents[fileName] || readTemplate(fileName, `# ${fileName}`); fs.writeFileSync(filePath, content, 'utf-8'); return { name: fileName, path: filePath, exists: fs.existsSync(filePath), }; }); return { ...clone, workspaceResolvedPath, bootstrapReady: files.every((file) => file.exists), bootstrapFiles: files, updatedAt: new Date().toISOString(), }; } // === Clone Management === api.registerGatewayMethod('zclaw.clones.list', ({ respond }: RpcContext) => { const data = readZclawData(); respond(true, { clones: data.clones }); }); api.registerGatewayMethod('zclaw.clones.create', ({ params, respond }: RpcContext) => { const data = readZclawData(); const rawClone: CloneConfig = { id: `clone_${Date.now().toString(36)}`, name: params.name || 'New Clone', role: params.role, nickname: params.nickname, scenarios: params.scenarios || [], model: params.model, workspaceDir: params.workspaceDir, restrictFiles: params.restrictFiles, privacyOptIn: params.privacyOptIn, userName: params.userName, userRole: params.userRole, createdAt: new Date().toISOString(), }; const clone = ensureCloneWorkspace(rawClone); data.clones.push(clone); writeZclawData(data); respond(true, { clone }); }); api.registerGatewayMethod('zclaw.clones.update', ({ params, respond }: RpcContext) => { const data = readZclawData(); const index = data.clones.findIndex((c: CloneConfig) => c.id === params.id); if (index === -1) { respond(false, { error: 'Clone not found' }); return; } const updatedClone = ensureCloneWorkspace({ ...data.clones[index], ...params.updates, updatedAt: new Date().toISOString(), }); data.clones[index] = updatedClone; writeZclawData(data); respond(true, { clone: updatedClone }); }); api.registerGatewayMethod('zclaw.clones.delete', ({ params, respond }: RpcContext) => { const data = readZclawData(); data.clones = data.clones.filter((c: CloneConfig) => c.id !== params.id); writeZclawData(data); respond(true, { ok: true }); }); // === Statistics === api.registerGatewayMethod('zclaw.stats.usage', ({ respond }: RpcContext) => { // Read session files to compute token usage const sessionsDir = path.join(configDir, 'agents'); const stats = { totalSessions: 0, totalMessages: 0, totalTokens: 0, byModel: {} as Record, }; try { if (fs.existsSync(sessionsDir)) { const agentDirs = fs.readdirSync(sessionsDir); for (const agentDir of agentDirs) { const sessDir = path.join(sessionsDir, agentDir, 'sessions'); if (!fs.existsSync(sessDir)) continue; const sessionFiles = fs.readdirSync(sessDir).filter((f: string) => f.endsWith('.jsonl')); stats.totalSessions += sessionFiles.length; for (const file of sessionFiles) { try { const content = fs.readFileSync(path.join(sessDir, file), 'utf-8'); const lines = content.split('\n').filter(Boolean); for (const line of lines) { try { const entry = JSON.parse(line); if (entry.role === 'assistant' || entry.role === 'user') { stats.totalMessages++; } if (entry.usage) { const model = entry.model || 'unknown'; if (!stats.byModel[model]) { stats.byModel[model] = { messages: 0, inputTokens: 0, outputTokens: 0 }; } stats.byModel[model].messages++; stats.byModel[model].inputTokens += entry.usage.input_tokens || 0; stats.byModel[model].outputTokens += entry.usage.output_tokens || 0; stats.totalTokens += (entry.usage.input_tokens || 0) + (entry.usage.output_tokens || 0); } } catch { /* skip malformed lines */ } } } catch { /* skip unreadable files */ } } } } } catch { /* sessions dir may not exist yet */ } respond(true, stats); }); api.registerGatewayMethod('zclaw.stats.sessions', ({ respond }: RpcContext) => { const sessionsDir = path.join(configDir, 'agents'); const sessions: any[] = []; try { if (fs.existsSync(sessionsDir)) { const agentDirs = fs.readdirSync(sessionsDir); for (const agentDir of agentDirs) { const sessDir = path.join(sessionsDir, agentDir, 'sessions'); if (!fs.existsSync(sessDir)) continue; const sessionFiles = fs.readdirSync(sessDir).filter((f: string) => f.endsWith('.jsonl')); for (const file of sessionFiles) { const stat = fs.statSync(path.join(sessDir, file)); sessions.push({ id: file.replace('.jsonl', ''), agentId: agentDir, createdAt: stat.birthtime.toISOString(), updatedAt: stat.mtime.toISOString(), size: stat.size, }); } } } } catch { /* ignore */ } sessions.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); respond(true, { sessions }); }); // === Quick Configuration === api.registerGatewayMethod('zclaw.config.quick', ({ params, respond }: RpcContext) => { const data = readZclawData(); if (params.get) { respond(true, { quickConfig: data.quickConfig || {} }); return; } const { get, ...rest } = params; data.quickConfig = { ...(data.quickConfig || {}), ...rest, scenarios: Array.isArray(rest.scenarios) ? rest.scenarios : (data.quickConfig?.scenarios || []), }; writeZclawData(data); respond(true, { ok: true, quickConfig: data.quickConfig }); }); api.registerGatewayMethod('zclaw.skills.list', ({ respond }: RpcContext) => { const data = readZclawData(); const builtinRoot = path.resolve(__dirname, '../../skills'); const extraRoots = [ ...(api.config?.skills?.load?.extraDirs || []), ...(data.quickConfig?.skillsExtraDirs || []), ].map((item: string) => item.replace('~', process.env.HOME || process.env.USERPROFILE || '')); const searchRoots = [ { root: builtinRoot, source: 'builtin' as const }, ...uniquePaths(extraRoots).map((root) => ({ root, source: 'extra' as const })), ]; const skills: SkillInfo[] = []; for (const target of searchRoots) { try { if (!fs.existsSync(target.root)) continue; const entries = fs.readdirSync(target.root, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const skillPath = path.join(target.root, entry.name, 'SKILL.md'); if (!fs.existsSync(skillPath)) continue; skills.push({ id: `${target.source}:${entry.name}`, name: entry.name, path: skillPath, source: target.source, }); } } catch { /* ignore skill scan failures */ } } respond(true, { skills, extraDirs: data.quickConfig?.skillsExtraDirs || api.config?.skills?.load?.extraDirs || [], }); }); // === Workspace Info === api.registerGatewayMethod('zclaw.workspace.info', ({ respond }: RpcContext) => { const data = readZclawData(); const workspace = data.quickConfig?.workspaceDir || api.config?.agents?.defaults?.workspace || '~/.openclaw/zclaw-workspace'; const resolvedPath = workspace.replace('~', process.env.HOME || process.env.USERPROFILE || ''); let exists = false; let fileCount = 0; let totalSize = 0; try { exists = fs.existsSync(resolvedPath); if (exists) { const files = fs.readdirSync(resolvedPath, { recursive: true }) as string[]; for (const file of files) { const fullPath = path.join(resolvedPath, file); try { const stat = fs.statSync(fullPath); if (stat.isFile()) { fileCount++; totalSize += stat.size; } } catch { /* skip */ } } } } catch { /* ignore */ } respond(true, { path: workspace, resolvedPath, exists, fileCount, totalSize, }); }); // === Plugin Status === api.registerGatewayMethod('zclaw.plugins.status', ({ respond }: RpcContext) => { respond(true, { plugins: [ { id: 'zclaw-chinese-models', status: 'active', version: '0.1.0' }, { id: 'zclaw-feishu', status: api.config?.channels?.feishu?.enabled ? 'active' : 'inactive', version: '0.1.0' }, { id: 'zclaw-ui', status: 'active', version: '0.1.0' }, ], }); }); // Startup log api.registerHook('gateway:startup', async () => { console.log('[ZCLAW] UI extension RPC methods registered'); }, { name: 'zclaw-ui.startup', description: 'Log ZCLAW UI extensions registration' }); }