Files
zclaw_openfang/desktop/src/lib/skill-discovery.ts
iven 05762261be
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(audit): P0 反思引擎 LLM 接入 + P1 hand run_id/skill triggers/pipeline v2
审计修复 Batch 1 (M4-02/M3-01/M5-01/M6-02):

P0 M4-02: reflection_reflect 从 KernelState 获取 LLM driver
  - 新增 kernel_state 参数,从 kernel.driver() 获取驱动
  - 自动路径(post_conversation_hook)已正常,手动 Tauri 命令路径已修复

P1 M3-01: hand_execute 返回 run_id 给前端
  - HandResult 新增 run_id 字段
  - execute_hand 结果包含 run_id.to_string()

P1 M5-01: skill-discovery 使用后端 triggers 字段
  - BackendSkillInfo 新增 triggers 字段
  - convertFromBackend 优先使用 triggers,fallback tags

P1 M6-02: pipeline_list 支持 v2 YAML 格式
  - scan_pipelines_with_paths 增加 v2 fallback 解析
  - 新增 pipeline_v2_to_info 转换函数
  - discovery.rs 导入 parse_pipeline_v2_yaml

注: M4-01 双数据库问题已在之前批次修复
     M6-01 route_intent 已确认注册,审计结论过时
2026-04-04 18:11:21 +08:00

449 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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[];
triggers: 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<void> {
try {
// Dynamic import to avoid bundling issues in non-Tauri environments
const { invoke } = await import('@tauri-apps/api/core');
const backendSkills = await invoke<BackendSkillInfo[]>('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.triggers?.length ? backend.triggers : backend.tags,
capabilities: backend.capabilities,
mode: backend.mode,
toolDeps: [],
installed: backend.enabled,
category: backend.tags[0] || 'general',
};
}
/**
* Refresh skills from backend.
* Optionally specify a custom directory to scan.
*/
async refresh(skillDir?: string): Promise<number> {
try {
const { invoke } = await import('@tauri-apps/api/core');
const backendSkills = await invoke<BackendSkillInfo[]>('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<SkillSuggestion[]> {
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 (e) { log.debug('Memory search in suggestSkills failed', { error: e }); }
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<string>();
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 (e) {
log.debug('Failed to load skill index from localStorage', { error: e });
this.skills = [];
}
}
private saveIndex(): void {
try {
localStorage.setItem(SKILL_INDEX_KEY, JSON.stringify(this.skills));
} catch (e) { log.debug('Failed to save skill index', { error: e }); }
}
private loadSuggestions(): void {
try {
const raw = localStorage.getItem(SKILL_SUGGESTIONS_KEY);
if (raw) this.suggestionHistory = JSON.parse(raw);
} catch (e) {
log.debug('Failed to load skill suggestions', { error: e });
this.suggestionHistory = [];
}
}
private saveSuggestions(): void {
try {
localStorage.setItem(SKILL_SUGGESTIONS_KEY, JSON.stringify(this.suggestionHistory));
} catch (e) { log.debug('Failed to save skill suggestions', { error: e }); }
}
}
// === Singleton ===
let _instance: SkillDiscoveryEngine | null = null;
export function getSkillDiscovery(): SkillDiscoveryEngine {
if (!_instance) {
_instance = new SkillDiscoveryEngine();
}
return _instance;
}
export function resetSkillDiscovery(): void {
_instance = null;
}