paperclip/packages/adapters/hermes/src/server/skills.ts

271 lines
9.1 KiB
TypeScript

import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type {
AdapterSkillContext,
AdapterSkillEntry,
AdapterSkillSnapshot,
} from "@paperclipai/adapter-utils";
import {
ensurePaperclipSkillSymlink,
isPaperclipSkillSourceMissing,
readInstalledSkillTargets,
readPaperclipRuntimeSkillEntries,
resolveLegacyPaperclipDesiredSkillNames,
} from "@paperclipai/adapter-utils/server-utils";
import { fileURLToPath } from "node:url";
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function asString(value: unknown): string | null {
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
}
function resolveHermesHome(config: Record<string, unknown>): string {
const env =
typeof config.env === "object" && config.env !== null && !Array.isArray(config.env)
? (config.env as Record<string, unknown>)
: {};
const configuredHome = asString(env.HOME);
return configuredHome ? path.resolve(configuredHome) : os.homedir();
}
interface SkillFrontmatter {
name?: string;
description?: string;
version?: string;
category?: string;
metadata?: Record<string, unknown>;
}
function parseSkillFrontmatter(content: string): SkillFrontmatter {
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (!match) return {};
const frontmatter: Record<string, unknown> = {};
for (const line of match[1].split("\n")) {
const idx = line.indexOf(":");
if (idx === -1) continue;
const key = line.slice(0, idx).trim();
let val: unknown = line.slice(idx + 1).trim();
// Strip quotes
if (typeof val === "string" && ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'")))) {
val = val.slice(1, -1);
}
frontmatter[key] = val;
}
return frontmatter as SkillFrontmatter;
}
async function scanHermesSkills(
skillsHome: string,
): Promise<AdapterSkillEntry[]> {
const entries: AdapterSkillEntry[] = [];
try {
const categories = await fs.readdir(skillsHome, { withFileTypes: true });
for (const cat of categories) {
if (!cat.isDirectory()) continue;
const catPath = path.join(skillsHome, cat.name);
// Check if the category directory itself has a SKILL.md (top-level skill)
const topLevelSkillMd = path.join(catPath, "SKILL.md");
if (await fs.stat(topLevelSkillMd).catch(() => null)) {
entries.push(await buildSkillEntry(cat.name, topLevelSkillMd, cat.name));
}
// Scan for sub-skills
const items = await fs.readdir(catPath, { withFileTypes: true }).catch(() => []);
for (const item of items) {
if (!item.isDirectory()) continue;
const skillMd = path.join(catPath, item.name, "SKILL.md");
if (await fs.stat(skillMd).catch(() => null)) {
const key = item.name;
entries.push(await buildSkillEntry(key, skillMd, `${cat.name}/${item.name}`));
}
}
}
} catch {
// ~/.hermes/skills/ doesn't exist — no skills available
}
return entries.sort((a, b) => a.key.localeCompare(b.key));
}
async function buildSkillEntry(
key: string,
skillMdPath: string,
categoryPath: string,
): Promise<AdapterSkillEntry> {
let description: string | null = null;
try {
const content = await fs.readFile(skillMdPath, "utf8");
const fm = parseSkillFrontmatter(content);
description = fm.description ?? null;
} catch {
// ignore
}
return {
key,
runtimeName: key,
desired: true, // Hermes loads all available skills
managed: false,
state: "installed",
origin: "user_installed",
originLabel: "Hermes skill",
locationLabel: `~/.hermes/skills/${categoryPath}`,
readOnly: true, // Hermes manages its own skills — Paperclip can't toggle them
sourcePath: skillMdPath,
targetPath: null,
detail: description,
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
async function buildHermesSkillSnapshot(config: Record<string, unknown>): Promise<AdapterSkillSnapshot> {
const home = resolveHermesHome(config);
const hermesSkillsHome = path.join(home, ".hermes", "skills");
// 1. Scan Paperclip-managed skills (bundled with the adapter)
const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkills = resolveLegacyPaperclipDesiredSkillNames(config, paperclipEntries);
const desiredSet = new Set(desiredSkills);
const availableByKey = new Map(paperclipEntries.map((e) => [e.key, e]));
// 2. Scan Hermes's own skills from ~/.hermes/skills/
const hermesSkillEntries = await scanHermesSkills(hermesSkillsHome);
const hermesKeys = new Set(hermesSkillEntries.map((e) => e.key));
// 3. Merge: Paperclip skills first (ephemeral), then Hermes skills
const entries: AdapterSkillEntry[] = [];
const warnings: string[] = [];
// Paperclip-managed skills
for (const entry of paperclipEntries) {
const desired = desiredSet.has(entry.key);
entries.push({
key: entry.key,
runtimeName: entry.runtimeName,
desired,
managed: true,
state: desired ? "configured" : "available",
origin: "company_managed",
originLabel: "Managed by Paperclip",
readOnly: false,
sourcePath: entry.source,
targetPath: null,
detail: desired
? "Will be available on the next run via Hermes skill loading."
: null,
});
}
// Hermes-installed skills (read-only, always loaded)
for (const entry of hermesSkillEntries) {
// Skip if Paperclip already manages a skill with the same key
if (availableByKey.has(entry.key)) continue;
entries.push(entry);
}
// Check for desired skills that don't exist
for (const desiredSkill of desiredSkills) {
if (availableByKey.has(desiredSkill) || hermesKeys.has(desiredSkill)) continue;
warnings.push(
`Desired skill "${desiredSkill}" is not available in Paperclip or Hermes skills.`,
);
entries.push({
key: desiredSkill,
runtimeName: null,
desired: true,
managed: true,
state: "missing",
origin: "external_unknown",
originLabel: "External or unavailable",
readOnly: false,
sourcePath: null,
targetPath: null,
detail:
"Cannot find this skill in Paperclip or ~/.hermes/skills/.",
});
}
return {
adapterType: "hermes_local",
supported: true,
mode: "persistent",
desiredSkills,
entries,
warnings,
};
}
export async function listHermesSkills(
ctx: AdapterSkillContext,
): Promise<AdapterSkillSnapshot> {
return buildHermesSkillSnapshot(ctx.config);
}
export async function reconcileHermesPaperclipSkills(
config: Record<string, unknown>,
requestedDesiredSkills?: string[],
): Promise<string[]> {
const availableEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
const desiredSkills = requestedDesiredSkills
? Array.from(new Set([
...resolveLegacyPaperclipDesiredSkillNames({}, availableEntries),
...requestedDesiredSkills,
]))
: resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
const desiredSet = new Set(desiredSkills);
const skillsHome = path.join(resolveHermesHome(config), ".hermes", "skills");
await fs.mkdir(skillsHome, { recursive: true });
const installed = await readInstalledSkillTargets(skillsHome);
const availableByRuntimeName = new Map(availableEntries.map((entry) => [entry.runtimeName, entry]));
for (const entry of availableEntries) {
if (!desiredSet.has(entry.key) || isPaperclipSkillSourceMissing(entry)) continue;
const target = path.join(skillsHome, entry.runtimeName);
await ensurePaperclipSkillSymlink(entry.source, target);
const linkedSource = await fs.readlink(target).catch(() => null);
const resolvedSource = linkedSource
? path.resolve(path.dirname(target), linkedSource)
: null;
if (resolvedSource !== path.resolve(entry.source)) {
throw new Error(
`Cannot reconcile Hermes skill "${entry.key}" because ${target} is occupied by another installation.`,
);
}
}
for (const [name, installedEntry] of installed.entries()) {
const available = availableByRuntimeName.get(name);
if (!available || desiredSet.has(available.key)) continue;
if (installedEntry.targetPath !== available.source) continue;
await fs.unlink(path.join(skillsHome, name)).catch(() => {});
}
return desiredSkills;
}
export async function syncHermesSkills(
ctx: AdapterSkillContext,
desiredSkills: string[],
): Promise<AdapterSkillSnapshot> {
await reconcileHermesPaperclipSkills(ctx.config, desiredSkills);
return buildHermesSkillSnapshot(ctx.config);
}
export function resolveHermesDesiredSkillNames(
config: Record<string, unknown>,
availableEntries: Array<{ key: string; runtimeName?: string | null }>,
): string[] {
return resolveLegacyPaperclipDesiredSkillNames(config, availableEntries);
}