mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-15 06:04:40 +00:00
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:
parent
439f55b0eb
commit
e707ac4cd0
8 changed files with 175 additions and 65 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue