oricore 1.5.0 → 1.5.2

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.
@@ -6,6 +6,11 @@ import type { Context } from '../core/context';
6
6
  import type { Paths } from '../core/paths';
7
7
  import { PluginHookType } from '../core/plugin';
8
8
  import { safeFrontMatter } from '../utils/safeFrontMatter';
9
+ import {
10
+ bundledSkillRegistry,
11
+ bundledSkillToMetadata,
12
+ type BundledSkillDefinition,
13
+ } from './bundled';
9
14
 
10
15
  /**
11
16
  * Check if a directory entry is a directory or a symlink pointing to a directory.
@@ -28,13 +33,48 @@ export enum SkillSource {
28
33
  Global = 'global',
29
34
  ProjectClaude = 'project-claude',
30
35
  Project = 'project',
36
+ Builtin = 'builtin',
31
37
  }
32
38
 
39
+ export type SkillContext = 'inline' | 'fork';
40
+
33
41
  export interface SkillMetadata {
34
42
  name: string;
35
43
  description: string;
36
44
  path: string;
37
45
  source: SkillSource;
46
+ /**
47
+ * Tools that this skill is allowed to use.
48
+ * If specified, only these tools will be available when the skill executes.
49
+ * Example: ['Read', 'Grep', 'Bash']
50
+ */
51
+ allowedTools?: string[];
52
+ /**
53
+ * Execution context for the skill.
54
+ * - 'inline': Skill content is injected into current conversation (default)
55
+ * - 'fork': Skill runs in an isolated sub-agent
56
+ */
57
+ context?: SkillContext;
58
+ /**
59
+ * Agent type to use when context is 'fork'.
60
+ * If not specified, defaults to a general-purpose agent.
61
+ */
62
+ agent?: string;
63
+ /**
64
+ * File path patterns that trigger this skill's availability.
65
+ * Example: ['src/asterisk/asterisk.ts', '*.test.js']
66
+ */
67
+ paths?: string[];
68
+ /**
69
+ * Whether this skill can be invoked by users via slash commands.
70
+ * @default true
71
+ */
72
+ userInvocable?: boolean;
73
+ /**
74
+ * Whether this skill can be invoked by the model via SkillTool.
75
+ * @default true
76
+ */
77
+ modelInvocable?: boolean;
38
78
  }
39
79
 
40
80
  export interface SkillError {
@@ -86,26 +126,149 @@ export class SkillManager {
86
126
  private errors: SkillError[] = [];
87
127
  private paths: Paths;
88
128
  private context: Context;
129
+ /**
130
+ * Tracks which skills are currently "active" based on file operations.
131
+ * Skills with `paths` frontmatter are activated when matching files are accessed.
132
+ */
133
+ private activeSkillNames: Set<string> = new Set();
134
+ /**
135
+ * Maps alias names to original skill names for bundled skills.
136
+ */
137
+ private aliasMap: Map<string, string> = new Map();
89
138
 
90
139
  constructor(opts: SkillManagerOpts) {
91
140
  this.context = opts.context;
92
141
  this.paths = opts.context.paths;
93
142
  }
94
143
 
95
- getSkills(): SkillMetadata[] {
96
- return Array.from(this.skillsMap.values());
144
+ /**
145
+ * Get all loaded skills.
146
+ * @param options Filter options
147
+ * @returns Array of skill metadata
148
+ */
149
+ getSkills(options?: {
150
+ /** Only return skills that are user-invocable */
151
+ userInvocable?: boolean;
152
+ /** Only return skills that are model-invocable */
153
+ modelInvocable?: boolean;
154
+ /** Only return active skills (those with matching paths) */
155
+ activeOnly?: boolean;
156
+ }): SkillMetadata[] {
157
+ let skills = Array.from(this.skillsMap.values());
158
+
159
+ if (options?.userInvocable !== undefined) {
160
+ skills = skills.filter(
161
+ (s) => (s.userInvocable ?? true) === options.userInvocable,
162
+ );
163
+ }
164
+
165
+ if (options?.modelInvocable !== undefined) {
166
+ skills = skills.filter(
167
+ (s) => (s.modelInvocable ?? true) === options.modelInvocable,
168
+ );
169
+ }
170
+
171
+ if (options?.activeOnly) {
172
+ skills = skills.filter((s) => this.activeSkillNames.has(s.name));
173
+ }
174
+
175
+ return skills;
97
176
  }
98
177
 
178
+ /**
179
+ * Get a specific skill by name.
180
+ * Automatically resolves aliases to the original skill.
181
+ * @param name Skill name or alias
182
+ * @returns Skill metadata or undefined if not found
183
+ */
99
184
  getSkill(name: string): SkillMetadata | undefined {
100
- return this.skillsMap.get(name);
185
+ // Check direct name first
186
+ const skill = this.skillsMap.get(name);
187
+ if (skill) return skill;
188
+
189
+ // Check if it's an alias
190
+ const originalName = this.aliasMap.get(name);
191
+ if (originalName) {
192
+ return this.skillsMap.get(originalName);
193
+ }
194
+
195
+ return undefined;
196
+ }
197
+
198
+ /**
199
+ * Check if a skill is active (has matching paths for current context).
200
+ * @param name Skill name
201
+ */
202
+ isSkillActive(name: string): boolean {
203
+ return this.activeSkillNames.has(name);
204
+ }
205
+
206
+ /**
207
+ * Activate skills based on file paths.
208
+ * Skills with `paths` frontmatter that match the given paths will be activated.
209
+ * @param filePaths Array of file paths to check
210
+ * @returns Array of newly activated skill names
211
+ */
212
+ activateSkillsForPaths(filePaths: string[]): string[] {
213
+ const newlyActivated: string[] = [];
214
+ const minimatch = require('minimatch');
215
+
216
+ for (const [name, skill] of this.skillsMap) {
217
+ if (skill.paths && skill.paths.length > 0) {
218
+ const isMatch = skill.paths.some((pattern) =>
219
+ filePaths.some((filePath) => minimatch(filePath, pattern)),
220
+ );
221
+
222
+ if (isMatch && !this.activeSkillNames.has(name)) {
223
+ this.activeSkillNames.add(name);
224
+ newlyActivated.push(name);
225
+ }
226
+ }
227
+ }
228
+
229
+ return newlyActivated;
230
+ }
231
+
232
+ /**
233
+ * Clear all active skill activations.
234
+ * Useful when switching contexts or projects.
235
+ */
236
+ clearActiveSkills(): void {
237
+ this.activeSkillNames.clear();
238
+ }
239
+
240
+ /**
241
+ * Get all skills that have path-based conditional activation.
242
+ * @returns Array of skills with paths defined
243
+ */
244
+ getConditionalSkills(): SkillMetadata[] {
245
+ return Array.from(this.skillsMap.values()).filter(
246
+ (s) => s.paths && s.paths.length > 0,
247
+ );
101
248
  }
102
249
 
103
250
  getErrors(): SkillError[] {
104
251
  return this.errors;
105
252
  }
106
253
 
107
- async readSkillBody(skill: SkillMetadata): Promise<string> {
254
+ /**
255
+ * Read the body content of a skill.
256
+ * For file-based skills, reads from disk.
257
+ * For bundled skills, generates content via getPrompt.
258
+ */
259
+ async readSkillBody(skill: SkillMetadata, args: string = ''): Promise<string> {
108
260
  try {
261
+ // Handle bundled skills
262
+ if (skill.path.startsWith('bundled://')) {
263
+ const bundledName = skill.path.replace('bundled://', '');
264
+ const bundledSkill = bundledSkillRegistry.get(bundledName);
265
+ if (!bundledSkill) {
266
+ throw new Error(`Bundled skill "${bundledName}" not found in registry`);
267
+ }
268
+ return await bundledSkill.getPrompt(args, this.context);
269
+ }
270
+
271
+ // Handle file-based skills
109
272
  const content = fs.readFileSync(skill.path, 'utf-8');
110
273
  const { body } = safeFrontMatter(content, skill.path);
111
274
  return body;
@@ -116,10 +279,32 @@ export class SkillManager {
116
279
  }
117
280
  }
118
281
 
282
+ /**
283
+ * Get a bundled skill definition by name.
284
+ * @param name Bundled skill name
285
+ * @returns Bundled skill definition or undefined if not found
286
+ */
287
+ getBundledSkill(name: string): BundledSkillDefinition | undefined {
288
+ return bundledSkillRegistry.get(name);
289
+ }
290
+
291
+ /**
292
+ * Register a bundled skill programmatically.
293
+ * @param definition Bundled skill definition
294
+ */
295
+ registerBundledSkill(definition: BundledSkillDefinition): void {
296
+ bundledSkillRegistry.register(definition);
297
+ // Reload to include the new skill
298
+ this.loadBuiltinSkills();
299
+ }
300
+
119
301
  async loadSkills(): Promise<void> {
120
302
  this.skillsMap.clear();
121
303
  this.errors = [];
122
304
 
305
+ // Load builtin/bundled skills first
306
+ this.loadBuiltinSkills();
307
+
123
308
  const pluginSkills = await this.context.apply({
124
309
  hook: 'skill',
125
310
  args: [],
@@ -168,6 +353,27 @@ export class SkillManager {
168
353
  this.loadSkillsFromDirectory(projectDir, SkillSource.Project);
169
354
  }
170
355
 
356
+ /**
357
+ * Load builtin/bundled skills from the registry.
358
+ */
359
+ private loadBuiltinSkills(): void {
360
+ // Clear alias map when reloading
361
+ this.aliasMap.clear();
362
+
363
+ const bundledSkills = bundledSkillRegistry.getAll(this.context);
364
+ for (const skill of bundledSkills) {
365
+ const metadata = bundledSkillToMetadata(skill, SkillSource.Builtin);
366
+ this.skillsMap.set(skill.name, metadata);
367
+
368
+ // Register aliases in the alias map
369
+ if (skill.aliases) {
370
+ for (const alias of skill.aliases) {
371
+ this.aliasMap.set(alias, skill.name);
372
+ }
373
+ }
374
+ }
375
+ }
376
+
171
377
  private loadSkillsFromDirectory(
172
378
  skillsDir: string,
173
379
  source: SkillSource,
@@ -225,6 +431,12 @@ export class SkillManager {
225
431
  const { attributes } = safeFrontMatter<{
226
432
  name?: string;
227
433
  description?: string;
434
+ allowedTools?: string | string[];
435
+ context?: string;
436
+ agent?: string;
437
+ paths?: string | string[];
438
+ userInvocable?: boolean;
439
+ modelInvocable?: boolean;
228
440
  }>(content, skillPath);
229
441
 
230
442
  if (!attributes.name) {
@@ -267,10 +479,28 @@ export class SkillManager {
267
479
  return null;
268
480
  }
269
481
 
482
+ // Parse allowedTools - supports both string and array formats
483
+ const allowedTools = this.parseStringArrayField(attributes.allowedTools);
484
+
485
+ // Parse context (inline or fork)
486
+ const context: SkillContext | undefined =
487
+ attributes.context === 'inline' || attributes.context === 'fork'
488
+ ? attributes.context
489
+ : undefined;
490
+
491
+ // Parse paths - supports both string and array formats
492
+ const paths = this.parseStringArrayField(attributes.paths);
493
+
270
494
  return {
271
495
  name: attributes.name,
272
496
  description: attributes.description,
273
497
  path: skillPath,
498
+ allowedTools,
499
+ context,
500
+ agent: attributes.agent,
501
+ paths,
502
+ userInvocable: attributes.userInvocable ?? true,
503
+ modelInvocable: attributes.modelInvocable ?? true,
274
504
  };
275
505
  } catch (error) {
276
506
  this.errors.push({
@@ -284,6 +514,32 @@ export class SkillManager {
284
514
  }
285
515
  }
286
516
 
517
+ /**
518
+ * Parse a field that can be either a comma-separated string or an array of strings.
519
+ * @param value The value to parse
520
+ * @returns Array of strings or undefined if empty
521
+ */
522
+ private parseStringArrayField(
523
+ value: string | string[] | undefined,
524
+ ): string[] | undefined {
525
+ if (!value) return undefined;
526
+
527
+ if (Array.isArray(value)) {
528
+ return value
529
+ .map((item) => item.trim())
530
+ .filter((item) => item.length > 0);
531
+ }
532
+
533
+ if (typeof value === 'string') {
534
+ return value
535
+ .split(',')
536
+ .map((item) => item.trim())
537
+ .filter((item) => item.length > 0);
538
+ }
539
+
540
+ return undefined;
541
+ }
542
+
287
543
  async addSkill(
288
544
  source: string,
289
545
  options: AddSkillOptions = {},
package/src/tools/tool.ts CHANGED
@@ -51,9 +51,6 @@ export async function resolveTools(opts: ResolveToolsOpts) {
51
51
  createGlobTool({ cwd }),
52
52
  createGrepTool({ cwd }),
53
53
  createFetchTool({ model, fetch: opts.context.fetch }),
54
- ...(hasSkills
55
- ? [createSkillTool({ skillManager: opts.context.skillManager! })]
56
- : []),
57
54
  ];
58
55
  const askUserQuestionTools = opts.askUserQuestion
59
56
  ? [createAskUserQuestionTool()]
@@ -90,7 +87,7 @@ export async function resolveTools(opts: ResolveToolsOpts) {
90
87
 
91
88
  const mcpTools = await getMcpTools(opts.context);
92
89
 
93
- const allTools = [
90
+ let allTools = [
94
91
  ...readonlyTools,
95
92
  ...askUserQuestionTools,
96
93
  ...writeTools,
@@ -99,6 +96,19 @@ export async function resolveTools(opts: ResolveToolsOpts) {
99
96
  ...mcpTools,
100
97
  ];
101
98
 
99
+ // Add skill tool if skills are available
100
+ // Note: skill tool is added after initial tool list so it can reference allTools for fork execution
101
+ if (hasSkills) {
102
+ const skillTool = createSkillTool({
103
+ skillManager: opts.context.skillManager!,
104
+ context: opts.context,
105
+ tools: allTools,
106
+ sessionId: opts.sessionId,
107
+ signal: opts.signal,
108
+ });
109
+ allTools = [...allTools, skillTool];
110
+ }
111
+
102
112
  // 1. First, execute plugin hook to allow plugins to add/modify tools
103
113
  let availableTools = allTools;
104
114
  try {
@@ -1,11 +1,15 @@
1
1
  import path from 'pathe';
2
2
  import { z } from 'zod';
3
+ import type { Context } from '../../core/context';
3
4
  import type { SkillManager, SkillMetadata } from '../../skill/skill';
4
- import { createTool } from '../tool';
5
+ import { createTool, type Tool } from '../tool';
5
6
  import { safeStringify } from '../../utils/safeStringify';
7
+ import { createTaskTool } from './task';
8
+ import { randomUUID } from '../../utils/randomUUID';
6
9
 
7
10
  function renderAvailableSkills(skills: SkillMetadata[]): string {
8
11
  return skills
12
+ .filter((skill) => skill.modelInvocable !== false)
9
13
  .map(
10
14
  (skill) =>
11
15
  `<skill>\n<name>${skill.name}</name>\n<description>${skill.description}</description>\n</skill>`,
@@ -14,7 +18,7 @@ function renderAvailableSkills(skills: SkillMetadata[]): string {
14
18
  }
15
19
 
16
20
  function generateDescription(skillManager: SkillManager): string {
17
- const skills = skillManager.getSkills();
21
+ const skills = skillManager.getSkills({ modelInvocable: true });
18
22
  return `Execute a skill within the main conversation
19
23
  <skills_instructions>
20
24
  When users ask you to perform tasks, check if any of the available skills below match the task. If a skill matches, use this tool to invoke it. Skills provide specialized knowledge and procedures for specific tasks.
@@ -24,22 +28,38 @@ ${renderAvailableSkills(skills)}
24
28
  </available_skills>`;
25
29
  }
26
30
 
27
- export function createSkillTool(opts: { skillManager: SkillManager }) {
31
+ export interface CreateSkillToolOpts {
32
+ skillManager: SkillManager;
33
+ context: Context;
34
+ tools: Tool[];
35
+ sessionId: string;
36
+ signal?: AbortSignal;
37
+ }
38
+
39
+ export function createSkillTool(opts: CreateSkillToolOpts) {
40
+ const { skillManager, context, tools, sessionId, signal } = opts;
41
+
28
42
  return createTool({
29
43
  name: 'skill',
30
- description: generateDescription(opts.skillManager),
44
+ description: generateDescription(skillManager),
31
45
  parameters: z.object({
32
46
  skill: z.string().describe('The skill name to execute'),
47
+ args: z
48
+ .string()
49
+ .optional()
50
+ .describe('Optional arguments to pass to the skill'),
33
51
  }),
34
52
  getDescription: ({ params }) => {
35
- return params.skill;
53
+ return params.args
54
+ ? `${params.skill} ${params.args}`
55
+ : params.skill;
36
56
  },
37
- async execute({ skill }) {
57
+ async execute({ skill, args }) {
38
58
  const trimmed = skill.trim();
39
59
  const skillName = trimmed.startsWith('/')
40
60
  ? trimmed.substring(1)
41
61
  : trimmed;
42
- const foundSkill = opts.skillManager.getSkill(skillName);
62
+ const foundSkill = skillManager.getSkill(skillName);
43
63
 
44
64
  if (!foundSkill) {
45
65
  return {
@@ -48,9 +68,67 @@ export function createSkillTool(opts: { skillManager: SkillManager }) {
48
68
  };
49
69
  }
50
70
 
51
- const body = await opts.skillManager.readSkillBody(foundSkill);
71
+ // Check if skill can be invoked by model
72
+ if (foundSkill.modelInvocable === false) {
73
+ return {
74
+ isError: true,
75
+ llmContent: `Skill "${skillName}" cannot be invoked by the model`,
76
+ };
77
+ }
78
+
79
+ const skillArgs = args || '';
80
+ const body = await skillManager.readSkillBody(foundSkill, skillArgs);
52
81
  const baseDir = path.dirname(foundSkill.path);
53
82
 
83
+ // If skill has context: 'fork', use task tool for isolated execution
84
+ if (foundSkill.context === 'fork') {
85
+ if (!context.agentManager) {
86
+ return {
87
+ isError: true,
88
+ llmContent: `Skill "${skillName}" requires fork execution but agent manager is not available`,
89
+ };
90
+ }
91
+
92
+ // Create filtered tools list based on allowedTools
93
+ // Exclude 'skill' tool itself to prevent recursive invocation loops
94
+ const allowedTools = foundSkill.allowedTools;
95
+ const filteredTools = allowedTools
96
+ ? tools.filter(
97
+ (t) =>
98
+ t.name !== 'skill' &&
99
+ allowedTools.some(
100
+ (allowed) =>
101
+ allowed.toLowerCase() === t.name.toLowerCase(),
102
+ ),
103
+ )
104
+ : tools.filter((t) => t.name !== 'skill');
105
+
106
+ // Create task tool with filtered tools
107
+ const taskTool = createTaskTool({
108
+ context,
109
+ tools: filteredTools,
110
+ sessionId,
111
+ signal,
112
+ });
113
+
114
+ // Execute skill as a task
115
+ const agentType = foundSkill.agent || 'general-purpose';
116
+ const prompt = `Base directory for this skill: ${baseDir}\n\n${body}`;
117
+
118
+ // Generate a unique toolCallId for tracking
119
+ const toolCallId = `skill-${skillName}-${randomUUID()}`;
120
+
121
+ return taskTool.execute(
122
+ {
123
+ description: `Execute skill: ${skillName}`,
124
+ prompt,
125
+ subagent_type: agentType,
126
+ },
127
+ toolCallId,
128
+ );
129
+ }
130
+
131
+ // Inline execution (default)
54
132
  const messages = [
55
133
  {
56
134
  type: 'text',