feat: disk-backed agent skills with live-reload

This commit is contained in:
Prakhar Pandey 2026-07-01 22:28:28 +05:30
parent 30356e36b1
commit 810034b13a
6 changed files with 286 additions and 25 deletions

View file

@ -335,9 +335,9 @@ export async function buildCopilotInstructions(): Promise<string> {
const excludeIds: string[] = [];
if (!composioEnabled) excludeIds.push('composio-integration');
if (!codeModeEnabled) excludeIds.push('code-with-agents');
const catalog = excludeIds.length > 0
? buildSkillCatalog({ excludeIds })
: skillCatalog;
// Always build from the live skill set so disk skills added/removed at
// runtime (after refreshDiskSkills + cache invalidation) are reflected.
const catalog = buildSkillCatalog({ excludeIds });
const baseInstructions = buildStaticInstructions(composioEnabled, catalog, codeModeEnabled);
const composioPrompt = await getComposioToolsPrompt();
cachedInstructions = composioPrompt

View file

@ -0,0 +1,99 @@
import fs from "node:fs";
import path from "node:path";
import { homedir } from "node:os";
import { WorkDir } from "../../../config/config.js";
import { splitFrontmatter } from "../../lib/parse-frontmatter.js";
/**
* A skill discovered on disk. Matches the bundled SkillDefinition shape
* (id/title/summary/content) plus provenance about where it came from.
*/
export type DiskSkill = {
id: string; // slugified folder name
title: string; // frontmatter `name`
summary: string; // frontmatter `description`
content: string; // full raw SKILL.md text
dir: string; // absolute path to the skill folder
skillFile: string; // absolute path to the SKILL.md
};
// Locations scanned for <skill-name>/SKILL.md subfolders. The rowboat root is
// derived from WorkDir so it honors the ROWBOAT_WORKDIR override (defaults to
// ~/.rowboat/skills); it is scanned first so it wins on id collisions across
// the two roots. The ~/.agents/skills root is an external shared convention,
// not tied to WorkDir. Exported so the live-reload watcher watches exactly the
// same locations.
export const SKILL_ROOTS = [
path.join(WorkDir, "skills"),
path.join(homedir(), ".agents", "skills"),
];
const slugify = (value: string): string =>
value.trim().toLowerCase().replace(/[\s_]+/g, "-");
/**
* Synchronously scan the known skill roots (one level deep) for disk skills.
* A missing/empty directory or an unreadable/invalid SKILL.md simply yields no
* skill for that folder this function never throws.
*/
export function loadDiskSkills(): DiskSkill[] {
const skills: DiskSkill[] = [];
const seen = new Map<string, string>(); // id -> dir that claimed it
for (const root of SKILL_ROOTS) {
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(root, { withFileTypes: true });
} catch {
// Directory missing (e.g. ~/.agents/skills absent) — nothing to scan.
continue;
}
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dir = path.join(root, entry.name);
const skillFile = path.join(dir, "SKILL.md");
let raw: string;
try {
raw = fs.readFileSync(skillFile, "utf8");
} catch {
console.warn(`[disk-skills] Skipping '${entry.name}': no readable SKILL.md at ${skillFile}`);
continue;
}
// Parse with the real YAML frontmatter helper so folded/multi-line
// scalars and nested keys (e.g. `metadata:`) are handled correctly.
let frontmatter: Record<string, unknown>;
try {
({ frontmatter } = splitFrontmatter(raw));
} catch (err) {
console.warn(`[disk-skills] Skipping '${entry.name}': failed to parse frontmatter:`, err);
continue;
}
// Coerce a scalar (including folded/multi-line) frontmatter value to a
// single trimmed string; anything non-string yields "".
const asString = (value: unknown): string =>
typeof value === "string" ? value.trim() : "";
const name = asString(frontmatter.name);
const description = asString(frontmatter.description);
if (!name || !description) {
console.warn(`[disk-skills] Skipping '${entry.name}': SKILL.md is missing required 'name' and/or 'description' frontmatter.`);
continue;
}
const id = slugify(entry.name);
const existing = seen.get(id);
if (existing) {
console.warn(`[disk-skills] Duplicate skill id '${id}' from ${dir} ignored; already provided by ${existing}.`);
continue;
}
seen.set(id, dir);
skills.push({ id, title: name, summary: description, content: raw, dir, skillFile });
}
}
return skills;
}

View file

@ -1,5 +1,6 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { loadDiskSkills } from "./disk-loader.js";
import builtinToolsSkill from "./builtin-tools/skill.js";
import deletionGuardrailsSkill from "./deletion-guardrails/skill.js";
import docCollabSkill from "./doc-collab/skill.js";
@ -128,32 +129,56 @@ const definitions: SkillDefinition[] = [
},
];
const skillEntries = definitions.map((definition) => ({
type SkillEntry = SkillDefinition & { catalogPath: string; skillFile?: string };
const bundledEntries: SkillEntry[] = definitions.map((definition) => ({
...definition,
catalogPath: `${CATALOG_PREFIX}/${definition.id}/skill.ts`,
}));
const catalogSections = skillEntries.map((entry) => [
`## ${entry.title}`,
`- **Skill file:** \`${entry.catalogPath}\``,
`- **Use it for:** ${entry.summary}`,
].join("\n"));
// A disk skill may not shadow a bundled skill: on id collision, bundled wins.
const bundledIds = new Set(definitions.map((d) => d.id));
export const skillCatalog = [
"# Rowboat Skill Catalog",
"",
"Use this catalog to see which specialized skills you can load. Each entry lists the exact skill file plus a short description of when it helps.",
"",
catalogSections.join("\n\n"),
].join("\n");
// ---- Disk skill layer (refreshable at runtime) ----
// Bundled skills are static. Disk skills (~/.rowboat/skills, ~/.agents/skills)
// can be added/edited/removed while the app runs, so they live in a mutable
// list rebuilt by refreshDiskSkills() and resolved via a separate alias map.
let diskEntries: SkillEntry[] = [];
const diskAliasMap = new Map<string, ResolvedSkill>();
function loadDiskEntries(): SkillEntry[] {
return loadDiskSkills()
.filter((skill) => {
if (bundledIds.has(skill.id)) {
console.warn(`[disk-skills] Disk skill '${skill.id}' (${skill.dir}) shadows a built-in skill; keeping the built-in.`);
return false;
}
return true;
})
.map((skill) => ({
id: skill.id,
title: skill.title,
summary: skill.summary,
content: skill.content,
// For disk skills the catalog/loadSkill reference is the absolute SKILL.md path.
catalogPath: skill.skillFile,
skillFile: skill.skillFile,
}));
}
// The full, current skill set (bundled first so it wins on any collision).
function getSkillEntries(): SkillEntry[] {
return [...bundledEntries, ...diskEntries];
}
/**
* Build a skill catalog string, optionally excluding specific skills by ID.
* Reads the live skill set, so it reflects disk skills added/removed at runtime.
*/
export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
const entries = options?.excludeIds
? skillEntries.filter(e => !options.excludeIds!.includes(e.id))
: skillEntries;
? getSkillEntries().filter(e => !options.excludeIds!.includes(e.id))
: getSkillEntries();
const sections = entries.map((entry) => [
`## ${entry.title}`,
`- **Skill file:** \`${entry.catalogPath}\``,
@ -171,15 +196,17 @@ export function buildSkillCatalog(options?: { excludeIds?: string[] }): string {
const normalizeIdentifier = (value: string) =>
value.trim().replace(/\\/g, "/").replace(/^\.\/+/, "");
// Bundled aliases are static; disk aliases live in diskAliasMap so they can be
// rebuilt wholesale on refresh without disturbing the bundled entries.
const aliasMap = new Map<string, ResolvedSkill>();
const registerAlias = (alias: string, entry: ResolvedSkill) => {
const registerAlias = (alias: string, entry: ResolvedSkill, target: Map<string, ResolvedSkill> = aliasMap) => {
const normalized = normalizeIdentifier(alias);
if (!normalized) return;
aliasMap.set(normalized, entry);
target.set(normalized, entry);
};
const registerAliasVariants = (alias: string, entry: ResolvedSkill) => {
const registerAliasVariants = (alias: string, entry: ResolvedSkill, target: Map<string, ResolvedSkill> = aliasMap) => {
const normalized = normalizeIdentifier(alias);
if (!normalized) return;
@ -196,11 +223,11 @@ const registerAliasVariants = (alias: string, entry: ResolvedSkill) => {
}
for (const variant of variants) {
registerAlias(variant, entry);
registerAlias(variant, entry, target);
}
};
for (const entry of skillEntries) {
for (const entry of bundledEntries) {
const absoluteTs = path.join(CURRENT_DIR, entry.id, "skill.ts");
const absoluteJs = path.join(CURRENT_DIR, entry.id, "skill.js");
const resolvedEntry: ResolvedSkill = {
@ -227,11 +254,52 @@ for (const entry of skillEntries) {
}
}
export const availableSkills = skillEntries.map((entry) => entry.id);
// Disk skills resolve by their id and by their absolute on-disk SKILL.md path,
// so the existing loadSkill tool can load them unchanged. Rebuilt on refresh.
function rebuildDiskAliases(): void {
diskAliasMap.clear();
for (const entry of diskEntries) {
const resolvedEntry: ResolvedSkill = {
id: entry.id,
catalogPath: entry.catalogPath,
content: entry.content,
};
registerAlias(entry.id, resolvedEntry, diskAliasMap);
if (entry.skillFile) {
registerAlias(entry.skillFile, resolvedEntry, diskAliasMap);
}
}
}
// Live-updated in place (same array binding) so importers see current ids.
export const availableSkills: string[] = [];
/**
* Re-scan disk skills and rebuild the disk catalog/alias entries. Bundled
* skills are untouched. Callers (the disk-skills watcher) should follow this
* with invalidateCopilotInstructionsCache() so the next agent run picks it up.
* Returns the number of disk skills currently loaded.
*/
export function refreshDiskSkills(): number {
diskEntries = loadDiskEntries();
rebuildDiskAliases();
availableSkills.length = 0;
for (const entry of getSkillEntries()) availableSkills.push(entry.id);
return diskEntries.length;
}
// Initial disk load at module init.
refreshDiskSkills();
// Snapshot of the catalog at module load, kept for backward-compatible
// consumers (e.g. the legacy CopilotInstructions export). Runtime consumers
// should call buildSkillCatalog() to get the live set.
export const skillCatalog = buildSkillCatalog();
export function resolveSkill(identifier: string): ResolvedSkill | null {
const normalized = normalizeIdentifier(identifier);
if (!normalized) return null;
return aliasMap.get(normalized) ?? null;
// Bundled wins over disk on any alias collision.
return aliasMap.get(normalized) ?? diskAliasMap.get(normalized) ?? null;
}

View file

@ -0,0 +1,87 @@
import chokidar, { type FSWatcher } from "chokidar";
import fs from "node:fs";
import { SKILL_ROOTS } from "./disk-loader.js";
import { refreshDiskSkills } from "./index.js";
import { invalidateCopilotInstructionsCache } from "../instructions.js";
// The skills CLI writes many files per install (~13 paths for one skill), so we
// coalesce a burst of events into a single refresh.
const DEBOUNCE_MS = 400;
let watcher: FSWatcher | null = null;
let debounceTimer: NodeJS.Timeout | null = null;
function scheduleReload(): void {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => {
debounceTimer = null;
try {
const count = refreshDiskSkills();
invalidateCopilotInstructionsCache();
console.log(`[disk-skills] reloaded ${count} skills after change`);
} catch (err) {
// A bad SKILL.md (or any refresh error) must never crash the app.
console.error("[disk-skills] failed to reload after change:", err);
}
}, DEBOUNCE_MS);
}
/**
* Start watching the disk skill roots (~/.rowboat/skills, ~/.agents/skills) for
* changes and live-reload the skill catalog. Idempotent. Missing directories
* are skipped so watching ~/.agents when it doesn't exist never errors.
*/
export function startSkillsWatcher(): void {
if (watcher) return;
const roots = SKILL_ROOTS.filter((root) => {
try {
return fs.statSync(root).isDirectory();
} catch {
return false; // Directory absent (e.g. ~/.agents/skills) — nothing to watch.
}
});
if (roots.length === 0) {
console.log("[disk-skills] no skill directories present; watcher idle");
return;
}
try {
watcher = chokidar.watch(roots, {
ignoreInitial: true,
awaitWriteFinish: {
stabilityThreshold: 150,
pollInterval: 50,
},
});
// Any add/change/unlink (files or dirs) under a root triggers a debounced reload.
watcher
.on("add", scheduleReload)
.on("change", scheduleReload)
.on("unlink", scheduleReload)
.on("addDir", scheduleReload)
.on("unlinkDir", scheduleReload)
.on("error", (error: unknown) => {
console.error("[disk-skills] watcher error:", error);
});
console.log(`[disk-skills] watching ${roots.length} skill director${roots.length === 1 ? "y" : "ies"} for changes`);
} catch (err) {
console.error("[disk-skills] failed to start watcher:", err);
watcher = null;
}
}
/** Stop the skills watcher and clear any pending reload. Idempotent. */
export function stopSkillsWatcher(): void {
if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}
if (watcher) {
watcher.close().catch(() => { /* ignore close errors on shutdown */ });
watcher = null;
}
}

View file

@ -33,6 +33,7 @@ function ensureDirs() {
ensure(path.join(WorkDir, "agents"));
ensure(path.join(WorkDir, "config"));
ensure(path.join(WorkDir, "knowledge"));
ensure(path.join(WorkDir, "skills"));
}
function ensureDefaultConfigs() {