Merge pull request #651 from prakhar1605/feat/disk-skills

feat: disk-backed agent skills with live-reload
This commit is contained in:
PRAKHAR PANDEY 2026-07-07 17:17:36 +05:30 committed by GitHub
commit 678130ab40
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 657 additions and 126 deletions

101
AGENTS.md
View file

@ -1,101 +0,0 @@
# AGENTS.md — Working on the Rowboat runtime
Context for AI coding agents (and humans) working on this repo. General
codebase orientation lives in `CLAUDE.md`; this file covers the **new
turn/session runtime** in `apps/x` — its storage, its debugging tools, and
the invariants you must not break.
## The runtime in one paragraph
Chats are **sessions**; each user message starts a **turn**. Both are
append-only JSONL event logs under `~/.rowboat/storage/{turns,sessions}/YYYY/MM/DD/`.
All state is derived by pure reducers (`reduceTurn`, `reduceSession` in
`@x/shared/src/turns.ts` / `sessions.ts`) shared byte-for-byte between the
main process and the renderer. Design specs:
`apps/x/packages/core/docs/turn-runtime-design.md` and `session-design.md`
read the relevant spec before changing runtime behavior.
## Storage is reference-based — files store each fact once
Three applications of the same mechanism:
1. **Context**: a session turn's `context` is `{ previousTurnId }`; the
conversation prefix is materialized by walking the chain
(`TurnRepoContextResolver`).
2. **Model requests**: `model_call_requested.request.messages` is a list of
string refs into the turn's own events — `"context"`, `"input"`,
`"assistant:<index>"`, `"toolResult:<toolCallId>"` — recording only what
is NEW since the previous call.
3. **Agent snapshots**: when a turn's system prompt + tools are
byte-identical to its predecessor's, `turn_created.agent.resolved` is
`{ agentId, model, inheritedFrom }` instead of re-persisting ~70KB. The
model stays concrete (mid-session model switches still inherit).
The exact provider payload is rebuilt by `composeModelRequest`
(`packages/core/src/turns/compose-model-request.ts`) — **the same code path
the loop transmits through**, so the file plus the composer reproduce the
wire bytes exactly (there is a property test asserting composed == sent).
## Inspecting turns and sessions
```bash
cd apps/x/packages/core
# A whole session: title, per-turn status/size/input preview
npm run inspect -- <sessionId | path/to/session.jsonl>
# One turn: per model call, the EXACT provider payload — resolved system
# prompt, tool list, wire-form messages (user-message context woven in,
# tool-result envelopes), and the response/failure
npm run inspect -- <turnId | path/to/turn.jsonl> [modelCallIndex] [--full]
# Cascade full turn inspection across a session
npm run inspect -- <sessionId> --turns
```
`--full` prints untruncated system prompts and message contents. Turn vs
session ids are auto-detected. This is the intended way to see "what did the
model actually receive" — the raw JSONL deliberately stores structural facts
and references, never the derived wire form.
## The legacy runs runtime is code-mode-only
The old runs runtime (`packages/core/src/runs/`, `AgentRuntime`/`streamAgent`
in `packages/core/src/agents/runtime.ts`, the `runs:*` IPC channels, and the
renderer's legacy chat-tab state machine in `App.tsx`) remains in the tree
**solely for code-mode sessions** — a deliberate, documented carve-out:
- A code session IS a run (`sessionId === runId`). Direct-mode prompts drive
an external ACP agent and hand-assemble `code-run-event`s into the run log
(`code-mode/sessions/service.ts`); rowboat-mode code tabs go through
`runs:createMessage` → the old `AgentRuntime` loop.
- Everything else — main chat, background tasks, live notes, knowledge
pipelines, scheduled agents — runs on turns/sessions. Do NOT add new
callers of `createRun`/`createMessage`/`streamAgent`; headless work uses
`packages/core/src/agents/headless.ts` (`startHeadlessAgent`/
`runHeadlessAgent`).
- Temporary bridges that die when code-mode is unified: the renderer
transcript loader's `runs:fetch` fallback (`lib/agent-transcript.ts`), the
`runs:downloadLog` fallback in the chat sidebar, and the `notify-user`
gate's `fetchRun` fallback (`application/lib/builtin-tools.ts`).
- The remaining migration (designed but deferred): port code sessions onto
sessions/turns — rowboat-mode prompts become normal turns with
`codeMode`/`codeCwd` composition (already supported by the agent
resolver's composition overrides), direct-mode prompts become a delegated
turn kind carrying opaque code events. That project deletes `runs/`
entirely.
## Invariants to respect
- Turn/session files are **append-only**; reducers reject impossible
histories loudly (`TurnCorruptionError`). Never hand-edit files.
- Durable events are persisted **before** side effects (model calls, tool
invocations). Deltas (`text_delta`, …) are stream-only, never persisted.
- The reducers in `@x/shared` must stay pure (no I/O, no node imports) —
the renderer imports them directly.
- Every behavior change needs tests: reducers in `packages/shared`,
runtime/sessions in `packages/core`, renderer stores/views in
`apps/renderer` (all vitest; run `npm test` per package).
- Schema changes: the schema is pre-release (`schemaVersion: 1` throughout);
breaking changes are acceptable but require wiping `~/.rowboat/storage`
and a note in the commit message.

View file

@ -37,6 +37,7 @@ import { init as initEventProcessor, registerConsumer } from "@x/core/dist/event
import { liveNoteEventConsumer } from "@x/core/dist/knowledge/live-note/event-consumer.js";
import { init as initBackgroundTaskScheduler } from "@x/core/dist/background-tasks/scheduler.js";
import { backgroundTaskEventConsumer } from "@x/core/dist/background-tasks/event-consumer.js";
import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/application/assistant/skills/watcher.js";
import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js";
import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js";
import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js";
@ -481,6 +482,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)
@ -550,6 +555,7 @@ app.on("before-quit", () => {
stopWorkspaceWatcher();
stopRunsWatcher();
stopServicesWatcher();
stopSkillsWatcher();
// Tear down any live ACP coding-agent adapter processes so they don't outlive the app.
try {
container.resolve<CodeModeManager>('codeModeManager').disposeAll();

View file

@ -429,9 +429,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, slackConnected, slackChannelsHint, googleConnected);
const composioPrompt = await getComposioToolsPrompt(slackConnected, googleConnected);
cachedInstructions = composioPrompt

View file

@ -0,0 +1,223 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// SKILL_ROOTS is computed when disk-loader.js loads, from WorkDir (resolved in
// config.js from ROWBOAT_WORKDIR) and homedir(). Both overrides must be in
// place before the module is imported — hence resetModules + dynamic import
// per test, mirroring filesystem/files.test.ts.
let tmpDir: string;
let workDir: string;
let fakeHomeDir: string;
let rowboatSkillsRoot: string;
let agentsSkillsRoot: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "disk-skills-test-"));
workDir = path.join(tmpDir, "rowboat");
fakeHomeDir = path.join(tmpDir, "home");
rowboatSkillsRoot = path.join(workDir, "skills");
agentsSkillsRoot = path.join(fakeHomeDir, ".agents", "skills");
process.env.ROWBOAT_WORKDIR = workDir;
vi.resetModules();
// config.js kicks off these imports on load; stub them so tests stay
// hermetic (no git init or Today.md migration against the temp workdir).
vi.doMock("../../../knowledge/version_history.js", () => ({
commitAll: vi.fn(async () => undefined),
initRepo: vi.fn(async () => undefined),
}));
vi.doMock("../../../knowledge/deprecate_today_note.js", () => ({
deprecateTodayNote: vi.fn(async () => undefined),
}));
// Point homedir() at the temp dir so the ~/.agents/skills root never touches
// the developer's real home directory.
vi.doMock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
return {
...actual,
homedir: () => fakeHomeDir,
default: { ...actual, homedir: () => fakeHomeDir },
};
});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
delete process.env.ROWBOAT_WORKDIR;
vi.doUnmock("../../../knowledge/version_history.js");
vi.doUnmock("../../../knowledge/deprecate_today_note.js");
vi.doUnmock("node:os");
vi.resetModules();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
async function loadDiskLoader() {
return import("./disk-loader.js");
}
function writeSkill(root: string, folder: string, contents: string): string {
const dir = path.join(root, folder);
fs.mkdirSync(dir, { recursive: true });
const skillFile = path.join(dir, "SKILL.md");
fs.writeFileSync(skillFile, contents);
return skillFile;
}
describe("SKILL_ROOTS", () => {
it("derives the rowboat root from ROWBOAT_WORKDIR and the agents root from homedir", async () => {
const { SKILL_ROOTS } = await loadDiskLoader();
expect(SKILL_ROOTS).toEqual([rowboatSkillsRoot, agentsSkillsRoot]);
});
});
describe("loadDiskSkills", () => {
it("loads a valid skill with id/title/summary, raw content, and absolute paths", async () => {
const raw = [
"---",
"name: Test Skill",
"description: Does test things.",
"---",
"",
"# Test Skill",
"",
"Body of the skill.",
"",
].join("\n");
const skillFile = writeSkill(rowboatSkillsRoot, "test-skill", raw);
const { loadDiskSkills } = await loadDiskLoader();
const skills = loadDiskSkills();
expect(skills).toHaveLength(1);
const skill = skills[0];
expect(skill.id).toBe("test-skill");
expect(skill.title).toBe("Test Skill");
expect(skill.summary).toBe("Does test things.");
expect(skill.content).toBe(raw);
expect(skill.dir).toBe(path.join(rowboatSkillsRoot, "test-skill"));
expect(path.isAbsolute(skill.dir)).toBe(true);
expect(skill.skillFile).toBe(skillFile);
expect(path.isAbsolute(skill.skillFile)).toBe(true);
});
it("handles folded multi-line descriptions and nested metadata blocks", async () => {
const raw = [
"---",
"name: vercel-deploy",
"description: >-",
" Deploy a project to Vercel",
" with zero configuration.",
"metadata:",
" category: deployment",
" tags:",
" - vercel",
" - deploy",
"---",
"",
"Instructions here.",
"",
].join("\n");
writeSkill(rowboatSkillsRoot, "vercel-deploy", raw);
const { loadDiskSkills } = await loadDiskLoader();
const skills = loadDiskSkills();
expect(skills).toHaveLength(1);
expect(skills[0].title).toBe("vercel-deploy");
expect(skills[0].summary).toBe("Deploy a project to Vercel with zero configuration.");
expect(skills[0].content).toBe(raw);
});
it("skips skills missing name or description without throwing", async () => {
writeSkill(rowboatSkillsRoot, "no-description", [
"---",
"name: No Description",
"---",
"Body.",
].join("\n"));
writeSkill(rowboatSkillsRoot, "no-name", [
"---",
"description: Has no name.",
"---",
"Body.",
].join("\n"));
writeSkill(rowboatSkillsRoot, "valid", [
"---",
"name: Valid",
"description: The only loadable one.",
"---",
"Body.",
].join("\n"));
const { loadDiskSkills } = await loadDiskLoader();
const skills = loadDiskSkills();
expect(skills.map((s) => s.id)).toEqual(["valid"]);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("Skipping 'no-description'"),
);
expect(console.warn).toHaveBeenCalledWith(
expect.stringContaining("Skipping 'no-name'"),
);
});
it("skips folders without a SKILL.md", async () => {
fs.mkdirSync(path.join(rowboatSkillsRoot, "empty-folder"), { recursive: true });
fs.writeFileSync(path.join(rowboatSkillsRoot, "stray-file.md"), "not a skill folder");
const { loadDiskSkills } = await loadDiskLoader();
expect(loadDiskSkills()).toEqual([]);
});
it("prefers the rowboat root when the same skill id exists in both roots", async () => {
writeSkill(rowboatSkillsRoot, "dup", [
"---",
"name: Rowboat Dup",
"description: From the rowboat root.",
"---",
"Rowboat body.",
].join("\n"));
writeSkill(agentsSkillsRoot, "dup", [
"---",
"name: Agents Dup",
"description: From the agents root.",
"---",
"Agents body.",
].join("\n"));
const { loadDiskSkills } = await loadDiskLoader();
const skills = loadDiskSkills();
expect(skills).toHaveLength(1);
expect(skills[0].title).toBe("Rowboat Dup");
expect(skills[0].dir).toBe(path.join(rowboatSkillsRoot, "dup"));
});
it("returns [] when the roots do not exist", async () => {
// config.js creates the rowboat skills dir on load, so import first, then
// remove it. The agents root was never created.
const { loadDiskSkills } = await loadDiskLoader();
fs.rmSync(rowboatSkillsRoot, { recursive: true, force: true });
expect(loadDiskSkills()).toEqual([]);
});
it("slugifies folder names into skill ids", async () => {
writeSkill(rowboatSkillsRoot, "My_Skill Name", [
"---",
"name: My Skill",
"description: Slug test.",
"---",
"Body.",
].join("\n"));
const { loadDiskSkills } = await loadDiskLoader();
const skills = loadDiskSkills();
expect(skills).toHaveLength(1);
expect(skills[0].id).toBe("my-skill-name");
});
});

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

@ -0,0 +1,148 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// index.js scans the disk skill roots at module init, and the roots are fixed
// when disk-loader.js/config.js load — so the ROWBOAT_WORKDIR and homedir
// overrides must be in place before the dynamic import in each test, mirroring
// filesystem/files.test.ts.
let tmpDir: string;
let workDir: string;
let fakeHomeDir: string;
let rowboatSkillsRoot: string;
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "skills-index-test-"));
workDir = path.join(tmpDir, "rowboat");
fakeHomeDir = path.join(tmpDir, "home");
rowboatSkillsRoot = path.join(workDir, "skills");
process.env.ROWBOAT_WORKDIR = workDir;
vi.resetModules();
// config.js kicks off these imports on load; stub them so tests stay
// hermetic (no git init or Today.md migration against the temp workdir).
vi.doMock("../../../knowledge/version_history.js", () => ({
commitAll: vi.fn(async () => undefined),
initRepo: vi.fn(async () => undefined),
}));
vi.doMock("../../../knowledge/deprecate_today_note.js", () => ({
deprecateTodayNote: vi.fn(async () => undefined),
}));
// Point homedir() at the temp dir so the ~/.agents/skills root never touches
// the developer's real home directory.
vi.doMock("node:os", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:os")>();
return {
...actual,
homedir: () => fakeHomeDir,
default: { ...actual, homedir: () => fakeHomeDir },
};
});
vi.spyOn(console, "warn").mockImplementation(() => {});
});
afterEach(() => {
delete process.env.ROWBOAT_WORKDIR;
vi.doUnmock("../../../knowledge/version_history.js");
vi.doUnmock("../../../knowledge/deprecate_today_note.js");
vi.doUnmock("node:os");
vi.resetModules();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
async function loadSkillsIndex() {
return import("./index.js");
}
function writeSkill(folder: string, contents: string): string {
const dir = path.join(rowboatSkillsRoot, folder);
fs.mkdirSync(dir, { recursive: true });
const skillFile = path.join(dir, "SKILL.md");
fs.writeFileSync(skillFile, contents);
return skillFile;
}
describe("skills index (disk skill merge layer)", () => {
it("includes disk skills in the catalog and resolves them by id and file path", async () => {
const raw = [
"---",
"name: Ship It",
"description: Ship the thing to production.",
"---",
"",
"# Ship It",
"",
"Disk skill body.",
"",
].join("\n");
const skillFile = writeSkill("ship-it", raw);
const skills = await loadSkillsIndex();
expect(skills.availableSkills).toContain("ship-it");
const catalog = skills.buildSkillCatalog();
expect(catalog).toContain("## Ship It");
expect(catalog).toContain(skillFile);
expect(catalog).toContain("Ship the thing to production.");
expect(skills.resolveSkill("ship-it")?.content).toBe(raw);
expect(skills.resolveSkill(skillFile)?.content).toBe(raw);
expect(skills.resolveSkill("ship-it")?.catalogPath).toBe(skillFile);
});
it("keeps the bundled skill when a disk skill uses the same id", async () => {
writeSkill("meeting-prep", [
"---",
"name: Shadow Meeting Prep",
"description: Disk impostor.",
"---",
"DISK CONTENT MARKER",
].join("\n"));
const skills = await loadSkillsIndex();
const resolved = skills.resolveSkill("meeting-prep");
expect(resolved?.catalogPath).toBe("src/application/assistant/skills/meeting-prep/skill.ts");
expect(resolved?.content).not.toContain("DISK CONTENT MARKER");
const catalog = skills.buildSkillCatalog();
expect(catalog).toContain("## Meeting Prep");
expect(catalog).not.toContain("Shadow Meeting Prep");
expect(skills.availableSkills.filter((id) => id === "meeting-prep")).toHaveLength(1);
});
it("refreshDiskSkills picks up skills added and removed on disk", async () => {
const skills = await loadSkillsIndex();
expect(skills.resolveSkill("late-arrival")).toBeNull();
expect(skills.availableSkills).not.toContain("late-arrival");
const raw = [
"---",
"name: Late Arrival",
"description: Added after module init.",
"---",
"Late body.",
].join("\n");
const skillFile = writeSkill("late-arrival", raw);
// Not visible until refreshed.
expect(skills.resolveSkill("late-arrival")).toBeNull();
expect(skills.buildSkillCatalog()).not.toContain("## Late Arrival");
expect(skills.refreshDiskSkills()).toBe(1);
expect(skills.availableSkills).toContain("late-arrival");
expect(skills.buildSkillCatalog()).toContain("## Late Arrival");
expect(skills.resolveSkill("late-arrival")?.content).toBe(raw);
expect(skills.resolveSkill(skillFile)?.content).toBe(raw);
fs.rmSync(path.join(rowboatSkillsRoot, "late-arrival"), { recursive: true, force: true });
expect(skills.refreshDiskSkills()).toBe(0);
expect(skills.availableSkills).not.toContain("late-arrival");
expect(skills.buildSkillCatalog()).not.toContain("## Late Arrival");
expect(skills.resolveSkill("late-arrival")).toBeNull();
expect(skills.resolveSkill(skillFile)).toBeNull();
});
});

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";
@ -135,32 +136,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}\``,
@ -178,15 +203,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;
@ -203,11 +230,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 = {
@ -234,11 +261,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() {