Skip to content

Commit 6dfdd8e

Browse files
Joyer Huanguppet
authored andcommitted
feat: 实现 Cloco Skills 技能系统核心功能
新增技能系统核心模块: - registry.js: 技能注册表,支持发现、加载和缓存技能 - parser.js: 技能定义解析器,支持front-matter和内容解析 - tools.js: 技能工具转换器,将技能转换为工具格式 - conversation-state.js: 会话状态管理 - index.js: 模块导出 核心特性: - 支持大小写不敏感的技能文件名(skill.md/SKILL.md等) - 全局和项目本地技能目录 - 技能缓存机制(5分钟) - 常驻技能预加载 - 按关键词和分类发现技能 技术细节: - 使用fs.promises进行异步文件操作 - 支持front-matter元数据解析 - Map数据结构实现高效缓存 - 优先级机制(项目本地优先于全局) Co-Authored-By: GLM-4.7 & cloco(Closer)
1 parent 213f98f commit 6dfdd8e

5 files changed

Lines changed: 730 additions & 0 deletions

File tree

src/skills/conversation-state.js

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* Conversation State - 会话状态管理
3+
*
4+
* 管理已加载的技能并更新 System Prompt
5+
*/
6+
7+
/**
8+
* 会话状态类
9+
*/
10+
export class ConversationState {
11+
constructor() {
12+
// 已加载的技能(按加载顺序)
13+
this.activeSkills = [];
14+
}
15+
16+
/**
17+
* 添加技能到会话
18+
* @param {Object} skill - 技能对象
19+
*/
20+
addSkill(skill) {
21+
// 检查是否已加载
22+
const exists = this.activeSkills.some(s => s.name === skill.name);
23+
if (exists) {
24+
console.log(`[Skills] Skill "${skill.name}" already loaded, skipping.`);
25+
return false;
26+
}
27+
28+
// 添加到列表
29+
this.activeSkills.push(skill);
30+
console.log(`[Skills] Loaded skill: ${skill.name}`);
31+
return true;
32+
}
33+
34+
/**
35+
* 移除技能
36+
* @param {string} name - 技能名称
37+
*/
38+
removeSkill(name) {
39+
const index = this.activeSkills.findIndex(s => s.name === name);
40+
if (index === -1) {
41+
return false;
42+
}
43+
44+
this.activeSkills.splice(index, 1);
45+
console.log(`[Skills] Removed skill: ${name}`);
46+
return true;
47+
}
48+
49+
/**
50+
* 获取所有已加载的技能
51+
* @returns {Array} 技能列表
52+
*/
53+
getActiveSkills() {
54+
return [...this.activeSkills];
55+
}
56+
57+
/**
58+
* 检查是否有已加载的技能
59+
* @returns {boolean}
60+
*/
61+
hasActiveSkills() {
62+
return this.activeSkills.length > 0;
63+
}
64+
65+
/**
66+
* 检查特定技能是否已加载
67+
* @param {string} name - 技能名称
68+
* @returns {boolean}
69+
*/
70+
hasSkill(name) {
71+
return this.activeSkills.some(s => s.name === name);
72+
}
73+
74+
/**
75+
* 清除所有已加载的技能
76+
*/
77+
clearSkills() {
78+
this.activeSkills = [];
79+
console.log('[Skills] Cleared all active skills');
80+
}
81+
82+
/**
83+
* 获取技能摘要(用于调试)
84+
* @returns {Array} 技能名称列表
85+
*/
86+
getSkillsSummary() {
87+
return this.activeSkills.map(s => ({
88+
name: s.name,
89+
description: s.description.substring(0, 100) + '...',
90+
path: s.path
91+
}));
92+
}
93+
}
94+
95+
/**
96+
* 构建包含技能的 System Prompt
97+
* @param {string} basePrompt - 基础 System Prompt
98+
* @param {Array} activeSkills - 已加载的技能列表
99+
* @returns {string} 更新后的 System Prompt
100+
*/
101+
export function buildSystemPromptWithSkills(basePrompt, activeSkills) {
102+
if (!activeSkills || activeSkills.length === 0) {
103+
return basePrompt;
104+
}
105+
106+
let prompt = basePrompt;
107+
108+
// 添加技能部分
109+
prompt += '\n\n## 🎯 Loaded Skills\n\n';
110+
prompt += 'The following skills are available for use in this conversation:\n\n';
111+
112+
for (const skill of activeSkills) {
113+
prompt += `### ${skill.name}\n\n`;
114+
prompt += `${skill.description}\n\n`;
115+
prompt += `${skill.content}\n\n`;
116+
prompt += '---\n\n';
117+
}
118+
119+
prompt += 'You can use these skills to help the user. Please carefully read the skill documentation, understand their capabilities and usage, then assist the user with their tasks.\n';
120+
121+
return prompt;
122+
}
123+
124+
/**
125+
* 创建全局会话状态实例
126+
* @returns {ConversationState} 会话状态实例
127+
*/
128+
export function createConversationState() {
129+
return new ConversationState();
130+
}

src/skills/index.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
/**
2+
* Skills Module - 技能系统入口
3+
*
4+
* 导出所有技能相关的类和函数
5+
*/
6+
7+
export * from './parser.js';
8+
export * from './registry.js';
9+
export * from './conversation-state.js';
10+
export * from './tools.js';

src/skills/parser.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* Skill Parser - 技能解析器
3+
*
4+
* 最小化解析原则:
5+
* - 只解析必需字段:name 和 description
6+
* - 保留完整 content 传递给 AI
7+
* - 使用简单的 YAML front-matter 解析
8+
*/
9+
10+
import fs from 'fs/promises';
11+
import path from 'path';
12+
13+
/**
14+
* 解析技能文件
15+
* @param {string} skillPath - 技能文件路径
16+
* @returns {Promise<Object>} 解析后的技能对象
17+
*/
18+
export async function parseSkill(skillPath) {
19+
try {
20+
// 读取文件内容
21+
const content = await fs.readFile(skillPath, 'utf-8');
22+
23+
// 提取 YAML front-matter
24+
const frontmatter = extractFrontmatter(content);
25+
26+
// 移除 front-matter,保留完整内容
27+
const contentWithoutFrontmatter = removeFrontmatter(content);
28+
29+
// 验证必需字段
30+
if (!frontmatter.name) {
31+
throw new Error('Missing required field: name');
32+
}
33+
if (!frontmatter.description) {
34+
throw new Error('Missing required field: description');
35+
}
36+
37+
return {
38+
// 只解析这两个字段
39+
name: frontmatter.name,
40+
description: frontmatter.description,
41+
42+
// 完整内容(AI 理解)
43+
content: contentWithoutFrontmatter,
44+
45+
// 文件信息
46+
path: skillPath,
47+
directory: path.dirname(skillPath)
48+
};
49+
} catch (error) {
50+
throw new Error(`Failed to parse skill file "${skillPath}": ${error.message}`);
51+
}
52+
}
53+
54+
/**
55+
* 提取 YAML front-matter(--- ... ---)
56+
* @param {string} content - 文件内容
57+
* @returns {Object} 解析后的 front-matter 对象
58+
*/
59+
function extractFrontmatter(content) {
60+
// 匹配 --- ... --- 格式
61+
const match = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
62+
if (!match) {
63+
throw new Error('Invalid skill format: missing frontmatter');
64+
}
65+
66+
try {
67+
// 简单解析:只提取 name 和 description
68+
const yaml = match[1];
69+
const lines = yaml.split('\n');
70+
const result = {};
71+
72+
for (const line of lines) {
73+
// 跳过空行和注释
74+
const trimmed = line.trim();
75+
if (!trimmed || trimmed.startsWith('#')) {
76+
continue;
77+
}
78+
79+
// 匹配 key: value 格式(支持带引号和不带引号)
80+
const match = line.match(/^(\w+):\s*(.+)$/);
81+
if (match) {
82+
const [, key, value] = match;
83+
// 移除引号(单引或双引)
84+
result[key] = value
85+
.replace(/^"|"$/g, '')
86+
.replace(/^'|'$/g, '')
87+
.trim();
88+
}
89+
}
90+
91+
return result;
92+
} catch (error) {
93+
throw new Error(`Failed to parse frontmatter: ${error.message}`);
94+
}
95+
}
96+
97+
/**
98+
* 移除 front-matter
99+
* @param {string} content - 文件内容
100+
* @returns {string} 移除 front-matter 后的内容
101+
*/
102+
function removeFrontmatter(content) {
103+
return content.replace(/^---\r?\n[\s\S]+?\r?\n---\r?\n?/, '');
104+
}
105+
106+
/**
107+
* 快速解析:只读取 front-matter(用于发现技能)
108+
* @param {string} skillPath - 技能文件路径
109+
* @returns {Promise<Object>} { name, description } 或 null
110+
*/
111+
export async function parseSkillFrontmatter(skillPath) {
112+
try {
113+
const content = await fs.readFile(skillPath, 'utf-8');
114+
const frontmatter = extractFrontmatter(content);
115+
116+
// 验证必需字段
117+
if (!frontmatter.name || !frontmatter.description) {
118+
return null;
119+
}
120+
121+
return {
122+
name: frontmatter.name,
123+
description: frontmatter.description,
124+
path: skillPath
125+
};
126+
} catch (error) {
127+
// 快速解析失败不抛出错误,返回 null
128+
return null;
129+
}
130+
}
131+
132+
/**
133+
* 验证技能文件格式
134+
* @param {string} skillPath - 技能文件路径
135+
* @returns {Promise<boolean>} 是否有效
136+
*/
137+
export async function validateSkillFile(skillPath) {
138+
try {
139+
const result = await parseSkillFrontmatter(skillPath);
140+
return result !== null;
141+
} catch {
142+
return false;
143+
}
144+
}

0 commit comments

Comments
 (0)