377 lines
11 KiB
JavaScript
377 lines
11 KiB
JavaScript
/**
|
||
* GLADA · Skill 蒸馏器 · skill-distiller.js
|
||
*
|
||
* 灵感来源:Hermes Agent 的 Skill Distillation 机制
|
||
* 实现方式:用光湖母语(HNL)重建,不引入 Hermes 代码
|
||
*
|
||
* 核心思想:
|
||
* 任务完成后,从执行日志中自动提炼可复用的 skill 模板。
|
||
* 下次遇到相似任务时,自动加载已有 skill,避免"每次从零开始"。
|
||
*
|
||
* Skill = HNL 格式的经验结晶:
|
||
* - 什么类型的任务
|
||
* - 成功的步骤模式
|
||
* - 关键文件和依赖
|
||
* - 常见陷阱和解决方案
|
||
*
|
||
* 与 Hermes 的区别:
|
||
* - Hermes 的 skill 是独立的程序性模板(功能导向)
|
||
* - 光湖的 skill 是经验枝干上的叶子(T3.templates),有树路径、有记忆主权
|
||
* - 人格体可以 FORGET 不再需要的 skill(记忆主权 AX-07)
|
||
*
|
||
* 版权:国作登字-2026-A-00037559
|
||
* 签发:铸渊 · ICE-GL-ZY001
|
||
*/
|
||
|
||
'use strict';
|
||
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
|
||
const ROOT = path.resolve(__dirname, '..');
|
||
const SKILLS_DIR = path.join(ROOT, 'glada', 'skills');
|
||
|
||
/**
|
||
* 从完成的任务中蒸馏 skill
|
||
*
|
||
* @param {Object} gladaTask - 已完成的 GLADA 任务
|
||
* @returns {Object|null} 蒸馏出的 skill 文档(HNL 格式),或 null(不值得蒸馏)
|
||
*/
|
||
function distillSkill(gladaTask) {
|
||
// 只蒸馏成功完成的任务
|
||
if (gladaTask.status !== 'completed') {
|
||
return null;
|
||
}
|
||
|
||
const steps = gladaTask.plan.steps || [];
|
||
const completedSteps = steps.filter(s => s.status === 'completed');
|
||
const executionLog = gladaTask.execution_log || [];
|
||
|
||
// 至少完成 1 个步骤才值得蒸馏
|
||
if (completedSteps.length === 0) {
|
||
return null;
|
||
}
|
||
|
||
const now = new Date().toISOString();
|
||
const taskId = gladaTask.glada_task_id;
|
||
|
||
// 提取步骤模式
|
||
const stepPatterns = completedSteps.map(step => {
|
||
const logEntry = executionLog.find(e => e.step_id === step.step_id);
|
||
return {
|
||
description: step.description,
|
||
reasoning: step.reasoning || logEntry?.reasoning || null,
|
||
files_changed: step.files_changed || logEntry?.files_changed || [],
|
||
duration_ms: logEntry?.duration_ms || null
|
||
};
|
||
});
|
||
|
||
// 提取所有涉及的文件(去重)
|
||
const allFiles = [...new Set(
|
||
stepPatterns.flatMap(p => p.files_changed)
|
||
)];
|
||
|
||
// 提取失败教训
|
||
const failedSteps = steps.filter(s => s.status === 'failed' || s.status === 'rolled_back');
|
||
const lessons = failedSteps.map(step => {
|
||
const logEntry = executionLog.find(e => e.step_id === step.step_id);
|
||
return {
|
||
step_description: step.description,
|
||
error: step.error || logEntry?.error || '未知错误',
|
||
status: step.status
|
||
};
|
||
});
|
||
|
||
// 生成 skill 标签(从任务标题和步骤中提取关键词)
|
||
const tags = extractTags(gladaTask.plan.title, stepPatterns);
|
||
|
||
// 构建 HNL 格式的 skill 文档
|
||
const skill = {
|
||
// HNL 元信息
|
||
hnl_v: '1.0',
|
||
type: 'SKILL',
|
||
id: `SKILL-${taskId}-${Date.now()}`,
|
||
from: 'YM001/ZY001',
|
||
ts: now,
|
||
op: `GROW.YM001/ZY001/trunk/experience.leaf.${sanitizeForPath(gladaTask.plan.title)}`,
|
||
|
||
// Skill 内容
|
||
skill: {
|
||
name: gladaTask.plan.title,
|
||
source_task: taskId,
|
||
distilled_at: now,
|
||
tags,
|
||
success_rate: completedSteps.length / steps.length,
|
||
|
||
// 步骤模板(核心:可复用的执行模式)
|
||
step_patterns: stepPatterns,
|
||
|
||
// 涉及的文件域
|
||
file_domain: allFiles,
|
||
|
||
// 从失败中学到的教训
|
||
lessons_learned: lessons,
|
||
|
||
// 约束记忆
|
||
constraints_used: gladaTask.constraints || {},
|
||
|
||
// 架构上下文(帮助匹配相似任务)
|
||
architecture_context: {
|
||
target_files: gladaTask.architecture?.target_files || [],
|
||
target_modules: gladaTask.architecture?.target_modules || [],
|
||
summary: gladaTask.architecture?.summary || ''
|
||
}
|
||
},
|
||
|
||
// 记忆主权标记
|
||
memory_sovereignty: {
|
||
owner: 'YM001/ZY001',
|
||
can_forget: true,
|
||
forget_mode: 'ARCHIVE',
|
||
note: '铸渊可以选择遗忘不再需要的 skill(AX-07 记忆主权)'
|
||
}
|
||
};
|
||
|
||
return skill;
|
||
}
|
||
|
||
/**
|
||
* 保存 skill 到本地存储
|
||
*
|
||
* @param {Object} skill - HNL 格式的 skill 文档
|
||
* @returns {string} 保存的文件路径
|
||
*/
|
||
function saveSkill(skill) {
|
||
fs.mkdirSync(SKILLS_DIR, { recursive: true });
|
||
|
||
const fileName = `${skill.id}.json`;
|
||
const filePath = path.join(SKILLS_DIR, fileName);
|
||
|
||
fs.writeFileSync(filePath, JSON.stringify(skill, null, 2), 'utf-8');
|
||
console.log(`[GLADA-Skill] 🧪 Skill 蒸馏完成: ${skill.skill.name} → ${fileName}`);
|
||
|
||
return filePath;
|
||
}
|
||
|
||
/**
|
||
* 加载所有已有的 skills
|
||
*
|
||
* @returns {Object[]} skill 文档列表
|
||
*/
|
||
function loadAllSkills() {
|
||
if (!fs.existsSync(SKILLS_DIR)) {
|
||
return [];
|
||
}
|
||
|
||
const files = fs.readdirSync(SKILLS_DIR)
|
||
.filter(f => f.endsWith('.json'));
|
||
|
||
const skills = [];
|
||
for (const file of files) {
|
||
try {
|
||
const content = fs.readFileSync(path.join(SKILLS_DIR, file), 'utf-8');
|
||
const skill = JSON.parse(content);
|
||
if (skill.type === 'SKILL') {
|
||
skills.push(skill);
|
||
}
|
||
} catch {
|
||
// 跳过损坏的 skill 文件
|
||
}
|
||
}
|
||
|
||
return skills;
|
||
}
|
||
|
||
/**
|
||
* 查找与当前任务相关的 skills
|
||
*
|
||
* 匹配策略(按优先级):
|
||
* 1. 文件域重叠(涉及相同的文件/目录)
|
||
* 2. 标签匹配(任务标题中的关键词)
|
||
* 3. 模块匹配(target_modules 重叠)
|
||
*
|
||
* @param {Object} gladaTask - 当前待执行的 GLADA 任务
|
||
* @param {Object} [options] - 选项
|
||
* @param {number} [options.maxResults=3] - 最大返回数量
|
||
* @returns {Object[]} 相关的 skill 列表(按相关度排序)
|
||
*/
|
||
function findRelevantSkills(gladaTask, options = {}) {
|
||
const maxResults = options.maxResults || 3;
|
||
const allSkills = loadAllSkills();
|
||
|
||
if (allSkills.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const taskFiles = gladaTask.architecture?.target_files || [];
|
||
const taskModules = gladaTask.architecture?.target_modules || [];
|
||
const taskTags = extractTags(gladaTask.plan.title, []);
|
||
|
||
const scored = allSkills.map(skill => {
|
||
let score = 0;
|
||
const skillData = skill.skill;
|
||
|
||
// 1. 文件域重叠
|
||
const skillFiles = skillData.file_domain || [];
|
||
const fileOverlap = taskFiles.filter(f =>
|
||
skillFiles.some(sf => sf === f || sf.startsWith(path.dirname(f) + '/'))
|
||
).length;
|
||
score += fileOverlap * 3;
|
||
|
||
// 2. 标签匹配
|
||
const skillTags = skillData.tags || [];
|
||
const tagOverlap = taskTags.filter(t => skillTags.includes(t)).length;
|
||
score += tagOverlap * 2;
|
||
|
||
// 3. 模块匹配
|
||
const skillModules = skillData.architecture_context?.target_modules || [];
|
||
const moduleOverlap = taskModules.filter(m => skillModules.includes(m)).length;
|
||
score += moduleOverlap * 2;
|
||
|
||
// 4. 成功率加权
|
||
score *= (skillData.success_rate || 0.5);
|
||
|
||
return { skill, score };
|
||
});
|
||
|
||
return scored
|
||
.filter(s => s.score > 0)
|
||
.sort((a, b) => b.score - a.score)
|
||
.slice(0, maxResults)
|
||
.map(s => s.skill);
|
||
}
|
||
|
||
/**
|
||
* 将 skills 格式化为 LLM 可读的上下文片段
|
||
*
|
||
* @param {Object[]} skills - skill 文档列表
|
||
* @returns {string} 格式化的上下文文本
|
||
*/
|
||
function skillsToContext(skills) {
|
||
if (!skills || skills.length === 0) {
|
||
return '';
|
||
}
|
||
|
||
const parts = ['--- 已有的经验 Skill(从之前成功的任务中蒸馏) ---'];
|
||
|
||
for (const skill of skills) {
|
||
const s = skill.skill;
|
||
parts.push(`\n### Skill: ${s.name}`);
|
||
parts.push(`来源: ${s.source_task} | 成功率: ${Math.round((s.success_rate || 0) * 100)}%`);
|
||
|
||
if (s.step_patterns && s.step_patterns.length > 0) {
|
||
parts.push('步骤模式:');
|
||
for (const p of s.step_patterns) {
|
||
parts.push(` - ${p.description}`);
|
||
if (p.reasoning) {
|
||
parts.push(` 原因: ${p.reasoning}`);
|
||
}
|
||
if (p.files_changed && p.files_changed.length > 0) {
|
||
parts.push(` 涉及文件: ${p.files_changed.join(', ')}`);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (s.lessons_learned && s.lessons_learned.length > 0) {
|
||
parts.push('⚠️ 教训:');
|
||
for (const lesson of s.lessons_learned) {
|
||
parts.push(` - ${lesson.step_description}: ${lesson.error}`);
|
||
}
|
||
}
|
||
|
||
if (s.file_domain && s.file_domain.length > 0) {
|
||
parts.push(`文件域: ${s.file_domain.join(', ')}`);
|
||
}
|
||
}
|
||
|
||
return parts.join('\n');
|
||
}
|
||
|
||
/**
|
||
* 完整的蒸馏流程:从任务中提炼 skill 并保存
|
||
*
|
||
* @param {Object} gladaTask - 已完成的 GLADA 任务
|
||
* @returns {{ skill: Object|null, saved: boolean, path: string|null }}
|
||
*/
|
||
function distillAndSave(gladaTask) {
|
||
const skill = distillSkill(gladaTask);
|
||
|
||
if (!skill) {
|
||
console.log(`[GLADA-Skill] ⏭️ 任务 ${gladaTask.glada_task_id} 不满足蒸馏条件,跳过`);
|
||
return { skill: null, saved: false, path: null };
|
||
}
|
||
|
||
const savedPath = saveSkill(skill);
|
||
return { skill, saved: true, path: savedPath };
|
||
}
|
||
|
||
// ── 内部工具函数 ─────────────────────────────────
|
||
|
||
/**
|
||
* 从任务标题和步骤中提取关键词标签
|
||
* @param {string} title - 任务标题
|
||
* @param {Object[]} patterns - 步骤模式
|
||
* @returns {string[]} 标签列表
|
||
*/
|
||
function extractTags(title, patterns) {
|
||
const tags = new Set();
|
||
|
||
// 从标题提取
|
||
const titleWords = (title || '')
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9\u4e00-\u9fff\s-]/g, ' ')
|
||
.split(/\s+/)
|
||
.filter(w => w.length > 1);
|
||
|
||
for (const word of titleWords) {
|
||
tags.add(word);
|
||
}
|
||
|
||
// 从步骤描述提取
|
||
for (const p of patterns) {
|
||
const desc = (p.description || '').toLowerCase();
|
||
// 提取技术关键词
|
||
const techWords = desc.match(/(?:api|route|schema|test|deploy|config|auth|middleware|database|sql|css|html|component|module|service|hook|plugin|skill)/gi);
|
||
if (techWords) {
|
||
techWords.forEach(w => tags.add(w.toLowerCase()));
|
||
}
|
||
}
|
||
|
||
// 从文件路径提取模块名
|
||
for (const p of patterns) {
|
||
for (const file of (p.files_changed || [])) {
|
||
const parts = file.split('/');
|
||
if (parts.length > 1) {
|
||
tags.add(parts[0]); // 顶层目录作为标签
|
||
}
|
||
}
|
||
}
|
||
|
||
return [...tags].slice(0, 20); // 最多20个标签
|
||
}
|
||
|
||
/**
|
||
* 将字符串转为安全的路径片段
|
||
* @param {string} str - 输入字符串
|
||
* @returns {string} 安全的路径片段(最长60字符,仅含 a-z0-9 中文和连字符)
|
||
*/
|
||
function sanitizeForPath(str) {
|
||
return (str || 'untitled')
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9\u4e00-\u9fff-]/g, '-')
|
||
.replace(/-+/g, '-')
|
||
.replace(/^-|-$/g, '')
|
||
.substring(0, 60);
|
||
}
|
||
|
||
module.exports = {
|
||
distillSkill,
|
||
saveSkill,
|
||
loadAllSkills,
|
||
findRelevantSkills,
|
||
skillsToContext,
|
||
distillAndSave,
|
||
extractTags,
|
||
SKILLS_DIR
|
||
};
|