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.
Files changed (36) hide show
  1. package/README.md +119 -0
  2. package/dist/channels/discord.js +109 -0
  3. package/dist/channels/telegram.js +94 -0
  4. package/dist/cli/commands/start.js +12 -0
  5. package/dist/config/manager.js +3 -0
  6. package/dist/config/paths.js +1 -0
  7. package/dist/config/schemas.js +11 -2
  8. package/dist/http/api.js +3 -0
  9. package/dist/http/routers/skills.js +291 -0
  10. package/dist/runtime/__tests__/keymaker.test.js +145 -0
  11. package/dist/runtime/keymaker.js +162 -0
  12. package/dist/runtime/oracle.js +15 -4
  13. package/dist/runtime/scaffold.js +75 -0
  14. package/dist/runtime/skills/__tests__/loader.test.js +187 -0
  15. package/dist/runtime/skills/__tests__/registry.test.js +201 -0
  16. package/dist/runtime/skills/__tests__/tool.test.js +266 -0
  17. package/dist/runtime/skills/index.js +8 -0
  18. package/dist/runtime/skills/loader.js +213 -0
  19. package/dist/runtime/skills/registry.js +141 -0
  20. package/dist/runtime/skills/schema.js +30 -0
  21. package/dist/runtime/skills/tool.js +204 -0
  22. package/dist/runtime/skills/types.js +7 -0
  23. package/dist/runtime/tasks/context.js +16 -0
  24. package/dist/runtime/tasks/worker.js +22 -0
  25. package/dist/runtime/tools/apoc-tool.js +28 -1
  26. package/dist/runtime/tools/morpheus-tools.js +3 -0
  27. package/dist/runtime/tools/neo-tool.js +32 -0
  28. package/dist/runtime/tools/trinity-tool.js +27 -0
  29. package/dist/types/config.js +3 -0
  30. package/dist/ui/assets/index-CsMDzmtQ.js +117 -0
  31. package/dist/ui/assets/index-Dz_qYlIb.css +1 -0
  32. package/dist/ui/index.html +2 -2
  33. package/dist/ui/sw.js +1 -1
  34. package/package.json +4 -1
  35. package/dist/ui/assets/index-7e8TCoiy.js +0 -111
  36. package/dist/ui/assets/index-B9ngtbja.css +0 -1
@@ -0,0 +1,266 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ // Use vi.hoisted to define mocks before they're used in vi.mock calls
3
+ const { mockRegistry, mockRepository, mockDisplay, mockContext, mockKeymaker } = vi.hoisted(() => ({
4
+ mockRegistry: {
5
+ get: vi.fn(),
6
+ getEnabled: vi.fn(() => []),
7
+ getContent: vi.fn(() => null),
8
+ },
9
+ mockRepository: {
10
+ createTask: vi.fn(),
11
+ },
12
+ mockDisplay: {
13
+ log: vi.fn(),
14
+ },
15
+ mockContext: {
16
+ get: vi.fn(() => ({
17
+ origin_channel: 'telegram',
18
+ session_id: 'test-session',
19
+ origin_message_id: '123',
20
+ origin_user_id: 'user-1',
21
+ })),
22
+ findDuplicateDelegation: vi.fn(() => null),
23
+ canEnqueueDelegation: vi.fn(() => true),
24
+ setDelegationAck: vi.fn(),
25
+ },
26
+ mockKeymaker: {
27
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
28
+ executeKeymakerTask: vi.fn((_skill, _obj, _ctx) => Promise.resolve('Keymaker result')),
29
+ },
30
+ }));
31
+ vi.mock('../registry.js', () => ({
32
+ SkillRegistry: {
33
+ getInstance: () => mockRegistry,
34
+ },
35
+ }));
36
+ vi.mock('../../tasks/repository.js', () => ({
37
+ TaskRepository: {
38
+ getInstance: () => mockRepository,
39
+ },
40
+ }));
41
+ vi.mock('../../tasks/context.js', () => ({
42
+ TaskRequestContext: mockContext,
43
+ }));
44
+ vi.mock('../../display.js', () => ({
45
+ DisplayManager: {
46
+ getInstance: () => mockDisplay,
47
+ },
48
+ }));
49
+ vi.mock('../../keymaker.js', () => ({
50
+ executeKeymakerTask: (skillName, objective, context) => mockKeymaker.executeKeymakerTask(skillName, objective, context),
51
+ }));
52
+ // Now import the module under test
53
+ import { SkillExecuteTool, SkillDelegateTool, getSkillExecuteDescription, getSkillDelegateDescription } from '../tool.js';
54
+ describe('SkillExecuteTool (sync)', () => {
55
+ beforeEach(() => {
56
+ vi.clearAllMocks();
57
+ mockRegistry.get.mockReset();
58
+ mockRegistry.getEnabled.mockReset();
59
+ mockKeymaker.executeKeymakerTask.mockReset();
60
+ mockKeymaker.executeKeymakerTask.mockResolvedValue('Keymaker result');
61
+ mockRegistry.getEnabled.mockReturnValue([]);
62
+ });
63
+ describe('getSkillExecuteDescription()', () => {
64
+ it('should include enabled sync skills in description', () => {
65
+ mockRegistry.getEnabled.mockReturnValue([
66
+ { name: 'code-review', description: 'Review code for issues', execution_mode: 'sync' },
67
+ { name: 'git-ops', description: 'Git operations helper', execution_mode: 'sync' },
68
+ { name: 'deploy', description: 'Deploy to prod', execution_mode: 'async' }, // should not appear
69
+ ]);
70
+ const description = getSkillExecuteDescription();
71
+ expect(description).toContain('code-review: Review code for issues');
72
+ expect(description).toContain('git-ops: Git operations helper');
73
+ expect(description).not.toContain('deploy');
74
+ });
75
+ it('should show no sync skills message when none enabled', () => {
76
+ mockRegistry.getEnabled.mockReturnValue([]);
77
+ const description = getSkillExecuteDescription();
78
+ expect(description).toContain('(no sync skills enabled)');
79
+ });
80
+ });
81
+ describe('invoke()', () => {
82
+ it('should execute sync skill via Keymaker', async () => {
83
+ mockRegistry.get.mockReturnValue({
84
+ name: 'test-skill',
85
+ description: 'Test',
86
+ enabled: true,
87
+ execution_mode: 'sync',
88
+ content: 'Instructions here',
89
+ });
90
+ mockRegistry.getEnabled.mockReturnValue([{ name: 'test-skill', execution_mode: 'sync' }]);
91
+ const result = await SkillExecuteTool.invoke({
92
+ skillName: 'test-skill',
93
+ objective: 'do the thing',
94
+ });
95
+ expect(mockKeymaker.executeKeymakerTask).toHaveBeenCalledWith('test-skill', 'do the thing', expect.objectContaining({
96
+ origin_channel: 'telegram',
97
+ session_id: 'test-session',
98
+ }));
99
+ expect(result).toBe('Keymaker result');
100
+ });
101
+ it('should return error for non-existent skill', async () => {
102
+ mockRegistry.get.mockReturnValue(undefined);
103
+ mockRegistry.getEnabled.mockReturnValue([
104
+ { name: 'other-skill', execution_mode: 'sync' },
105
+ ]);
106
+ const result = await SkillExecuteTool.invoke({
107
+ skillName: 'non-existent',
108
+ objective: 'do something',
109
+ });
110
+ expect(result).toContain('Error');
111
+ expect(result).toContain('not found');
112
+ expect(result).toContain('other-skill');
113
+ });
114
+ it('should return error for async skill', async () => {
115
+ mockRegistry.get.mockReturnValue({
116
+ name: 'async-skill',
117
+ description: 'Async only',
118
+ enabled: true,
119
+ execution_mode: 'async',
120
+ });
121
+ const result = await SkillExecuteTool.invoke({
122
+ skillName: 'async-skill',
123
+ objective: 'do something',
124
+ });
125
+ expect(result).toContain('Error');
126
+ expect(result).toContain('async-only');
127
+ expect(result).toContain('skill_delegate');
128
+ });
129
+ });
130
+ });
131
+ describe('SkillDelegateTool (async)', () => {
132
+ beforeEach(() => {
133
+ vi.clearAllMocks();
134
+ mockRegistry.get.mockReset();
135
+ mockRegistry.getEnabled.mockReset();
136
+ mockRepository.createTask.mockReset();
137
+ mockDisplay.log.mockReset();
138
+ mockContext.findDuplicateDelegation.mockReturnValue(null);
139
+ mockContext.canEnqueueDelegation.mockReturnValue(true);
140
+ mockRegistry.getEnabled.mockReturnValue([]);
141
+ });
142
+ describe('getSkillDelegateDescription()', () => {
143
+ it('should include enabled async skills in description', () => {
144
+ mockRegistry.getEnabled.mockReturnValue([
145
+ { name: 'deploy-staging', description: 'Deploy to staging', execution_mode: 'async' },
146
+ { name: 'batch-process', description: 'Process batch jobs', execution_mode: 'async' },
147
+ { name: 'code-review', description: 'Review code', execution_mode: 'sync' }, // should not appear
148
+ ]);
149
+ const description = getSkillDelegateDescription();
150
+ expect(description).toContain('deploy-staging: Deploy to staging');
151
+ expect(description).toContain('batch-process: Process batch jobs');
152
+ expect(description).not.toContain('code-review');
153
+ });
154
+ it('should show no async skills message when none enabled', () => {
155
+ mockRegistry.getEnabled.mockReturnValue([]);
156
+ const description = getSkillDelegateDescription();
157
+ expect(description).toContain('(no async skills enabled)');
158
+ });
159
+ });
160
+ describe('invoke()', () => {
161
+ it('should create task for valid async skill', async () => {
162
+ mockRegistry.get.mockReturnValue({
163
+ name: 'deploy-staging',
164
+ description: 'Deploy',
165
+ enabled: true,
166
+ execution_mode: 'async',
167
+ });
168
+ mockRegistry.getEnabled.mockReturnValue([{ name: 'deploy-staging', execution_mode: 'async' }]);
169
+ mockRepository.createTask.mockReturnValue({
170
+ id: 'task-123',
171
+ agent: 'keymaker',
172
+ status: 'pending',
173
+ });
174
+ const result = await SkillDelegateTool.invoke({
175
+ skillName: 'deploy-staging',
176
+ objective: 'deploy to staging',
177
+ });
178
+ expect(mockRepository.createTask).toHaveBeenCalledWith(expect.objectContaining({
179
+ agent: 'keymaker',
180
+ input: 'deploy to staging',
181
+ context: JSON.stringify({ skill: 'deploy-staging' }),
182
+ origin_channel: 'telegram',
183
+ session_id: 'test-session',
184
+ }));
185
+ expect(result).toContain('task-123');
186
+ expect(result).toContain('queued');
187
+ });
188
+ it('should return error for sync skill', async () => {
189
+ mockRegistry.get.mockReturnValue({
190
+ name: 'sync-skill',
191
+ description: 'Sync',
192
+ enabled: true,
193
+ execution_mode: 'sync',
194
+ });
195
+ const result = await SkillDelegateTool.invoke({
196
+ skillName: 'sync-skill',
197
+ objective: 'do something',
198
+ });
199
+ expect(result).toContain('Error');
200
+ expect(result).toContain('sync');
201
+ expect(result).toContain('skill_execute');
202
+ expect(mockRepository.createTask).not.toHaveBeenCalled();
203
+ });
204
+ it('should return error for non-existent skill', async () => {
205
+ mockRegistry.get.mockReturnValue(undefined);
206
+ mockRegistry.getEnabled.mockReturnValue([
207
+ { name: 'other-skill', execution_mode: 'async' },
208
+ ]);
209
+ const result = await SkillDelegateTool.invoke({
210
+ skillName: 'non-existent',
211
+ objective: 'do something',
212
+ });
213
+ expect(result).toContain('Error');
214
+ expect(result).toContain('not found');
215
+ expect(mockRepository.createTask).not.toHaveBeenCalled();
216
+ });
217
+ it('should return error for disabled skill', async () => {
218
+ mockRegistry.get.mockReturnValue({
219
+ name: 'disabled-skill',
220
+ description: 'Disabled',
221
+ enabled: false,
222
+ execution_mode: 'async',
223
+ });
224
+ const result = await SkillDelegateTool.invoke({
225
+ skillName: 'disabled-skill',
226
+ objective: 'do something',
227
+ });
228
+ expect(result).toContain('Error');
229
+ expect(result).toContain('disabled');
230
+ expect(mockRepository.createTask).not.toHaveBeenCalled();
231
+ });
232
+ it('should deduplicate delegation requests', async () => {
233
+ mockRegistry.get.mockReturnValue({
234
+ name: 'dup-skill',
235
+ enabled: true,
236
+ execution_mode: 'async',
237
+ });
238
+ mockContext.findDuplicateDelegation.mockReturnValue({
239
+ task_id: 'existing-task',
240
+ agent: 'keymaker',
241
+ task: 'dup-skill:objective',
242
+ });
243
+ const result = await SkillDelegateTool.invoke({
244
+ skillName: 'dup-skill',
245
+ objective: 'objective',
246
+ });
247
+ expect(result).toContain('existing-task');
248
+ expect(result).toContain('already queued');
249
+ expect(mockRepository.createTask).not.toHaveBeenCalled();
250
+ });
251
+ it('should block when delegation limit reached', async () => {
252
+ mockRegistry.get.mockReturnValue({
253
+ name: 'limit-skill',
254
+ enabled: true,
255
+ execution_mode: 'async',
256
+ });
257
+ mockContext.canEnqueueDelegation.mockReturnValue(false);
258
+ const result = await SkillDelegateTool.invoke({
259
+ skillName: 'limit-skill',
260
+ objective: 'objective',
261
+ });
262
+ expect(result).toContain('limit reached');
263
+ expect(mockRepository.createTask).not.toHaveBeenCalled();
264
+ });
265
+ });
266
+ });
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Skills System - Public API
3
+ */
4
+ export { SkillRegistry } from './registry.js';
5
+ export { SkillLoader } from './loader.js';
6
+ export { SkillMetadataSchema } from './schema.js';
7
+ export { SkillExecuteTool, SkillDelegateTool, getSkillExecuteDescription, getSkillDelegateDescription, updateSkillToolDescriptions, updateSkillDelegateDescription, // backwards compat
8
+ } from './tool.js';
@@ -0,0 +1,213 @@
1
+ /**
2
+ * SkillLoader - Discovers and loads skills from filesystem
3
+ *
4
+ * Skills are SKILL.md files with YAML frontmatter containing metadata.
5
+ * Format:
6
+ * ---
7
+ * name: my-skill
8
+ * description: What this skill does
9
+ * execution_mode: sync
10
+ * ---
11
+ *
12
+ * # Skill Instructions...
13
+ */
14
+ import fs from 'fs';
15
+ import path from 'path';
16
+ import { SkillMetadataSchema } from './schema.js';
17
+ import { DisplayManager } from '../display.js';
18
+ const SKILL_MD = 'SKILL.md';
19
+ const MAX_SKILL_MD_SIZE = 50 * 1024; // 50KB
20
+ const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
21
+ /**
22
+ * Simple YAML frontmatter parser
23
+ * Handles basic key: value pairs and arrays
24
+ */
25
+ function parseFrontmatter(yaml) {
26
+ const result = {};
27
+ const lines = yaml.split('\n');
28
+ let currentKey = null;
29
+ let currentArray = null;
30
+ for (const line of lines) {
31
+ const trimmed = line.trim();
32
+ if (!trimmed || trimmed.startsWith('#'))
33
+ continue;
34
+ // Check for array item (indented with -)
35
+ if (trimmed.startsWith('- ') && currentKey && currentArray !== null) {
36
+ currentArray.push(trimmed.slice(2).trim().replace(/^["']|["']$/g, ''));
37
+ continue;
38
+ }
39
+ // Check for key: value
40
+ const colonIndex = line.indexOf(':');
41
+ if (colonIndex > 0) {
42
+ // Save previous array if exists
43
+ if (currentKey && currentArray !== null && currentArray.length > 0) {
44
+ result[currentKey] = currentArray;
45
+ }
46
+ currentArray = null;
47
+ const key = line.slice(0, colonIndex).trim();
48
+ const value = line.slice(colonIndex + 1).trim();
49
+ currentKey = key;
50
+ if (value === '') {
51
+ // Could be start of array
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
+ // Remove quotes if present
68
+ result[key] = value.replace(/^["']|["']$/g, '');
69
+ }
70
+ }
71
+ }
72
+ // Save last array if exists
73
+ if (currentKey && currentArray !== null && currentArray.length > 0) {
74
+ result[currentKey] = currentArray;
75
+ }
76
+ return result;
77
+ }
78
+ export class SkillLoader {
79
+ skillsDir;
80
+ display = DisplayManager.getInstance();
81
+ constructor(skillsDir) {
82
+ this.skillsDir = skillsDir;
83
+ }
84
+ /**
85
+ * Scan skills directory and load all valid skills
86
+ */
87
+ async scan() {
88
+ const skills = [];
89
+ const errors = [];
90
+ // Check if skills directory exists
91
+ if (!fs.existsSync(this.skillsDir)) {
92
+ this.display.log(`Skills directory does not exist: ${this.skillsDir}`, {
93
+ source: 'SkillLoader',
94
+ level: 'debug',
95
+ });
96
+ return { skills, errors };
97
+ }
98
+ // Read directory contents
99
+ let entries;
100
+ try {
101
+ entries = fs.readdirSync(this.skillsDir, { withFileTypes: true });
102
+ }
103
+ catch (err) {
104
+ this.display.log(`Failed to read skills directory: ${err}`, {
105
+ source: 'SkillLoader',
106
+ level: 'error',
107
+ });
108
+ return { skills, errors };
109
+ }
110
+ // Process each subdirectory
111
+ for (const entry of entries) {
112
+ if (!entry.isDirectory())
113
+ continue;
114
+ const dirPath = path.join(this.skillsDir, entry.name);
115
+ const result = this.loadSkillFromDir(dirPath, entry.name);
116
+ if (result.skill) {
117
+ skills.push(result.skill);
118
+ }
119
+ else if (result.error) {
120
+ errors.push(result.error);
121
+ }
122
+ }
123
+ return { skills, errors };
124
+ }
125
+ /**
126
+ * Load a single skill from a directory
127
+ */
128
+ loadSkillFromDir(dirPath, dirName) {
129
+ const mdPath = path.join(dirPath, SKILL_MD);
130
+ // Check SKILL.md exists
131
+ if (!fs.existsSync(mdPath)) {
132
+ return {
133
+ error: {
134
+ directory: dirName,
135
+ message: `Missing ${SKILL_MD}`,
136
+ },
137
+ };
138
+ }
139
+ // Read SKILL.md content
140
+ let rawContent;
141
+ try {
142
+ const stats = fs.statSync(mdPath);
143
+ if (stats.size > MAX_SKILL_MD_SIZE) {
144
+ this.display.log(`SKILL.md for "${dirName}" exceeds ${MAX_SKILL_MD_SIZE / 1024}KB`, { source: 'SkillLoader', level: 'warning' });
145
+ }
146
+ rawContent = fs.readFileSync(mdPath, 'utf-8').slice(0, MAX_SKILL_MD_SIZE);
147
+ }
148
+ catch (err) {
149
+ return {
150
+ error: {
151
+ directory: dirName,
152
+ message: `Failed to read ${SKILL_MD}: ${err instanceof Error ? err.message : String(err)}`,
153
+ error: err instanceof Error ? err : undefined,
154
+ },
155
+ };
156
+ }
157
+ // Parse frontmatter
158
+ const match = rawContent.match(FRONTMATTER_REGEX);
159
+ if (!match) {
160
+ return {
161
+ error: {
162
+ directory: dirName,
163
+ message: `Invalid format: ${SKILL_MD} must start with YAML frontmatter (--- ... ---)`,
164
+ },
165
+ };
166
+ }
167
+ const [, frontmatterYaml, content] = match;
168
+ // Parse YAML frontmatter
169
+ let rawMeta;
170
+ try {
171
+ rawMeta = parseFrontmatter(frontmatterYaml);
172
+ }
173
+ catch (err) {
174
+ return {
175
+ error: {
176
+ directory: dirName,
177
+ message: `Invalid YAML frontmatter: ${err instanceof Error ? err.message : String(err)}`,
178
+ error: err instanceof Error ? err : undefined,
179
+ },
180
+ };
181
+ }
182
+ // Validate against schema
183
+ const parseResult = SkillMetadataSchema.safeParse(rawMeta);
184
+ if (!parseResult.success) {
185
+ const issues = parseResult.error.issues
186
+ .map((i) => `${i.path.join('.')}: ${i.message}`)
187
+ .join('; ');
188
+ return {
189
+ error: {
190
+ directory: dirName,
191
+ message: `Schema validation failed: ${issues}`,
192
+ },
193
+ };
194
+ }
195
+ const metadata = parseResult.data;
196
+ // Build Skill object
197
+ const skill = {
198
+ ...metadata,
199
+ path: mdPath,
200
+ dirName,
201
+ content: content.trim(),
202
+ enabled: metadata.enabled ?? true,
203
+ execution_mode: metadata.execution_mode ?? 'sync',
204
+ };
205
+ return { skill };
206
+ }
207
+ /**
208
+ * Read SKILL.md content for a skill (returns just the body, no frontmatter)
209
+ */
210
+ readContent(skill) {
211
+ return skill.content || null;
212
+ }
213
+ }
@@ -0,0 +1,141 @@
1
+ /**
2
+ * SkillRegistry - Singleton registry for loaded skills
3
+ */
4
+ import { PATHS } from '../../config/paths.js';
5
+ import { DisplayManager } from '../display.js';
6
+ import { SkillLoader } from './loader.js';
7
+ export class SkillRegistry {
8
+ static instance = null;
9
+ skills = new Map();
10
+ loader;
11
+ display = DisplayManager.getInstance();
12
+ constructor() {
13
+ this.loader = new SkillLoader(PATHS.skills);
14
+ }
15
+ static getInstance() {
16
+ if (!SkillRegistry.instance) {
17
+ SkillRegistry.instance = new SkillRegistry();
18
+ }
19
+ return SkillRegistry.instance;
20
+ }
21
+ static resetInstance() {
22
+ SkillRegistry.instance = null;
23
+ }
24
+ /**
25
+ * Load skills from filesystem
26
+ */
27
+ async load() {
28
+ const result = await this.loader.scan();
29
+ this.skills.clear();
30
+ for (const skill of result.skills) {
31
+ if (this.skills.has(skill.name)) {
32
+ this.display.log(`Duplicate skill name "${skill.name}", overwriting`, {
33
+ source: 'SkillRegistry',
34
+ level: 'warning',
35
+ });
36
+ }
37
+ this.skills.set(skill.name, skill);
38
+ }
39
+ // Log errors
40
+ for (const error of result.errors) {
41
+ this.display.log(`Failed to load skill from "${error.directory}": ${error.message}`, {
42
+ source: 'SkillRegistry',
43
+ level: 'warning',
44
+ });
45
+ }
46
+ this.display.log(`Loaded ${this.skills.size} skills`, { source: 'SkillRegistry' });
47
+ return result;
48
+ }
49
+ /**
50
+ * Reload skills from filesystem
51
+ */
52
+ async reload() {
53
+ return this.load();
54
+ }
55
+ /**
56
+ * Get all loaded skills
57
+ */
58
+ getAll() {
59
+ return Array.from(this.skills.values());
60
+ }
61
+ /**
62
+ * Get only enabled skills
63
+ */
64
+ getEnabled() {
65
+ return this.getAll().filter((s) => s.enabled);
66
+ }
67
+ /**
68
+ * Get a skill by name
69
+ */
70
+ get(name) {
71
+ return this.skills.get(name);
72
+ }
73
+ /**
74
+ * Enable a skill (runtime only, doesn't persist to YAML)
75
+ */
76
+ enable(name) {
77
+ const skill = this.skills.get(name);
78
+ if (!skill)
79
+ return false;
80
+ skill.enabled = true;
81
+ return true;
82
+ }
83
+ /**
84
+ * Disable a skill (runtime only, doesn't persist to YAML)
85
+ */
86
+ disable(name) {
87
+ const skill = this.skills.get(name);
88
+ if (!skill)
89
+ return false;
90
+ skill.enabled = false;
91
+ return true;
92
+ }
93
+ /**
94
+ * Read SKILL.md content for a skill
95
+ */
96
+ getContent(name) {
97
+ const skill = this.skills.get(name);
98
+ if (!skill)
99
+ return null;
100
+ return skill.content || null;
101
+ }
102
+ /**
103
+ * Generate system prompt section listing available skills
104
+ */
105
+ getSystemPromptSection() {
106
+ const enabled = this.getEnabled();
107
+ if (enabled.length === 0) {
108
+ return '';
109
+ }
110
+ const syncSkills = enabled.filter((s) => s.execution_mode === 'sync');
111
+ const asyncSkills = enabled.filter((s) => s.execution_mode === 'async');
112
+ const lines = ['## Available Skills', ''];
113
+ if (syncSkills.length > 0) {
114
+ lines.push('### Sync Skills (immediate result via skill_execute)');
115
+ for (const s of syncSkills) {
116
+ lines.push(`- **${s.name}**: ${s.description}`);
117
+ }
118
+ lines.push('');
119
+ }
120
+ if (asyncSkills.length > 0) {
121
+ lines.push('### Async Skills (background task via skill_delegate)');
122
+ for (const s of asyncSkills) {
123
+ lines.push(`- **${s.name}**: ${s.description}`);
124
+ }
125
+ lines.push('');
126
+ }
127
+ lines.push('Use `skill_execute(skillName, objective)` for sync skills — result returned immediately.');
128
+ lines.push('Use `skill_delegate(skillName, objective)` for async skills — runs in background, notifies when done.');
129
+ return lines.join('\n');
130
+ }
131
+ /**
132
+ * Get skill names for tool description
133
+ */
134
+ getSkillNamesForTool() {
135
+ const enabled = this.getEnabled();
136
+ if (enabled.length === 0) {
137
+ return 'No skills available.';
138
+ }
139
+ return `Available skills: ${enabled.map((s) => s.name).join(', ')}`;
140
+ }
141
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Skills Zod Schema for SKILL.md frontmatter validation
3
+ */
4
+ import { z } from 'zod';
5
+ /**
6
+ * Schema for SKILL.md frontmatter metadata
7
+ */
8
+ export const SkillMetadataSchema = z.object({
9
+ name: z
10
+ .string()
11
+ .min(1, 'Skill name is required')
12
+ .max(64, 'Skill name must be at most 64 characters')
13
+ .regex(/^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/, 'Skill name must be lowercase alphanumeric with hyphens, cannot start/end with hyphen'),
14
+ description: z
15
+ .string()
16
+ .min(1, 'Description is required')
17
+ .max(500, 'Description must be at most 500 characters'),
18
+ execution_mode: z
19
+ .enum(['sync', 'async'])
20
+ .default('sync')
21
+ .describe('Execution mode: sync returns result inline, async creates background task'),
22
+ version: z
23
+ .string()
24
+ .regex(/^\d+\.\d+\.\d+$/, 'Version must be semver format (e.g., 1.0.0)')
25
+ .optional(),
26
+ author: z.string().max(100).optional(),
27
+ enabled: z.boolean().optional().default(true),
28
+ tags: z.array(z.string().max(32)).max(10).optional(),
29
+ examples: z.array(z.string().max(200)).max(5).optional(),
30
+ });