morpheus-cli 0.7.2 → 0.7.4
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/manager.js +3 -0
- package/dist/config/paths.js +1 -0
- package/dist/config/schemas.js +11 -2
- package/dist/http/api.js +3 -0
- package/dist/http/routers/skills.js +291 -0
- package/dist/runtime/__tests__/keymaker.test.js +145 -0
- package/dist/runtime/keymaker.js +162 -0
- package/dist/runtime/oracle.js +15 -4
- 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/context.js +16 -0
- package/dist/runtime/tasks/worker.js +22 -0
- package/dist/runtime/tools/apoc-tool.js +28 -1
- package/dist/runtime/tools/morpheus-tools.js +3 -0
- package/dist/runtime/tools/neo-tool.js +32 -0
- package/dist/runtime/tools/trinity-tool.js +27 -0
- package/dist/types/config.js +3 -0
- package/dist/ui/assets/index-CsMDzmtQ.js +117 -0
- package/dist/ui/assets/index-Dz_qYlIb.css +1 -0
- package/dist/ui/index.html +2 -2
- package/dist/ui/sw.js +1 -1
- package/package.json +4 -1
- package/dist/ui/assets/index-7e8TCoiy.js +0 -111
- package/dist/ui/assets/index-B9ngtbja.css +0 -1
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import multer from 'multer';
|
|
3
|
+
import extract from 'extract-zip';
|
|
4
|
+
import fs from 'fs-extra';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import os from 'os';
|
|
7
|
+
import { SkillRegistry, updateSkillDelegateDescription } from '../../runtime/skills/index.js';
|
|
8
|
+
import { DisplayManager } from '../../runtime/display.js';
|
|
9
|
+
import { PATHS } from '../../config/paths.js';
|
|
10
|
+
import { SkillMetadataSchema } from '../../runtime/skills/schema.js';
|
|
11
|
+
// Multer config for ZIP uploads
|
|
12
|
+
const upload = multer({
|
|
13
|
+
storage: multer.memoryStorage(),
|
|
14
|
+
limits: { fileSize: 10 * 1024 * 1024 }, // 10MB max
|
|
15
|
+
fileFilter: (_req, file, cb) => {
|
|
16
|
+
if (file.mimetype === 'application/zip' || file.mimetype === 'application/x-zip-compressed' || file.originalname.endsWith('.zip')) {
|
|
17
|
+
cb(null, true);
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
cb(new Error('Only ZIP files are allowed'));
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
// YAML frontmatter regex
|
|
25
|
+
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
26
|
+
/**
|
|
27
|
+
* Simple YAML frontmatter parser
|
|
28
|
+
*/
|
|
29
|
+
function parseFrontmatter(yaml) {
|
|
30
|
+
const result = {};
|
|
31
|
+
const lines = yaml.split('\n');
|
|
32
|
+
let currentKey = null;
|
|
33
|
+
let currentArray = null;
|
|
34
|
+
for (const line of lines) {
|
|
35
|
+
const trimmed = line.trim();
|
|
36
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
37
|
+
continue;
|
|
38
|
+
if (trimmed.startsWith('- ') && currentKey && currentArray !== null) {
|
|
39
|
+
currentArray.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ''));
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const colonIndex = line.indexOf(':');
|
|
43
|
+
if (colonIndex > 0) {
|
|
44
|
+
if (currentKey && currentArray !== null && currentArray.length > 0) {
|
|
45
|
+
result[currentKey] = currentArray;
|
|
46
|
+
}
|
|
47
|
+
currentArray = null;
|
|
48
|
+
const key = line.slice(0, colonIndex).trim();
|
|
49
|
+
const value = line.slice(colonIndex + 1).trim();
|
|
50
|
+
currentKey = key;
|
|
51
|
+
if (value === '') {
|
|
52
|
+
currentArray = [];
|
|
53
|
+
}
|
|
54
|
+
else if (value === 'true') {
|
|
55
|
+
result[key] = true;
|
|
56
|
+
}
|
|
57
|
+
else if (value === 'false') {
|
|
58
|
+
result[key] = false;
|
|
59
|
+
}
|
|
60
|
+
else if (/^\d+$/.test(value)) {
|
|
61
|
+
result[key] = parseInt(value, 10);
|
|
62
|
+
}
|
|
63
|
+
else if (/^\d+\.\d+$/.test(value)) {
|
|
64
|
+
result[key] = parseFloat(value);
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
result[key] = value.replace(/^["']|["']$/g, '');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (currentKey && currentArray !== null && currentArray.length > 0) {
|
|
72
|
+
result[currentKey] = currentArray;
|
|
73
|
+
}
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Create the skills API router
|
|
78
|
+
*
|
|
79
|
+
* Endpoints:
|
|
80
|
+
* - GET /api/skills - List all skills
|
|
81
|
+
* - GET /api/skills/:name - Get single skill with content
|
|
82
|
+
* - POST /api/skills/reload - Reload skills from filesystem
|
|
83
|
+
* - POST /api/skills/upload - Upload a skill ZIP
|
|
84
|
+
* - POST /api/skills/:name/enable - Enable a skill
|
|
85
|
+
* - POST /api/skills/:name/disable - Disable a skill
|
|
86
|
+
*/
|
|
87
|
+
export function createSkillsRouter() {
|
|
88
|
+
const router = Router();
|
|
89
|
+
const display = DisplayManager.getInstance();
|
|
90
|
+
// GET /api/skills - List all skills
|
|
91
|
+
router.get('/', (_req, res) => {
|
|
92
|
+
try {
|
|
93
|
+
const registry = SkillRegistry.getInstance();
|
|
94
|
+
const skills = registry.getAll();
|
|
95
|
+
const response = skills.map(skill => ({
|
|
96
|
+
name: skill.name,
|
|
97
|
+
description: skill.description,
|
|
98
|
+
version: skill.version,
|
|
99
|
+
author: skill.author,
|
|
100
|
+
enabled: skill.enabled,
|
|
101
|
+
tags: skill.tags,
|
|
102
|
+
examples: skill.examples,
|
|
103
|
+
path: skill.path,
|
|
104
|
+
}));
|
|
105
|
+
res.json({
|
|
106
|
+
skills: response,
|
|
107
|
+
total: skills.length,
|
|
108
|
+
enabled: skills.filter(s => s.enabled).length,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
display.log(`Skills API error: ${err.message}`, { source: 'SkillsAPI', level: 'error' });
|
|
113
|
+
res.status(500).json({ error: err.message });
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
// POST /api/skills/reload - Reload from filesystem
|
|
117
|
+
// Must be before /:name routes
|
|
118
|
+
router.post('/reload', async (_req, res) => {
|
|
119
|
+
try {
|
|
120
|
+
const registry = SkillRegistry.getInstance();
|
|
121
|
+
const result = await registry.reload();
|
|
122
|
+
// Update skill_delegate tool description with new skills
|
|
123
|
+
updateSkillDelegateDescription();
|
|
124
|
+
display.log(`Skills reloaded: ${result.skills.length} loaded, ${result.errors.length} errors`, {
|
|
125
|
+
source: 'SkillsAPI',
|
|
126
|
+
});
|
|
127
|
+
res.json({
|
|
128
|
+
success: true,
|
|
129
|
+
loaded: result.skills.length,
|
|
130
|
+
errors: result.errors.map(e => ({
|
|
131
|
+
directory: e.directory,
|
|
132
|
+
message: e.message,
|
|
133
|
+
})),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
display.log(`Skills reload error: ${err.message}`, { source: 'SkillsAPI', level: 'error' });
|
|
138
|
+
res.status(500).json({ error: err.message });
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
// POST /api/skills/upload - Upload a skill ZIP
|
|
142
|
+
// Must be before /:name routes
|
|
143
|
+
router.post('/upload', upload.single('file'), async (req, res) => {
|
|
144
|
+
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'morpheus-skill-'));
|
|
145
|
+
try {
|
|
146
|
+
if (!req.file) {
|
|
147
|
+
return res.status(400).json({ error: 'No file uploaded' });
|
|
148
|
+
}
|
|
149
|
+
const zipPath = path.join(tempDir, 'skill.zip');
|
|
150
|
+
const extractDir = path.join(tempDir, 'extracted');
|
|
151
|
+
// Write buffer to temp file
|
|
152
|
+
await fs.writeFile(zipPath, req.file.buffer);
|
|
153
|
+
// Extract ZIP
|
|
154
|
+
await extract(zipPath, { dir: extractDir });
|
|
155
|
+
// Find root entries
|
|
156
|
+
const entries = await fs.readdir(extractDir, { withFileTypes: true });
|
|
157
|
+
const folders = entries.filter(e => e.isDirectory());
|
|
158
|
+
const files = entries.filter(e => e.isFile());
|
|
159
|
+
// Validate: exactly one folder at root, no loose files
|
|
160
|
+
if (folders.length !== 1 || files.length > 0) {
|
|
161
|
+
return res.status(400).json({
|
|
162
|
+
error: 'Invalid ZIP structure',
|
|
163
|
+
details: 'ZIP must contain exactly one folder at root level (no loose files)',
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
const skillFolderName = folders[0].name;
|
|
167
|
+
const skillFolderPath = path.join(extractDir, skillFolderName);
|
|
168
|
+
// Check for SKILL.md
|
|
169
|
+
const skillMdPath = path.join(skillFolderPath, 'SKILL.md');
|
|
170
|
+
if (!await fs.pathExists(skillMdPath)) {
|
|
171
|
+
return res.status(400).json({
|
|
172
|
+
error: 'Missing SKILL.md',
|
|
173
|
+
details: `Folder "${skillFolderName}" must contain a SKILL.md file`,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
// Read and validate SKILL.md frontmatter
|
|
177
|
+
const skillMdContent = await fs.readFile(skillMdPath, 'utf-8');
|
|
178
|
+
const match = skillMdContent.match(FRONTMATTER_REGEX);
|
|
179
|
+
if (!match) {
|
|
180
|
+
return res.status(400).json({
|
|
181
|
+
error: 'Invalid SKILL.md format',
|
|
182
|
+
details: 'SKILL.md must have YAML frontmatter between --- delimiters',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
const [, frontmatterYaml] = match;
|
|
186
|
+
const rawMetadata = parseFrontmatter(frontmatterYaml);
|
|
187
|
+
// Validate with Zod schema
|
|
188
|
+
const parseResult = SkillMetadataSchema.safeParse(rawMetadata);
|
|
189
|
+
if (!parseResult.success) {
|
|
190
|
+
const issues = parseResult.error.issues.map(i => `${i.path.join('.')}: ${i.message}`);
|
|
191
|
+
return res.status(400).json({
|
|
192
|
+
error: 'Invalid skill metadata',
|
|
193
|
+
details: issues.join('; '),
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
const metadata = parseResult.data;
|
|
197
|
+
// Check if skill already exists
|
|
198
|
+
const targetPath = path.join(PATHS.skills, metadata.name);
|
|
199
|
+
if (await fs.pathExists(targetPath)) {
|
|
200
|
+
return res.status(409).json({
|
|
201
|
+
error: 'Skill already exists',
|
|
202
|
+
details: `A skill named "${metadata.name}" already exists. Delete it first or choose a different name.`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
// Copy to skills directory
|
|
206
|
+
await fs.copy(skillFolderPath, targetPath);
|
|
207
|
+
// Reload skills
|
|
208
|
+
const registry = SkillRegistry.getInstance();
|
|
209
|
+
await registry.reload();
|
|
210
|
+
updateSkillDelegateDescription();
|
|
211
|
+
display.log(`Skill "${metadata.name}" uploaded successfully`, { source: 'SkillsAPI' });
|
|
212
|
+
res.json({
|
|
213
|
+
success: true,
|
|
214
|
+
skill: {
|
|
215
|
+
name: metadata.name,
|
|
216
|
+
description: metadata.description,
|
|
217
|
+
version: metadata.version,
|
|
218
|
+
author: metadata.author,
|
|
219
|
+
path: targetPath,
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
catch (err) {
|
|
224
|
+
display.log(`Skill upload error: ${err.message}`, { source: 'SkillsAPI', level: 'error' });
|
|
225
|
+
res.status(500).json({ error: err.message });
|
|
226
|
+
}
|
|
227
|
+
finally {
|
|
228
|
+
// Cleanup temp directory
|
|
229
|
+
await fs.remove(tempDir).catch(() => { });
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
// GET /api/skills/:name - Get single skill with content
|
|
233
|
+
router.get('/:name', (req, res) => {
|
|
234
|
+
try {
|
|
235
|
+
const { name } = req.params;
|
|
236
|
+
const registry = SkillRegistry.getInstance();
|
|
237
|
+
const skill = registry.get(name);
|
|
238
|
+
if (!skill) {
|
|
239
|
+
return res.status(404).json({ error: `Skill "${name}" not found` });
|
|
240
|
+
}
|
|
241
|
+
const content = registry.getContent(name);
|
|
242
|
+
res.json({
|
|
243
|
+
...skill,
|
|
244
|
+
content: content || null,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
display.log(`Skills API error: ${err.message}`, { source: 'SkillsAPI', level: 'error' });
|
|
249
|
+
res.status(500).json({ error: err.message });
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
// POST /api/skills/:name/enable - Enable a skill
|
|
253
|
+
router.post('/:name/enable', (req, res) => {
|
|
254
|
+
try {
|
|
255
|
+
const { name } = req.params;
|
|
256
|
+
const registry = SkillRegistry.getInstance();
|
|
257
|
+
const success = registry.enable(name);
|
|
258
|
+
if (!success) {
|
|
259
|
+
return res.status(404).json({ error: `Skill "${name}" not found` });
|
|
260
|
+
}
|
|
261
|
+
// Update skill_delegate tool description
|
|
262
|
+
updateSkillDelegateDescription();
|
|
263
|
+
display.log(`Skill "${name}" enabled`, { source: 'SkillsAPI' });
|
|
264
|
+
res.json({ success: true, name, enabled: true });
|
|
265
|
+
}
|
|
266
|
+
catch (err) {
|
|
267
|
+
display.log(`Skills API error: ${err.message}`, { source: 'SkillsAPI', level: 'error' });
|
|
268
|
+
res.status(500).json({ error: err.message });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
// POST /api/skills/:name/disable - Disable a skill
|
|
272
|
+
router.post('/:name/disable', (req, res) => {
|
|
273
|
+
try {
|
|
274
|
+
const { name } = req.params;
|
|
275
|
+
const registry = SkillRegistry.getInstance();
|
|
276
|
+
const success = registry.disable(name);
|
|
277
|
+
if (!success) {
|
|
278
|
+
return res.status(404).json({ error: `Skill "${name}" not found` });
|
|
279
|
+
}
|
|
280
|
+
// Update skill_delegate tool description
|
|
281
|
+
updateSkillDelegateDescription();
|
|
282
|
+
display.log(`Skill "${name}" disabled`, { source: 'SkillsAPI' });
|
|
283
|
+
res.json({ success: true, name, enabled: false });
|
|
284
|
+
}
|
|
285
|
+
catch (err) {
|
|
286
|
+
display.log(`Skills API error: ${err.message}`, { source: 'SkillsAPI', level: 'error' });
|
|
287
|
+
res.status(500).json({ error: err.message });
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
return router;
|
|
291
|
+
}
|
|
@@ -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();
|
|
@@ -337,9 +341,11 @@ Use it to inform your response and tool selection (if needed), but do not assume
|
|
|
337
341
|
origin_user_id: taskContext?.origin_user_id,
|
|
338
342
|
};
|
|
339
343
|
let contextDelegationAcks = [];
|
|
344
|
+
let syncDelegationCount = 0;
|
|
340
345
|
const response = await TaskRequestContext.run(invokeContext, async () => {
|
|
341
346
|
const agentResponse = await this.provider.invoke({ messages });
|
|
342
347
|
contextDelegationAcks = TaskRequestContext.getDelegationAcks();
|
|
348
|
+
syncDelegationCount = TaskRequestContext.getSyncDelegationCount();
|
|
343
349
|
return agentResponse;
|
|
344
350
|
});
|
|
345
351
|
// Identify new messages generated during the interaction
|
|
@@ -360,13 +366,17 @@ Use it to inform your response and tool selection (if needed), but do not assume
|
|
|
360
366
|
const toolDelegationAcks = this.extractDelegationAcksFromMessages(newGeneratedMessages);
|
|
361
367
|
const hadDelegationToolCall = this.hasDelegationToolCall(newGeneratedMessages);
|
|
362
368
|
const hadChronosToolCall = this.hasChronosToolCall(newGeneratedMessages);
|
|
369
|
+
// When all delegation tool calls ran synchronously, there are no task IDs to validate.
|
|
370
|
+
// Treat as a normal (non-delegation) response so the inline result flows through.
|
|
371
|
+
const allDelegationsSyncInline = hadDelegationToolCall && syncDelegationCount > 0
|
|
372
|
+
&& contextDelegationAcks.length === 0;
|
|
363
373
|
const mergedDelegationAcks = [
|
|
364
374
|
...contextDelegationAcks.map((ack) => ({ task_id: ack.task_id, agent: ack.agent })),
|
|
365
375
|
...toolDelegationAcks,
|
|
366
376
|
];
|
|
367
377
|
const validDelegationAcks = this.validateDelegationAcks(mergedDelegationAcks, message);
|
|
368
378
|
if (mergedDelegationAcks.length > 0) {
|
|
369
|
-
this.display.log(`Delegation trace: context=${contextDelegationAcks.length}, tool_messages=${toolDelegationAcks.length}, valid=${validDelegationAcks.length}`, { source: "Oracle", level: "info" });
|
|
379
|
+
this.display.log(`Delegation trace: context=${contextDelegationAcks.length}, tool_messages=${toolDelegationAcks.length}, valid=${validDelegationAcks.length}, sync_inline=${syncDelegationCount}`, { source: "Oracle", level: "info" });
|
|
370
380
|
}
|
|
371
381
|
const delegatedThisTurn = validDelegationAcks.length > 0;
|
|
372
382
|
let blockedSyntheticDelegationAck = false;
|
|
@@ -388,7 +398,7 @@ Use it to inform your response and tool selection (if needed), but do not assume
|
|
|
388
398
|
// returned to the caller (Telegram / UI) immediately after this point.
|
|
389
399
|
this.taskRepository.markAckSent(validDelegationAcks.map(a => a.task_id));
|
|
390
400
|
}
|
|
391
|
-
else if (mergedDelegationAcks.length > 0 || hadDelegationToolCall) {
|
|
401
|
+
else if (!allDelegationsSyncInline && (mergedDelegationAcks.length > 0 || hadDelegationToolCall)) {
|
|
392
402
|
this.display.log(`Delegation attempted but no valid task id was confirmed (context=${contextDelegationAcks.length}, tool_messages=${toolDelegationAcks.length}, had_tool_call=${hadDelegationToolCall}).`, { source: "Oracle", level: "error" });
|
|
393
403
|
// Delegation was attempted but no valid task id could be confirmed in DB.
|
|
394
404
|
responseContent = this.buildDelegationFailureResponse();
|
|
@@ -538,7 +548,8 @@ Use it to inform your response and tool selection (if needed), but do not assume
|
|
|
538
548
|
}
|
|
539
549
|
await Neo.refreshDelegateCatalog().catch(() => { });
|
|
540
550
|
await Trinity.refreshDelegateCatalog().catch(() => { });
|
|
541
|
-
|
|
551
|
+
updateSkillToolDescriptions();
|
|
552
|
+
this.provider = await ProviderFactory.create(this.config.llm, [TaskQueryTool, NeoDelegateTool, ApocDelegateTool, TrinityDelegateTool, SkillExecuteTool, SkillDelegateTool, ...chronosTools]);
|
|
542
553
|
await Neo.getInstance().reload();
|
|
543
554
|
this.display.log(`Oracle and Neo tools reloaded`, { source: 'Oracle' });
|
|
544
555
|
}
|