UNPKG

comfyui-mcp

Version:

Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),

186 lines • 6.91 kB
import { createHash } from "node:crypto"; import { mkdir, readFile, rename, writeFile } from "node:fs/promises"; import { homedir } from "node:os"; import { normalizeInstallPathEnv } from "../utils/install-path-env.js"; import { join, resolve } from "node:path"; import { generateSkill } from "./skill-generator.js"; import { getNodePackDetails } from "./registry-client.js"; import { logger } from "../utils/logger.js"; const DEFAULT_CACHE_DIR = join(homedir(), ".comfyui-mcp", "skill-cache"); const HASH_CHARS = 12; const defaultDeps = { generate: generateSkill, getDetails: getNodePackDetails, now: () => new Date(), }; function cacheDir() { // #1526 — same treatment as the download cache root, and found the same way: // review pointed out that calling that one "the last raw path env var" was // false while this sat two files away. A trailing space here sends cached // skills to a directory that is not the configured one, silently. const configured = normalizeInstallPathEnv(process.env.COMFYUI_SKILL_CACHE_DIR, { varName: "COMFYUI_SKILL_CACHE_DIR", }).path; return resolve(configured || DEFAULT_CACHE_DIR); } function shortHash(value) { return createHash("sha256").update(value).digest("hex").slice(0, HASH_CHARS); } function contentHash(value) { return createHash("sha256").update(value).digest("hex"); } export function normalizeSkillSource(source) { const trimmed = source.trim(); try { const url = new URL(trimmed); url.hash = ""; url.search = ""; url.hostname = url.hostname.toLowerCase(); url.pathname = url.pathname.replace(/\/+$/, "").replace(/\.git$/, ""); return url.toString().replace(/\/$/, ""); } catch { return trimmed.replace(/\/+$/, ""); } } function safeSegment(value) { const segment = value .toLowerCase() .replace(/[^a-z0-9._-]+/g, "-") .replace(/^-+|-+$/g, "") .slice(0, 64); return segment || "source"; } export function buildSkillCacheKey(source, version) { const normalized = normalizeSkillSource(source); return `${safeSegment(normalized)}-${safeSegment(version)}-${shortHash(`${normalized}\0${version}`)}`; } async function resolveVersion(source, deps) { const normalized = normalizeSkillSource(source); if (!normalized.includes("github.com")) { try { const details = await deps.getDetails(normalized); // The registry's version fields can be objects, not strings; only accept // an actual string so safeSegment()'s .toLowerCase() never sees an object // (the "value.toLowerCase is not a function" crash on a bare registry id). const candidate = details.latest_version ?? details.versions?.[0]?.version; if (typeof candidate === "string" && candidate.trim()) { return candidate; } } catch (err) { logger.warn("Could not resolve node pack version for skill cache; using source hash", { source, error: err instanceof Error ? err.message : String(err), }); } } return `source-${shortHash(normalized)}`; } function parseMetadata(raw) { try { const data = JSON.parse(raw); if (typeof data.source === "string" && typeof data.version === "string" && typeof data.cachedAt === "string" && typeof data.contentHash === "string") { return { source: data.source, version: data.version, cachedAt: data.cachedAt, contentHash: data.contentHash, }; } } catch { // Ignore corrupt cache entries. } return undefined; } function isNotFoundError(err) { return (typeof err === "object" && err !== null && "code" in err && err.code === "ENOENT"); } async function readCachedSkill(source, version, dir, safeKey) { const normalized = normalizeSkillSource(source); const entryDir = join(dir, safeKey); let metadataRaw; try { metadataRaw = await readFile(join(entryDir, "metadata.json"), "utf-8"); } catch (err) { if (isNotFoundError(err)) return undefined; throw err; } const metadata = parseMetadata(metadataRaw); if (!metadata || normalizeSkillSource(metadata.source) !== normalized || metadata.version !== version) { return undefined; } let markdown; try { markdown = await readFile(join(entryDir, "SKILL.md"), "utf-8"); } catch (err) { if (isNotFoundError(err)) return undefined; throw err; } if (contentHash(markdown) !== metadata.contentHash) { return undefined; } return { markdown, cacheHit: true, cacheDir: dir, safeKey, metadata }; } async function writeCachedSkill(dir, safeKey, markdown, metadata) { const entryDir = join(dir, safeKey); const suffix = `.tmp-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`; const skillPath = join(entryDir, "SKILL.md"); const metadataPath = join(entryDir, "metadata.json"); const tempSkillPath = join(entryDir, `SKILL.md${suffix}`); const tempMetadataPath = join(entryDir, `metadata.json${suffix}`); await mkdir(entryDir, { recursive: true }); await writeFile(tempSkillPath, markdown, "utf-8"); await writeFile(tempMetadataPath, JSON.stringify(metadata, null, 2), "utf-8"); await rename(tempSkillPath, skillPath); await rename(tempMetadataPath, metadataPath); } export async function generateSkillCached(source, options = {}, deps = defaultDeps) { const dir = cacheDir(); const version = await resolveVersion(source, deps); const safeKey = buildSkillCacheKey(source, version); if (!options.refresh) { try { const cached = await readCachedSkill(source, version, dir, safeKey); if (cached) return cached; } catch (err) { logger.warn("Skill cache read failed; generating without cache hit", { source, error: err instanceof Error ? err.message : String(err), }); } } const markdown = await deps.generate(source); const metadata = { source, version, cachedAt: deps.now().toISOString(), contentHash: contentHash(markdown), }; try { await writeCachedSkill(dir, safeKey, markdown, metadata); } catch (err) { logger.warn("Skill cache write failed; returning generated skill without caching", { source, error: err instanceof Error ? err.message : String(err), }); } return { markdown, cacheHit: false, cacheDir: dir, safeKey, metadata }; } //# sourceMappingURL=skill-cache.js.map