maestro/src/engine/skills.ts
2026-06-03 05:08:00 +00:00

194 lines
5.6 KiB
TypeScript

import { readFileSync, readdirSync, existsSync, lstatSync } from 'fs';
import { join } from 'path';
import { logger } from '../logger.js';
import matter from 'gray-matter';
export const VALID_SKILL_NAME = /^[a-z0-9_-]+$/;
export interface SkillEntry {
name: string;
description: string;
triggers: string[];
source: 'system' | 'user';
filePath: string;
dirPath: string | null;
}
function parseSkillFile(filePath: string, source: 'system' | 'user', dirPath: string | null = null): SkillEntry | null {
try {
const raw = readFileSync(filePath, 'utf-8');
const { data } = matter(raw);
if (!data || typeof data.name !== 'string' || !data.name) return null;
if (!VALID_SKILL_NAME.test(data.name)) {
logger.warn(`[skill-catalog] skipping skill with invalid name: ${data.name} in ${filePath}`);
return null;
}
return {
name: data.name,
description: typeof data.description === 'string' ? data.description : '',
triggers: Array.isArray(data.triggers) ? data.triggers : [],
source,
filePath,
dirPath,
};
} catch (e) {
logger.warn(`[skill-catalog] failed to parse ${filePath}: ${e}`);
return null;
}
}
function scanDir(dir: string, source: 'system' | 'user'): SkillEntry[] {
if (!existsSync(dir)) return [];
const entries = readdirSync(dir);
const results: SkillEntry[] = [];
const dirNames = new Set<string>();
// Pass 1: directories — look for {dir}/SKILL.md
for (const entry of entries) {
const fullPath = join(dir, entry);
let stat;
try {
stat = lstatSync(fullPath);
} catch {
continue;
}
// Skip symlinks
if (stat.isSymbolicLink()) continue;
if (!stat.isDirectory()) continue;
const skillMdPath = join(fullPath, 'SKILL.md');
if (!existsSync(skillMdPath)) continue;
// Skip if SKILL.md itself is a symlink
try {
if (lstatSync(skillMdPath).isSymbolicLink()) continue;
} catch {
continue;
}
const parsed = parseSkillFile(skillMdPath, source, fullPath);
if (parsed) {
dirNames.add(entry);
results.push(parsed);
}
}
// Pass 2: flat .md files — skip if same-name directory was found in pass 1
for (const entry of entries) {
if (!entry.endsWith('.md')) continue;
const baseName = entry.slice(0, -3);
if (dirNames.has(baseName)) continue;
const fullPath = join(dir, entry);
let stat;
try {
stat = lstatSync(fullPath);
} catch {
continue;
}
// Skip symlinks
if (stat.isSymbolicLink()) continue;
if (!stat.isFile()) continue;
const parsed = parseSkillFile(fullPath, source, null);
if (parsed) {
results.push(parsed);
}
}
return results;
}
export class SkillCatalog {
private systemSkills: SkillEntry[] = [];
private cache = new Map<string, { ts: number; entries: SkillEntry[] }>();
private readonly ttlMs = 60_000;
constructor(
private readonly systemDir: string,
private readonly userRoot: string,
) {
this.systemSkills = scanDir(systemDir, 'system');
if (this.systemSkills.length > 0) {
logger.info(`[skill-catalog] loaded ${this.systemSkills.length} system skills from ${systemDir}`);
}
}
getForUser(userId: string): SkillEntry[] {
const cached = this.cache.get(userId);
if (cached && Date.now() - cached.ts < this.ttlMs) return cached.entries;
const userDir = join(this.userRoot, userId, 'skills');
const userSkills = scanDir(userDir, 'user');
const byName = new Map<string, SkillEntry>(this.systemSkills.map(s => [s.name, s]));
for (const u of userSkills) byName.set(u.name, u);
const entries = Array.from(byName.values());
this.cache.set(userId, { ts: Date.now(), entries });
return entries;
}
getSkillContent(name: string, userId: string): { content: string; dirPath: string | null } | null {
const entries = this.getForUser(userId);
const entry = entries.find(e => e.name === name);
if (!entry) return null;
try {
const raw = readFileSync(entry.filePath, 'utf-8');
const { content } = matter(raw);
return { content: content.trim(), dirPath: entry.dirPath };
} catch {
return null;
}
}
buildIndex(userId: string, maxChars: number = 2000): string {
const entries = this.getForUser(userId);
if (entries.length === 0) return '';
const lines: string[] = [];
let totalLen = 0;
let included = 0;
for (const e of entries) {
const line = `- **${e.name}**: ${e.description}`;
if (totalLen + line.length + 1 > maxChars && included > 0) break;
lines.push(line);
totalLen += line.length + 1;
included++;
}
const remaining = entries.length - included;
if (remaining > 0) {
lines.push(`... and ${remaining} more skills (use ListSkills to see all)`);
}
return lines.join('\n');
}
invalidate(userId: string): void {
this.cache.delete(userId);
this.systemSkills = scanDir(this.systemDir, 'system');
}
getSkillBinds(userId: string): Array<{ src: string; dest: string }> {
const binds: Array<{ src: string; dest: string }> = [];
if (existsSync(this.systemDir)) {
binds.push({ src: this.systemDir, dest: '/skills' });
}
const userDir = join(this.userRoot, userId, 'skills');
if (existsSync(userDir)) {
binds.push({ src: userDir, dest: '/user-skills' });
}
return binds;
}
getSystemDir(): string { return this.systemDir; }
getUserSkillDir(userId: string): string { return join(this.userRoot, userId, 'skills'); }
refreshSystem(): void {
this.systemSkills = scanDir(this.systemDir, 'system');
this.cache.clear();
}
}