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",
|
"socket.io-client": "^4.8.3",
|
||||||
"turndown": "^7.2.2",
|
"turndown": "^7.2.2",
|
||||||
"undici": "^7.19.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:
|
uuid:
|
||||||
specifier: ^13.0.0
|
specifier: ^13.0.0
|
||||||
version: 13.0.0
|
version: 13.0.0
|
||||||
|
yaml:
|
||||||
|
specifier: ^2.8.2
|
||||||
|
version: 2.8.2
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^25.0.10
|
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 "./types.js";
|
||||||
export * from "./profile/index.js";
|
export * from "./profile/index.js";
|
||||||
export * from "./context-window/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 { resolveModel, resolveTools } from "./tools.js";
|
||||||
import { SessionManager } from "./session/session-manager.js";
|
import { SessionManager } from "./session/session-manager.js";
|
||||||
import { ProfileManager } from "./profile/index.js";
|
import { ProfileManager } from "./profile/index.js";
|
||||||
|
import { SkillManager } from "./skills/index.js";
|
||||||
import {
|
import {
|
||||||
checkContextWindow,
|
checkContextWindow,
|
||||||
DEFAULT_CONTEXT_TOKENS,
|
DEFAULT_CONTEXT_TOKENS,
|
||||||
|
|
@ -43,6 +44,7 @@ export class Agent {
|
||||||
private readonly output;
|
private readonly output;
|
||||||
private readonly session: SessionManager;
|
private readonly session: SessionManager;
|
||||||
private readonly profile?: ProfileManager;
|
private readonly profile?: ProfileManager;
|
||||||
|
private readonly skillManager?: SkillManager;
|
||||||
private readonly contextWindowGuard: ContextWindowGuardResult;
|
private readonly contextWindowGuard: ContextWindowGuardResult;
|
||||||
private readonly debug: boolean;
|
private readonly debug: boolean;
|
||||||
|
|
||||||
|
|
@ -57,7 +59,7 @@ export class Agent {
|
||||||
|
|
||||||
this.agent = new PiAgentCore();
|
this.agent = new PiAgentCore();
|
||||||
|
|
||||||
// 加载 Agent Profile(如果指定了 profileId)
|
// Load Agent Profile (if profileId is specified)
|
||||||
let systemPrompt: string | undefined;
|
let systemPrompt: string | undefined;
|
||||||
if (options.profileId) {
|
if (options.profileId) {
|
||||||
this.profile = new ProfileManager({
|
this.profile = new ProfileManager({
|
||||||
|
|
@ -65,13 +67,29 @@ export class Agent {
|
||||||
baseDir: options.profileBaseDir,
|
baseDir: options.profileBaseDir,
|
||||||
});
|
});
|
||||||
systemPrompt = this.profile.buildSystemPrompt();
|
systemPrompt = this.profile.buildSystemPrompt();
|
||||||
if (systemPrompt) {
|
|
||||||
this.agent.setSystemPrompt(systemPrompt);
|
|
||||||
}
|
|
||||||
} else if (options.systemPrompt) {
|
} else if (options.systemPrompt) {
|
||||||
// 直接使用传入的 systemPrompt
|
// Use provided systemPrompt directly
|
||||||
systemPrompt = options.systemPrompt;
|
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();
|
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 */
|
/** Enable debug logging */
|
||||||
debug?: boolean | undefined;
|
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