186 lines
6.4 KiB
TypeScript
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;
|
|
}
|
|
}
|