fable-agent/src/pai/skill-sync.ts

186 lines
6.4 KiB
TypeScript

import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import type { SkillDefinition, SkillStep } from "../core/types.js";
import { StateStore } from "../core/state-store.js";
import { stripHiddenUnicode } from "../core/unicode-safety.js";
const PAI_SKILLS_DIR = path.join(os.homedir(), ".claude", "skills");
export interface PaiSkillManifest {
name: string;
description: string;
steps: string[];
tags: string[];
}
export class SkillSync {
private paiSkillsDir: string;
constructor(paiSkillsDir?: string) {
this.paiSkillsDir = paiSkillsDir ?? PAI_SKILLS_DIR;
}
findAllPaiSkills(): PaiSkillManifest[] {
if (!fs.existsSync(this.paiSkillsDir)) return [];
const skills: PaiSkillManifest[] = [];
const entries = fs.readdirSync(this.paiSkillsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const skillMd = path.join(this.paiSkillsDir, entry.name, "SKILL.md");
if (!fs.existsSync(skillMd)) continue;
const content = stripHiddenUnicode(fs.readFileSync(skillMd, "utf-8"));
const description = this.extractDescription(content);
const steps = this.extractSteps(content);
const tags = this.extractTags(content);
skills.push({ name: entry.name, description, steps, tags });
}
return skills;
}
toSkillDefinitions(skills: PaiSkillManifest[]): SkillDefinition[] {
return skills.map((s, i) => ({
id: `pai-${s.name.toLowerCase().replace(/[^a-z0-9]/g, "-")}`,
name: s.name,
description: s.description,
version: "1.0.0",
tags: [...new Set(["pai-imported", ...s.tags])],
contextTemplate: {},
steps: this.buildSteps(s),
metrics: {
totalExecutions: 0,
avgDurationMs: 0,
avgQualityScore: 0,
successRate: 1.0,
lastExecuted: null,
evolutionCount: 0,
},
history: [],
dependencies: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}));
}
private buildSteps(skill: PaiSkillManifest): SkillStep[] {
if (skill.steps.length > 0) {
return skill.steps.map((label) => ({
id: StateStore.uid(),
label,
instruction: `Execute step: ${label}`,
expectedOutcome: `Completed: ${label}`,
validationCriteria: [],
}));
}
return [
{ id: StateStore.uid(), label: "Analyze", instruction: "Analyze the task using this PAI skill", expectedOutcome: "Analysis complete", validationCriteria: [] },
{ id: StateStore.uid(), label: "Execute", instruction: `Execute: ${skill.description}`, expectedOutcome: "Execution complete", validationCriteria: [] },
{ id: StateStore.uid(), label: "Verify", instruction: "Verify results against expected outcomes", expectedOutcome: "Verification complete", validationCriteria: [] },
];
}
private extractDescription(content: string): string {
const descMatch = content.match(/description:\s*"([^"]+)"/);
if (descMatch) return descMatch[1];
const lines = content.split("\n").slice(0, 10);
return lines.find((l) => l.length > 20 && !l.startsWith("#"))?.trim() ?? "PAI skill";
}
private extractSteps(content: string): string[] {
const steps: string[] = [];
const stepHeadings = content.match(/### .+Step[^:]*:/gi) ?? [];
const numberedSteps = content.match(/^\d+\.\s+(.+)$/gm) ?? [];
for (const h of stepHeadings) {
steps.push(h.replace(/### /, "").trim());
}
for (const ns of numberedSteps) {
steps.push(ns.replace(/^\d+\.\s+/, "").trim());
}
return steps;
}
/**
* Compound a lesson into a PAI skill's SKILL.md.
* Appends a "Lessons Learned" section entry so the skill sharpens over time.
* This implements the "write the lesson into the Skill" pattern from the Fable 5 playbook.
*
* @returns the skill file path that was updated, or null if skill not found
*/
compoundLesson(skillName: string, lesson: string, source: string): string | null {
const skillPath = path.join(this.paiSkillsDir, skillName, "SKILL.md");
if (!fs.existsSync(skillPath)) return null;
let content = stripHiddenUnicode(fs.readFileSync(skillPath, "utf-8"));
// Find or create a Lessons Learned section
const lessonsHeader = "## Lessons Learned";
const entry = `- ${new Date().toISOString().slice(0, 10)}: ${lesson} (from ${source})`;
if (content.includes(lessonsHeader)) {
// Append after the Lessons Learned header
const idx = content.indexOf(lessonsHeader);
const afterHeader = content.indexOf("\n", idx);
content = content.slice(0, afterHeader + 1) + "\n" + entry + content.slice(afterHeader + 1);
} else {
// Append before EOF — create a new Lessons Learned section
const trimmed = content.trimEnd();
content = trimmed + "\n\n" + lessonsHeader + "\n\n" + entry + "\n";
}
fs.writeFileSync(skillPath, content, "utf-8");
return skillPath;
}
/**
* Find the most relevant PAI skill for a given task or failure context.
* Scores skills by tag overlap with the context.
*/
findRelevantSkill(context: string): { name: string; score: number } | null {
const skills = this.findAllPaiSkills();
if (skills.length === 0) return null;
const ctxLower = context.toLowerCase();
let best: { name: string; score: number } | null = null;
for (const skill of skills) {
let score = 0;
for (const tag of skill.tags) {
if (ctxLower.includes(tag.toLowerCase())) score += 3;
}
for (const step of skill.steps) {
if (ctxLower.includes(step.toLowerCase())) score += 1;
}
if (ctxLower.includes(skill.name.toLowerCase())) score += 5;
if (ctxLower.includes(skill.description.toLowerCase().slice(0, 20))) score += 2;
if (!best || score > best.score) {
best = { name: skill.name, score };
}
}
return best && best.score > 0 ? best : null;
}
private extractTags(content: string): string[] {
const tags: string[] = [];
const tagMatch = content.match(/tags:\s*\[([^\]]+)\]/);
if (tagMatch) {
tags.push(...tagMatch[1].split(",").map((t) => t.trim().replace(/"/g, "")));
}
const keywordMatch = content.match(/(?:USE WHEN|Keywords?):\s*(.+)/i);
if (keywordMatch) {
tags.push(...keywordMatch[1].split(",").map((t) => t.trim().toLowerCase().replace(/\s+/g, "-")));
}
return tags;
}
}