- 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>
453 lines
16 KiB
TypeScript
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');
|
|
}
|
|
}
|