coding-agent, mom: add skills API export and mom skills auto-discovery

coding-agent:
- Export loadSkillsFromDir, formatSkillsForPrompt, and related types
- Refactor skills.ts to expose public API

mom:
- Add skills auto-discovery from workspace/skills and channel/skills
- Fix skill loading to use host paths (not Docker container paths)
- Update README and system prompt with SKILL.md format docs
This commit is contained in:
Mario Zechner 2025-12-13 00:56:10 +01:00
parent 439f55b0eb
commit e707ac4cd0
8 changed files with 175 additions and 65 deletions

View file

@ -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

View file

@ -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

View file

@ -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<available_skills>",
"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("</available_skills>");
return lines.join("\n");
}
export function loadSkills(): Skill[] {
const skillMap = new Map<string, Skill>();
// 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);
}

View file

@ -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<available_skills>",
"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("</available_skills>");
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

View file

@ -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";

View file

@ -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

View file

@ -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/<channel>/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

View file

@ -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<string, Skill>();
// 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/<name>/\` or \`${channelPath}/skills/<name>/\`.
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/<name>/\` (global) or \`${channelPath}/skills/<name>/\` (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);