Files
nanobot-ts/src/agent/skills.ts

195 lines
6.1 KiB
TypeScript

import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
const BUILTIN_SKILLS_DIR = join(import.meta.dir, '..', '..', 'skills');
interface SkillEntry {
name: string;
path: string;
source: 'workspace' | 'builtin';
}
interface SkillMeta {
description?: string;
always?: boolean;
metadata?: string; // JSON string with nanobot-specific config
}
interface NanobotMeta {
always?: boolean;
description?: string;
requires?: {
bins?: string[];
env?: string[];
};
}
export class SkillsLoader {
private _workspace: string;
private _workspaceSkills: string;
private _builtinSkills: string;
constructor(workspace: string, builtinSkillsDir?: string) {
this._workspace = workspace;
this._workspaceSkills = join(workspace, 'skills');
this._builtinSkills = builtinSkillsDir ?? BUILTIN_SKILLS_DIR;
}
listSkills(filterUnavailable = true): SkillEntry[] {
const skills: SkillEntry[] = [];
// Workspace skills take priority
if (existsSync(this._workspaceSkills)) {
for (const name of readdirSync(this._workspaceSkills)) {
const skillFile = join(this._workspaceSkills, name, 'SKILL.md');
if (existsSync(skillFile)) {
skills.push({ name, path: skillFile, source: 'workspace' });
}
}
}
// Builtin skills — skip if workspace already has one with the same name
if (existsSync(this._builtinSkills)) {
for (const name of readdirSync(this._builtinSkills)) {
const skillFile = join(this._builtinSkills, name, 'SKILL.md');
if (existsSync(skillFile) && !skills.some((s) => s.name === name)) {
skills.push({ name, path: skillFile, source: 'builtin' });
}
}
}
if (!filterUnavailable) return skills;
return skills.filter((s) => this._isAvailable(this._getNanobotMeta(s.name)));
}
loadSkill(name: string): string | null {
const workspacePath = join(this._workspaceSkills, name, 'SKILL.md');
if (existsSync(workspacePath)) return readFileSync(workspacePath, 'utf8');
const builtinPath = join(this._builtinSkills, name, 'SKILL.md');
if (existsSync(builtinPath)) return readFileSync(builtinPath, 'utf8');
return null;
}
loadSkillsForContext(names: string[]): string {
const parts: string[] = [];
for (const name of names) {
const content = this.loadSkill(name);
if (content) {
parts.push(`### Skill: ${name}\n\n${this._stripFrontmatter(content)}`);
}
}
return parts.join('\n\n---\n\n');
}
buildSkillsSummary(): string {
const all = this.listSkills(false);
if (all.length === 0) return '';
const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
const lines = ['<skills>'];
for (const s of all) {
const meta = this._getNanobotMeta(s.name);
const available = this._isAvailable(meta);
const desc = esc(meta.description ?? s.name);
lines.push(` <skill available="${available}">`);
lines.push(` <name>${esc(s.name)}</name>`);
lines.push(` <description>${desc}</description>`);
lines.push(` <location>${s.path}</location>`);
if (!available) {
const missing = this._getMissing(meta);
if (missing) lines.push(` <requires>${esc(missing)}</requires>`);
}
lines.push(' </skill>');
}
lines.push('</skills>');
return lines.join('\n');
}
getAlwaysSkills(): string[] {
return this.listSkills(true)
.filter((s) => {
const raw = this._getRawMeta(s.name);
const nano = this._getNanobotMeta(s.name);
return nano.always === true || raw?.always === true;
})
.map((s) => s.name);
}
// ---------------------------------------------------------------------------
// Private helpers
// ---------------------------------------------------------------------------
private _stripFrontmatter(content: string): string {
if (!content.startsWith('---')) return content;
const match = /^---\n[\s\S]*?\n---\n/.exec(content);
return match ? content.slice(match[0].length).trimStart() : content;
}
private _getRawMeta(name: string): SkillMeta | null {
const content = this.loadSkill(name);
if (!content?.startsWith('---')) return null;
const match = /^---\n([\s\S]*?)\n---/.exec(content);
if (!match) return null;
const meta: SkillMeta = {};
for (const line of match[1]!.split('\n')) {
const colon = line.indexOf(':');
if (colon < 0) continue;
const key = line.slice(0, colon).trim();
const val = line.slice(colon + 1).trim().replace(/^["']|["']$/g, '');
if (key === 'description') meta.description = val;
if (key === 'always') meta.always = val === 'true';
if (key === 'metadata') meta.metadata = val;
}
return meta;
}
private _getNanobotMeta(name: string): NanobotMeta {
const raw = this._getRawMeta(name);
if (!raw?.metadata) return { description: raw?.description, always: raw?.always };
try {
const parsed = JSON.parse(raw.metadata) as Record<string, unknown>;
const nano = (parsed['nanobot'] ?? parsed['openclaw'] ?? parsed) as NanobotMeta;
return { description: raw.description, always: raw.always, ...nano };
} catch {
return { description: raw.description, always: raw.always };
}
}
private _isAvailable(meta: NanobotMeta): boolean {
const req = meta.requires;
if (!req) return true;
for (const bin of req.bins ?? []) {
if (!this._which(bin)) return false;
}
for (const env of req.env ?? []) {
if (!process.env[env]) return false;
}
return true;
}
private _getMissing(meta: NanobotMeta): string {
const missing: string[] = [];
const req = meta.requires;
if (!req) return '';
for (const bin of req.bins ?? []) {
if (!this._which(bin)) missing.push(`CLI: ${bin}`);
}
for (const env of req.env ?? []) {
if (!process.env[env]) missing.push(`ENV: ${env}`);
}
return missing.join(', ');
}
private _which(bin: string): boolean {
try {
const result = Bun.spawnSync(['which', bin]);
return result.exitCode === 0;
} catch {
return false;
}
}
}