mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
feat: disk-backed agent skills with live-reload
This commit is contained in:
parent
30356e36b1
commit
810034b13a
6 changed files with 286 additions and 25 deletions
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue