feat: claude one-shot port from nanobot python codebase (v0.1.4.post4)
This commit is contained in:
194
src/agent/skills.ts
Normal file
194
src/agent/skills.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user