From 810034b13a55280d56aff834e656d687b51a03e1 Mon Sep 17 00:00:00 2001 From: Prakhar Pandey Date: Wed, 1 Jul 2026 22:28:28 +0530 Subject: [PATCH] feat: disk-backed agent skills with live-reload --- apps/x/apps/main/src/main.ts | 6 + .../src/application/assistant/instructions.ts | 6 +- .../assistant/skills/disk-loader.ts | 99 ++++++++++++++++ .../src/application/assistant/skills/index.ts | 112 ++++++++++++++---- .../application/assistant/skills/watcher.ts | 87 ++++++++++++++ apps/x/packages/core/src/config/config.ts | 1 + 6 files changed, 286 insertions(+), 25 deletions(-) create mode 100644 apps/x/packages/core/src/application/assistant/skills/disk-loader.ts create mode 100644 apps/x/packages/core/src/application/assistant/skills/watcher.ts diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 81d43553..30de0c1e 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -31,6 +31,7 @@ import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-co import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js"; import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js"; import { init as initLocalSites, shutdown as shutdownLocalSites } from "@x/core/dist/local-sites/server.js"; +import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/application/assistant/skills/watcher.js"; import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js"; import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js"; @@ -345,6 +346,10 @@ app.whenReady().then(async () => { // start bg-task scheduler (cron / window) initBackgroundTaskScheduler(); + // start disk-skills watcher: live-reload skills dropped into + // ~/.rowboat/skills or ~/.agents/skills without an app restart + startSkillsWatcher(); + // register event consumers and start the shared event processor // (consumes $WorkDir/events/pending/, routes events to all consumers // concurrently for Pass-1, then fires each consumer's candidates in parallel) @@ -416,6 +421,7 @@ app.on("before-quit", () => { stopWorkspaceWatcher(); stopRunsWatcher(); stopServicesWatcher(); + stopSkillsWatcher(); shutdownLocalSites().catch((error) => { console.error('[LocalSites] Failed to shut down cleanly:', error); }); diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index b3611fe4..b0094944 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -335,9 +335,9 @@ export async function buildCopilotInstructions(): Promise { 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 diff --git a/apps/x/packages/core/src/application/assistant/skills/disk-loader.ts b/apps/x/packages/core/src/application/assistant/skills/disk-loader.ts new file mode 100644 index 00000000..510e4140 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/disk-loader.ts @@ -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.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(); // 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; + 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; +} diff --git a/apps/x/packages/core/src/application/assistant/skills/index.ts b/apps/x/packages/core/src/application/assistant/skills/index.ts index 30ceea95..9752338f 100644 --- a/apps/x/packages/core/src/application/assistant/skills/index.ts +++ b/apps/x/packages/core/src/application/assistant/skills/index.ts @@ -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(); + +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(); -const registerAlias = (alias: string, entry: ResolvedSkill) => { +const registerAlias = (alias: string, entry: ResolvedSkill, target: Map = 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 = 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; } diff --git a/apps/x/packages/core/src/application/assistant/skills/watcher.ts b/apps/x/packages/core/src/application/assistant/skills/watcher.ts new file mode 100644 index 00000000..9e5fd459 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/watcher.ts @@ -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; + } +} diff --git a/apps/x/packages/core/src/config/config.ts b/apps/x/packages/core/src/config/config.ts index 7f230514..94c6db79 100644 --- a/apps/x/packages/core/src/config/config.ts +++ b/apps/x/packages/core/src/config/config.ts @@ -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() {