Files
homeclaw/src/gateway/skills-manager.ts
T
kimandClaude Sonnet 4.6 70fc406b2a Release 2.9.2: trigger-based skill injection + vision support for all adapters
- skills-manager: conditional skill injection via triggers frontmatter field
  (skills only sent to model when message matches trigger keywords)
- skills: added triggers to all skill SKILL.md files; cleaned up headers/disclaimers
- anthropic-adapter: toAnthropicContent() converts OpenAI image_url → Anthropic image blocks
- multi-agent: vision flag in SecondaryProfile; secondarySupportsVision() checks config
- config: secondary set to mistral-large-3:675b-cloud with vision:true

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-02 14:28:40 +09:00

453 lines
16 KiB
TypeScript

/**
* skills-manager.ts - Skills System for SmallClaw
*
* Reads SKILL.md files from .smallclaw/skills/<name>/SKILL.md
* Parses YAML frontmatter + markdown instructions
* Tracks enabled/disabled state in config
* Injects enabled skills into system prompt
*
* Compatible with OpenClaw SKILL.md format.
*/
import fs from 'fs';
import path from 'path';
// ─── Types ─────────────────────────────────────────────────────────────────────
export interface Skill {
id: string; // folder name = id
name: string; // from frontmatter or id
description: string; // from frontmatter
emoji: string; // from frontmatter or default
version: string; // from frontmatter
model?: string; // optional model override from frontmatter
triggers?: string[]; // if set, only inject when message contains one of these words
enabled: boolean; // from config
instructions: string; // markdown body (everything after frontmatter)
filePath: string; // full path to SKILL.md
createdAt: number; // file creation time
}
export interface SkillFrontmatter {
name?: string;
description?: string;
emoji?: string;
version?: string;
[key: string]: any;
}
// ─── YAML Frontmatter Parser (simple, no deps) ────────────────────────────────
function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; body: string } {
const trimmed = content.trim();
if (!trimmed.startsWith('---')) {
return { frontmatter: {}, body: trimmed };
}
const endIndex = trimmed.indexOf('---', 3);
if (endIndex === -1) {
return { frontmatter: {}, body: trimmed };
}
const yamlBlock = trimmed.slice(3, endIndex).trim();
const body = trimmed.slice(endIndex + 3).trim();
// Simple YAML key: value parser (handles most SKILL.md files)
const frontmatter: SkillFrontmatter = {};
for (const line of yamlBlock.split('\n')) {
const match = line.match(/^(\w[\w-]*)\s*:\s*(.+)$/);
if (match) {
const key = match[1].trim();
let val = match[2].trim();
// Strip surrounding quotes
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
// Parse inline array: [a, b, c]
if (val.startsWith('[') && val.endsWith(']')) {
frontmatter[key] = val.slice(1, -1).split(',').map(s => s.trim()).filter(Boolean);
} else {
frontmatter[key] = val;
}
}
}
return { frontmatter, body };
}
// ─── Skills Manager ────────────────────────────────────────────────────────────
export class SkillsManager {
private skillsDir: string;
private configPath: string;
private skills: Map<string, Skill> = new Map();
private enabledState: Record<string, boolean> = {};
constructor(skillsDir: string, configPath: string) {
this.skillsDir = skillsDir;
this.configPath = configPath;
// Ensure skills directory exists
if (!fs.existsSync(this.skillsDir)) {
fs.mkdirSync(this.skillsDir, { recursive: true });
}
this.loadEnabledState();
this.scanSkills();
}
// Load enabled/disabled state from a simple JSON file
private loadEnabledState() {
const statePath = path.join(path.dirname(this.skillsDir), 'skills_state.json');
try {
if (fs.existsSync(statePath)) {
this.enabledState = JSON.parse(fs.readFileSync(statePath, 'utf-8'));
}
} catch {
this.enabledState = {};
}
}
private saveEnabledState() {
const statePath = path.join(path.dirname(this.skillsDir), 'skills_state.json');
try {
fs.writeFileSync(statePath, JSON.stringify(this.enabledState, null, 2), 'utf-8');
} catch (err) {
console.error('[Skills] Failed to save state:', err);
}
}
// Scan skills directory for SKILL.md files
scanSkills() {
// Refresh persisted enabled-state before rebuilding skill list.
this.loadEnabledState();
this.skills.clear();
if (!fs.existsSync(this.skillsDir)) return;
const entries = fs.readdirSync(this.skillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillDir = path.join(this.skillsDir, entry.name);
const skillMd = path.join(skillDir, 'SKILL.md');
if (!fs.existsSync(skillMd)) continue;
try {
const content = fs.readFileSync(skillMd, 'utf-8');
const { frontmatter, body } = parseFrontmatter(content);
const stat = fs.statSync(skillMd);
const skill: Skill = {
id: entry.name,
name: frontmatter.name || entry.name,
description: frontmatter.description || '',
emoji: frontmatter.emoji || '🧩',
version: frontmatter.version || '1.0.0',
model: frontmatter.model || undefined,
triggers: Array.isArray(frontmatter.triggers) ? frontmatter.triggers : undefined,
enabled: this.enabledState[entry.name] ?? false,
instructions: body,
filePath: skillMd,
createdAt: stat.ctimeMs || Date.now(),
};
this.skills.set(entry.name, skill);
} catch (err) {
console.error(`[Skills] Failed to load ${entry.name}:`, err);
}
}
console.log(`[Skills] Loaded ${this.skills.size} skills (${this.getEnabledSkills().length} enabled)`);
}
// Persist current enabled-state map to disk (best effort).
persistState() {
this.saveEnabledState();
}
// Get all skills
getAll(): Skill[] {
return Array.from(this.skills.values()).sort((a, b) => a.name.localeCompare(b.name));
}
// Get enabled skills only
getEnabledSkills(): Skill[] {
return this.getAll().filter(s => s.enabled);
}
// Get a single skill
get(id: string): Skill | undefined {
return this.skills.get(id);
}
// Toggle skill enabled/disabled
toggle(id: string): Skill | null {
const skill = this.skills.get(id);
if (!skill) return null;
skill.enabled = !skill.enabled;
this.enabledState[id] = skill.enabled;
this.saveEnabledState();
console.log(`[Skills] ${skill.name}: ${skill.enabled ? 'ENABLED' : 'DISABLED'}`);
return skill;
}
// Enable or disable explicitly
setEnabled(id: string, enabled: boolean): Skill | null {
const skill = this.skills.get(id);
if (!skill) return null;
skill.enabled = enabled;
this.enabledState[id] = enabled;
this.saveEnabledState();
console.log(`[Skills] ${skill.name}: ${enabled ? 'ENABLED' : 'DISABLED'}`);
return skill;
}
// ─── Per-User State ──────────────────────────────────────────────────────
private GLOBAL_ONLY_SKILLS = new Set(['multi-agent-orchestrator']);
/** 유저별 skills_state.json 읽기. 없으면 null 반환. */
getUserState(userDir: string): Record<string, boolean> | null {
const p = path.join(userDir, '..', 'skills_state.json');
try {
return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : null;
} catch {
return null;
}
}
/** 유저별 skills_state.json이 없으면 글로벌 defaults로 초기화. 이미 있으면 no-op. */
initUserSkillsState(userDir: string): void {
const p = path.join(userDir, '..', 'skills_state.json');
if (fs.existsSync(p)) return;
const state = Object.fromEntries(
Object.entries(this.enabledState).filter(([k]) => !this.GLOBAL_ONLY_SKILLS.has(k)),
);
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(state, null, 2), 'utf-8');
console.log(`[Skills] Initialized per-user state for ${path.basename(path.dirname(p))}`);
} catch (err) {
console.error('[Skills] Failed to init user state:', err);
}
}
/** 유저별 스킬 상태 저장. 첫 저장 시 글로벌 state를 baseline으로 복사. */
setUserSkillEnabled(userDir: string, skillId: string, enabled: boolean): Skill | null {
if (this.GLOBAL_ONLY_SKILLS.has(skillId)) return null;
const skill = this.skills.get(skillId);
if (!skill) return null;
const p = path.join(userDir, '..', 'skills_state.json');
const state = this.getUserState(userDir) ?? Object.fromEntries(
Object.entries(this.enabledState).filter(([k]) => !this.GLOBAL_ONLY_SKILLS.has(k)),
);
state[skillId] = enabled;
try {
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, JSON.stringify(state, null, 2), 'utf-8');
} catch (err) {
console.error('[Skills] Failed to save user state:', err);
}
return { ...skill, enabled };
}
/** 유저 state 기반 prompt context 빌드. userDir 없으면 글로벌 fallback. */
buildPromptContextForUser(userDir: string | null, maxCharsPerSkill = 300, userMessage = ''): string {
if (!userDir) return this.buildPromptContext(maxCharsPerSkill, userMessage);
const userState = this.getUserState(userDir);
const msgLower = userMessage.toLowerCase();
const enabled = this.getAll().filter(s => {
if (this.GLOBAL_ONLY_SKILLS.has(s.id)) return s.enabled;
const isEnabled = userState !== null && s.id in userState ? userState[s.id] : s.enabled;
if (!isEnabled) return false;
// Trigger filtering: if skill has triggers, only inject when message matches
if (s.triggers && s.triggers.length > 0) {
return s.triggers.some(t => msgLower.includes(t.toLowerCase()));
}
return true;
});
if (enabled.length === 0) return '';
const parts: string[] = ['[ACTIVE SKILLS]'];
for (const skill of enabled) {
const dir = path.resolve(this.skillsDir, skill.id);
let instr = skill.instructions
.replace(/<skill_dir_slash>/g, dir.replace(/\\/g, '/'))
.replace(/<skill_dir_posix>/g, dir.replace(/\\/g, '/'))
.replace(/<skill_dir>/g, dir);
if (instr.length > maxCharsPerSkill) instr = instr.slice(0, maxCharsPerSkill) + '...';
parts.push(`\n## ${skill.emoji} ${skill.name}\n${instr}`);
}
return parts.join('\n');
}
/** Return the model override from the first enabled skill that specifies one, or undefined. */
getModelOverrideForUser(userDir: string | null): string | undefined {
const userState = userDir ? this.getUserState(userDir) : null;
const enabled = this.getAll().filter(s => {
if (this.GLOBAL_ONLY_SKILLS.has(s.id)) return s.enabled;
if (userState !== null && s.id in userState) return userState[s.id];
return s.enabled;
});
for (const skill of enabled) {
if (skill.model) return skill.model;
}
return undefined;
}
// Create a new skill from user input
create(data: {
id: string;
name: string;
description: string;
emoji?: string;
instructions: string;
}): Skill {
// Sanitize id: lowercase, alphanumeric + hyphens only
const id = data.id
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
if (!id) throw new Error('Invalid skill ID');
const skillDir = path.join(this.skillsDir, id);
fs.mkdirSync(skillDir, { recursive: true });
// Build SKILL.md content
const frontmatterLines = [
'---',
`name: ${data.name}`,
`description: ${data.description}`,
];
if (data.emoji) frontmatterLines.push(`emoji: "${data.emoji}"`);
frontmatterLines.push(`version: 1.0.0`);
frontmatterLines.push('---');
const content = frontmatterLines.join('\n') + '\n\n' + data.instructions;
const skillMdPath = path.join(skillDir, 'SKILL.md');
fs.writeFileSync(skillMdPath, content, 'utf-8');
// Auto-enable new skills
this.enabledState[id] = true;
this.saveEnabledState();
// Insert directly — no need to rescan all skills from disk
const stat = fs.statSync(skillMdPath);
const skill: Skill = {
id,
name: data.name,
description: data.description || '',
emoji: data.emoji || '🧩',
version: '1.0.0',
enabled: true,
instructions: data.instructions,
filePath: skillMdPath,
createdAt: stat.ctimeMs || Date.now(),
};
this.skills.set(id, skill);
console.log(`[Skills] Created: ${data.name} (${id})`);
return skill;
}
// Delete a skill
delete(id: string): boolean {
const skill = this.skills.get(id);
if (!skill) return false;
try {
const skillDir = path.join(this.skillsDir, id);
fs.rmSync(skillDir, { recursive: true, force: true });
this.skills.delete(id);
delete this.enabledState[id];
this.saveEnabledState();
console.log(`[Skills] Deleted: ${id}`);
return true;
} catch (err) {
console.error(`[Skills] Failed to delete ${id}:`, err);
return false;
}
}
// Update a skill's instructions
update(id: string, data: {
name?: string;
description?: string;
emoji?: string;
instructions?: string;
}): Skill | null {
const skill = this.skills.get(id);
if (!skill) return null;
// Read existing file, update frontmatter and body
const content = fs.readFileSync(skill.filePath, 'utf-8');
const { frontmatter, body } = parseFrontmatter(content);
if (data.name) frontmatter.name = data.name;
if (data.description) frontmatter.description = data.description;
if (data.emoji) frontmatter.emoji = data.emoji;
const newBody = data.instructions ?? body;
const frontmatterLines = ['---'];
for (const [key, val] of Object.entries(frontmatter)) {
if (val !== undefined && val !== null) {
frontmatterLines.push(`${key}: ${String(val)}`);
}
}
frontmatterLines.push('---');
const newContent = frontmatterLines.join('\n') + '\n\n' + newBody;
fs.writeFileSync(skill.filePath, newContent, 'utf-8');
// Update in-memory skill directly — no need to rescan all skills from disk
if (data.name !== undefined) skill.name = data.name;
if (data.description !== undefined) skill.description = data.description;
if (data.emoji !== undefined) skill.emoji = data.emoji;
if (data.instructions !== undefined) skill.instructions = newBody;
return skill;
}
/**
* Build the skills context string for the system prompt.
* Only includes enabled skills. Keeps it compact for 4B context.
*/
buildPromptContext(maxCharsPerSkill: number = 300, userMessage = ''): string {
const msgLower = userMessage.toLowerCase();
const enabled = this.getEnabledSkills().filter(s => {
if (s.triggers && s.triggers.length > 0) {
return s.triggers.some(t => msgLower.includes(t.toLowerCase()));
}
return true;
});
if (enabled.length === 0) return '';
const parts: string[] = ['[ACTIVE SKILLS]'];
for (const skill of enabled) {
// Resolve <skill_dir> and <skill_dir_slash> to actual path
const skillDir = path.resolve(this.skillsDir, skill.id);
let instructions = skill.instructions
.replace(/<skill_dir_slash>/g, skillDir.replace(/\\/g, '/'))
.replace(/<skill_dir_posix>/g, skillDir.replace(/\\/g, '/'))
.replace(/<skill_dir>/g, skillDir);
// Trim instructions to fit context budget
instructions = instructions.length > maxCharsPerSkill
? instructions.slice(0, maxCharsPerSkill) + '...'
: instructions;
parts.push(`\n## ${skill.emoji} ${skill.name}\n${instructions}`);
}
return parts.join('\n');
}
}