feat(agent): add skills system with profile integration (#21)
* chore(deps): add yaml package for skill parsing Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * feat(agent): add skills system Implement a skills system inspired by moltbot's approach: - Skills are markdown files (SKILL.md) with YAML frontmatter - Multi-source loading with precedence: bundled < user < workspace - Eligibility filtering based on platform, binaries, and env vars - Skills are automatically included in agent system prompt - New AgentOptions: enableSkills, skillsBaseDir, extraSkillDirs Includes two bundled skills: - commit: Git commit helper with conventional commit guidelines - code-review: Code review checklist and best practices Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * refactor(skills): use profile-based skills instead of workspace Change skill loading from workspace-based (.skills/) to profile-based: - Skills now load from ~/.super-multica/agent-profiles/<profileId>/skills/ - Remove workspace and user skill sources - Simplify to only bundled and profile sources - Profile skills have higher precedence than bundled This is more appropriate for non-coding agents where skills are associated with agent identity rather than working directory. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9b3ffd1e90
commit
50ae997ab4
12 changed files with 839 additions and 7 deletions
|
|
@ -53,6 +53,7 @@
|
|||
"socket.io-client": "^4.8.3",
|
||||
"turndown": "^7.2.2",
|
||||
"undici": "^7.19.2",
|
||||
"uuid": "^13.0.0"
|
||||
"uuid": "^13.0.0",
|
||||
"yaml": "^2.8.2"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
|
|
@ -80,6 +80,9 @@ importers:
|
|||
uuid:
|
||||
specifier: ^13.0.0
|
||||
version: 13.0.0
|
||||
yaml:
|
||||
specifier: ^2.8.2
|
||||
version: 2.8.2
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^25.0.10
|
||||
|
|
|
|||
81
skills/code-review/SKILL.md
Normal file
81
skills/code-review/SKILL.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
---
|
||||
name: Code Review
|
||||
description: Review code for bugs, security issues, and best practices
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
emoji: "🔍"
|
||||
tags:
|
||||
- code-quality
|
||||
- security
|
||||
- review
|
||||
---
|
||||
|
||||
## Instructions
|
||||
|
||||
When the user asks you to review code, follow these guidelines:
|
||||
|
||||
### Review Checklist
|
||||
|
||||
1. **Correctness**
|
||||
- Does the code do what it's supposed to do?
|
||||
- Are there any logic errors?
|
||||
- Are edge cases handled?
|
||||
|
||||
2. **Security**
|
||||
- Input validation and sanitization
|
||||
- SQL injection vulnerabilities
|
||||
- XSS vulnerabilities
|
||||
- Command injection
|
||||
- Path traversal
|
||||
- Sensitive data exposure
|
||||
- Authentication/authorization issues
|
||||
|
||||
3. **Code Quality**
|
||||
- Is the code readable and maintainable?
|
||||
- Are variable/function names descriptive?
|
||||
- Is there unnecessary complexity?
|
||||
- Are there code duplications?
|
||||
|
||||
4. **Performance**
|
||||
- Are there obvious performance issues?
|
||||
- N+1 queries
|
||||
- Unnecessary loops or computations
|
||||
- Memory leaks
|
||||
|
||||
5. **Error Handling**
|
||||
- Are errors properly caught and handled?
|
||||
- Are error messages helpful?
|
||||
- Is there proper logging?
|
||||
|
||||
6. **Testing**
|
||||
- Are there tests for the new code?
|
||||
- Do the tests cover edge cases?
|
||||
|
||||
### Review Format
|
||||
|
||||
Structure your review as follows:
|
||||
|
||||
```
|
||||
## Summary
|
||||
[Brief overview of what the code does and overall assessment]
|
||||
|
||||
## Critical Issues
|
||||
[Must-fix issues: bugs, security vulnerabilities]
|
||||
|
||||
## Suggestions
|
||||
[Improvements and best practices recommendations]
|
||||
|
||||
## Questions
|
||||
[Clarifications needed about intent or design decisions]
|
||||
|
||||
## Positive Aspects
|
||||
[Good practices observed in the code]
|
||||
```
|
||||
|
||||
### Guidelines
|
||||
|
||||
- Be constructive, not critical
|
||||
- Explain the "why" behind suggestions
|
||||
- Provide concrete examples for improvements
|
||||
- Prioritize issues by severity
|
||||
- Acknowledge good practices
|
||||
75
skills/commit/SKILL.md
Normal file
75
skills/commit/SKILL.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
---
|
||||
name: Git Commit Helper
|
||||
description: Create well-formatted git commits following conventional commit standards
|
||||
version: 1.0.0
|
||||
metadata:
|
||||
emoji: "📝"
|
||||
requiresBinaries:
|
||||
- git
|
||||
tags:
|
||||
- git
|
||||
- developer-tools
|
||||
---
|
||||
|
||||
## Instructions
|
||||
|
||||
When the user asks you to create a commit or commit their changes, follow these guidelines:
|
||||
|
||||
### Step 1: Review Changes
|
||||
|
||||
1. Run `git status` to see what files have changed
|
||||
2. Run `git diff` to see the actual changes
|
||||
3. If there are staged changes, also run `git diff --staged`
|
||||
|
||||
### Step 2: Analyze and Group Changes
|
||||
|
||||
Group related changes into logical commits:
|
||||
- Feature additions
|
||||
- Bug fixes
|
||||
- Refactoring (no functional change)
|
||||
- Documentation
|
||||
- Tests
|
||||
- Configuration/dependencies
|
||||
|
||||
### Step 3: Create Atomic Commits
|
||||
|
||||
For each logical group of changes:
|
||||
|
||||
1. Stage only the relevant files: `git add <file1> <file2>`
|
||||
2. Create a commit with conventional message format
|
||||
|
||||
### Commit Message Format
|
||||
|
||||
Use conventional commits:
|
||||
- `feat`: New feature
|
||||
- `fix`: Bug fix
|
||||
- `refactor`: Code refactoring (no functional change)
|
||||
- `docs`: Documentation changes
|
||||
- `test`: Adding or updating tests
|
||||
- `chore`: Build, config, dependencies
|
||||
|
||||
Format: `<type>(<scope>): <description>`
|
||||
|
||||
Example: `feat(auth): add user login endpoint`
|
||||
|
||||
### Rules
|
||||
|
||||
- Each commit should be independently meaningful and buildable
|
||||
- Related test files should be committed with their implementation
|
||||
- Never create empty commits
|
||||
- Never combine unrelated changes in one commit
|
||||
- Keep commit messages concise but descriptive
|
||||
- If all changes are related to one logical unit, a single commit is fine
|
||||
|
||||
### Example
|
||||
|
||||
If the user modified:
|
||||
- `src/api/user.ts` (added new endpoint)
|
||||
- `src/api/user.test.ts` (tests for new endpoint)
|
||||
- `src/utils/format.ts` (refactored helper)
|
||||
- `README.md` (updated docs)
|
||||
|
||||
Create three commits:
|
||||
1. `git add src/api/user.ts src/api/user.test.ts && git commit -m "feat(api): add user profile endpoint"`
|
||||
2. `git add src/utils/format.ts && git commit -m "refactor(utils): simplify date formatting logic"`
|
||||
3. `git add README.md && git commit -m "docs: update API documentation"`
|
||||
|
|
@ -2,3 +2,4 @@ export * from "./runner.js";
|
|||
export * from "./types.js";
|
||||
export * from "./profile/index.js";
|
||||
export * from "./context-window/index.js";
|
||||
export * from "./skills/index.js";
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { createAgentOutput } from "./output.js";
|
|||
import { resolveModel, resolveTools } from "./tools.js";
|
||||
import { SessionManager } from "./session/session-manager.js";
|
||||
import { ProfileManager } from "./profile/index.js";
|
||||
import { SkillManager } from "./skills/index.js";
|
||||
import {
|
||||
checkContextWindow,
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
|
|
@ -43,6 +44,7 @@ export class Agent {
|
|||
private readonly output;
|
||||
private readonly session: SessionManager;
|
||||
private readonly profile?: ProfileManager;
|
||||
private readonly skillManager?: SkillManager;
|
||||
private readonly contextWindowGuard: ContextWindowGuardResult;
|
||||
private readonly debug: boolean;
|
||||
|
||||
|
|
@ -57,7 +59,7 @@ export class Agent {
|
|||
|
||||
this.agent = new PiAgentCore();
|
||||
|
||||
// 加载 Agent Profile(如果指定了 profileId)
|
||||
// Load Agent Profile (if profileId is specified)
|
||||
let systemPrompt: string | undefined;
|
||||
if (options.profileId) {
|
||||
this.profile = new ProfileManager({
|
||||
|
|
@ -65,13 +67,29 @@ export class Agent {
|
|||
baseDir: options.profileBaseDir,
|
||||
});
|
||||
systemPrompt = this.profile.buildSystemPrompt();
|
||||
if (systemPrompt) {
|
||||
this.agent.setSystemPrompt(systemPrompt);
|
||||
}
|
||||
} else if (options.systemPrompt) {
|
||||
// 直接使用传入的 systemPrompt
|
||||
// Use provided systemPrompt directly
|
||||
systemPrompt = options.systemPrompt;
|
||||
this.agent.setSystemPrompt(options.systemPrompt);
|
||||
}
|
||||
|
||||
// Initialize SkillManager (enabled by default)
|
||||
if (options.enableSkills !== false) {
|
||||
this.skillManager = new SkillManager({
|
||||
profileId: options.profileId,
|
||||
profileBaseDir: options.profileBaseDir,
|
||||
extraDirs: options.extraSkillDirs,
|
||||
});
|
||||
|
||||
// Append skills prompt to system prompt
|
||||
const skillsPrompt = this.skillManager.buildSkillsPrompt();
|
||||
if (skillsPrompt) {
|
||||
systemPrompt = systemPrompt ? `${systemPrompt}\n\n${skillsPrompt}` : skillsPrompt;
|
||||
}
|
||||
}
|
||||
|
||||
// Set the combined system prompt
|
||||
if (systemPrompt) {
|
||||
this.agent.setSystemPrompt(systemPrompt);
|
||||
}
|
||||
|
||||
this.sessionId = options.sessionId ?? uuidv7();
|
||||
|
|
|
|||
110
src/agent/skills/eligibility.ts
Normal file
110
src/agent/skills/eligibility.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/**
|
||||
* Skill Eligibility Checker
|
||||
*
|
||||
* Filter skills based on platform, binaries, and environment requirements
|
||||
*/
|
||||
|
||||
import { execSync } from "node:child_process";
|
||||
import type { Skill, EligibilityResult } from "./types.js";
|
||||
|
||||
/**
|
||||
* Check if a binary exists in PATH
|
||||
*
|
||||
* @param binary - Binary name to check
|
||||
* @returns True if binary exists
|
||||
*/
|
||||
function binaryExists(binary: string): boolean {
|
||||
try {
|
||||
// Use 'which' on Unix, 'where' on Windows
|
||||
const cmd = process.platform === "win32" ? `where ${binary}` : `which ${binary}`;
|
||||
execSync(cmd, { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an environment variable is set
|
||||
*
|
||||
* @param envVar - Environment variable name
|
||||
* @returns True if set (even if empty string)
|
||||
*/
|
||||
function envExists(envVar: string): boolean {
|
||||
return envVar in process.env;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a skill is eligible based on its requirements
|
||||
*
|
||||
* @param skill - Skill to check
|
||||
* @param platform - Platform to check against (defaults to current)
|
||||
* @returns Eligibility result with reasons if ineligible
|
||||
*/
|
||||
export function checkEligibility(
|
||||
skill: Skill,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
): EligibilityResult {
|
||||
const reasons: string[] = [];
|
||||
const metadata = skill.frontmatter.metadata;
|
||||
|
||||
// No metadata means no requirements
|
||||
if (!metadata) {
|
||||
return { eligible: true };
|
||||
}
|
||||
|
||||
// Platform check
|
||||
if (metadata.platforms && metadata.platforms.length > 0) {
|
||||
if (!metadata.platforms.includes(platform)) {
|
||||
reasons.push(
|
||||
`Platform '${platform}' not supported (requires: ${metadata.platforms.join(", ")})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Binary requirements check
|
||||
if (metadata.requiresBinaries && metadata.requiresBinaries.length > 0) {
|
||||
for (const binary of metadata.requiresBinaries) {
|
||||
if (!binaryExists(binary)) {
|
||||
reasons.push(`Required binary not found: ${binary}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Environment variable check
|
||||
if (metadata.requiresEnv && metadata.requiresEnv.length > 0) {
|
||||
for (const envVar of metadata.requiresEnv) {
|
||||
if (!envExists(envVar)) {
|
||||
reasons.push(`Required environment variable not set: ${envVar}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
eligible: reasons.length === 0,
|
||||
reasons: reasons.length > 0 ? reasons : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter skills by eligibility
|
||||
*
|
||||
* @param skills - Map of skills to filter
|
||||
* @param platform - Platform to check against
|
||||
* @returns Map containing only eligible skills
|
||||
*/
|
||||
export function filterEligibleSkills(
|
||||
skills: Map<string, Skill>,
|
||||
platform?: NodeJS.Platform,
|
||||
): Map<string, Skill> {
|
||||
const eligible = new Map<string, Skill>();
|
||||
|
||||
for (const [id, skill] of skills) {
|
||||
const result = checkEligibility(skill, platform);
|
||||
if (result.eligible) {
|
||||
eligible.set(id, skill);
|
||||
}
|
||||
}
|
||||
|
||||
return eligible;
|
||||
}
|
||||
166
src/agent/skills/index.ts
Normal file
166
src/agent/skills/index.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
/**
|
||||
* Skills Module
|
||||
*
|
||||
* Manages skill loading, eligibility filtering, and system prompt generation
|
||||
*/
|
||||
|
||||
import type { Skill, SkillManagerOptions } from "./types.js";
|
||||
import { loadAllSkills, getBundledSkillsDir, getProfileSkillsDir } from "./loader.js";
|
||||
import { filterEligibleSkills, checkEligibility } from "./eligibility.js";
|
||||
|
||||
// Re-export types and utilities
|
||||
export type {
|
||||
Skill,
|
||||
SkillFrontmatter,
|
||||
SkillMetadata,
|
||||
SkillSource,
|
||||
SkillManagerOptions,
|
||||
EligibilityResult,
|
||||
} from "./types.js";
|
||||
|
||||
export { SKILL_FILE, SKILL_SOURCE_PRECEDENCE } from "./types.js";
|
||||
export { checkEligibility, filterEligibleSkills } from "./eligibility.js";
|
||||
export { parseFrontmatter, parseSkillFile } from "./parser.js";
|
||||
export { loadAllSkills, getBundledSkillsDir, getProfileSkillsDir } from "./loader.js";
|
||||
|
||||
/**
|
||||
* SkillManager - Loads and manages skills
|
||||
*
|
||||
* Provides access to skills from multiple sources with precedence handling
|
||||
* and eligibility filtering.
|
||||
*/
|
||||
export class SkillManager {
|
||||
private readonly options: SkillManagerOptions;
|
||||
private skills: Map<string, Skill> | undefined;
|
||||
private eligibleSkills: Map<string, Skill> | undefined;
|
||||
|
||||
constructor(options: SkillManagerOptions = {}) {
|
||||
this.options = options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure skills are loaded (lazy loading)
|
||||
*/
|
||||
private ensureLoaded(): void {
|
||||
if (this.skills) return;
|
||||
this.skills = loadAllSkills(this.options);
|
||||
this.eligibleSkills = filterEligibleSkills(this.skills, this.options.platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all loaded skills (including ineligible)
|
||||
*/
|
||||
getAllSkills(): Map<string, Skill> {
|
||||
this.ensureLoaded();
|
||||
return this.skills!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get only eligible skills
|
||||
*/
|
||||
getEligibleSkills(): Map<string, Skill> {
|
||||
this.ensureLoaded();
|
||||
return this.eligibleSkills!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific skill by ID (only from eligible skills)
|
||||
*
|
||||
* @param skillId - Skill identifier
|
||||
* @returns Skill or undefined if not found or ineligible
|
||||
*/
|
||||
getSkill(skillId: string): Skill | undefined {
|
||||
this.ensureLoaded();
|
||||
return this.eligibleSkills!.get(skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill by ID from all skills (including ineligible)
|
||||
*
|
||||
* @param skillId - Skill identifier
|
||||
* @returns Skill or undefined if not found
|
||||
*/
|
||||
getSkillFromAll(skillId: string): Skill | undefined {
|
||||
this.ensureLoaded();
|
||||
return this.skills!.get(skillId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload skills from disk
|
||||
* Clears cache and reloads on next access
|
||||
*/
|
||||
reload(): void {
|
||||
this.skills = undefined;
|
||||
this.eligibleSkills = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build skills section for system prompt
|
||||
*
|
||||
* Generates formatted documentation of all eligible skills
|
||||
* for inclusion in the agent's system prompt.
|
||||
*
|
||||
* @returns Formatted skill documentation or empty string if no skills
|
||||
*/
|
||||
buildSkillsPrompt(): string {
|
||||
this.ensureLoaded();
|
||||
|
||||
if (this.eligibleSkills!.size === 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
parts.push("# Available Skills\n");
|
||||
parts.push("You have access to the following skills:\n");
|
||||
|
||||
for (const [id, skill] of this.eligibleSkills!) {
|
||||
const emoji = skill.frontmatter.metadata?.emoji ?? "🔧";
|
||||
const name = skill.frontmatter.name;
|
||||
const desc = skill.frontmatter.description ?? "No description provided";
|
||||
|
||||
parts.push(`## ${emoji} ${name} (${id})`);
|
||||
parts.push(`${desc}\n`);
|
||||
|
||||
// Include full instructions
|
||||
if (skill.instructions) {
|
||||
parts.push(skill.instructions);
|
||||
parts.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get skill instructions for a specific skill
|
||||
*
|
||||
* @param skillId - Skill identifier
|
||||
* @returns Instructions markdown or undefined if not found
|
||||
*/
|
||||
getSkillInstructions(skillId: string): string | undefined {
|
||||
const skill = this.getSkill(skillId);
|
||||
return skill?.instructions;
|
||||
}
|
||||
|
||||
/**
|
||||
* List skill IDs with their display info
|
||||
*
|
||||
* @returns Array of skill info for display
|
||||
*/
|
||||
listSkills(): Array<{ id: string; name: string; emoji: string; description: string }> {
|
||||
this.ensureLoaded();
|
||||
|
||||
const result: Array<{ id: string; name: string; emoji: string; description: string }> = [];
|
||||
|
||||
for (const [id, skill] of this.eligibleSkills!) {
|
||||
result.push({
|
||||
id,
|
||||
name: skill.frontmatter.name,
|
||||
emoji: skill.frontmatter.metadata?.emoji ?? "🔧",
|
||||
description: skill.frontmatter.description ?? "No description",
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
142
src/agent/skills/loader.ts
Normal file
142
src/agent/skills/loader.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* Skills Loader
|
||||
*
|
||||
* Multi-source loading with precedence handling
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Skill, SkillSource, SkillManagerOptions } from "./types.js";
|
||||
import { SKILL_FILE, SKILL_SOURCE_PRECEDENCE } from "./types.js";
|
||||
import { parseSkillFile } from "./parser.js";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Default profile base directory */
|
||||
const DEFAULT_PROFILE_BASE_DIR = join(homedir(), ".super-multica", "agent-profiles");
|
||||
|
||||
/** Bundled skills directory (relative to package) */
|
||||
const BUNDLED_DIR = join(__dirname, "../../../skills");
|
||||
|
||||
/**
|
||||
* Discover skill directories in a given base path
|
||||
* A valid skill directory contains a SKILL.md file
|
||||
*
|
||||
* @param baseDir - Base directory to search
|
||||
* @returns Array of absolute paths to skill directories
|
||||
*/
|
||||
function discoverSkillDirs(baseDir: string): string[] {
|
||||
if (!existsSync(baseDir)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const entries = readdirSync(baseDir);
|
||||
return entries
|
||||
.map((name) => join(baseDir, name))
|
||||
.filter((path) => {
|
||||
try {
|
||||
if (!statSync(path).isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
return existsSync(join(path, SKILL_FILE));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all skills from a source directory
|
||||
*
|
||||
* @param baseDir - Base directory containing skill subdirectories
|
||||
* @param source - Source type for loaded skills
|
||||
* @returns Array of loaded skills
|
||||
*/
|
||||
function loadSkillsFromSource(baseDir: string, source: SkillSource): Skill[] {
|
||||
const skillDirs = discoverSkillDirs(baseDir);
|
||||
const skills: Skill[] = [];
|
||||
|
||||
for (const dir of skillDirs) {
|
||||
const skillId = dir.split("/").pop();
|
||||
if (!skillId) continue;
|
||||
|
||||
const filePath = join(dir, SKILL_FILE);
|
||||
const skill = parseSkillFile(filePath, skillId, source);
|
||||
if (skill) {
|
||||
skills.push(skill);
|
||||
}
|
||||
}
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile skills directory path
|
||||
*
|
||||
* @param profileId - Agent profile ID
|
||||
* @param profileBaseDir - Profile base directory
|
||||
* @returns Path to profile skills directory
|
||||
*/
|
||||
export function getProfileSkillsDir(profileId: string, profileBaseDir?: string): string {
|
||||
const baseDir = profileBaseDir ?? DEFAULT_PROFILE_BASE_DIR;
|
||||
return join(baseDir, profileId, "skills");
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all skills from all sources, applying precedence
|
||||
* Higher precedence sources override skills with the same ID
|
||||
*
|
||||
* Loading order (lowest to highest precedence):
|
||||
* 1. bundled - Package bundled skills
|
||||
* 2. extra - User-configured extra directories
|
||||
* 3. profile - ~/.super-multica/agent-profiles/<profileId>/skills/
|
||||
*
|
||||
* @param options - Loader options
|
||||
* @returns Map of skill ID to Skill
|
||||
*/
|
||||
export function loadAllSkills(options: SkillManagerOptions = {}): Map<string, Skill> {
|
||||
const skillMap = new Map<string, Skill>();
|
||||
|
||||
// Define sources in order of precedence (lowest first)
|
||||
const sources: Array<[string, SkillSource]> = [
|
||||
// Bundled skills (lowest precedence)
|
||||
[BUNDLED_DIR, "bundled"],
|
||||
// Extra directories (treated as bundled)
|
||||
...(options.extraDirs ?? []).map((d): [string, SkillSource] => [d, "bundled"]),
|
||||
];
|
||||
|
||||
// Add profile skills if profileId is provided (highest precedence)
|
||||
if (options.profileId) {
|
||||
const profileSkillsDir = getProfileSkillsDir(options.profileId, options.profileBaseDir);
|
||||
sources.push([profileSkillsDir, "profile"]);
|
||||
}
|
||||
|
||||
for (const [dir, source] of sources) {
|
||||
const skills = loadSkillsFromSource(dir, source);
|
||||
for (const skill of skills) {
|
||||
const existing = skillMap.get(skill.id);
|
||||
// Higher precedence overwrites lower
|
||||
if (
|
||||
!existing ||
|
||||
SKILL_SOURCE_PRECEDENCE[source] > SKILL_SOURCE_PRECEDENCE[existing.source]
|
||||
) {
|
||||
skillMap.set(skill.id, skill);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return skillMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get path to bundled skills directory
|
||||
*/
|
||||
export function getBundledSkillsDir(): string {
|
||||
return BUNDLED_DIR;
|
||||
}
|
||||
132
src/agent/skills/parser.ts
Normal file
132
src/agent/skills/parser.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
/**
|
||||
* SKILL.md Parser
|
||||
*
|
||||
* Parse skill files with YAML frontmatter and markdown body
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { parse as parseYaml } from "yaml";
|
||||
import type { Skill, SkillFrontmatter, SkillSource } from "./types.js";
|
||||
|
||||
/**
|
||||
* Parse YAML frontmatter from markdown content
|
||||
*
|
||||
* @param content - Raw markdown content
|
||||
* @returns Tuple of [frontmatter object or null, body content]
|
||||
*/
|
||||
export function parseFrontmatter(content: string): [Record<string, unknown> | null, string] {
|
||||
// Match frontmatter between --- delimiters at the start
|
||||
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
if (!match) {
|
||||
return [null, content.trim()];
|
||||
}
|
||||
|
||||
const frontmatterRaw = match[1] ?? "";
|
||||
const body = match[2] ?? "";
|
||||
|
||||
if (!frontmatterRaw) {
|
||||
return [null, content.trim()];
|
||||
}
|
||||
|
||||
try {
|
||||
const frontmatter = parseYaml(frontmatterRaw) as Record<string, unknown>;
|
||||
return [frontmatter, body.trim()];
|
||||
} catch {
|
||||
// Invalid YAML, return null frontmatter
|
||||
return [null, content.trim()];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and coerce frontmatter to SkillFrontmatter type
|
||||
*
|
||||
* @param raw - Raw parsed frontmatter
|
||||
* @returns Validated SkillFrontmatter or null if invalid
|
||||
*/
|
||||
function validateFrontmatter(raw: Record<string, unknown>): SkillFrontmatter | null {
|
||||
// Name is required
|
||||
if (typeof raw.name !== "string" || raw.name.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frontmatter: SkillFrontmatter = {
|
||||
name: raw.name.trim(),
|
||||
};
|
||||
|
||||
if (typeof raw.description === "string") {
|
||||
frontmatter.description = raw.description;
|
||||
}
|
||||
|
||||
if (typeof raw.version === "string") {
|
||||
frontmatter.version = raw.version;
|
||||
}
|
||||
|
||||
if (typeof raw.author === "string") {
|
||||
frontmatter.author = raw.author;
|
||||
}
|
||||
|
||||
if (typeof raw.homepage === "string") {
|
||||
frontmatter.homepage = raw.homepage;
|
||||
}
|
||||
|
||||
// Parse metadata if present
|
||||
if (typeof raw.metadata === "object" && raw.metadata !== null) {
|
||||
const meta = raw.metadata as Record<string, unknown>;
|
||||
frontmatter.metadata = {
|
||||
emoji: typeof meta.emoji === "string" ? meta.emoji : undefined,
|
||||
requiresEnv: Array.isArray(meta.requiresEnv)
|
||||
? meta.requiresEnv.filter((v): v is string => typeof v === "string")
|
||||
: undefined,
|
||||
requiresBinaries: Array.isArray(meta.requiresBinaries)
|
||||
? meta.requiresBinaries.filter((v): v is string => typeof v === "string")
|
||||
: undefined,
|
||||
platforms: Array.isArray(meta.platforms)
|
||||
? meta.platforms.filter((v): v is string => typeof v === "string")
|
||||
: undefined,
|
||||
tags: Array.isArray(meta.tags)
|
||||
? meta.tags.filter((v): v is string => typeof v === "string")
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a SKILL.md file into a Skill object
|
||||
*
|
||||
* @param filePath - Full path to SKILL.md file
|
||||
* @param skillId - Unique identifier for the skill
|
||||
* @param source - Source type of the skill
|
||||
* @returns Parsed Skill or null if invalid
|
||||
*/
|
||||
export function parseSkillFile(
|
||||
filePath: string,
|
||||
skillId: string,
|
||||
source: SkillSource,
|
||||
): Skill | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const [rawFrontmatter, instructions] = parseFrontmatter(content);
|
||||
|
||||
if (!rawFrontmatter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frontmatter = validateFrontmatter(rawFrontmatter);
|
||||
if (!frontmatter) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: skillId,
|
||||
frontmatter,
|
||||
instructions,
|
||||
source,
|
||||
filePath,
|
||||
};
|
||||
} catch {
|
||||
// File read error or other issues
|
||||
return null;
|
||||
}
|
||||
}
|
||||
97
src/agent/skills/types.ts
Normal file
97
src/agent/skills/types.ts
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
/**
|
||||
* Skills Module Types
|
||||
*
|
||||
* Type definitions for the skills system
|
||||
*/
|
||||
|
||||
/**
|
||||
* Skill metadata for eligibility and display
|
||||
*/
|
||||
export interface SkillMetadata {
|
||||
/** Emoji for display (e.g., "📝") */
|
||||
emoji?: string | undefined;
|
||||
/** Required environment variables */
|
||||
requiresEnv?: string[] | undefined;
|
||||
/** Required binaries in PATH */
|
||||
requiresBinaries?: string[] | undefined;
|
||||
/** Supported platforms (darwin, linux, win32) */
|
||||
platforms?: string[] | undefined;
|
||||
/** Skill tags for categorization */
|
||||
tags?: string[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* SKILL.md frontmatter schema
|
||||
*/
|
||||
export interface SkillFrontmatter {
|
||||
/** Skill name (required) */
|
||||
name: string;
|
||||
/** Human-readable description */
|
||||
description?: string | undefined;
|
||||
/** Skill version */
|
||||
version?: string | undefined;
|
||||
/** Author information */
|
||||
author?: string | undefined;
|
||||
/** Homepage/documentation URL */
|
||||
homepage?: string | undefined;
|
||||
/** Skill-specific metadata */
|
||||
metadata?: SkillMetadata | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill source type with precedence (lower value = lower priority)
|
||||
*/
|
||||
export type SkillSource = "bundled" | "profile";
|
||||
|
||||
/**
|
||||
* Skill source precedence values
|
||||
*/
|
||||
export const SKILL_SOURCE_PRECEDENCE: Record<SkillSource, number> = {
|
||||
bundled: 0,
|
||||
profile: 1,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parsed skill entry
|
||||
*/
|
||||
export interface Skill {
|
||||
/** Unique skill identifier (directory name) */
|
||||
id: string;
|
||||
/** Parsed frontmatter */
|
||||
frontmatter: SkillFrontmatter;
|
||||
/** Skill instructions (markdown body after frontmatter) */
|
||||
instructions: string;
|
||||
/** Source type */
|
||||
source: SkillSource;
|
||||
/** Full path to SKILL.md */
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill Manager options
|
||||
*/
|
||||
export interface SkillManagerOptions {
|
||||
/** Agent profile ID (for profile-specific skills) */
|
||||
profileId?: string | undefined;
|
||||
/** Profile base directory, defaults to ~/.super-multica/agent-profiles */
|
||||
profileBaseDir?: string | undefined;
|
||||
/** Additional directories to search for skills */
|
||||
extraDirs?: string[] | undefined;
|
||||
/** Platform override (for testing) */
|
||||
platform?: NodeJS.Platform | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Skill eligibility check result
|
||||
*/
|
||||
export interface EligibilityResult {
|
||||
/** Whether the skill is eligible */
|
||||
eligible: boolean;
|
||||
/** Reasons for ineligibility */
|
||||
reasons?: string[] | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filename constant for skill definition file
|
||||
*/
|
||||
export const SKILL_FILE = "SKILL.md";
|
||||
|
|
@ -48,4 +48,10 @@ export type AgentOptions = {
|
|||
|
||||
/** Enable debug logging */
|
||||
debug?: boolean | undefined;
|
||||
|
||||
// === Skills Configuration ===
|
||||
/** Enable skills system (default: true) */
|
||||
enableSkills?: boolean | undefined;
|
||||
/** Additional directories to search for skills */
|
||||
extraSkillDirs?: string[] | undefined;
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue