194 lines
5.6 KiB
TypeScript
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();
|
|
}
|
|
}
|