morpheus-cli 0.7.2 → 0.7.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -0
- package/dist/channels/discord.js +109 -0
- package/dist/channels/telegram.js +94 -0
- package/dist/cli/commands/start.js +12 -0
- package/dist/config/paths.js +1 -0
- package/dist/config/schemas.js +4 -0
- package/dist/http/api.js +3 -0
- package/dist/http/routers/skills.js +127 -0
- package/dist/runtime/__tests__/keymaker.test.js +145 -0
- package/dist/runtime/keymaker.js +162 -0
- package/dist/runtime/oracle.js +7 -2
- package/dist/runtime/scaffold.js +75 -0
- package/dist/runtime/skills/__tests__/loader.test.js +187 -0
- package/dist/runtime/skills/__tests__/registry.test.js +201 -0
- package/dist/runtime/skills/__tests__/tool.test.js +266 -0
- package/dist/runtime/skills/index.js +8 -0
- package/dist/runtime/skills/loader.js +213 -0
- package/dist/runtime/skills/registry.js +141 -0
- package/dist/runtime/skills/schema.js +30 -0
- package/dist/runtime/skills/tool.js +204 -0
- package/dist/runtime/skills/types.js +7 -0
- package/dist/runtime/tasks/worker.js +22 -0
- package/dist/ui/assets/index-CiT3ltw7.css +1 -0
- package/dist/ui/assets/{index-7e8TCoiy.js → index-DfDByABF.js} +21 -21
- package/dist/ui/index.html +2 -2
- package/dist/ui/sw.js +1 -1
- package/package.json +1 -1
- package/dist/ui/assets/index-B9ngtbja.css +0 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { Keymaker, executeKeymakerTask } from '../keymaker.js';
|
|
3
|
+
import { SkillRegistry } from '../skills/registry.js';
|
|
4
|
+
// Mock all heavy dependencies
|
|
5
|
+
vi.mock('../skills/registry.js', () => ({
|
|
6
|
+
SkillRegistry: {
|
|
7
|
+
getInstance: vi.fn(),
|
|
8
|
+
},
|
|
9
|
+
}));
|
|
10
|
+
vi.mock('../../config/manager.js', () => ({
|
|
11
|
+
ConfigManager: {
|
|
12
|
+
getInstance: vi.fn(() => ({
|
|
13
|
+
get: vi.fn(() => ({
|
|
14
|
+
llm: {
|
|
15
|
+
provider: 'openai',
|
|
16
|
+
model: 'gpt-4o',
|
|
17
|
+
temperature: 0.7,
|
|
18
|
+
},
|
|
19
|
+
keymaker: {
|
|
20
|
+
provider: 'openai',
|
|
21
|
+
model: 'gpt-4o',
|
|
22
|
+
personality: 'versatile_specialist',
|
|
23
|
+
},
|
|
24
|
+
})),
|
|
25
|
+
})),
|
|
26
|
+
},
|
|
27
|
+
}));
|
|
28
|
+
vi.mock('../display.js', () => ({
|
|
29
|
+
DisplayManager: {
|
|
30
|
+
getInstance: vi.fn(() => ({
|
|
31
|
+
log: vi.fn(),
|
|
32
|
+
})),
|
|
33
|
+
},
|
|
34
|
+
}));
|
|
35
|
+
vi.mock('../../devkit/index.js', () => ({
|
|
36
|
+
buildDevKit: vi.fn(() => [
|
|
37
|
+
{ name: 'fs_read', description: 'Read file' },
|
|
38
|
+
{ name: 'shell_exec', description: 'Execute shell command' },
|
|
39
|
+
]),
|
|
40
|
+
}));
|
|
41
|
+
vi.mock('../tools/factory.js', () => ({
|
|
42
|
+
Construtor: {
|
|
43
|
+
create: vi.fn(() => Promise.resolve([
|
|
44
|
+
{ name: 'mcp_tool', description: 'MCP tool' },
|
|
45
|
+
])),
|
|
46
|
+
},
|
|
47
|
+
}));
|
|
48
|
+
vi.mock('../tools/index.js', () => ({
|
|
49
|
+
morpheusTools: [
|
|
50
|
+
{ name: 'morpheus_tool', description: 'Internal tool' },
|
|
51
|
+
],
|
|
52
|
+
}));
|
|
53
|
+
vi.mock('../providers/factory.js', () => ({
|
|
54
|
+
ProviderFactory: {
|
|
55
|
+
createBare: vi.fn(() => Promise.resolve({
|
|
56
|
+
invoke: vi.fn(),
|
|
57
|
+
})),
|
|
58
|
+
},
|
|
59
|
+
}));
|
|
60
|
+
vi.mock('../memory/sqlite.js', () => {
|
|
61
|
+
return {
|
|
62
|
+
SQLiteChatMessageHistory: class MockSQLiteChatMessageHistory {
|
|
63
|
+
addMessage = vi.fn();
|
|
64
|
+
close = vi.fn();
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
describe('Keymaker', () => {
|
|
69
|
+
const mockRegistry = {
|
|
70
|
+
get: vi.fn(),
|
|
71
|
+
getContent: vi.fn(),
|
|
72
|
+
};
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
SkillRegistry.getInstance.mockReturnValue(mockRegistry);
|
|
76
|
+
mockRegistry.get.mockReturnValue({
|
|
77
|
+
name: 'test-skill',
|
|
78
|
+
description: 'A test skill',
|
|
79
|
+
tags: ['test'],
|
|
80
|
+
enabled: true,
|
|
81
|
+
});
|
|
82
|
+
mockRegistry.getContent.mockReturnValue('# Test Skill\n\nInstructions here.');
|
|
83
|
+
});
|
|
84
|
+
describe('constructor', () => {
|
|
85
|
+
it('should create instance with skill name and content', () => {
|
|
86
|
+
const keymaker = new Keymaker('test-skill', '# Instructions');
|
|
87
|
+
expect(keymaker).toBeInstanceOf(Keymaker);
|
|
88
|
+
});
|
|
89
|
+
it('should accept custom config', () => {
|
|
90
|
+
const customConfig = {
|
|
91
|
+
llm: { provider: 'anthropic', model: 'claude-3' },
|
|
92
|
+
};
|
|
93
|
+
const keymaker = new Keymaker('test-skill', '# Instructions', customConfig);
|
|
94
|
+
expect(keymaker).toBeInstanceOf(Keymaker);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
describe('initialize()', () => {
|
|
98
|
+
it('should initialize agent with all tools', async () => {
|
|
99
|
+
const { ProviderFactory } = await import('../providers/factory.js');
|
|
100
|
+
const { buildDevKit } = await import('../../devkit/index.js');
|
|
101
|
+
const { Construtor } = await import('../tools/factory.js');
|
|
102
|
+
const keymaker = new Keymaker('test-skill', '# Instructions');
|
|
103
|
+
await keymaker.initialize();
|
|
104
|
+
expect(buildDevKit).toHaveBeenCalled();
|
|
105
|
+
expect(Construtor.create).toHaveBeenCalled();
|
|
106
|
+
expect(ProviderFactory.createBare).toHaveBeenCalled();
|
|
107
|
+
// Verify tools were combined
|
|
108
|
+
const createBareCall = ProviderFactory.createBare.mock.calls[0];
|
|
109
|
+
const tools = createBareCall[1];
|
|
110
|
+
// Should have DevKit (2) + MCP (1) + Morpheus (1) = 4 tools
|
|
111
|
+
expect(tools.length).toBe(4);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
describe('executeKeymakerTask', () => {
|
|
116
|
+
const mockRegistry = {
|
|
117
|
+
get: vi.fn(),
|
|
118
|
+
getContent: vi.fn(),
|
|
119
|
+
};
|
|
120
|
+
beforeEach(() => {
|
|
121
|
+
vi.clearAllMocks();
|
|
122
|
+
SkillRegistry.getInstance.mockReturnValue(mockRegistry);
|
|
123
|
+
});
|
|
124
|
+
it('should throw error when SKILL.md not found', async () => {
|
|
125
|
+
mockRegistry.getContent.mockReturnValue(null);
|
|
126
|
+
await expect(executeKeymakerTask('missing-skill', 'do something')).rejects.toThrow('SKILL.md not found for skill: missing-skill');
|
|
127
|
+
});
|
|
128
|
+
it('should create and execute keymaker with skill content', async () => {
|
|
129
|
+
mockRegistry.getContent.mockReturnValue('# Test Instructions\n\nDo this.');
|
|
130
|
+
mockRegistry.get.mockReturnValue({
|
|
131
|
+
name: 'test-skill',
|
|
132
|
+
description: 'Test skill',
|
|
133
|
+
enabled: true,
|
|
134
|
+
});
|
|
135
|
+
const { ProviderFactory } = await import('../providers/factory.js');
|
|
136
|
+
ProviderFactory.createBare.mockResolvedValue({
|
|
137
|
+
invoke: vi.fn().mockResolvedValue({
|
|
138
|
+
messages: [{ content: 'Task completed successfully.' }],
|
|
139
|
+
}),
|
|
140
|
+
});
|
|
141
|
+
const result = await executeKeymakerTask('test-skill', 'do the task');
|
|
142
|
+
expect(mockRegistry.getContent).toHaveBeenCalledWith('test-skill');
|
|
143
|
+
expect(result).toBe('Task completed successfully.');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { HumanMessage, SystemMessage, AIMessage } from "@langchain/core/messages";
|
|
2
|
+
import { ConfigManager } from "../config/manager.js";
|
|
3
|
+
import { ProviderFactory } from "./providers/factory.js";
|
|
4
|
+
import { ProviderError } from "./errors.js";
|
|
5
|
+
import { DisplayManager } from "./display.js";
|
|
6
|
+
import { buildDevKit } from "../devkit/index.js";
|
|
7
|
+
import { Construtor } from "./tools/factory.js";
|
|
8
|
+
import { morpheusTools } from "./tools/index.js";
|
|
9
|
+
import { SkillRegistry } from "./skills/registry.js";
|
|
10
|
+
import { TaskRequestContext } from "./tasks/context.js";
|
|
11
|
+
import { SQLiteChatMessageHistory } from "./memory/sqlite.js";
|
|
12
|
+
/**
|
|
13
|
+
* Keymaker is a specialized agent for executing skills.
|
|
14
|
+
* "The one who opens any door" - has access to ALL tools:
|
|
15
|
+
* - DevKit (filesystem, shell, git, browser, network, processes, packages, system)
|
|
16
|
+
* - MCP tools (all configured MCP servers)
|
|
17
|
+
* - Morpheus internal tools
|
|
18
|
+
*
|
|
19
|
+
* Keymaker is instantiated per-task with a specific skill's SKILL.md as context.
|
|
20
|
+
* It executes the skill instructions autonomously and returns the result.
|
|
21
|
+
*/
|
|
22
|
+
export class Keymaker {
|
|
23
|
+
agent;
|
|
24
|
+
config;
|
|
25
|
+
display = DisplayManager.getInstance();
|
|
26
|
+
skillName;
|
|
27
|
+
skillContent;
|
|
28
|
+
constructor(skillName, skillContent, config) {
|
|
29
|
+
this.skillName = skillName;
|
|
30
|
+
this.skillContent = skillContent;
|
|
31
|
+
this.config = config || ConfigManager.getInstance().get();
|
|
32
|
+
}
|
|
33
|
+
async initialize() {
|
|
34
|
+
const keymakerConfig = this.config.keymaker || this.config.llm;
|
|
35
|
+
const personality = this.config.keymaker?.personality || 'versatile_specialist';
|
|
36
|
+
// Build DevKit tools (filesystem, shell, git, browser, network, etc.)
|
|
37
|
+
const working_dir = process.cwd();
|
|
38
|
+
const timeout_ms = 30_000;
|
|
39
|
+
await import("../devkit/index.js");
|
|
40
|
+
const devKitTools = buildDevKit({
|
|
41
|
+
working_dir,
|
|
42
|
+
allowed_commands: [], // no restriction
|
|
43
|
+
timeout_ms,
|
|
44
|
+
});
|
|
45
|
+
// Load MCP tools from configured servers
|
|
46
|
+
const mcpTools = await Construtor.create();
|
|
47
|
+
// Combine all tools
|
|
48
|
+
const tools = [...devKitTools, ...mcpTools, ...morpheusTools];
|
|
49
|
+
this.display.log(`Keymaker initialized for skill "${this.skillName}" with ${tools.length} tools (personality: ${personality})`, { source: "Keymaker" });
|
|
50
|
+
try {
|
|
51
|
+
this.agent = await ProviderFactory.createBare(keymakerConfig, tools);
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
throw new ProviderError(keymakerConfig.provider, err, "Keymaker agent initialization failed");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Execute the skill with the given objective.
|
|
59
|
+
* @param objective User's task description
|
|
60
|
+
* @param taskContext Context for routing responses
|
|
61
|
+
*/
|
|
62
|
+
async execute(objective, taskContext) {
|
|
63
|
+
if (!this.agent) {
|
|
64
|
+
await this.initialize();
|
|
65
|
+
}
|
|
66
|
+
this.display.log(`Keymaker executing skill "${this.skillName}": ${objective.slice(0, 80)}...`, { source: "Keymaker" });
|
|
67
|
+
const personality = this.config.keymaker?.personality || 'versatile_specialist';
|
|
68
|
+
const registry = SkillRegistry.getInstance();
|
|
69
|
+
const skill = registry.get(this.skillName);
|
|
70
|
+
const systemMessage = new SystemMessage(`
|
|
71
|
+
You are Keymaker, ${personality === 'versatile_specialist' ? 'a versatile specialist who can open any door' : personality}, executing the "${this.skillName}" skill.
|
|
72
|
+
|
|
73
|
+
You have access to ALL tools:
|
|
74
|
+
- Filesystem: read, write, list, delete, copy, move files and directories
|
|
75
|
+
- Shell: execute commands, spawn processes
|
|
76
|
+
- Git: clone, commit, push, pull, branch, diff
|
|
77
|
+
- Network: HTTP requests, health checks
|
|
78
|
+
- Browser: navigate, screenshot, extract content
|
|
79
|
+
- MCP tools: all configured MCP server tools
|
|
80
|
+
- System: CPU, memory, disk info
|
|
81
|
+
|
|
82
|
+
## Skill: ${skill?.description || this.skillName}
|
|
83
|
+
${skill?.tags?.length ? `Tags: ${skill.tags.join(', ')}` : ''}
|
|
84
|
+
|
|
85
|
+
## Skill Instructions
|
|
86
|
+
${this.skillContent}
|
|
87
|
+
|
|
88
|
+
## Your Objective
|
|
89
|
+
${objective}
|
|
90
|
+
|
|
91
|
+
IMPORTANT:
|
|
92
|
+
1. Follow the skill instructions carefully to accomplish the objective.
|
|
93
|
+
2. Be thorough and autonomous. Use the tools at your disposal.
|
|
94
|
+
3. If you encounter errors, try alternative approaches.
|
|
95
|
+
4. Provide a clear summary of what was accomplished.
|
|
96
|
+
5. Respond in the same language as the objective.
|
|
97
|
+
|
|
98
|
+
CRITICAL — NEVER FABRICATE DATA:
|
|
99
|
+
- If none of your available tools can retrieve the requested information, state this clearly.
|
|
100
|
+
- NEVER generate fake data, fake IDs, fake results of any kind.
|
|
101
|
+
- An honest "I cannot do this" is always correct. A fabricated answer is never acceptable.
|
|
102
|
+
`);
|
|
103
|
+
const userMessage = new HumanMessage(objective);
|
|
104
|
+
const messages = [systemMessage, userMessage];
|
|
105
|
+
try {
|
|
106
|
+
const invokeContext = {
|
|
107
|
+
origin_channel: taskContext?.origin_channel ?? "api",
|
|
108
|
+
session_id: taskContext?.session_id ?? "keymaker",
|
|
109
|
+
origin_message_id: taskContext?.origin_message_id,
|
|
110
|
+
origin_user_id: taskContext?.origin_user_id,
|
|
111
|
+
};
|
|
112
|
+
const response = await TaskRequestContext.run(invokeContext, () => this.agent.invoke({ messages }));
|
|
113
|
+
const lastMessage = response.messages[response.messages.length - 1];
|
|
114
|
+
const content = typeof lastMessage.content === "string"
|
|
115
|
+
? lastMessage.content
|
|
116
|
+
: JSON.stringify(lastMessage.content);
|
|
117
|
+
// Persist message with token usage metadata (like Trinity/Neo/Apoc)
|
|
118
|
+
const keymakerConfig = this.config.keymaker || this.config.llm;
|
|
119
|
+
const targetSession = taskContext?.session_id ?? "keymaker";
|
|
120
|
+
const history = new SQLiteChatMessageHistory({ sessionId: targetSession });
|
|
121
|
+
try {
|
|
122
|
+
const persisted = new AIMessage(content);
|
|
123
|
+
persisted.usage_metadata =
|
|
124
|
+
lastMessage.usage_metadata ??
|
|
125
|
+
lastMessage.response_metadata?.usage ??
|
|
126
|
+
lastMessage.response_metadata?.tokenUsage ??
|
|
127
|
+
lastMessage.usage;
|
|
128
|
+
persisted.provider_metadata = {
|
|
129
|
+
provider: keymakerConfig.provider,
|
|
130
|
+
model: keymakerConfig.model,
|
|
131
|
+
};
|
|
132
|
+
await history.addMessage(persisted);
|
|
133
|
+
}
|
|
134
|
+
finally {
|
|
135
|
+
history.close();
|
|
136
|
+
}
|
|
137
|
+
this.display.log(`Keymaker completed skill "${this.skillName}" execution`, { source: "Keymaker" });
|
|
138
|
+
return content;
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
this.display.log(`Keymaker execution error: ${err.message}`, { source: "Keymaker", level: "error" });
|
|
142
|
+
throw err;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Factory function to create and execute a Keymaker task.
|
|
148
|
+
* Used by TaskWorker when routing keymaker tasks.
|
|
149
|
+
*
|
|
150
|
+
* @param skillName Name of the skill to execute
|
|
151
|
+
* @param objective User's task description
|
|
152
|
+
* @param taskContext Optional context for routing responses
|
|
153
|
+
*/
|
|
154
|
+
export async function executeKeymakerTask(skillName, objective, taskContext) {
|
|
155
|
+
const registry = SkillRegistry.getInstance();
|
|
156
|
+
const skillContent = registry.getContent(skillName);
|
|
157
|
+
if (!skillContent) {
|
|
158
|
+
throw new Error(`SKILL.md not found for skill: ${skillName}`);
|
|
159
|
+
}
|
|
160
|
+
const keymaker = new Keymaker(skillName, skillContent);
|
|
161
|
+
return keymaker.execute(objective, taskContext);
|
|
162
|
+
}
|
package/dist/runtime/oracle.js
CHANGED
|
@@ -15,6 +15,7 @@ import { ApocDelegateTool } from "./tools/apoc-tool.js";
|
|
|
15
15
|
import { TrinityDelegateTool } from "./tools/trinity-tool.js";
|
|
16
16
|
import { TaskQueryTool, chronosTools, timeVerifierTool } from "./tools/index.js";
|
|
17
17
|
import { MCPManager } from "../config/mcp-manager.js";
|
|
18
|
+
import { SkillRegistry, SkillExecuteTool, SkillDelegateTool, updateSkillToolDescriptions } from "./skills/index.js";
|
|
18
19
|
export class Oracle {
|
|
19
20
|
provider;
|
|
20
21
|
config;
|
|
@@ -143,7 +144,8 @@ export class Oracle {
|
|
|
143
144
|
// Fail-open: Oracle can still initialize even if catalog refresh fails.
|
|
144
145
|
await Neo.refreshDelegateCatalog().catch(() => { });
|
|
145
146
|
await Trinity.refreshDelegateCatalog().catch(() => { });
|
|
146
|
-
|
|
147
|
+
updateSkillToolDescriptions();
|
|
148
|
+
this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool, SkillExecuteTool, SkillDelegateTool, timeVerifierTool, ...chronosTools]);
|
|
147
149
|
if (!this.provider) {
|
|
148
150
|
throw new Error("Provider factory returned undefined");
|
|
149
151
|
}
|
|
@@ -291,6 +293,8 @@ good:
|
|
|
291
293
|
- Answer directly acknowledging the fact. Do NOT delegate.
|
|
292
294
|
bad:
|
|
293
295
|
- delegate to "neo_delegate" or "apoc_delegate" to save the fact. (Sati handles this automatically in the background)
|
|
296
|
+
|
|
297
|
+
${SkillRegistry.getInstance().getSystemPromptSection()}
|
|
294
298
|
`);
|
|
295
299
|
// Load existing history from database in reverse order (most recent first)
|
|
296
300
|
let previousMessages = await this.history.getMessages();
|
|
@@ -538,7 +542,8 @@ Use it to inform your response and tool selection (if needed), but do not assume
|
|
|
538
542
|
}
|
|
539
543
|
await Neo.refreshDelegateCatalog().catch(() => { });
|
|
540
544
|
await Trinity.refreshDelegateCatalog().catch(() => { });
|
|
541
|
-
|
|
545
|
+
updateSkillToolDescriptions();
|
|
546
|
+
this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool, SkillExecuteTool, SkillDelegateTool, ...chronosTools]);
|
|
542
547
|
await Neo.getInstance().reload();
|
|
543
548
|
this.display.log(`Oracle and Neo tools reloaded`, { source: 'Oracle' });
|
|
544
549
|
}
|
package/dist/runtime/scaffold.js
CHANGED
|
@@ -1,10 +1,79 @@
|
|
|
1
1
|
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
2
3
|
import { PATHS } from '../config/paths.js';
|
|
3
4
|
import { ConfigManager } from '../config/manager.js';
|
|
4
5
|
import { DEFAULT_MCP_TEMPLATE } from '../types/mcp.js';
|
|
5
6
|
import chalk from 'chalk';
|
|
6
7
|
import ora from 'ora';
|
|
7
8
|
import { migrateConfigFile } from './migration.js';
|
|
9
|
+
const SKILLS_README = `# Morpheus Skills
|
|
10
|
+
|
|
11
|
+
This folder contains custom skills for Morpheus.
|
|
12
|
+
|
|
13
|
+
## Creating a Skill
|
|
14
|
+
|
|
15
|
+
1. Create a folder with your skill name (lowercase, hyphens allowed):
|
|
16
|
+
\`\`\`
|
|
17
|
+
mkdir my-skill
|
|
18
|
+
\`\`\`
|
|
19
|
+
|
|
20
|
+
2. Create \`SKILL.md\` with YAML frontmatter + instructions:
|
|
21
|
+
\`\`\`markdown
|
|
22
|
+
---
|
|
23
|
+
name: my-skill
|
|
24
|
+
description: What this skill does (max 500 chars)
|
|
25
|
+
execution_mode: sync
|
|
26
|
+
version: 1.0.0
|
|
27
|
+
author: your-name
|
|
28
|
+
tags:
|
|
29
|
+
- category
|
|
30
|
+
examples:
|
|
31
|
+
- "Example request that triggers this skill"
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
# My Skill
|
|
35
|
+
|
|
36
|
+
Instructions for Keymaker to follow when executing this skill.
|
|
37
|
+
|
|
38
|
+
## Steps
|
|
39
|
+
1. First step
|
|
40
|
+
2. Second step
|
|
41
|
+
|
|
42
|
+
## Output Format
|
|
43
|
+
How to format the result.
|
|
44
|
+
\`\`\`
|
|
45
|
+
|
|
46
|
+
## Execution Modes
|
|
47
|
+
|
|
48
|
+
| Mode | Tool | Description |
|
|
49
|
+
|------|------|-------------|
|
|
50
|
+
| sync | skill_execute | Result returned immediately (default) |
|
|
51
|
+
| async | skill_delegate | Runs in background, notifies when done |
|
|
52
|
+
|
|
53
|
+
**sync** (default): Best for quick tasks like code review, analysis.
|
|
54
|
+
**async**: Best for long-running tasks like builds, deployments.
|
|
55
|
+
|
|
56
|
+
## How It Works
|
|
57
|
+
|
|
58
|
+
- Oracle lists available skills in its system prompt
|
|
59
|
+
- When a request matches a sync skill, Oracle calls \`skill_execute\`
|
|
60
|
+
- When a request matches an async skill, Oracle calls \`skill_delegate\`
|
|
61
|
+
- Keymaker has access to ALL tools (filesystem, shell, git, MCP, databases)
|
|
62
|
+
- Keymaker follows SKILL.md instructions to complete the task
|
|
63
|
+
|
|
64
|
+
## Frontmatter Schema
|
|
65
|
+
|
|
66
|
+
| Field | Required | Default | Description |
|
|
67
|
+
|-------|----------|---------|-------------|
|
|
68
|
+
| name | Yes | - | Unique identifier (a-z, 0-9, hyphens) |
|
|
69
|
+
| description | Yes | - | Short description (max 500 chars) |
|
|
70
|
+
| execution_mode | No | sync | sync or async |
|
|
71
|
+
| version | No | - | Semver (e.g., 1.0.0) |
|
|
72
|
+
| author | No | - | Your name |
|
|
73
|
+
| enabled | No | true | true/false |
|
|
74
|
+
| tags | No | - | Array of tags (max 10) |
|
|
75
|
+
| examples | No | - | Example requests (max 5) |
|
|
76
|
+
`;
|
|
8
77
|
export async function scaffold() {
|
|
9
78
|
const spinner = ora('Ensuring Morpheus environment...').start();
|
|
10
79
|
try {
|
|
@@ -15,6 +84,7 @@ export async function scaffold() {
|
|
|
15
84
|
fs.ensureDir(PATHS.memory),
|
|
16
85
|
fs.ensureDir(PATHS.cache),
|
|
17
86
|
fs.ensureDir(PATHS.commands),
|
|
87
|
+
fs.ensureDir(PATHS.skills),
|
|
18
88
|
]);
|
|
19
89
|
// Migrate config.yaml -> zaion.yaml if needed
|
|
20
90
|
await migrateConfigFile();
|
|
@@ -30,6 +100,11 @@ export async function scaffold() {
|
|
|
30
100
|
if (!(await fs.pathExists(PATHS.mcps))) {
|
|
31
101
|
await fs.writeJson(PATHS.mcps, DEFAULT_MCP_TEMPLATE, { spaces: 2 });
|
|
32
102
|
}
|
|
103
|
+
// Create skills README if not exists
|
|
104
|
+
const skillsReadme = path.join(PATHS.skills, 'README.md');
|
|
105
|
+
if (!(await fs.pathExists(skillsReadme))) {
|
|
106
|
+
await fs.writeFile(skillsReadme, SKILLS_README, 'utf-8');
|
|
107
|
+
}
|
|
33
108
|
spinner.succeed('Morpheus environment ready at ' + chalk.cyan(PATHS.root));
|
|
34
109
|
}
|
|
35
110
|
catch (error) {
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { SkillLoader } from '../loader.js';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
/**
|
|
6
|
+
* Helper to create SKILL.md with YAML frontmatter
|
|
7
|
+
*/
|
|
8
|
+
function createSkillMd(dir, frontmatter, content = '') {
|
|
9
|
+
const lines = [];
|
|
10
|
+
for (const [key, value] of Object.entries(frontmatter)) {
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
lines.push(`${key}:`);
|
|
13
|
+
for (const item of value) {
|
|
14
|
+
lines.push(` - ${item}`);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
else {
|
|
18
|
+
lines.push(`${key}: ${value}`);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const yaml = lines.join('\n');
|
|
22
|
+
const md = `---\n${yaml}\n---\n${content}`;
|
|
23
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), md);
|
|
24
|
+
}
|
|
25
|
+
describe('SkillLoader', () => {
|
|
26
|
+
const testDir = path.join(process.cwd(), 'test-skills');
|
|
27
|
+
let loader;
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
fs.ensureDirSync(testDir);
|
|
30
|
+
loader = new SkillLoader(testDir);
|
|
31
|
+
});
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
fs.removeSync(testDir);
|
|
34
|
+
});
|
|
35
|
+
describe('scan()', () => {
|
|
36
|
+
it('should return empty list for non-existent directory', async () => {
|
|
37
|
+
fs.removeSync(testDir);
|
|
38
|
+
const result = await loader.scan();
|
|
39
|
+
expect(result.skills).toHaveLength(0);
|
|
40
|
+
expect(result.errors).toHaveLength(0);
|
|
41
|
+
});
|
|
42
|
+
it('should return empty list for empty directory', async () => {
|
|
43
|
+
const result = await loader.scan();
|
|
44
|
+
expect(result.skills).toHaveLength(0);
|
|
45
|
+
expect(result.errors).toHaveLength(0);
|
|
46
|
+
});
|
|
47
|
+
it('should load valid skill with all metadata', async () => {
|
|
48
|
+
const skillDir = path.join(testDir, 'test-skill');
|
|
49
|
+
fs.ensureDirSync(skillDir);
|
|
50
|
+
createSkillMd(skillDir, {
|
|
51
|
+
name: 'test-skill',
|
|
52
|
+
description: 'A test skill for unit testing',
|
|
53
|
+
version: '1.0.0',
|
|
54
|
+
author: 'Test Author',
|
|
55
|
+
enabled: true,
|
|
56
|
+
execution_mode: 'sync',
|
|
57
|
+
tags: ['test', 'unit'],
|
|
58
|
+
examples: ['do something', 'do another thing'],
|
|
59
|
+
}, '# Test Skill\n\nInstructions here.');
|
|
60
|
+
const result = await loader.scan();
|
|
61
|
+
expect(result.skills).toHaveLength(1);
|
|
62
|
+
expect(result.errors).toHaveLength(0);
|
|
63
|
+
const skill = result.skills[0];
|
|
64
|
+
expect(skill.name).toBe('test-skill');
|
|
65
|
+
expect(skill.description).toBe('A test skill for unit testing');
|
|
66
|
+
expect(skill.version).toBe('1.0.0');
|
|
67
|
+
expect(skill.author).toBe('Test Author');
|
|
68
|
+
expect(skill.enabled).toBe(true);
|
|
69
|
+
expect(skill.execution_mode).toBe('sync');
|
|
70
|
+
expect(skill.tags).toEqual(['test', 'unit']);
|
|
71
|
+
expect(skill.examples).toEqual(['do something', 'do another thing']);
|
|
72
|
+
expect(skill.content).toBe('# Test Skill\n\nInstructions here.');
|
|
73
|
+
});
|
|
74
|
+
it('should load skill with minimal metadata (defaults)', async () => {
|
|
75
|
+
const skillDir = path.join(testDir, 'minimal-skill');
|
|
76
|
+
fs.ensureDirSync(skillDir);
|
|
77
|
+
createSkillMd(skillDir, {
|
|
78
|
+
name: 'minimal-skill',
|
|
79
|
+
description: 'A minimal skill',
|
|
80
|
+
}, 'Minimal instructions');
|
|
81
|
+
const result = await loader.scan();
|
|
82
|
+
expect(result.skills).toHaveLength(1);
|
|
83
|
+
expect(result.errors).toHaveLength(0);
|
|
84
|
+
const skill = result.skills[0];
|
|
85
|
+
expect(skill.name).toBe('minimal-skill');
|
|
86
|
+
expect(skill.enabled).toBe(true); // default
|
|
87
|
+
expect(skill.execution_mode).toBe('sync'); // default
|
|
88
|
+
});
|
|
89
|
+
it('should report error for missing SKILL.md', async () => {
|
|
90
|
+
const skillDir = path.join(testDir, 'no-md-skill');
|
|
91
|
+
fs.ensureDirSync(skillDir);
|
|
92
|
+
// No SKILL.md file created
|
|
93
|
+
const result = await loader.scan();
|
|
94
|
+
expect(result.skills).toHaveLength(0);
|
|
95
|
+
expect(result.errors).toHaveLength(1);
|
|
96
|
+
expect(result.errors[0].directory).toBe('no-md-skill');
|
|
97
|
+
expect(result.errors[0].message).toContain('Missing SKILL.md');
|
|
98
|
+
});
|
|
99
|
+
it('should report error for SKILL.md without frontmatter', async () => {
|
|
100
|
+
const skillDir = path.join(testDir, 'no-frontmatter');
|
|
101
|
+
fs.ensureDirSync(skillDir);
|
|
102
|
+
fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# No Frontmatter\n\nJust plain markdown.');
|
|
103
|
+
const result = await loader.scan();
|
|
104
|
+
expect(result.skills).toHaveLength(0);
|
|
105
|
+
expect(result.errors).toHaveLength(1);
|
|
106
|
+
expect(result.errors[0].directory).toBe('no-frontmatter');
|
|
107
|
+
expect(result.errors[0].message).toContain('Invalid format');
|
|
108
|
+
});
|
|
109
|
+
it('should report error for schema validation failure', async () => {
|
|
110
|
+
const skillDir = path.join(testDir, 'bad-schema');
|
|
111
|
+
fs.ensureDirSync(skillDir);
|
|
112
|
+
// Missing required 'description' field
|
|
113
|
+
createSkillMd(skillDir, {
|
|
114
|
+
name: 'bad-schema',
|
|
115
|
+
}, 'No description provided');
|
|
116
|
+
const result = await loader.scan();
|
|
117
|
+
expect(result.skills).toHaveLength(0);
|
|
118
|
+
expect(result.errors).toHaveLength(1);
|
|
119
|
+
expect(result.errors[0].message).toContain('Schema validation failed');
|
|
120
|
+
});
|
|
121
|
+
it('should reject invalid skill name format', async () => {
|
|
122
|
+
const skillDir = path.join(testDir, 'invalid-name');
|
|
123
|
+
fs.ensureDirSync(skillDir);
|
|
124
|
+
createSkillMd(skillDir, {
|
|
125
|
+
name: 'Invalid Name With Spaces!',
|
|
126
|
+
description: 'Should fail validation',
|
|
127
|
+
}, 'Content');
|
|
128
|
+
const result = await loader.scan();
|
|
129
|
+
expect(result.skills).toHaveLength(0);
|
|
130
|
+
expect(result.errors).toHaveLength(1);
|
|
131
|
+
expect(result.errors[0].message).toContain('Schema validation failed');
|
|
132
|
+
});
|
|
133
|
+
it('should load multiple skills', async () => {
|
|
134
|
+
// Create skill 1
|
|
135
|
+
const skill1Dir = path.join(testDir, 'skill-one');
|
|
136
|
+
fs.ensureDirSync(skill1Dir);
|
|
137
|
+
createSkillMd(skill1Dir, { name: 'skill-one', description: 'First skill' }, 'Instructions 1');
|
|
138
|
+
// Create skill 2
|
|
139
|
+
const skill2Dir = path.join(testDir, 'skill-two');
|
|
140
|
+
fs.ensureDirSync(skill2Dir);
|
|
141
|
+
createSkillMd(skill2Dir, { name: 'skill-two', description: 'Second skill' }, 'Instructions 2');
|
|
142
|
+
const result = await loader.scan();
|
|
143
|
+
expect(result.skills).toHaveLength(2);
|
|
144
|
+
expect(result.errors).toHaveLength(0);
|
|
145
|
+
const names = result.skills.map(s => s.name).sort();
|
|
146
|
+
expect(names).toEqual(['skill-one', 'skill-two']);
|
|
147
|
+
});
|
|
148
|
+
it('should ignore non-directory entries', async () => {
|
|
149
|
+
// Create a file instead of directory
|
|
150
|
+
fs.writeFileSync(path.join(testDir, 'not-a-dir.yaml'), 'name: test');
|
|
151
|
+
const result = await loader.scan();
|
|
152
|
+
expect(result.skills).toHaveLength(0);
|
|
153
|
+
expect(result.errors).toHaveLength(0);
|
|
154
|
+
});
|
|
155
|
+
it('should load async skill correctly', async () => {
|
|
156
|
+
const skillDir = path.join(testDir, 'async-skill');
|
|
157
|
+
fs.ensureDirSync(skillDir);
|
|
158
|
+
createSkillMd(skillDir, {
|
|
159
|
+
name: 'async-skill',
|
|
160
|
+
description: 'An async skill',
|
|
161
|
+
execution_mode: 'async',
|
|
162
|
+
}, 'Long-running task instructions');
|
|
163
|
+
const result = await loader.scan();
|
|
164
|
+
expect(result.skills).toHaveLength(1);
|
|
165
|
+
expect(result.skills[0].execution_mode).toBe('async');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
describe('content handling', () => {
|
|
169
|
+
it('should include content in skill object', async () => {
|
|
170
|
+
const skillDir = path.join(testDir, 'content-skill');
|
|
171
|
+
fs.ensureDirSync(skillDir);
|
|
172
|
+
const mdContent = '# Content Skill\n\nThis is the instruction content.';
|
|
173
|
+
createSkillMd(skillDir, { name: 'content-skill', description: 'Test' }, mdContent);
|
|
174
|
+
const result = await loader.scan();
|
|
175
|
+
expect(result.skills).toHaveLength(1);
|
|
176
|
+
expect(result.skills[0].content).toBe(mdContent);
|
|
177
|
+
});
|
|
178
|
+
it('should handle empty content after frontmatter', async () => {
|
|
179
|
+
const skillDir = path.join(testDir, 'empty-content');
|
|
180
|
+
fs.ensureDirSync(skillDir);
|
|
181
|
+
createSkillMd(skillDir, { name: 'empty-content', description: 'Test' }, '');
|
|
182
|
+
const result = await loader.scan();
|
|
183
|
+
expect(result.skills).toHaveLength(1);
|
|
184
|
+
expect(result.skills[0].content).toBe('');
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
});
|