ruby_llm-skills 0.1.0 → 0.3.0
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +22 -0
- data/README.md +77 -316
- data/lib/generators/skill/USAGE +29 -0
- data/lib/generators/skill/skill_generator.rb +14 -5
- data/lib/generators/skill/templates/SKILL.md.tt +43 -7
- data/lib/ruby_llm/skills/agent_extensions.rb +148 -0
- data/lib/ruby_llm/skills/chat_extensions.rb +85 -32
- data/lib/ruby_llm/skills/composite_loader.rb +1 -1
- data/lib/ruby_llm/skills/database_loader.rb +20 -91
- data/lib/ruby_llm/skills/error.rb +1 -1
- data/lib/ruby_llm/skills/filesystem_loader.rb +43 -10
- data/lib/ruby_llm/skills/loader.rb +1 -1
- data/lib/ruby_llm/skills/parser.rb +1 -1
- data/lib/ruby_llm/skills/railtie.rb +14 -5
- data/lib/ruby_llm/skills/skill.rb +21 -6
- data/lib/ruby_llm/skills/skill_tool.rb +82 -40
- data/lib/ruby_llm/skills/validator.rb +1 -1
- data/lib/ruby_llm/skills/version.rb +2 -2
- data/lib/ruby_llm/skills.rb +60 -24
- metadata +20 -5
- data/lib/ruby_llm/skills/zip_loader.rb +0 -129
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Skills
|
|
5
|
+
# Extensions for RubyLLM::Agent to enable declarative skill configuration.
|
|
6
|
+
#
|
|
7
|
+
# @example Static skills
|
|
8
|
+
# class SupportAgent < RubyLLM::Agent
|
|
9
|
+
# skills "app/skills", only: [:faq]
|
|
10
|
+
# end
|
|
11
|
+
#
|
|
12
|
+
# @example Dynamic skills
|
|
13
|
+
# class WorkspaceAgent < RubyLLM::Agent
|
|
14
|
+
# inputs :workspace
|
|
15
|
+
# skills { [workspace.skill_collection] }
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
module AgentExtensions
|
|
19
|
+
REQUIRED_AGENT_SINGLETON_METHODS = %i[apply_configuration runtime_context llm_chat_for].freeze
|
|
20
|
+
|
|
21
|
+
module ClassMethods
|
|
22
|
+
def self.extended(base)
|
|
23
|
+
base.instance_variable_set(:@skill_sources, nil)
|
|
24
|
+
base.instance_variable_set(:@skill_only, nil)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def inherited(subclass)
|
|
28
|
+
super
|
|
29
|
+
subclass.instance_variable_set(:@skill_sources, @skill_sources.is_a?(Proc) ? @skill_sources : @skill_sources&.dup)
|
|
30
|
+
subclass.instance_variable_set(:@skill_only, @skill_only&.dup)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Declare skill sources for this agent class.
|
|
34
|
+
#
|
|
35
|
+
# Called with no arguments, returns the current configuration.
|
|
36
|
+
# Called with sources or a block, sets the configuration.
|
|
37
|
+
#
|
|
38
|
+
# @param sources [Array] skill sources
|
|
39
|
+
# @param only [Array<Symbol, String>, nil] include only these skills
|
|
40
|
+
# @return [Hash] current configuration when called as a getter
|
|
41
|
+
def skills(*sources, only: nil, &block)
|
|
42
|
+
if sources.empty? && only.nil? && !block_given?
|
|
43
|
+
return {
|
|
44
|
+
sources: @skill_sources.is_a?(Proc) ? @skill_sources : @skill_sources&.dup,
|
|
45
|
+
only: @skill_only&.dup
|
|
46
|
+
}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
@skill_sources = block_given? ? block : normalize_skill_sources(sources)
|
|
50
|
+
@skill_only = only&.dup
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def normalize_skill_sources(raw_sources)
|
|
56
|
+
flatten_skill_sources(raw_sources).compact
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def flatten_skill_sources(source)
|
|
60
|
+
return [] if source.nil?
|
|
61
|
+
return [source] if source.is_a?(String)
|
|
62
|
+
return [source] if loader_source?(source)
|
|
63
|
+
return [source] if database_collection_source?(source)
|
|
64
|
+
return source.flat_map { |item| flatten_skill_sources(item) } if source.is_a?(Array)
|
|
65
|
+
|
|
66
|
+
[source]
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def loader_source?(source)
|
|
70
|
+
source.respond_to?(:list) && source.respond_to?(:find)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def database_collection_source?(source)
|
|
74
|
+
source.respond_to?(:to_a) && source.first&.respond_to?(:name) && source.first.respond_to?(:content)
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
module InstanceMethods
|
|
79
|
+
# Add skills to this agent instance at runtime.
|
|
80
|
+
#
|
|
81
|
+
# @param sources [Array] skill sources
|
|
82
|
+
# @param only [Array<Symbol, String>, nil] include only these skills
|
|
83
|
+
# @return [self] for chaining
|
|
84
|
+
def with_skills(*sources, only: nil)
|
|
85
|
+
chat.with_skills(*sources, only: only)
|
|
86
|
+
self
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
module ConfigurationPatch
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def apply_configuration(chat_object, **kwargs)
|
|
94
|
+
super
|
|
95
|
+
input_values = kwargs[:input_values] || {}
|
|
96
|
+
runtime = runtime_context(chat: chat_object, inputs: input_values)
|
|
97
|
+
apply_skills(llm_chat_for(chat_object), runtime)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def apply_skills(llm_chat, runtime)
|
|
101
|
+
config = skills
|
|
102
|
+
sources = config[:sources]
|
|
103
|
+
return if sources.nil?
|
|
104
|
+
|
|
105
|
+
resolved_sources = if sources.is_a?(Proc)
|
|
106
|
+
runtime.instance_exec(&sources)
|
|
107
|
+
else
|
|
108
|
+
sources
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
normalized_sources = normalize_skill_sources(resolved_sources)
|
|
112
|
+
return if normalized_sources.empty?
|
|
113
|
+
|
|
114
|
+
validate_skill_sources!(normalized_sources)
|
|
115
|
+
llm_chat.with_skills(*normalized_sources, only: config[:only])
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def validate_skill_sources!(sources)
|
|
119
|
+
invalid_sources = sources.reject { |source| valid_skill_source?(source) }
|
|
120
|
+
return if invalid_sources.empty?
|
|
121
|
+
|
|
122
|
+
invalid_types = invalid_sources.map { |source| source.class.name || source.class.to_s }.uniq.join(", ")
|
|
123
|
+
raise ArgumentError,
|
|
124
|
+
"Invalid skill source(s): #{invalid_types}. Expected String path, Loader, or record collection."
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def valid_skill_source?(source)
|
|
128
|
+
source.is_a?(String) || loader_source?(source) || database_collection_source?(source)
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def self.included(base)
|
|
133
|
+
missing_methods = REQUIRED_AGENT_SINGLETON_METHODS.reject do |method_name|
|
|
134
|
+
base.singleton_class.private_method_defined?(method_name) || base.singleton_class.method_defined?(method_name)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
if missing_methods.any?
|
|
138
|
+
raise LoadError,
|
|
139
|
+
"RubyLLM::Agent is missing required methods for ruby_llm-skills integration: #{missing_methods.join(", ")}"
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
base.extend(ClassMethods)
|
|
143
|
+
base.include(InstanceMethods)
|
|
144
|
+
base.singleton_class.prepend(ConfigurationPatch)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -1,49 +1,102 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
require_relative "skill_tool"
|
|
4
|
+
|
|
5
|
+
module RubyLLM
|
|
4
6
|
module Skills
|
|
5
7
|
# Extensions for RubyLLM::Chat to enable skill integration.
|
|
6
8
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
+
# @example Default (app/skills)
|
|
10
|
+
# chat.with_skills
|
|
11
|
+
#
|
|
12
|
+
# @example Custom path
|
|
13
|
+
# chat.with_skills("lib/skills")
|
|
9
14
|
#
|
|
10
|
-
# @example
|
|
11
|
-
# chat
|
|
12
|
-
#
|
|
13
|
-
#
|
|
15
|
+
# @example Multiple sources (auto-detected)
|
|
16
|
+
# chat.with_skills("app/skills", "app/commands", user.skills)
|
|
17
|
+
#
|
|
18
|
+
# @example Filter skills
|
|
19
|
+
# chat.with_skills(only: [:pdf_report])
|
|
14
20
|
#
|
|
15
21
|
module ChatExtensions
|
|
16
|
-
# Add skills
|
|
22
|
+
# Add skills to this chat.
|
|
17
23
|
#
|
|
18
|
-
# @param
|
|
24
|
+
# @param sources [Array] skill sources - auto-detects type (directory, zip, collection)
|
|
25
|
+
# @param only [Array<Symbol, String>, nil] include only these skills
|
|
19
26
|
# @return [self] for chaining
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
27
|
+
def with_skills(*sources, only: nil)
|
|
28
|
+
sources = [RubyLLM::Skills.default_path] if sources.empty?
|
|
29
|
+
loaders = sources.map { |s| to_loader(s) }
|
|
30
|
+
|
|
31
|
+
loader = (loaders.length == 1) ? loaders.first : RubyLLM::Skills.compose(*loaders)
|
|
32
|
+
loader = FilteredLoader.new(loader, only) if only
|
|
33
|
+
|
|
34
|
+
skill_tool = RubyLLM::Skills::SkillTool.new(loader)
|
|
25
35
|
with_tool(skill_tool)
|
|
26
36
|
end
|
|
27
37
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def to_loader(source)
|
|
41
|
+
case source
|
|
42
|
+
when String
|
|
43
|
+
RubyLLM::Skills.from_directory(source)
|
|
44
|
+
when ->(s) { database_collection_source?(s) }
|
|
45
|
+
RubyLLM::Skills.from_database(source)
|
|
46
|
+
when ->(s) { loader_source?(s) }
|
|
47
|
+
source
|
|
48
|
+
else
|
|
49
|
+
raise ArgumentError,
|
|
50
|
+
"Invalid skill source: #{source.class}. Expected String path, Loader, or record collection."
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def loader_source?(source)
|
|
55
|
+
source.respond_to?(:list) && source.respond_to?(:find)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def database_collection_source?(source)
|
|
59
|
+
source.respond_to?(:to_a) && source.first&.respond_to?(:name) && source.first.respond_to?(:content)
|
|
41
60
|
end
|
|
42
61
|
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
62
|
|
|
46
|
-
#
|
|
47
|
-
|
|
48
|
-
|
|
63
|
+
# Simple wrapper that filters skills by name.
|
|
64
|
+
class FilteredLoader
|
|
65
|
+
def initialize(loader, only)
|
|
66
|
+
@loader = loader
|
|
67
|
+
@only = Array(only).map(&:to_s)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def list
|
|
71
|
+
@loader.list.select { |s| @only.include?(s.name) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def find(name)
|
|
75
|
+
return nil unless @only.include?(name.to_s)
|
|
76
|
+
@loader.find(name)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def get(name)
|
|
80
|
+
raise NotFoundError, "Skill not found: #{name}" unless @only.include?(name.to_s)
|
|
81
|
+
@loader.get(name)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def exists?(name)
|
|
85
|
+
@only.include?(name.to_s) && @loader.exists?(name)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def reload!
|
|
89
|
+
@loader.reload!
|
|
90
|
+
self
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Extensions for ActiveRecord models using acts_as_chat.
|
|
95
|
+
module ActiveRecordExtensions
|
|
96
|
+
def with_skills(*sources, only: nil)
|
|
97
|
+
to_llm.with_skills(*sources, only: only)
|
|
98
|
+
self
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
49
102
|
end
|
|
@@ -1,46 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module RubyLLM
|
|
4
4
|
module Skills
|
|
5
|
-
# Loads skills from database records
|
|
5
|
+
# Loads skills from database records.
|
|
6
6
|
#
|
|
7
|
-
# Records must respond to
|
|
8
|
-
#
|
|
9
|
-
# - Binary storage: #name, #description, #data (zip blob)
|
|
7
|
+
# Records must respond to: #name, #description, #content
|
|
8
|
+
# Optional: #license, #compatibility, #skill_metadata
|
|
10
9
|
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
# @example With text content
|
|
14
|
-
# class SkillRecord
|
|
15
|
-
# attr_accessor :name, :description, :content
|
|
16
|
-
# end
|
|
17
|
-
# loader = DatabaseLoader.new(SkillRecord.all)
|
|
18
|
-
#
|
|
19
|
-
# @example With ActiveRecord
|
|
20
|
-
# loader = DatabaseLoader.new(Skill.where(active: true))
|
|
10
|
+
# @example
|
|
11
|
+
# loader = DatabaseLoader.new(Skill.where(user: current_user))
|
|
21
12
|
#
|
|
22
13
|
class DatabaseLoader < Loader
|
|
23
14
|
attr_reader :records
|
|
24
15
|
|
|
25
|
-
# Initialize with a collection of records.
|
|
26
|
-
#
|
|
27
|
-
# @param records [Enumerable] collection responding to #each
|
|
28
16
|
def initialize(records)
|
|
29
17
|
super()
|
|
30
18
|
@records = records
|
|
31
19
|
end
|
|
32
20
|
|
|
33
|
-
# List all skills from the records.
|
|
34
|
-
#
|
|
35
|
-
# @return [Array<Skill>] skills from records
|
|
36
21
|
def list
|
|
37
22
|
skills
|
|
38
23
|
end
|
|
39
24
|
|
|
40
|
-
# Reload skills by re-iterating records.
|
|
41
|
-
# Also reloads the records if they respond to #reload.
|
|
42
|
-
#
|
|
43
|
-
# @return [self]
|
|
44
25
|
def reload!
|
|
45
26
|
@records.reload if @records.respond_to?(:reload)
|
|
46
27
|
super
|
|
@@ -52,7 +33,7 @@ module RubyLlm
|
|
|
52
33
|
@records.filter_map do |record|
|
|
53
34
|
load_skill_from_record(record)
|
|
54
35
|
rescue => e
|
|
55
|
-
warn "
|
|
36
|
+
warn "Failed to load skill from record: #{e.message}"
|
|
56
37
|
nil
|
|
57
38
|
end
|
|
58
39
|
end
|
|
@@ -60,22 +41,20 @@ module RubyLlm
|
|
|
60
41
|
private
|
|
61
42
|
|
|
62
43
|
def load_skill_from_record(record)
|
|
63
|
-
|
|
64
|
-
load_from_binary(record)
|
|
65
|
-
else
|
|
66
|
-
load_from_text(record)
|
|
67
|
-
end
|
|
68
|
-
end
|
|
44
|
+
validate_record!(record)
|
|
69
45
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
46
|
+
metadata = {
|
|
47
|
+
"name" => record.name.to_s,
|
|
48
|
+
"description" => record.description.to_s,
|
|
49
|
+
"__content__" => record.content.to_s
|
|
50
|
+
}
|
|
73
51
|
|
|
74
|
-
|
|
75
|
-
|
|
52
|
+
metadata["license"] = record.license.to_s if record.respond_to?(:license) && record.license
|
|
53
|
+
metadata["compatibility"] = record.compatibility.to_s if record.respond_to?(:compatibility) && record.compatibility
|
|
76
54
|
|
|
77
|
-
|
|
78
|
-
|
|
55
|
+
if record.respond_to?(:skill_metadata) && record.skill_metadata.is_a?(Hash)
|
|
56
|
+
metadata["metadata"] = record.skill_metadata
|
|
57
|
+
end
|
|
79
58
|
|
|
80
59
|
Skill.new(
|
|
81
60
|
path: "database:#{record_id(record)}",
|
|
@@ -83,64 +62,14 @@ module RubyLlm
|
|
|
83
62
|
)
|
|
84
63
|
end
|
|
85
64
|
|
|
86
|
-
def
|
|
87
|
-
# Extract skill from zip data
|
|
88
|
-
require "zip"
|
|
89
|
-
require "stringio"
|
|
90
|
-
|
|
91
|
-
io = StringIO.new(record.data)
|
|
92
|
-
Zip::File.open_buffer(io) do |zip|
|
|
93
|
-
skill_md_entry = zip.find_entry("SKILL.md")
|
|
94
|
-
raise LoadError, "SKILL.md not found in binary data" unless skill_md_entry
|
|
95
|
-
|
|
96
|
-
content = skill_md_entry.get_input_stream.read
|
|
97
|
-
metadata = Parser.parse_string(content)
|
|
98
|
-
body = Parser.extract_body(content)
|
|
99
|
-
|
|
100
|
-
# Override name/description from record if present
|
|
101
|
-
metadata["name"] = record.name if record.respond_to?(:name) && record.name
|
|
102
|
-
metadata["description"] = record.description if record.respond_to?(:description) && record.description
|
|
103
|
-
metadata["__content__"] = body
|
|
104
|
-
|
|
105
|
-
Skill.new(
|
|
106
|
-
path: "database:#{record_id(record)}",
|
|
107
|
-
metadata: metadata
|
|
108
|
-
)
|
|
109
|
-
end
|
|
110
|
-
rescue ::LoadError
|
|
111
|
-
raise LoadError, "rubyzip gem required for binary storage. Add 'gem \"rubyzip\"' to your Gemfile."
|
|
112
|
-
end
|
|
113
|
-
|
|
114
|
-
def validate_text_record!(record)
|
|
65
|
+
def validate_record!(record)
|
|
115
66
|
raise InvalidSkillError, "Record must respond to #name" unless record.respond_to?(:name)
|
|
116
67
|
raise InvalidSkillError, "Record must respond to #description" unless record.respond_to?(:description)
|
|
117
68
|
raise InvalidSkillError, "Record must respond to #content" unless record.respond_to?(:content)
|
|
118
69
|
end
|
|
119
70
|
|
|
120
|
-
def build_metadata(record)
|
|
121
|
-
metadata = {
|
|
122
|
-
"name" => record.name.to_s,
|
|
123
|
-
"description" => record.description.to_s
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
metadata["license"] = record.license.to_s if record.respond_to?(:license) && record.license
|
|
127
|
-
metadata["compatibility"] = record.compatibility.to_s if record.respond_to?(:compatibility) && record.compatibility
|
|
128
|
-
|
|
129
|
-
if record.respond_to?(:skill_metadata) && record.skill_metadata.is_a?(Hash)
|
|
130
|
-
metadata["metadata"] = record.skill_metadata
|
|
131
|
-
end
|
|
132
|
-
|
|
133
|
-
metadata
|
|
134
|
-
end
|
|
135
|
-
|
|
136
71
|
def record_id(record)
|
|
137
|
-
|
|
138
|
-
record.id
|
|
139
|
-
elsif record.respond_to?(:name)
|
|
140
|
-
record.name
|
|
141
|
-
else
|
|
142
|
-
record.object_id
|
|
143
|
-
end
|
|
72
|
+
record.respond_to?(:id) ? record.id : record.name
|
|
144
73
|
end
|
|
145
74
|
end
|
|
146
75
|
end
|
|
@@ -1,15 +1,21 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module RubyLLM
|
|
4
4
|
module Skills
|
|
5
5
|
# Loads skills from a filesystem directory.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
7
|
+
# Supports two formats:
|
|
8
|
+
# 1. Directory skills: subdirectories containing SKILL.md files
|
|
9
|
+
# 2. Single-file commands: .md files with frontmatter at the root level
|
|
9
10
|
#
|
|
10
|
-
# @example
|
|
11
|
-
#
|
|
12
|
-
#
|
|
11
|
+
# @example Directory skills
|
|
12
|
+
# app/skills/
|
|
13
|
+
# └── pdf-report/
|
|
14
|
+
# └── SKILL.md
|
|
15
|
+
#
|
|
16
|
+
# @example Single-file commands
|
|
17
|
+
# app/commands/
|
|
18
|
+
# └── write-poem.md
|
|
13
19
|
#
|
|
14
20
|
class FilesystemLoader < Loader
|
|
15
21
|
attr_reader :path
|
|
@@ -34,22 +40,49 @@ module RubyLlm
|
|
|
34
40
|
def load_all
|
|
35
41
|
return [] unless File.directory?(@path)
|
|
36
42
|
|
|
43
|
+
directory_skills + single_file_skills
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# Load skills from subdirectories containing SKILL.md
|
|
49
|
+
def directory_skills
|
|
37
50
|
Dir.glob(File.join(@path, "*", "SKILL.md")).filter_map do |skill_md_path|
|
|
38
|
-
|
|
51
|
+
load_directory_skill(skill_md_path)
|
|
39
52
|
rescue ParseError => e
|
|
40
|
-
|
|
53
|
+
log_warning("Failed to parse #{skill_md_path}: #{e.message}")
|
|
41
54
|
nil
|
|
42
55
|
end
|
|
43
56
|
end
|
|
44
57
|
|
|
45
|
-
|
|
58
|
+
# Load single-file .md commands from root level
|
|
59
|
+
def single_file_skills
|
|
60
|
+
Dir.glob(File.join(@path, "*.md")).filter_map do |md_path|
|
|
61
|
+
load_single_file_skill(md_path)
|
|
62
|
+
rescue ParseError => e
|
|
63
|
+
log_warning("Failed to parse #{md_path}: #{e.message}")
|
|
64
|
+
nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
46
67
|
|
|
47
|
-
def
|
|
68
|
+
def load_directory_skill(skill_md_path)
|
|
48
69
|
skill_dir = File.dirname(skill_md_path)
|
|
49
70
|
metadata = Parser.parse_file(skill_md_path)
|
|
50
71
|
|
|
51
72
|
Skill.new(path: skill_dir, metadata: metadata)
|
|
52
73
|
end
|
|
74
|
+
|
|
75
|
+
def load_single_file_skill(md_path)
|
|
76
|
+
metadata = Parser.parse_file(md_path)
|
|
77
|
+
|
|
78
|
+
# For single-file skills, the path is the file itself
|
|
79
|
+
# They are virtual in that they have no resources
|
|
80
|
+
Skill.new(path: md_path, metadata: metadata, virtual: true)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def log_warning(message)
|
|
84
|
+
warn(message)
|
|
85
|
+
end
|
|
53
86
|
end
|
|
54
87
|
end
|
|
55
88
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module RubyLLM
|
|
4
4
|
module Skills
|
|
5
5
|
# Rails integration for RubyLLM::Skills.
|
|
6
6
|
#
|
|
@@ -9,14 +9,23 @@ module RubyLlm
|
|
|
9
9
|
#
|
|
10
10
|
class Railtie < ::Rails::Railtie
|
|
11
11
|
initializer "ruby_llm_skills.configure" do
|
|
12
|
-
|
|
12
|
+
RubyLLM::Skills.default_path = Rails.root.join("app", "skills").to_s
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
# Add app/skills to autoload paths
|
|
16
|
-
|
|
15
|
+
# Add app/skills to autoload paths (before initialization)
|
|
16
|
+
config.before_configuration do |app|
|
|
17
17
|
skills_path = Rails.root.join("app", "skills")
|
|
18
18
|
if skills_path.exist?
|
|
19
|
-
app.config.autoload_paths
|
|
19
|
+
app.config.autoload_paths += [skills_path.to_s]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Extend acts_as_chat models with skill methods
|
|
24
|
+
initializer "ruby_llm_skills.active_record" do
|
|
25
|
+
ActiveSupport.on_load(:active_record) do
|
|
26
|
+
if defined?(RubyLLM::ActiveRecord::ChatMethods)
|
|
27
|
+
RubyLLM::ActiveRecord::ChatMethods.include(RubyLLM::Skills::ActiveRecordExtensions)
|
|
28
|
+
end
|
|
20
29
|
end
|
|
21
30
|
end
|
|
22
31
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
module
|
|
3
|
+
module RubyLLM
|
|
4
4
|
module Skills
|
|
5
5
|
# Represents a single skill with its metadata and content.
|
|
6
6
|
#
|
|
@@ -23,13 +23,15 @@ module RubyLlm
|
|
|
23
23
|
|
|
24
24
|
# Initialize a skill from parsed metadata.
|
|
25
25
|
#
|
|
26
|
-
# @param path [String] path to skill directory or virtual identifier
|
|
26
|
+
# @param path [String] path to skill directory, file, or virtual identifier
|
|
27
27
|
# @param metadata [Hash] parsed YAML frontmatter
|
|
28
28
|
# @param content [String, nil] pre-loaded content (optional)
|
|
29
|
-
|
|
29
|
+
# @param virtual [Boolean] force virtual mode (no filesystem access)
|
|
30
|
+
def initialize(path:, metadata:, content: nil, virtual: false)
|
|
30
31
|
@path = path.to_s
|
|
31
32
|
@metadata = metadata || {}
|
|
32
33
|
@content = content
|
|
34
|
+
@virtual = virtual
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
# @return [String] skill name from frontmatter
|
|
@@ -101,11 +103,11 @@ module RubyLlm
|
|
|
101
103
|
!virtual?
|
|
102
104
|
end
|
|
103
105
|
|
|
104
|
-
# Check if skill is a virtual/database skill.
|
|
106
|
+
# Check if skill is a virtual/database skill (no filesystem resources).
|
|
105
107
|
#
|
|
106
|
-
# @return [Boolean] true if
|
|
108
|
+
# @return [Boolean] true if skill has no filesystem resources
|
|
107
109
|
def virtual?
|
|
108
|
-
@path.start_with?("database:")
|
|
110
|
+
@virtual || @path.start_with?("database:")
|
|
109
111
|
end
|
|
110
112
|
|
|
111
113
|
# Path to the SKILL.md file.
|
|
@@ -150,14 +152,27 @@ module RubyLlm
|
|
|
150
152
|
|
|
151
153
|
def load_content
|
|
152
154
|
return @metadata["__content__"] if @metadata["__content__"]
|
|
155
|
+
|
|
156
|
+
# For single-file skills, path is the .md file itself
|
|
157
|
+
if single_file?
|
|
158
|
+
return "" unless File.exist?(@path)
|
|
159
|
+
return Parser.extract_body(File.read(@path))
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# For virtual skills (database), no filesystem content
|
|
153
163
|
return "" if virtual?
|
|
154
164
|
|
|
165
|
+
# For directory skills, load from SKILL.md
|
|
155
166
|
md_path = skill_md_path
|
|
156
167
|
return "" unless md_path && File.exist?(md_path)
|
|
157
168
|
|
|
158
169
|
Parser.extract_body(File.read(md_path))
|
|
159
170
|
end
|
|
160
171
|
|
|
172
|
+
def single_file?
|
|
173
|
+
@path.end_with?(".md") && File.file?(@path)
|
|
174
|
+
end
|
|
175
|
+
|
|
161
176
|
def list_resources(subdir)
|
|
162
177
|
return [] if virtual?
|
|
163
178
|
|