src / catalog.ts
src / catalog.ts
import * as yaml from "js-yaml";
import * as path from "path";
import { isValidSkillName } from "./activation";
import { MAX_ROUTABLE, type Skill, type SkillFs, type SkillIndex } from "./types";
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
/** Splits `---` YAML frontmatter from the Markdown body. A leading UTF-8 BOM is ignored. Never throws. */
export function splitFrontmatter(text: string): { data: Record<string, unknown> | null; body: string; error?: string } {
if (text.startsWith("\uFEFF")) text = text.slice(1);
const m = FRONTMATTER.exec(text);
if (!m) return { data: null, body: text };
const body = text.slice(m[0].length).replace(/^\s*\n/, "");
try {
const parsed = yaml.load(m[1]);
if (parsed === null || parsed === undefined) return { data: {}, body };
if (typeof parsed !== "object" || Array.isArray(parsed)) return { data: null, body, error: "frontmatter is not a mapping" };
return { data: parsed as Record<string, unknown>, body };
} catch (e) {
return { data: null, body, error: (e as Error).message };
}
}
export interface Catalog {
load(root: string): Promise<SkillIndex>;
}
interface CacheEntry {
index: SkillIndex;
rootMtime: number;
skillMdMtimes: Map<string, number>;
}
export function createCatalog(fs: SkillFs, log: (msg: string) => void = () => {}): Catalog {
const cache = new Map<string, CacheEntry>();
async function statMtime(p: string): Promise<number | null> {
try {
return (await fs.stat(p)).mtimeMs;
} catch {
return null;
}
}
async function isFresh(entry: CacheEntry, root: string): Promise<boolean> {
if ((await statMtime(root)) !== entry.rootMtime) return false;
for (const [p, mtime] of entry.skillMdMtimes) {
if ((await statMtime(p)) !== mtime) return false;
}
return true;
}
async function scan(root: string): Promise<CacheEntry> {
const empty: SkillIndex = { root, skills: [], byName: new Map(), routable: [] };
const rootMtime = await statMtime(root);
if (rootMtime === null) return { index: empty, rootMtime: -1, skillMdMtimes: new Map() };
let entries;
try {
entries = await fs.readdir(root);
} catch {
return { index: empty, rootMtime, skillMdMtimes: new Map() };
}
const skills: Skill[] = [];
const skillMdMtimes = new Map<string, number>();
const mtimeOf = new Map<string, number>();
// Every entry is considered (not just `isDirectory`): a symlink to a directory reports
// isDirectory=false, yet stat follows it, so `dir/SKILL.md` decides skill-or-not.
for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
const dir = path.join(root, entry.name);
const skillMdPath = path.join(dir, "SKILL.md");
const mtime = await statMtime(skillMdPath);
if (mtime === null) continue;
skillMdMtimes.set(skillMdPath, mtime);
mtimeOf.set(dir, mtime);
let text: string;
try {
text = (await fs.readFile(skillMdPath)).toString("utf8");
} catch (e) {
log(`skills: ${skillMdPath}: SKILL.md unreadable (${(e as Error).message})`);
skills.push({
name: entry.name,
description: "",
dir,
skillMdPath,
disableModelInvocation: false,
routable: false,
note: "SKILL.md unreadable",
});
continue;
}
const fm = splitFrontmatter(text);
const data = fm.data ?? {};
const description = typeof data.description === "string" ? data.description.trim() : "";
const disableModelInvocation = data["disable-model-invocation"] === true;
const skill: Skill = {
name: entry.name,
description,
dir,
skillMdPath,
disableModelInvocation,
routable: !disableModelInvocation && description !== "",
};
const fmName = typeof data.name === "string" ? data.name.trim() : "";
if (fmName !== "") {
if (isValidSkillName(fmName)) {
skill.name = fmName;
} else {
skill.note = "invalid name in frontmatter";
log(`skills: ${skillMdPath}: invalid name in frontmatter (${JSON.stringify(fmName)}); using folder name`);
}
}
if (!isValidSkillName(skill.name)) {
skill.note = "name not activatable";
skill.routable = false;
log(`skills: ${skillMdPath}: folder name ${JSON.stringify(skill.name)} is not activatable`);
}
if (fm.error !== undefined) {
skill.note = "frontmatter unreadable";
skill.routable = false;
log(`skills: ${skillMdPath}: frontmatter unreadable${fm.error ? ` (${fm.error})` : ""}`);
}
skills.push(skill);
}
const byName = new Map<string, Skill>();
for (const skill of skills) {
const existing = byName.get(skill.name);
if (existing) {
skill.routable = false;
skill.note = `duplicate name, folder: ${skill.dir}`;
} else {
byName.set(skill.name, skill);
}
}
let routable = skills.filter(s => s.routable).sort((a, b) => (mtimeOf.get(b.dir) ?? 0) - (mtimeOf.get(a.dir) ?? 0));
if (routable.length > MAX_ROUTABLE) {
log(`skills: ${routable.length} routable skills; only the ${MAX_ROUTABLE} most recently modified will be routed`);
routable = routable.slice(0, MAX_ROUTABLE);
}
return { index: { root, skills, byName, routable }, rootMtime, skillMdMtimes };
}
return {
async load(root) {
const cached = cache.get(root);
if (cached && (await isFresh(cached, root))) return cached.index;
const entry = await scan(root);
cache.set(root, entry);
return entry.index;
},
};
}
import * as yaml from "js-yaml";
import * as path from "path";
import { isValidSkillName } from "./activation";
import { MAX_ROUTABLE, type Skill, type SkillFs, type SkillIndex } from "./types";
const FRONTMATTER = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?/;
/** Splits `---` YAML frontmatter from the Markdown body. A leading UTF-8 BOM is ignored. Never throws. */
export function splitFrontmatter(text: string): { data: Record<string, unknown> | null; body: string; error?: string } {
if (text.startsWith("\uFEFF")) text = text.slice(1);
const m = FRONTMATTER.exec(text);
if (!m) return { data: null, body: text };
const body = text.slice(m[0].length).replace(/^\s*\n/, "");
try {
const parsed = yaml.load(m[1]);
if (parsed === null || parsed === undefined) return { data: {}, body };
if (typeof parsed !== "object" || Array.isArray(parsed)) return { data: null, body, error: "frontmatter is not a mapping" };
return { data: parsed as Record<string, unknown>, body };
} catch (e) {
return { data: null, body, error: (e as Error).message };
}
}
export interface Catalog {
load(root: string): Promise<SkillIndex>;
}
interface CacheEntry {
index: SkillIndex;
rootMtime: number;
skillMdMtimes: Map<string, number>;
}
export function createCatalog(fs: SkillFs, log: (msg: string) => void = () => {}): Catalog {
const cache = new Map<string, CacheEntry>();
async function statMtime(p: string): Promise<number | null> {
try {
return (await fs.stat(p)).mtimeMs;
} catch {
return null;
}
}
async function isFresh(entry: CacheEntry, root: string): Promise<boolean> {
if ((await statMtime(root)) !== entry.rootMtime) return false;
for (const [p, mtime] of entry.skillMdMtimes) {
if ((await statMtime(p)) !== mtime) return false;
}
return true;
}
async function scan(root: string): Promise<CacheEntry> {
const empty: SkillIndex = { root, skills: [], byName: new Map(), routable: [] };
const rootMtime = await statMtime(root);
if (rootMtime === null) return { index: empty, rootMtime: -1, skillMdMtimes: new Map() };
let entries;
try {
entries = await fs.readdir(root);
} catch {
return { index: empty, rootMtime, skillMdMtimes: new Map() };
}
const skills: Skill[] = [];
const skillMdMtimes = new Map<string, number>();
const mtimeOf = new Map<string, number>();
// Every entry is considered (not just `isDirectory`): a symlink to a directory reports
// isDirectory=false, yet stat follows it, so `dir/SKILL.md` decides skill-or-not.
for (const entry of [...entries].sort((a, b) => a.name.localeCompare(b.name))) {
const dir = path.join(root, entry.name);
const skillMdPath = path.join(dir, "SKILL.md");
const mtime = await statMtime(skillMdPath);
if (mtime === null) continue;
skillMdMtimes.set(skillMdPath, mtime);
mtimeOf.set(dir, mtime);
let text: string;
try {
text = (await fs.readFile(skillMdPath)).toString("utf8");
} catch (e) {
log(`skills: ${skillMdPath}: SKILL.md unreadable (${(e as Error).message})`);
skills.push({
name: entry.name,
description: "",
dir,
skillMdPath,
disableModelInvocation: false,
routable: false,
note: "SKILL.md unreadable",
});
continue;
}
const fm = splitFrontmatter(text);
const data = fm.data ?? {};
const description = typeof data.description === "string" ? data.description.trim() : "";
const disableModelInvocation = data["disable-model-invocation"] === true;
const skill: Skill = {
name: entry.name,
description,
dir,
skillMdPath,
disableModelInvocation,
routable: !disableModelInvocation && description !== "",
};
const fmName = typeof data.name === "string" ? data.name.trim() : "";
if (fmName !== "") {
if (isValidSkillName(fmName)) {
skill.name = fmName;
} else {
skill.note = "invalid name in frontmatter";
log(`skills: ${skillMdPath}: invalid name in frontmatter (${JSON.stringify(fmName)}); using folder name`);
}
}
if (!isValidSkillName(skill.name)) {
skill.note = "name not activatable";
skill.routable = false;
log(`skills: ${skillMdPath}: folder name ${JSON.stringify(skill.name)} is not activatable`);
}
if (fm.error !== undefined) {
skill.note = "frontmatter unreadable";
skill.routable = false;
log(`skills: ${skillMdPath}: frontmatter unreadable${fm.error ? ` (${fm.error})` : ""}`);
}
skills.push(skill);
}
const byName = new Map<string, Skill>();
for (const skill of skills) {
const existing = byName.get(skill.name);
if (existing) {
skill.routable = false;
skill.note = `duplicate name, folder: ${skill.dir}`;
} else {
byName.set(skill.name, skill);
}
}
let routable = skills.filter(s => s.routable).sort((a, b) => (mtimeOf.get(b.dir) ?? 0) - (mtimeOf.get(a.dir) ?? 0));
if (routable.length > MAX_ROUTABLE) {
log(`skills: ${routable.length} routable skills; only the ${MAX_ROUTABLE} most recently modified will be routed`);
routable = routable.slice(0, MAX_ROUTABLE);
}
return { index: { root, skills, byName, routable }, rootMtime, skillMdMtimes };
}
return {
async load(root) {
const cached = cache.get(root);
if (cached && (await isFresh(cached, root))) return cached.index;
const entry = await scan(root);
cache.set(root, entry);
return entry.index;
},
};
}