diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 0b40683a..00000000 --- a/AGENTS.md +++ /dev/null @@ -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:"`, `"toolResult:"` — 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 -- - -# 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 -- [modelCallIndex] [--full] - -# Cascade full turn inspection across a session -npm run inspect -- --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. diff --git a/apps/x/apps/main/src/main.ts b/apps/x/apps/main/src/main.ts index 8cd7cda2..f5927484 100644 --- a/apps/x/apps/main/src/main.ts +++ b/apps/x/apps/main/src/main.ts @@ -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').disposeAll(); diff --git a/apps/x/packages/core/src/application/assistant/instructions.ts b/apps/x/packages/core/src/application/assistant/instructions.ts index 1930b1b4..9518990c 100644 --- a/apps/x/packages/core/src/application/assistant/instructions.ts +++ b/apps/x/packages/core/src/application/assistant/instructions.ts @@ -429,9 +429,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, slackConnected, slackChannelsHint, googleConnected); const composioPrompt = await getComposioToolsPrompt(slackConnected, googleConnected); cachedInstructions = composioPrompt diff --git a/apps/x/packages/core/src/application/assistant/skills/disk-loader.test.ts b/apps/x/packages/core/src/application/assistant/skills/disk-loader.test.ts new file mode 100644 index 00000000..b0682c62 --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/disk-loader.test.ts @@ -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(); + 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"); + }); +}); 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.test.ts b/apps/x/packages/core/src/application/assistant/skills/index.test.ts new file mode 100644 index 00000000..e8b4e6ed --- /dev/null +++ b/apps/x/packages/core/src/application/assistant/skills/index.test.ts @@ -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(); + 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(); + }); +}); 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 ed4fe812..e6e60a7e 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"; @@ -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(); + +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(); -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; @@ -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; } 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 82b2021e..a6b74358 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() {