mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-07-12 21:02:17 +02:00
Merge pull request #651 from prakhar1605/feat/disk-skills
feat: disk-backed agent skills with live-reload
This commit is contained in:
commit
678130ab40
9 changed files with 657 additions and 126 deletions
101
AGENTS.md
101
AGENTS.md
|
|
@ -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.
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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