diff --git a/AGENTS.md b/AGENTS.md index 4c5ebafd..4bab1823 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,7 +37,6 @@ When closing issues via commit: ## Tools - GitHub CLI for issues/PRs - Add package labels to issues/PRs: pkg:agent, pkg:ai, pkg:coding-agent, pkg:mom, pkg:pods, pkg:proxy, pkg:tui, pkg:web-ui -- Browser tools (~/agent-tools/browser-tools/README.md): browser automation for frontend testing, web searches, fetching documentation - TUI interaction: use tmux ## Style diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7c08a8a8..4b01885b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **Exported skills API**: `loadSkillsFromDir`, `formatSkillsForPrompt`, and related types are now exported for use by other packages (e.g., mom). + ## [0.20.0] - 2025-12-13 ### Breaking Changes diff --git a/packages/coding-agent/src/core/skills.ts b/packages/coding-agent/src/core/skills.ts index f0252516..a20abd52 100644 --- a/packages/coding-agent/src/core/skills.ts +++ b/packages/coding-agent/src/core/skills.ts @@ -8,14 +8,12 @@ export interface SkillFrontmatter { description: string; } -export type SkillSource = "user" | "project" | "claude-user" | "claude-project" | "codex-user"; - export interface Skill { name: string; description: string; filePath: string; baseDir: string; - source: SkillSource; + source: string; } type SkillFormat = "recursive" | "claude"; @@ -60,9 +58,27 @@ function parseFrontmatter(content: string): { frontmatter: SkillFrontmatter; bod return { frontmatter, body }; } -function loadSkillsFromDir( +export interface LoadSkillsFromDirOptions { + /** Directory to scan for skills */ + dir: string; + /** Source identifier for these skills */ + source: string; + /** Use colon-separated path names (e.g., db:migrate) instead of simple directory name */ + useColonPath?: boolean; +} + +/** + * Load skills from a directory recursively. + * Skills are directories containing a SKILL.md file with frontmatter including a description. + */ +export function loadSkillsFromDir(options: LoadSkillsFromDirOptions, subdir: string = ""): Skill[] { + const { dir, source, useColonPath = false } = options; + return loadSkillsFromDirInternal(dir, source, "recursive", useColonPath, subdir); +} + +function loadSkillsFromDirInternal( dir: string, - source: SkillSource, + source: string, format: SkillFormat, useColonPath: boolean = false, subdir: string = "", @@ -91,7 +107,7 @@ function loadSkillsFromDir( // Recursive format: scan directories, look for SKILL.md files if (entry.isDirectory()) { const newSubdir = subdir ? `${subdir}:${entry.name}` : entry.name; - skills.push(...loadSkillsFromDir(fullPath, source, format, useColonPath, newSubdir)); + skills.push(...loadSkillsFromDirInternal(fullPath, source, format, useColonPath, newSubdir)); } else if (entry.isFile() && entry.name === "SKILL.md") { try { const rawContent = readFileSync(fullPath, "utf-8"); @@ -153,34 +169,60 @@ function loadSkillsFromDir( return skills; } +/** + * Format skills for inclusion in a system prompt. + */ +export function formatSkillsForPrompt(skills: Skill[]): string { + if (skills.length === 0) { + return ""; + } + + const lines = [ + "\n\n", + "The following skills provide specialized instructions for specific tasks.", + "Use the read tool to load a skill's file when the task matches its description.", + "Skills may contain {baseDir} placeholders - replace them with the skill's base directory path.\n", + ]; + + for (const skill of skills) { + lines.push(`- ${skill.name}: ${skill.description}`); + lines.push(` File: ${skill.filePath}`); + lines.push(` Base directory: ${skill.baseDir}`); + } + + lines.push(""); + + return lines.join("\n"); +} + export function loadSkills(): Skill[] { const skillMap = new Map(); // Codex: recursive, simple directory name const codexUserDir = join(homedir(), ".codex", "skills"); - for (const skill of loadSkillsFromDir(codexUserDir, "codex-user", "recursive", false)) { + for (const skill of loadSkillsFromDirInternal(codexUserDir, "codex-user", "recursive", false)) { skillMap.set(skill.name, skill); } // Claude: single level only const claudeUserDir = join(homedir(), ".claude", "skills"); - for (const skill of loadSkillsFromDir(claudeUserDir, "claude-user", "claude", false)) { + for (const skill of loadSkillsFromDirInternal(claudeUserDir, "claude-user", "claude", false)) { skillMap.set(skill.name, skill); } const claudeProjectDir = resolve(process.cwd(), ".claude", "skills"); - for (const skill of loadSkillsFromDir(claudeProjectDir, "claude-project", "claude", false)) { + for (const skill of loadSkillsFromDirInternal(claudeProjectDir, "claude-project", "claude", false)) { skillMap.set(skill.name, skill); } // Pi: recursive, colon-separated path names const globalSkillsDir = join(homedir(), CONFIG_DIR_NAME, "agent", "skills"); - for (const skill of loadSkillsFromDir(globalSkillsDir, "user", "recursive", true)) { + for (const skill of loadSkillsFromDirInternal(globalSkillsDir, "user", "recursive", true)) { skillMap.set(skill.name, skill); } const projectSkillsDir = resolve(process.cwd(), CONFIG_DIR_NAME, "skills"); - for (const skill of loadSkillsFromDir(projectSkillsDir, "project", "recursive", true)) { + for (const skill of loadSkillsFromDirInternal(projectSkillsDir, "project", "recursive", true)) { skillMap.set(skill.name, skill); } diff --git a/packages/coding-agent/src/core/system-prompt.ts b/packages/coding-agent/src/core/system-prompt.ts index b0cc6bb7..eaaf22b2 100644 --- a/packages/coding-agent/src/core/system-prompt.ts +++ b/packages/coding-agent/src/core/system-prompt.ts @@ -6,7 +6,7 @@ import chalk from "chalk"; import { existsSync, readFileSync } from "fs"; import { join, resolve } from "path"; import { getAgentDir, getDocsPath, getReadmePath } from "../config.js"; -import { loadSkills, type Skill } from "./skills.js"; +import { formatSkillsForPrompt, loadSkills } from "./skills.js"; import type { ToolName } from "./tools/index.js"; /** Tool descriptions for system prompt */ @@ -102,29 +102,6 @@ export function loadProjectContextFiles(): Array<{ path: string; content: string return contextFiles; } -function buildSkillsSection(skills: Skill[]): string { - if (skills.length === 0) { - return ""; - } - - const lines = [ - "\n\n", - "The following skills provide specialized instructions for specific tasks.", - "Use the read tool to load a skill's file when the task matches its description.", - "Skills may contain {baseDir} placeholders - replace them with the skill's base directory path.\n", - ]; - - for (const skill of skills) { - lines.push(`- ${skill.name}: ${skill.description}`); - lines.push(` File: ${skill.filePath}`); - lines.push(` Base directory: ${skill.baseDir}`); - } - - lines.push(""); - - return lines.join("\n"); -} - export interface BuildSystemPromptOptions { customPrompt?: string; selectedTools?: ToolName[]; @@ -173,7 +150,7 @@ export function buildSystemPrompt(options: BuildSystemPromptOptions = {}): strin const customPromptHasRead = !selectedTools || selectedTools.includes("read"); if (skillsEnabled && customPromptHasRead) { const skills = loadSkills(); - prompt += buildSkillsSection(skills); + prompt += formatSkillsForPrompt(skills); } // Add date/time and working directory last @@ -279,7 +256,7 @@ Documentation: // Append skills section (only if read tool is available) if (skillsEnabled && hasRead) { const skills = loadSkills(); - prompt += buildSkillsSection(skills); + prompt += formatSkillsForPrompt(skills); } // Add date/time and working directory last diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index a266086c..0226ca2e 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -65,6 +65,15 @@ export { type Settings, SettingsManager, } from "./core/settings-manager.js"; +// Skills +export { + formatSkillsForPrompt, + type LoadSkillsFromDirOptions, + loadSkills, + loadSkillsFromDir, + type Skill, + type SkillFrontmatter, +} from "./core/skills.js"; // Tools export { bashTool, codingTools, editTool, readTool, writeTool } from "./core/tools/index.js"; diff --git a/packages/mom/CHANGELOG.md b/packages/mom/CHANGELOG.md index a3e460e1..92d11c7b 100644 --- a/packages/mom/CHANGELOG.md +++ b/packages/mom/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- **Skills auto-discovery**: Mom now automatically discovers skills from `workspace/skills/` and `channel/skills/` directories. Skills are directories containing a `SKILL.md` file with a `description` in YAML frontmatter. Available skills are listed in the system prompt with their descriptions. Mom reads the `SKILL.md` file before using a skill. + ## [0.19.2] - 2025-12-12 ### Added diff --git a/packages/mom/README.md b/packages/mom/README.md index 89774b20..6f07c395 100644 --- a/packages/mom/README.md +++ b/packages/mom/README.md @@ -237,11 +237,50 @@ Mom can write custom CLI tools to help with recurring tasks, access specific sys - `data/skills/`: Global tools available everywhere - `data//skills/`: Channel-specific tools -Each skill includes: -- The tool implementation (Node.js script, Bash script, etc.) -- `SKILL.md`: Documentation on how to use the skill -- Configuration files for API keys/credentials -- Entry in global memory's skills table +**Skills are auto-discovered.** Each skill directory must contain a `SKILL.md` file with YAML frontmatter: + +```markdown +--- +description: Read, search, and send Gmail via IMAP/SMTP +name: gmail +--- + +# Gmail Skill + +## Setup +Run `node gmail.js setup` and enter your Gmail app password. + +## Usage +\`\`\`bash +node {baseDir}/gmail.js search --unread --limit 10 +node {baseDir}/gmail.js read 12345 +node {baseDir}/gmail.js send --to "user@example.com" --subject "Hello" --body "Message" +\`\`\` +``` + +**Frontmatter fields:** + +| Field | Required | Description | +|-------|----------|-------------| +| `description` | Yes | Short description shown in mom's system prompt | +| `name` | No | Override skill name (defaults to directory name) | + +**Variables:** + +Use `{baseDir}` as a placeholder for the skill's directory path. Mom substitutes the actual path when reading the skill. + +**How it works:** + +Mom sees available skills listed in her system prompt with their descriptions. When a task matches a skill, she reads the full `SKILL.md` to get usage instructions. + +**Skill directory structure:** +``` +data/skills/gmail/ +├── SKILL.md # Required: frontmatter + instructions +├── gmail.js # Tool implementation +├── config.json # Credentials (created on first use) +└── package.json # Dependencies (if Node.js) +``` You develop skills together with mom. Tell her what you need and she'll create the tools accordingly. Knowing how to program and how to steer coding agents helps with this task. Ask a friendly neighborhood programmer if you get stuck. Most tools take 5-10 minutes to create. You can even put them in a git repo for versioning and reuse across different mom instances. @@ -267,21 +306,7 @@ node fetch-content.js https://example.com/article ``` Mom creates a Node.js tool that fetches URLs and extracts readable content as markdown. No API key needed. Works for articles, docs, Wikipedia. -You can ask mom to document each skill in global memory. Here's what that looks like: - -```markdown -## Skills - -| Skill | Path | Description | -|-------|------|-------------| -| gmail | /workspace/skills/gmail/ | Read, search, send, archive Gmail via IMAP/SMTP | -| transcribe | /workspace/skills/transcribe/ | Transcribe audio to text via Groq Whisper API | -| fetch-content | /workspace/skills/fetch-content/ | Fetch URLs and extract content as markdown | - -To use a skill, read its SKILL.md first. -``` - -Mom will read the `SKILL.md` file before using a skill, and reuse stored credentials automatically. +Mom automatically discovers skills and lists them in her system prompt. She reads the `SKILL.md` before using a skill and reuses stored credentials automatically. ### Updating Mom diff --git a/packages/mom/src/agent.ts b/packages/mom/src/agent.ts index d8f279ef..6a967ba3 100644 --- a/packages/mom/src/agent.ts +++ b/packages/mom/src/agent.ts @@ -1,6 +1,12 @@ import { Agent, type AgentEvent, ProviderTransport } from "@mariozechner/pi-agent-core"; import { getModel } from "@mariozechner/pi-ai"; -import { AgentSession, messageTransformer } from "@mariozechner/pi-coding-agent"; +import { + AgentSession, + formatSkillsForPrompt, + loadSkillsFromDir, + messageTransformer, + type Skill, +} from "@mariozechner/pi-coding-agent"; import { existsSync, readFileSync } from "fs"; import { mkdir, writeFile } from "fs/promises"; import { join } from "path"; @@ -94,6 +100,28 @@ function getMemory(channelDir: string): string { return parts.join("\n\n"); } +function loadMomSkills(channelDir: string): Skill[] { + const skillMap = new Map(); + + // channelDir is the host path (e.g., /Users/.../data/C0A34FL8PMH) + // workspace is the parent directory + const hostWorkspacePath = join(channelDir, ".."); + + // Load workspace-level skills (global) + const workspaceSkillsDir = join(hostWorkspacePath, "skills"); + for (const skill of loadSkillsFromDir({ dir: workspaceSkillsDir, source: "workspace" })) { + skillMap.set(skill.name, skill); + } + + // Load channel-specific skills (override workspace skills on collision) + const channelSkillsDir = join(channelDir, "skills"); + for (const skill of loadSkillsFromDir({ dir: channelSkillsDir, source: "channel" })) { + skillMap.set(skill.name, skill); + } + + return Array.from(skillMap.values()); +} + function buildSystemPrompt( workspacePath: string, channelId: string, @@ -101,6 +129,7 @@ function buildSystemPrompt( sandboxConfig: SandboxConfig, channels: ChannelInfo[], users: UserInfo[], + skills: Skill[], ): string { const channelPath = `${workspacePath}/${channelId}`; const isDocker = sandboxConfig.type === "docker"; @@ -156,9 +185,27 @@ ${workspacePath}/ ## Skills (Custom CLI Tools) You can create reusable CLI tools for recurring tasks (email, APIs, data processing, etc.). -Store in \`${workspacePath}/skills//\` or \`${channelPath}/skills//\`. -Each skill needs a \`SKILL.md\` documenting usage. Read it before using a skill. -List skills in global memory so you remember them. + +### Creating Skills +Store in \`${workspacePath}/skills//\` (global) or \`${channelPath}/skills//\` (channel-specific). +Each skill directory needs a \`SKILL.md\` with YAML frontmatter: + +\`\`\`markdown +--- +name: skill-name +description: Short description of what this skill does +--- + +# Skill Name + +Usage instructions, examples, etc. +Scripts are in: {baseDir}/ +\`\`\` + +\`name\` and \`description\` are required. Use \`{baseDir}\` as placeholder for the skill's directory path. + +### Available Skills +${skills.length > 0 ? formatSkillsForPrompt(skills) : "(no skills installed yet)"} ## Events You can schedule events that wake you up at specific times or when external things happen. Events are JSON files in \`${workspacePath}/events/\`. @@ -352,9 +399,10 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi // Create tools const tools = createMomTools(executor); - // Initial system prompt (will be updated each run with fresh memory/channels/users) + // Initial system prompt (will be updated each run with fresh memory/channels/users/skills) const memory = getMemory(channelDir); - const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, [], []); + const skills = loadMomSkills(channelDir); + const systemPrompt = buildSystemPrompt(workspacePath, channelId, memory, sandboxConfig, [], [], skills); // Create session manager and settings manager // Pass model info so new sessions get a header written immediately @@ -572,8 +620,9 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi log.logInfo(`[${channelId}] Reloaded ${reloadedSession.messages.length} messages from context`); } - // Update system prompt with fresh memory and channel/user info + // Update system prompt with fresh memory, channel/user info, and skills const memory = getMemory(channelDir); + const skills = loadMomSkills(channelDir); const systemPrompt = buildSystemPrompt( workspacePath, channelId, @@ -581,6 +630,7 @@ function createRunner(sandboxConfig: SandboxConfig, channelId: string, channelDi sandboxConfig, ctx.channels, ctx.users, + skills, ); session.agent.setSystemPrompt(systemPrompt);