/** * Skill Discovery - Agent-driven skill search, recommendation, and management * * Enables ZCLAW agents to: * - Search available skills by keyword/capability * - Recommend skills based on recent conversation patterns * - Manage skill installation lifecycle (with user approval) * * Dynamically loads skills from the backend Kernel's SkillRegistry. * * Reference: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md §6.5.2 */ import { intelligenceClient } from './intelligence-client'; import { canAutoExecute } from './autonomy-manager'; import { createLogger } from './logger'; const log = createLogger('SkillDiscovery'); // === Types === export interface SkillInfo { id: string; name: string; description: string; triggers: string[]; capabilities: string[]; toolDeps: string[]; installed: boolean; category?: string; path?: string; version?: string; mode?: string; } /** Backend skill response format */ interface BackendSkillInfo { id: string; name: string; description: string; version: string; capabilities: string[]; tags: string[]; mode: string; enabled: boolean; } export interface SkillSuggestion { skill: SkillInfo; reason: string; confidence: number; // 0-1 matchedPatterns: string[]; } export interface SkillSearchResult { query: string; results: SkillInfo[]; totalAvailable: number; } export interface ConversationContext { role: string; content: string; } // === Storage === const SKILL_INDEX_KEY = 'zclaw-skill-index'; const SKILL_SUGGESTIONS_KEY = 'zclaw-skill-suggestions'; // === Skill Discovery Engine === export class SkillDiscoveryEngine { private skills: SkillInfo[] = []; private suggestionHistory: SkillSuggestion[] = []; private loadedFromBackend: boolean = false; constructor() { this.loadIndex(); this.loadSuggestions(); // Try to load from backend, fallback to cache this.loadFromBackend(); } /** * Load skills from backend Tauri command. * Falls back to cached skills if backend is unavailable. */ private async loadFromBackend(): Promise { try { // Dynamic import to avoid bundling issues in non-Tauri environments const { invoke } = await import('@tauri-apps/api/core'); const backendSkills = await invoke('skill_list'); // Convert backend format to frontend format this.skills = backendSkills.map(this.convertFromBackend); this.loadedFromBackend = true; this.saveIndex(); log.debug(`Loaded ${this.skills.length} skills from backend`); } catch (error) { log.warn('Failed to load skills from backend:', error); // Keep using cached skills (loaded in loadIndex) this.loadedFromBackend = false; } } /** * Convert backend skill format to frontend format. */ private convertFromBackend(backend: BackendSkillInfo): SkillInfo { return { id: backend.id, name: backend.name, description: backend.description, version: backend.version, triggers: backend.tags, // Use tags as triggers capabilities: backend.capabilities, mode: backend.mode, toolDeps: [], // Backend doesn't have this field installed: backend.enabled, category: backend.tags[0] || 'general', }; } /** * Refresh skills from backend. * Optionally specify a custom directory to scan. */ async refresh(skillDir?: string): Promise { try { const { invoke } = await import('@tauri-apps/api/core'); const backendSkills = await invoke('skill_refresh', { skillDir }); this.skills = backendSkills.map(this.convertFromBackend); this.loadedFromBackend = true; this.saveIndex(); log.debug(`Refreshed ${this.skills.length} skills`); return this.skills.length; } catch (error) { console.error('[SkillDiscovery] Failed to refresh skills:', error); throw error; } } /** * Check if skills were loaded from backend. */ isLoadedFromBackend(): boolean { return this.loadedFromBackend; } // === Search === /** * Search skills by keyword. Matches against name, description, triggers, capabilities. */ searchSkills(query: string): SkillSearchResult { const q = query.toLowerCase().trim(); if (!q) { return { query, results: [...this.skills], totalAvailable: this.skills.length }; } const tokens = q.split(/[\s,;.!?。,;!?]+/).filter(t => t.length > 0); const scored = this.skills.map(skill => { let score = 0; // Name match (highest weight) if (skill.name.toLowerCase().includes(q)) score += 10; // Description match if (skill.description.toLowerCase().includes(q)) score += 5; // Trigger match (exact or partial) for (const trigger of skill.triggers) { const tLower = trigger.toLowerCase(); if (tLower === q) { score += 15; break; } if (tLower.includes(q) || q.includes(tLower)) score += 8; } // Capability match for (const cap of skill.capabilities) { if (cap.toLowerCase().includes(q)) score += 4; } // Token-level matching for (const token of tokens) { if (skill.name.toLowerCase().includes(token)) score += 2; if (skill.description.toLowerCase().includes(token)) score += 1; for (const trigger of skill.triggers) { if (trigger.toLowerCase().includes(token)) score += 3; } } // Category match if (skill.category && skill.category.toLowerCase().includes(q)) score += 3; return { skill, score }; }); const results = scored .filter(s => s.score > 0) .sort((a, b) => b.score - a.score) .map(s => s.skill); return { query, results, totalAvailable: this.skills.length }; } // === Recommendation === /** * Suggest skills based on recent conversation content and memory patterns. */ async suggestSkills( recentConversations: ConversationContext[], agentId: string, limit: number = 5 ): Promise { const suggestions: SkillSuggestion[] = []; // 1. Extract key topics from conversations const topics = this.extractTopics(recentConversations); // 2. Match topics against skill triggers and capabilities for (const skill of this.skills) { const matchedPatterns: string[] = []; let confidence = 0; for (const topic of topics) { const topicLower = topic.toLowerCase(); // Check triggers for (const trigger of skill.triggers) { if (trigger.toLowerCase().includes(topicLower) || topicLower.includes(trigger.toLowerCase())) { matchedPatterns.push(`话题"${topic}"匹配触发词"${trigger}"`); confidence += 0.3; } } // Check capabilities for (const cap of skill.capabilities) { if (cap.toLowerCase().includes(topicLower)) { matchedPatterns.push(`话题"${topic}"匹配能力"${cap}"`); confidence += 0.2; } } } // 3. Check memory patterns for recurring needs try { const memories = await intelligenceClient.memory.search({ agentId, query: skill.name, limit: 5, minImportance: 3, }); if (memories.length > 0) { matchedPatterns.push(`记忆中有${memories.length}条相关记录`); confidence += memories.length * 0.1; } } catch { /* non-critical */ } if (matchedPatterns.length > 0 && confidence > 0) { suggestions.push({ skill, reason: matchedPatterns.slice(0, 3).join(';'), confidence: Math.min(confidence, 1), matchedPatterns, }); } } // Sort by confidence const sorted = suggestions .sort((a, b) => b.confidence - a.confidence) .slice(0, limit); // Cache suggestions this.suggestionHistory = sorted; this.saveSuggestions(); return sorted; } // === Skill Management === /** * Get all available skills. */ getAllSkills(): SkillInfo[] { return [...this.skills]; } /** * Get skills by category. */ getSkillsByCategory(category: string): SkillInfo[] { return this.skills.filter(s => s.category === category); } /** * Get unique categories. */ getCategories(): string[] { return [...new Set(this.skills.map(s => s.category).filter(Boolean))] as string[]; } /** * Register a new skill (e.g., from a SKILL.md file scan). */ registerSkill(skill: SkillInfo): void { const existing = this.skills.findIndex(s => s.id === skill.id); if (existing >= 0) { this.skills[existing] = skill; } else { this.skills.push(skill); } this.saveIndex(); } /** * Mark a skill as installed/uninstalled. * Includes autonomy check for skill_install/skill_uninstall actions. */ setSkillInstalled( skillId: string, installed: boolean, options?: { skipAutonomyCheck?: boolean } ): { success: boolean; reason?: string } { const skill = this.skills.find(s => s.id === skillId); if (!skill) { return { success: false, reason: `Skill not found: ${skillId}` }; } // Autonomy check - verify if skill installation is allowed if (!options?.skipAutonomyCheck) { const action = installed ? 'skill_install' : 'skill_uninstall'; const { canProceed, decision } = canAutoExecute(action, 6); log.debug(`Autonomy check for ${action}: ${decision.reason}`); if (!canProceed) { return { success: false, reason: decision.reason }; } } skill.installed = installed; this.saveIndex(); log.debug(`Skill ${skillId} ${installed ? 'installed' : 'uninstalled'}`); return { success: true }; } /** * Get last cached suggestions. */ getLastSuggestions(): SkillSuggestion[] { return [...this.suggestionHistory]; } // === Topic Extraction === private extractTopics(conversations: ConversationContext[]): string[] { const topics = new Set(); const patterns = [ // Chinese task patterns /帮我(.{2,15})/g, /我想(.{2,15})/g, /如何(.{2,15})/g, /怎么(.{2,15})/g, /需要(.{2,15})/g, /分析(.{2,15})/g, /写一个(.{2,15})/g, /做一个(.{2,15})/g, // English task patterns /(?:help me |I need to |how to |please )(.{3,30})/gi, // Technical terms /(?:React|Vue|Python|Docker|K8s|API|SQL|CSS|TypeScript|Node|Git)/gi, // Domain keywords /(?:部署|测试|审查|优化|设计|开发|分析|报告|运营|安全)/g, ]; for (const msg of conversations.filter(m => m.role === 'user')) { for (const pattern of patterns) { const matches = msg.content.matchAll(pattern); for (const match of matches) { const topic = (match[1] || match[0]).trim(); if (topic.length >= 2 && topic.length <= 30) { topics.add(topic); } } } } return [...topics].slice(0, 20); } // === Persistence === private loadIndex(): void { try { const raw = localStorage.getItem(SKILL_INDEX_KEY); if (raw) this.skills = JSON.parse(raw); } catch { this.skills = []; } } private saveIndex(): void { try { localStorage.setItem(SKILL_INDEX_KEY, JSON.stringify(this.skills)); } catch { /* silent */ } } private loadSuggestions(): void { try { const raw = localStorage.getItem(SKILL_SUGGESTIONS_KEY); if (raw) this.suggestionHistory = JSON.parse(raw); } catch { this.suggestionHistory = []; } } private saveSuggestions(): void { try { localStorage.setItem(SKILL_SUGGESTIONS_KEY, JSON.stringify(this.suggestionHistory)); } catch { /* silent */ } } } // === Singleton === let _instance: SkillDiscoveryEngine | null = null; export function getSkillDiscovery(): SkillDiscoveryEngine { if (!_instance) { _instance = new SkillDiscoveryEngine(); } return _instance; } export function resetSkillDiscovery(): void { _instance = null; }