A/B test (2026-05-05) showed DeepSeek V4-Pro is 2-3x faster and ~20x cheaper than Sonnet for style/lexicon pattern analysis, with comparable quality. Adds adapters/deepseek-paperclip-adapter/ package, documents adapter requirements (env injection, run-id headers), updates CLAUDE.md with adapter integration notes, and records lessons from ערר 1200-25 (block order for 1xxx, "להלן מתוך" pattern, expanded factual background, bridge planning analysis, flat heading structure). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
172 lines
5.3 KiB
JavaScript
172 lines
5.3 KiB
JavaScript
/**
|
|
* Skill snapshot for the DeepSeek-via-Hermes adapter.
|
|
*
|
|
* Hermes manages its own skills under ~/.hermes/skills/ (global; not per-profile).
|
|
* Paperclip-managed skills declared in adapter config are surfaced as
|
|
* "company_managed" entries — same behavior as the upstream Hermes adapter.
|
|
*/
|
|
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
readPaperclipRuntimeSkillEntries,
|
|
resolvePaperclipDesiredSkillNames,
|
|
} from "@paperclipai/adapter-utils/server-utils";
|
|
import { ADAPTER_TYPE } from "../shared/constants.js";
|
|
|
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
|
|
|
function asString(value) {
|
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
|
}
|
|
|
|
function parseSkillFrontmatter(content) {
|
|
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
if (!match) return {};
|
|
const fm = {};
|
|
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 = line.slice(idx + 1).trim();
|
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
val = val.slice(1, -1);
|
|
}
|
|
fm[key] = val;
|
|
}
|
|
return fm;
|
|
}
|
|
|
|
async function buildSkillEntry(key, skillMdPath, categoryPath) {
|
|
let description = null;
|
|
try {
|
|
const content = await fs.readFile(skillMdPath, "utf8");
|
|
description = parseSkillFrontmatter(content).description ?? null;
|
|
} catch {
|
|
// ignore
|
|
}
|
|
return {
|
|
key,
|
|
runtimeName: key,
|
|
desired: true,
|
|
managed: false,
|
|
state: "installed",
|
|
origin: "user_installed",
|
|
originLabel: "Hermes skill",
|
|
locationLabel: `~/.hermes/skills/${categoryPath}`,
|
|
readOnly: true,
|
|
sourcePath: skillMdPath,
|
|
targetPath: null,
|
|
detail: description,
|
|
};
|
|
}
|
|
|
|
async function scanHermesSkills(skillsHome) {
|
|
const entries = [];
|
|
try {
|
|
const cats = await fs.readdir(skillsHome, { withFileTypes: true });
|
|
for (const cat of cats) {
|
|
if (!cat.isDirectory()) continue;
|
|
const catPath = path.join(skillsHome, cat.name);
|
|
const topSkill = path.join(catPath, "SKILL.md");
|
|
if (await fs.stat(topSkill).catch(() => null)) {
|
|
entries.push(await buildSkillEntry(cat.name, topSkill, cat.name));
|
|
}
|
|
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)) {
|
|
entries.push(await buildSkillEntry(item.name, skillMd, `${cat.name}/${item.name}`));
|
|
}
|
|
}
|
|
}
|
|
} catch {
|
|
// ~/.hermes/skills/ doesn't exist
|
|
}
|
|
return entries.sort((a, b) => a.key.localeCompare(b.key));
|
|
}
|
|
|
|
async function buildSnapshot(config) {
|
|
const homedir =
|
|
asString(config.env?.HOME) ??
|
|
process.env.HOME ??
|
|
"/home/chaim";
|
|
const hermesSkillsHome = path.join(homedir, ".hermes", "skills");
|
|
|
|
const paperclipEntries = await readPaperclipRuntimeSkillEntries(config, __moduleDir);
|
|
const desiredSkills = resolvePaperclipDesiredSkillNames(config, paperclipEntries);
|
|
const desiredSet = new Set(desiredSkills);
|
|
const availableByKey = new Map(paperclipEntries.map((e) => [e.key, e]));
|
|
|
|
const hermesSkillEntries = await scanHermesSkills(hermesSkillsHome);
|
|
const hermesKeys = new Set(hermesSkillEntries.map((e) => e.key));
|
|
|
|
const entries = [];
|
|
const warnings = [];
|
|
|
|
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: entry.required ? "paperclip_required" : "company_managed",
|
|
originLabel: entry.required ? "Required by Paperclip" : "Managed by Paperclip",
|
|
readOnly: false,
|
|
sourcePath: entry.source,
|
|
targetPath: null,
|
|
detail: desired ? "Will be available on the next run via Hermes skill loading." : null,
|
|
required: Boolean(entry.required),
|
|
requiredReason: entry.requiredReason ?? null,
|
|
});
|
|
}
|
|
|
|
for (const entry of hermesSkillEntries) {
|
|
if (availableByKey.has(entry.key)) continue;
|
|
entries.push(entry);
|
|
}
|
|
|
|
for (const desired of desiredSkills) {
|
|
if (availableByKey.has(desired) || hermesKeys.has(desired)) continue;
|
|
warnings.push(`Desired skill "${desired}" is not available in Paperclip or Hermes skills.`);
|
|
entries.push({
|
|
key: desired,
|
|
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: ADAPTER_TYPE,
|
|
supported: true,
|
|
mode: "persistent",
|
|
desiredSkills,
|
|
entries,
|
|
warnings,
|
|
};
|
|
}
|
|
|
|
export async function listSkills(ctx) {
|
|
return buildSnapshot(ctx.config);
|
|
}
|
|
|
|
export async function syncSkills(ctx, _desired) {
|
|
return buildSnapshot(ctx.config);
|
|
}
|
|
|
|
export function resolveDesiredSkillNames(config, availableEntries) {
|
|
return resolvePaperclipDesiredSkillNames(config, availableEntries);
|
|
}
|