legion-llm 0.6.31 → 0.7.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0a2032854701a258fc788e3b3ef4cd495a2f031765c29775ed1486dd5eb8f35f
4
- data.tar.gz: 62cd0ed1943b0730be9adb30c6c1b77308f10f9e223cfaea47f13d90b9794b6c
3
+ metadata.gz: c9434ffcb40fb1c04975e72914ee52527be3cd88241ed9f173177abd67386e69
4
+ data.tar.gz: 1c56c1f6fb21f441d976f4cfc9012023c65f296693f2c18dde54b7dbe89e7f14
5
5
  SHA512:
6
- metadata.gz: 5e7a3f1e28c2af1d5cc3489a4bbe2592e7e019511920ad0c7c82943a0f444f36500d9707f6cda52f342a9af79922178bb5df249b1f53b477b52a9920ed856e9d
7
- data.tar.gz: 69d8dc01c5c43a4fa7acfde15e81986e757670127fae2140b22ca77c4d8938ff0d1fa91a61d4c6664269ae147b26416c066ef57ab4fecbc86b605d8541c047c2
6
+ metadata.gz: 1a2099e39f14bd3750728a6c3015b6cce4a84ea22beca9383ebe9455e5eb1452c0afc52ea380a90650e975a0b8cbe7f405e1a523dad902511eb1854be28f450b
7
+ data.tar.gz: b127e711a671a3f11ee40f7f854dc264a99d636be24b5f6ee7d46e943788247e669a65e1a6792745e91f4927c879e069e4e929b13f0ace4d30ab714b1beac792
data/CHANGELOG.md CHANGED
@@ -2,6 +2,19 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.0] - 2026-04-12
6
+
7
+ ### Added
8
+ - `Legion::LLM::Skills` subsystem — first-class daemon-side skill execution
9
+ - `Legion::LLM::Skills::Base` — DSL base class with `skill_name`, `namespace`, `description`, `trigger_words`, `trigger`, `steps`, `follows`, `file_change_triggers`, `content` DSL
10
+ - `Legion::LLM::Skills::Registry` — thread-safe registry with `register`, `find`, `all`, `by_trigger`, `chain_for`, `reset!`, trigger word index, file trigger index, and cycle detection
11
+ - `Legion::LLM::Skills::StepResult` — result struct from individual skill steps (`inject:`, `gate:`, `metadata:`)
12
+ - `Legion::LLM::Skills::SkillRunResult` — aggregated result struct from a full skill run (`complete:`, `gated:`, `inject:`, `gate:`, `resume_at:`)
13
+ - `Legion::LLM::Skills::InvalidSkill` and `Legion::LLM::Skills::StepError` — typed skill validation and execution errors
14
+ - `Legion::LLM::Pipeline::Steps::SkillInjector` — pipeline step (step 10.5) that matches trigger words and file change patterns, activates matching skills, and injects results as `skill:active` enrichment
15
+ - `Legion::LLM::ConversationStore` skill state methods: `set_skill_state`, `skill_state`, `clear_skill_state` for resumable multi-turn skills
16
+ - `Legion::LLM::Audit::SkillEvent` — audit event for skill invocations
17
+
5
18
  ## [0.6.31] - 2026-04-10
6
19
 
7
20
  ### Fixed
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../transport/message'
4
+
5
+ module Legion
6
+ module LLM
7
+ module Audit
8
+ class SkillEvent < Legion::LLM::Transport::Message
9
+ def type = 'llm.audit.skill'
10
+ def exchange = Legion::LLM::Transport::Exchanges::Audit
11
+ def routing_key = "audit.skill.#{@options[:namespace]}.#{@options[:skill_name]}"
12
+ def priority = 0
13
+ def encrypt? = true
14
+ def expiration = nil
15
+
16
+ def headers
17
+ super.merge(skill_headers).merge(classification_headers)
18
+ end
19
+
20
+ private
21
+
22
+ def message_id_prefix = 'audit_skill'
23
+
24
+ def skill_headers
25
+ h = {}
26
+ h['x-legion-skill-name'] = @options[:skill_name].to_s if @options[:skill_name]
27
+ h['x-legion-skill-namespace'] = @options[:namespace].to_s if @options[:namespace]
28
+ h['x-legion-skill-step'] = @options[:step_name].to_s if @options[:step_name]
29
+ h['x-legion-skill-gate'] = @options[:gate].to_s if @options[:gate]
30
+ h['x-legion-skill-status'] = @options[:status].to_s if @options[:status]
31
+ h
32
+ end
33
+
34
+ def classification_headers
35
+ cls = @options[:classification] || {}
36
+ h = {}
37
+ h['x-legion-classification'] = cls[:level].to_s if cls[:level]
38
+ h['x-legion-contains-phi'] = cls[:contains_phi].to_s unless cls[:contains_phi].nil?
39
+ h
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -6,6 +6,7 @@ if defined?(Legion::Transport::Message)
6
6
  require_relative 'audit/exchange'
7
7
  require_relative 'audit/prompt_event'
8
8
  require_relative 'audit/tool_event'
9
+ require_relative 'audit/skill_event'
9
10
  end
10
11
 
11
12
  module Legion
@@ -43,6 +44,20 @@ module Legion
43
44
  :dropped
44
45
  end
45
46
 
47
+ def emit_skill(**event)
48
+ if transport_connected? && defined?(Legion::LLM::Audit::SkillEvent)
49
+ Legion::LLM::Audit::SkillEvent.new(**event).publish
50
+ log.info('[llm][audit] published skill audit')
51
+ :published
52
+ else
53
+ log.warn('[llm][audit] dropped skill audit: transport unavailable')
54
+ :dropped
55
+ end
56
+ rescue StandardError => e
57
+ handle_exception(e, level: :warn, operation: 'llm.audit.emit_skill')
58
+ :dropped
59
+ end
60
+
46
61
  def transport_connected?
47
62
  !!(defined?(Legion::Transport) &&
48
63
  Legion::Transport.respond_to?(:connected?) &&
@@ -147,6 +147,53 @@ module Legion
147
147
  @lru_counter = 0
148
148
  end
149
149
 
150
+ def set_skill_state(conversation_id, skill_key:, resume_at:)
151
+ ensure_conversation(conversation_id)
152
+ conversations[conversation_id][:skill_state] = { skill_key: skill_key, resume_at: resume_at }
153
+ touch(conversation_id)
154
+ end
155
+
156
+ def skill_state(conversation_id)
157
+ return nil unless in_memory?(conversation_id)
158
+
159
+ touch(conversation_id)
160
+ conversations[conversation_id][:skill_state]&.dup
161
+ end
162
+
163
+ def clear_skill_state(conversation_id)
164
+ return unless in_memory?(conversation_id)
165
+
166
+ conversations[conversation_id].delete(:skill_state)
167
+ touch(conversation_id)
168
+ end
169
+
170
+ # Reads current state, clears :skill_state, sets :skill_cancelled flag.
171
+ # Returns the previous state (for use in skill.cancelled payload), or nil if none.
172
+ def cancel_skill!(conversation_id)
173
+ ensure_conversation(conversation_id)
174
+ state = conversations[conversation_id].delete(:skill_state)
175
+ if state
176
+ conversations[conversation_id][:skill_cancelled] = true
177
+ touch(conversation_id)
178
+ end
179
+ state
180
+ end
181
+
182
+ # :skill_cancelled is distinct from a nil :skill_state.
183
+ # nil skill_state also occurs after normal completion — use this flag to detect cancel.
184
+ def skill_cancelled?(conversation_id)
185
+ return false unless in_memory?(conversation_id)
186
+
187
+ conversations[conversation_id][:skill_cancelled] == true
188
+ end
189
+
190
+ def clear_cancel_flag(conversation_id)
191
+ return unless in_memory?(conversation_id)
192
+
193
+ conversations[conversation_id].delete(:skill_cancelled)
194
+ touch(conversation_id)
195
+ end
196
+
150
197
  # Migrate existing sequential messages to use parent links.
151
198
  # Safe to call on already-migrated data (no-op when parent links present).
152
199
  def migrate_parent_links!(conversation_id)
@@ -24,6 +24,11 @@ module Legion
24
24
  parts << "Relevant context:\n#{context_text}" unless context_text.empty?
25
25
  end
26
26
 
27
+ # Skill injection — active skill's step output appended after the RAG context
28
+ if (skill = enrichments['skill:active'])
29
+ parts << skill
30
+ end
31
+
27
32
  return system if parts.empty?
28
33
 
29
34
  parts << system if system
@@ -20,6 +20,7 @@ module Legion
20
20
  attr_accessor :tool_event_handler
21
21
 
22
22
  include Steps::TriggerMatch
23
+ include Steps::SkillInjector
23
24
  include Steps::ToolDiscovery
24
25
  include Steps::ToolCalls
25
26
  include Steps::KnowledgeCapture
@@ -30,14 +31,14 @@ module Legion
30
31
 
31
32
  STEPS = %i[
32
33
  tracing_init idempotency conversation_uuid context_load
33
- rbac classification billing gaia_advisory tier_assignment rag_context trigger_match tool_discovery
34
+ rbac classification billing gaia_advisory tier_assignment rag_context trigger_match skill_injector tool_discovery
34
35
  routing request_normalization token_budget provider_call response_normalization
35
36
  debate confidence_scoring tool_calls context_store post_response knowledge_capture response_return
36
37
  ].freeze
37
38
 
38
39
  PRE_PROVIDER_STEPS = %i[
39
40
  tracing_init idempotency conversation_uuid context_load
40
- rbac classification billing gaia_advisory tier_assignment rag_context trigger_match tool_discovery
41
+ rbac classification billing gaia_advisory tier_assignment rag_context trigger_match skill_injector tool_discovery
41
42
  routing request_normalization token_budget
42
43
  ].freeze
43
44
 
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging/helper'
4
+
5
+ module Legion
6
+ module LLM
7
+ module Pipeline
8
+ module Steps
9
+ module SkillInjector
10
+ include Legion::Logging::Helper
11
+
12
+ def step_skill_injector
13
+ return unless skills_enabled?
14
+
15
+ conv_id = @request.conversation_id
16
+ return unless conv_id
17
+
18
+ if (state = ConversationStore.skill_state(conv_id))
19
+ resume_active_skill(conv_id, state)
20
+ return
21
+ end
22
+
23
+ @skill_executed = false
24
+
25
+ check_trigger_words(conv_id)
26
+ return if @skill_executed
27
+
28
+ check_file_change_triggers(conv_id)
29
+ return if @skill_executed
30
+
31
+ check_auto_skills(conv_id)
32
+ rescue StandardError => e
33
+ @warnings << "SkillInjector error: #{e.message}"
34
+ handle_exception(e, level: :warn, operation: 'llm.pipeline.steps.skill_injector')
35
+ end
36
+
37
+ private
38
+
39
+ def skills_enabled?
40
+ defined?(Legion::LLM::Skills::Registry) &&
41
+ defined?(Legion::LLM) &&
42
+ Legion::LLM.respond_to?(:settings) &&
43
+ Legion::LLM.settings.dig(:skills, :enabled) != false
44
+ end
45
+
46
+ def resume_active_skill(conv_id, state)
47
+ skill_class = Legion::LLM::Skills::Registry.find(state[:skill_key])
48
+ unless skill_class
49
+ ConversationStore.clear_skill_state(conv_id)
50
+ return
51
+ end
52
+
53
+ result = skill_class.new.run(from_step: state[:resume_at],
54
+ context: build_skill_context(conv_id))
55
+ inject_skill_result(result)
56
+ end
57
+
58
+ def check_trigger_words(conv_id)
59
+ return if at_max_active_skills?(conv_id)
60
+
61
+ text = extract_message_text
62
+ words = text.downcase.gsub(/[^a-z ]/, ' ').split.to_set
63
+ return if words.empty?
64
+
65
+ index = Legion::LLM::Skills::Registry.trigger_word_index
66
+ matched_keys = words.flat_map { |w| index[w] || [] }.uniq
67
+ return if matched_keys.empty?
68
+
69
+ matched_keys.each do |key|
70
+ skill_class = Legion::LLM::Skills::Registry.find(key)
71
+ next unless skill_class
72
+ next if skill_disabled?(key)
73
+
74
+ activate_skill(conv_id, skill_class)
75
+ break
76
+ end
77
+ end
78
+
79
+ def check_file_change_triggers(conv_id)
80
+ return if at_max_active_skills?(conv_id)
81
+
82
+ changed = Array(@request.metadata&.dig(:changed_files) || [])
83
+ return if changed.empty?
84
+
85
+ Legion::LLM::Skills::Registry.file_trigger_skills.each do |skill_class|
86
+ key = "#{skill_class.namespace}:#{skill_class.skill_name}"
87
+ next if skill_disabled?(key)
88
+
89
+ matched = changed.any? do |path|
90
+ skill_class.file_change_trigger_patterns.any? do |pat|
91
+ ::File.fnmatch(pat, path, ::File::FNM_DOTMATCH)
92
+ end
93
+ end
94
+ next unless matched
95
+
96
+ activate_skill(conv_id, skill_class)
97
+ break
98
+ end
99
+ end
100
+
101
+ def check_auto_skills(conv_id)
102
+ return if at_max_active_skills?(conv_id)
103
+ return if Legion::LLM.settings.dig(:skills, :auto_inject) == false
104
+
105
+ Legion::LLM::Skills::Registry.by_trigger(:auto).each do |skill_class|
106
+ key = "#{skill_class.namespace}:#{skill_class.skill_name}"
107
+ next if skill_disabled?(key)
108
+ next unless when_conditions_match?(skill_class)
109
+
110
+ activate_skill(conv_id, skill_class)
111
+ break
112
+ end
113
+ end
114
+
115
+ def activate_skill(conv_id, skill_class)
116
+ result = skill_class.new.run(from_step: 0, context: build_skill_context(conv_id))
117
+ @skill_executed = true
118
+ inject_skill_result(result)
119
+ end
120
+
121
+ def inject_skill_result(result)
122
+ return unless result.inject && !result.inject.empty?
123
+
124
+ @enrichments['skill:active'] = result.inject
125
+ end
126
+
127
+ def when_conditions_match?(skill_class)
128
+ return true if skill_class.when_conditions.empty?
129
+
130
+ skill_class.when_conditions.all? do |key, expected|
131
+ unless @request.respond_to?(key)
132
+ log.warn("[skill_injector] unknown condition key #{key.inspect}, non-matching")
133
+ next false
134
+ end
135
+
136
+ deep_subset_match?(@request.public_send(key), expected)
137
+ end
138
+ end
139
+
140
+ def deep_subset_match?(actual, expected)
141
+ return actual == expected unless expected.is_a?(Hash)
142
+
143
+ expected.all? do |k, v|
144
+ actual.is_a?(Hash) && actual.key?(k) && deep_subset_match?(actual[k], v)
145
+ end
146
+ end
147
+
148
+ def at_max_active_skills?(conv_id)
149
+ max = Legion::LLM.settings.dig(:skills, :max_active_skills) || 1
150
+ active = ConversationStore.skill_state(conv_id) ? 1 : 0
151
+ active >= max
152
+ end
153
+
154
+ def skill_disabled?(key)
155
+ disabled = Array(Legion::LLM.settings.dig(:skills, :disabled_skills) || [])
156
+ enabled = Array(Legion::LLM.settings.dig(:skills, :enabled_skills) || [])
157
+ return true if disabled.include?(key)
158
+ return false if enabled.empty?
159
+
160
+ !enabled.include?(key)
161
+ end
162
+
163
+ def extract_message_text
164
+ @request.messages.last(2).map do |msg|
165
+ msg.is_a?(Hash) ? (msg[:content] || msg['content'] || '').to_s : msg.to_s
166
+ end.join(' ')
167
+ end
168
+
169
+ def build_skill_context(conv_id)
170
+ {
171
+ conversation_id: conv_id,
172
+ classification: @request.classification,
173
+ metadata: @request.metadata,
174
+ intent: @request.extra&.dig(:intent)
175
+ }
176
+ end
177
+ end
178
+ end
179
+ end
180
+ end
181
+ end
@@ -19,6 +19,7 @@ require_relative 'steps/tier_assigner'
19
19
  require_relative 'steps/post_response'
20
20
  require_relative 'steps/tool_discovery'
21
21
  require_relative 'steps/trigger_match'
22
+ require_relative 'steps/skill_injector'
22
23
  require_relative 'steps/tool_calls'
23
24
  require_relative 'steps/rag_context'
24
25
  require_relative 'steps/rag_guard'
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'step_result'
4
+ require_relative 'skill_run_result'
5
+ require_relative 'errors'
6
+
7
+ module Legion
8
+ module LLM
9
+ module Skills
10
+ class Base
11
+ class << self
12
+ def skill_name(name = nil)
13
+ name ? (@skill_name = name.to_s) : @skill_name
14
+ end
15
+
16
+ def description(text = nil)
17
+ text ? (@description = text) : @description
18
+ end
19
+
20
+ def trigger(type = nil)
21
+ type ? (@trigger = type) : (@trigger || :on_demand)
22
+ end
23
+
24
+ def namespace(nsp = nil)
25
+ nsp ? (@namespace = nsp.to_s) : @namespace
26
+ end
27
+
28
+ def steps(*names)
29
+ if names.any?
30
+ @steps = names
31
+ validate_steps!
32
+ else
33
+ @steps || []
34
+ end
35
+ end
36
+
37
+ def trigger_words(*words)
38
+ words.any? ? (@trigger_words_list = words.map(&:to_s)) : (@trigger_words_list || [])
39
+ end
40
+
41
+ def file_change_triggers(*patterns)
42
+ patterns.any? ? (@file_change_trigger_patterns = patterns.map(&:to_s)) : (@file_change_trigger_patterns || [])
43
+ end
44
+
45
+ def file_change_trigger_patterns
46
+ @file_change_trigger_patterns || []
47
+ end
48
+
49
+ def follows(skill_key = nil)
50
+ skill_key ? (@follows_skill = skill_key.to_s) : @follows_skill
51
+ end
52
+
53
+ attr_reader :follows_skill
54
+
55
+ # `condition` is used instead of `when` because `when` is a Ruby reserved keyword.
56
+ # DSL: `condition classification: { level: 'internal' }`
57
+ def condition(**conds)
58
+ @when_conditions = conds
59
+ end
60
+
61
+ def when_conditions
62
+ @when_conditions || {}
63
+ end
64
+
65
+ def content(context: {}) # rubocop:disable Lint/UnusedMethodArgument
66
+ path = content_path
67
+ return ::File.read(path) if path && ::File.exist?(path)
68
+
69
+ generate_content_from_step_names
70
+ end
71
+
72
+ private
73
+
74
+ def validate_steps!
75
+ missing = @steps.reject { |name| method_defined?(name) || private_method_defined?(name) }
76
+ return if missing.empty?
77
+
78
+ raise InvalidSkill, "#{self}: missing step methods: #{missing.join(', ')}"
79
+ end
80
+
81
+ def content_path
82
+ return nil unless @skill_name
83
+
84
+ # Derive gem name: Legion::Extensions::SkillSuperpowers -> lex-skill-superpowers
85
+ parts = name.to_s.split('::').drop(2)
86
+ return nil if parts.empty?
87
+
88
+ gem_name = parts.first.gsub(/([A-Z])/) { "-#{::Regexp.last_match(1).downcase}" }.sub(/^-/, 'lex-')
89
+ spec = begin
90
+ ::Gem::Specification.find_by_name(gem_name)
91
+ rescue ::Gem::MissingSpecError
92
+ nil
93
+ end
94
+ return nil unless spec
95
+
96
+ ::File.join(spec.gem_dir, 'content', @skill_name, 'SKILL.md')
97
+ end
98
+
99
+ def generate_content_from_step_names
100
+ lines = ["# #{@skill_name} — #{@skill_name.to_s.tr('-', ' ').capitalize}", '',
101
+ @description.to_s, '', '## Steps', '']
102
+ (@steps || []).each_with_index { |n, i| lines << "#{i + 1}. #{n.to_s.tr('_', ' ').capitalize}" }
103
+ lines.join("\n")
104
+ end
105
+ end
106
+
107
+ def run(from_step: 0, context: {})
108
+ inject_parts = []
109
+ total_duration = 0
110
+ classification = context[:classification]
111
+ conv_id = context[:conversation_id]
112
+ self_key = "#{self.class.namespace}:#{self.class.skill_name}"
113
+
114
+ emit_event(conv_id, 'skill.started',
115
+ skill_name: self.class.skill_name, namespace: self.class.namespace,
116
+ total_steps: self.class.steps.length)
117
+
118
+ remaining_steps = self.class.steps[from_step..] || []
119
+
120
+ remaining_steps.each_with_index do |method_name, offset|
121
+ step_idx = from_step + offset
122
+ if conv_id && Legion::LLM::ConversationStore.skill_cancelled?(conv_id)
123
+ Legion::LLM::ConversationStore.clear_cancel_flag(conv_id)
124
+ return SkillRunResult.build(inject: inject_parts.join("\n\n"),
125
+ gated: false, gate: nil, resume_at: nil, complete: false)
126
+ end
127
+
128
+ result, duration_ms = execute_step(method_name, step_idx, context, conv_id, classification)
129
+ total_duration += duration_ms
130
+ inject_parts << result.inject if result.inject
131
+
132
+ emit_step_success(conv_id, method_name, step_idx, duration_ms, result, classification)
133
+
134
+ next unless result.gate
135
+
136
+ if conv_id
137
+ Legion::LLM::ConversationStore.set_skill_state(
138
+ conv_id, skill_key: self_key, resume_at: step_idx + 1
139
+ )
140
+ end
141
+ emit_event(conv_id, 'skill.step.gated',
142
+ step_name: method_name, gate_type: result.gate)
143
+ return SkillRunResult.build(
144
+ inject: inject_parts.join("\n\n"), gated: true,
145
+ gate: result.gate, resume_at: step_idx + 1, complete: false
146
+ )
147
+ end
148
+
149
+ finalize_run(conv_id, self_key, inject_parts, total_duration, context)
150
+ end
151
+
152
+ private
153
+
154
+ def execute_step(method_name, step_idx, context, conv_id, classification)
155
+ t0 = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
156
+ emit_event(conv_id, 'skill.step.started',
157
+ step_name: method_name, step_index: step_idx)
158
+ Legion::LLM::Metering.emit(
159
+ request_type: 'skill.step.start', skill_name: self.class.skill_name,
160
+ namespace: self.class.namespace, step_name: method_name,
161
+ step_index: step_idx, tier: 'local'
162
+ )
163
+ result = public_send(method_name, context: context)
164
+ unless result.respond_to?(:inject) && result.respond_to?(:metadata) && result.respond_to?(:gate)
165
+ raise Legion::LLM::Skills::StepError.new(
166
+ "#{self.class.skill_name}##{method_name} returned #{result.class} instead of StepResult",
167
+ cause: nil
168
+ )
169
+ end
170
+
171
+ duration_ms = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - t0) * 1000).round
172
+ [result, duration_ms]
173
+ rescue StandardError => e
174
+ duration_ms = ((::Process.clock_gettime(::Process::CLOCK_MONOTONIC) - t0) * 1000).round
175
+ handle_step_error(e, method_name, step_idx, conv_id, duration_ms, classification)
176
+ end
177
+
178
+ def handle_step_error(err, method_name, step_idx, conv_id, duration_ms, classification)
179
+ Legion::LLM::ConversationStore.clear_skill_state(conv_id) if conv_id
180
+ emit_event(conv_id, 'skill.step.failed',
181
+ step_name: method_name, error: err.message)
182
+ Legion::LLM::Audit.emit_skill(
183
+ skill_name: self.class.skill_name, namespace: self.class.namespace,
184
+ step_name: method_name, gate: nil, status: :failed,
185
+ duration_ms: duration_ms, metadata: { error: err.message },
186
+ classification: classification
187
+ )
188
+ Legion::LLM::Metering.emit(
189
+ request_type: 'skill.step', skill_name: self.class.skill_name,
190
+ namespace: self.class.namespace, step_name: method_name,
191
+ step_index: step_idx, duration_ms: duration_ms, gate: nil, tier: 'local'
192
+ )
193
+ raise Legion::LLM::Skills::StepError.new(
194
+ "#{self.class.skill_name}##{method_name} failed: #{err.message}", cause: err
195
+ )
196
+ end
197
+
198
+ def emit_step_success(conv_id, method_name, step_idx, duration_ms, result, classification)
199
+ emit_event(conv_id, 'skill.step.completed',
200
+ step_name: method_name, duration_ms: duration_ms,
201
+ metadata: result.metadata)
202
+ Legion::LLM::Audit.emit_skill(
203
+ skill_name: self.class.skill_name, namespace: self.class.namespace,
204
+ step_name: method_name, gate: result.gate,
205
+ status: :completed, duration_ms: duration_ms,
206
+ metadata: result.metadata, classification: classification
207
+ )
208
+ Legion::LLM::Metering.emit(
209
+ request_type: 'skill.step', skill_name: self.class.skill_name,
210
+ namespace: self.class.namespace, step_name: method_name,
211
+ step_index: step_idx, duration_ms: duration_ms,
212
+ gate: result.gate&.to_s, tier: 'local'
213
+ )
214
+ end
215
+
216
+ def finalize_run(conv_id, self_key, inject_parts, total_duration, context)
217
+ chain_next = Legion::LLM::Skills::Registry.chain_for(self_key)
218
+ chained_class = chain_next ? Legion::LLM::Skills::Registry.find(chain_next) : nil
219
+ resolved_chain = chained_class ? chain_next : nil
220
+
221
+ Legion::LLM::ConversationStore.clear_skill_state(conv_id) if conv_id
222
+ emit_event(conv_id, 'skill.completed',
223
+ skill_name: self.class.skill_name, namespace: self.class.namespace,
224
+ total_duration_ms: total_duration, chained_to: resolved_chain)
225
+
226
+ return run_chained(chained_class, chain_next, conv_id, self_key, inject_parts, context) if chained_class
227
+
228
+ SkillRunResult.build(inject: inject_parts.join("\n\n"),
229
+ gated: false, gate: nil, resume_at: nil, complete: true)
230
+ end
231
+
232
+ def run_chained(chained_class, chain_key, conv_id, self_key, inject_parts, context)
233
+ emit_event(conv_id, 'skill.chained',
234
+ from_skill: self_key, to_skill: chain_key)
235
+ chained_result = chained_class.new.run(from_step: 0, context: context)
236
+ inject_parts << chained_result.inject if chained_result.inject
237
+ SkillRunResult.build(
238
+ inject: inject_parts.join("\n\n"),
239
+ gated: chained_result.gated,
240
+ gate: chained_result.gate,
241
+ resume_at: chained_result.resume_at,
242
+ complete: chained_result.complete
243
+ )
244
+ end
245
+
246
+ def emit_event(conv_id, event, **payload)
247
+ return unless conv_id
248
+
249
+ Legion::Events.emit(event, { conversation_id: conv_id }.merge(payload))
250
+ end
251
+
252
+ protected
253
+
254
+ def detect_project(context)
255
+ root = context[:project_root]
256
+ return 'unknown project' unless root
257
+
258
+ ::File.basename(root.to_s)
259
+ end
260
+
261
+ def conversation_id(context)
262
+ context[:conversation_id]
263
+ end
264
+
265
+ def current_intent(context)
266
+ context[:intent]
267
+ end
268
+ end
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging/helper'
4
+
5
+ module Legion
6
+ module LLM
7
+ module Skills
8
+ module DiskLoader
9
+ extend Legion::Logging::Helper
10
+
11
+ module_function
12
+
13
+ def load_from_directories(directories)
14
+ loaded = 0
15
+ Array(directories).each do |dir|
16
+ expanded = ::File.expand_path(dir)
17
+ next unless ::File.directory?(expanded)
18
+
19
+ loaded += load_directory(expanded)
20
+ end
21
+ loaded
22
+ end
23
+
24
+ def load_directory(dir)
25
+ loaded = 0
26
+ ::Dir.glob(::File.join(dir, '*.rb')).each do |path|
27
+ require path
28
+ loaded += 1
29
+ rescue StandardError => e
30
+ log.warn("[skills][disk_loader] failed to load #{path}: #{e.message}")
31
+ end
32
+ ::Dir.glob(::File.join(dir, '*.md')).each do |path|
33
+ load_md_skill(path)
34
+ loaded += 1
35
+ rescue StandardError => e
36
+ log.warn("[skills][disk_loader] failed to load #{path}: #{e.message}")
37
+ end
38
+ ::Dir.glob(::File.join(dir, '*/SKILL.md')).each do |path|
39
+ load_md_skill(path, skill_name: ::File.basename(::File.dirname(path)))
40
+ loaded += 1
41
+ rescue StandardError => e
42
+ log.warn("[skills][disk_loader] failed to load #{path}: #{e.message}")
43
+ end
44
+ loaded
45
+ end
46
+
47
+ # Public for testing. Accepts an optional content: kwarg to avoid disk reads in specs.
48
+ def load_md_skill(path, skill_name: nil, content: nil)
49
+ raw = content || ::File.read(path)
50
+ meta, body = parse_frontmatter(raw)
51
+ name = skill_name || meta[:name] || ::File.basename(path, '.md')
52
+ ns = (meta[:namespace] || 'disk').to_s
53
+ desc = (meta[:description] || '').to_s
54
+ trig = (meta[:trigger] || 'on_demand').to_sym
55
+ words = Array(meta[:trigger_words] || []).map(&:to_s)
56
+ klass = build_md_skill_class(name: name, namespace: ns, description: desc,
57
+ trigger: trig, trigger_words: words, content: body)
58
+ Registry.register(klass)
59
+ end
60
+
61
+ def parse_frontmatter(text)
62
+ return [{}, text] unless text.start_with?('---')
63
+
64
+ parts = text.split(/^---\s*$/, 3)
65
+ return [{}, text] unless parts.size >= 3
66
+
67
+ require 'yaml'
68
+ meta = ::YAML.safe_load(parts[1], permitted_classes: [], symbolize_names: true) || {}
69
+ [meta, parts[2].lstrip]
70
+ rescue StandardError
71
+ [{}, text]
72
+ end
73
+
74
+ def build_md_skill_class(name:, namespace:, description:, trigger:, trigger_words:, content:)
75
+ raw_content = content
76
+ klass = Class.new(Legion::LLM::Skills::Base)
77
+ klass.send(:define_method, :present_content) do |context: {}| # rubocop:disable Lint/UnusedBlockArgument
78
+ Legion::LLM::Skills::StepResult.build(inject: raw_content)
79
+ end
80
+ klass.skill_name(name)
81
+ klass.namespace(namespace)
82
+ klass.description(description)
83
+ klass.trigger(trigger)
84
+ klass.trigger_words(*trigger_words) if trigger_words.any?
85
+ klass.steps(:present_content)
86
+ klass
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/llm/errors'
4
+
5
+ module Legion
6
+ module LLM
7
+ module Skills
8
+ class InvalidSkill < Legion::LLM::LLMError
9
+ def initialize(msg = 'Invalid skill definition')
10
+ super
11
+ end
12
+ end
13
+
14
+ class StepError < Legion::LLM::LLMError
15
+ attr_reader :cause
16
+
17
+ def initialize(msg, cause: nil)
18
+ super(msg)
19
+ @cause = cause
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module LLM
5
+ module Skills
6
+ module ExternalDiscovery
7
+ module_function
8
+
9
+ def discover
10
+ dirs = []
11
+ dirs.concat(claude_directories) if claude_auto_discover?
12
+ dirs.concat(codex_directories) if codex_auto_discover?
13
+ dirs
14
+ end
15
+
16
+ def claude_directories
17
+ home = ::File.expand_path('~')
18
+ dirs = []
19
+
20
+ skills_dir = ::File.join(home, '.claude', 'skills')
21
+ dirs << skills_dir if ::File.directory?(skills_dir)
22
+
23
+ plugins_dir = ::File.join(home, '.claude', 'plugins')
24
+ if ::File.directory?(plugins_dir)
25
+ ::Dir.glob(::File.join(plugins_dir, '*', 'skills')).each do |skill_subdir|
26
+ dirs << skill_subdir if ::File.directory?(skill_subdir)
27
+ end
28
+ end
29
+
30
+ dirs.uniq
31
+ end
32
+
33
+ def codex_directories
34
+ candidate = ::File.join(::File.expand_path('~'), '.codex', 'skills')
35
+ ::File.directory?(candidate) ? [candidate] : []
36
+ end
37
+
38
+ def claude_auto_discover?
39
+ Legion::LLM.settings.dig(:skills, :auto_discover, :claude) != false
40
+ rescue StandardError
41
+ true
42
+ end
43
+
44
+ def codex_auto_discover?
45
+ Legion::LLM.settings.dig(:skills, :auto_discover, :codex) != false
46
+ rescue StandardError
47
+ true
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'legion/logging/helper'
4
+
5
+ module Legion
6
+ module LLM
7
+ module Skills
8
+ module Registry
9
+ extend Legion::Logging::Helper
10
+
11
+ MUTEX = Mutex.new
12
+
13
+ class << self
14
+ def register(skill_class)
15
+ MUTEX.synchronize do
16
+ validate!(skill_class)
17
+ key = registry_key(skill_class)
18
+ if (@by_key ||= {}).key?(key)
19
+ log.warn("[skills][registry] duplicate: #{key} replaced")
20
+ remove_from_indexes(@by_key[key], key)
21
+ end
22
+ @ordered ||= []
23
+ @ordered.reject! { |k| k == key }
24
+ @by_key[key] = skill_class
25
+ @ordered << key
26
+ index_trigger_words(key, skill_class)
27
+ index_file_triggers(skill_class)
28
+ index_chain(skill_class)
29
+ end
30
+ end
31
+
32
+ def all
33
+ MUTEX.synchronize { (@ordered || []).filter_map { |k| (@by_key || {})[k] } }
34
+ end
35
+
36
+ def find(key)
37
+ MUTEX.synchronize { (@by_key || {})[key] }
38
+ end
39
+
40
+ def by_trigger(type)
41
+ all.select { |c| c.trigger == type }
42
+ end
43
+
44
+ def chain_for(skill_key)
45
+ MUTEX.synchronize { (@chain_index || {})[skill_key] }
46
+ end
47
+
48
+ def trigger_word_index
49
+ MUTEX.synchronize do
50
+ (@trigger_word_index || {}).each_with_object({}) do |(word, keys), copy|
51
+ copy[word] = keys.dup
52
+ end
53
+ end
54
+ end
55
+
56
+ def file_trigger_skills
57
+ MUTEX.synchronize { (@file_trigger_skills || []).dup }
58
+ end
59
+
60
+ def reset!
61
+ MUTEX.synchronize do
62
+ @by_key = {}
63
+ @ordered = []
64
+ @chain_index = {}
65
+ @trigger_word_index = {}
66
+ @file_trigger_skills = []
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def registry_key(skill_class)
73
+ "#{skill_class.namespace}:#{skill_class.skill_name}"
74
+ end
75
+
76
+ def validate!(skill_class)
77
+ raise InvalidSkill, "#{skill_class}: skill_name required" if skill_class.skill_name.nil?
78
+ raise InvalidSkill, "#{skill_class}: namespace required" if skill_class.namespace.nil?
79
+
80
+ check_chain_cycle!(skill_class)
81
+ end
82
+
83
+ def check_chain_cycle!(skill_class)
84
+ return unless skill_class.follows_skill
85
+
86
+ self_key = registry_key(skill_class)
87
+ visited = Set.new([self_key])
88
+ current = skill_class.follows_skill
89
+ while current
90
+ raise InvalidSkill, "Cycle detected in skill chain involving #{self_key}" if visited.include?(current)
91
+
92
+ visited.add(current)
93
+ current = (@by_key || {})[current]&.follows_skill
94
+ end
95
+ end
96
+
97
+ def index_trigger_words(key, skill_class)
98
+ @trigger_word_index ||= {}
99
+ skill_class.trigger_words.each do |word|
100
+ @trigger_word_index[word] ||= []
101
+ @trigger_word_index[word] << key unless @trigger_word_index[word].include?(key)
102
+ end
103
+ end
104
+
105
+ def index_file_triggers(skill_class)
106
+ @file_trigger_skills ||= []
107
+ return if skill_class.file_change_trigger_patterns.empty?
108
+ return if @file_trigger_skills.include?(skill_class)
109
+
110
+ @file_trigger_skills << skill_class
111
+ end
112
+
113
+ def index_chain(skill_class)
114
+ @chain_index ||= {}
115
+ return unless skill_class.follows_skill
116
+
117
+ self_key = registry_key(skill_class)
118
+ @chain_index[skill_class.follows_skill] = self_key
119
+ end
120
+
121
+ def remove_from_indexes(old_class, key)
122
+ old_class.trigger_words.each { |w| @trigger_word_index&.[](w)&.delete(key) }
123
+ @file_trigger_skills&.delete(old_class)
124
+ @chain_index&.delete_if { |_parent, child_key| child_key == key }
125
+ @chain_index&.delete(old_class.follows_skill) if old_class.follows_skill
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module LLM
5
+ module Skills
6
+ module Settings
7
+ DEFAULTS = {
8
+ enabled: true,
9
+ auto_inject: true,
10
+ on_demand: true,
11
+ max_active_skills: 1,
12
+ directories: ['.legion/skills', '~/.legionio/skills'],
13
+ auto_discover: { claude: false, codex: false },
14
+ enabled_skills: [],
15
+ disabled_skills: []
16
+ }.freeze
17
+
18
+ module_function
19
+
20
+ def apply
21
+ return unless defined?(Legion::Settings)
22
+
23
+ llm_settings = (Legion::Settings[:llm] || {}).dup
24
+ current = llm_settings[:skills] || {}
25
+ merged = deep_merge(DEFAULTS, current)
26
+ llm_settings[:skills] = merged
27
+ Legion::Settings[:llm] = llm_settings
28
+ end
29
+
30
+ def deep_merge(base, override)
31
+ result = base.dup
32
+ override.each do |key, val|
33
+ result[key] = val.is_a?(Hash) && result[key].is_a?(Hash) ? deep_merge(result[key], val) : val
34
+ end
35
+ result
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module LLM
5
+ module Skills
6
+ SkillRunResult = ::Data.define(:inject, :gated, :gate, :resume_at, :complete) do
7
+ def self.build(inject:, gated:, gate:, resume_at:, complete:)
8
+ new(inject: inject, gated: gated, gate: gate, resume_at: resume_at, complete: complete)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module LLM
5
+ module Skills
6
+ StepResult = ::Data.define(:inject, :gate, :metadata) do
7
+ def self.build(inject:, gate: nil, metadata: {})
8
+ new(inject: inject, gate: gate, metadata: metadata)
9
+ end
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'skills/errors'
4
+ require_relative 'skills/step_result'
5
+ require_relative 'skills/skill_run_result'
6
+ require_relative 'skills/registry'
7
+ require_relative 'skills/settings'
8
+ require_relative 'skills/base'
9
+ require_relative 'skills/disk_loader'
10
+ require_relative 'skills/external_discovery'
11
+
12
+ module Legion
13
+ module LLM
14
+ module Skills
15
+ module_function
16
+
17
+ def start
18
+ Settings.apply
19
+ directories = settings_directories + ExternalDiscovery.discover
20
+ DiskLoader.load_from_directories(directories)
21
+ end
22
+
23
+ def settings_directories
24
+ Array(Legion::LLM.settings.dig(:skills, :directories) || [])
25
+ rescue StandardError
26
+ []
27
+ end
28
+ end
29
+ end
30
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Legion
4
4
  module LLM
5
- VERSION = '0.6.31'
5
+ VERSION = '0.7.0'
6
6
  end
7
7
  end
data/lib/legion/llm.rb CHANGED
@@ -38,6 +38,12 @@ require_relative 'llm/token_tracker'
38
38
  require_relative 'llm/override_confidence'
39
39
  require_relative 'llm/routes'
40
40
 
41
+ begin
42
+ require_relative 'llm/skills'
43
+ rescue LoadError => e
44
+ Legion::Logging.debug "LLM: skills not loadable: #{e.message}" if defined?(Legion::Logging)
45
+ end
46
+
41
47
  module Legion
42
48
  module LLM
43
49
  class EscalationExhausted < StandardError; end
@@ -72,6 +78,9 @@ module Legion
72
78
  install_hooks
73
79
  load_tool_interceptors
74
80
 
81
+ # Skills startup — load after settings, before pipeline is used
82
+ Legion::LLM::Skills.start if defined?(Legion::LLM::Skills) && settings.dig(:skills, :enabled) != false
83
+
75
84
  @started = true
76
85
  Legion::Settings[:llm][:connected] = true
77
86
  log.info 'Legion::LLM started'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: legion-llm
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.31
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -227,6 +227,7 @@ files:
227
227
  - lib/legion/llm/audit.rb
228
228
  - lib/legion/llm/audit/exchange.rb
229
229
  - lib/legion/llm/audit/prompt_event.rb
230
+ - lib/legion/llm/audit/skill_event.rb
230
231
  - lib/legion/llm/audit/tool_event.rb
231
232
  - lib/legion/llm/batch.rb
232
233
  - lib/legion/llm/bedrock_bearer_auth.rb
@@ -295,6 +296,7 @@ files:
295
296
  - lib/legion/llm/pipeline/steps/rag_context.rb
296
297
  - lib/legion/llm/pipeline/steps/rag_guard.rb
297
298
  - lib/legion/llm/pipeline/steps/rbac.rb
299
+ - lib/legion/llm/pipeline/steps/skill_injector.rb
298
300
  - lib/legion/llm/pipeline/steps/span_annotator.rb
299
301
  - lib/legion/llm/pipeline/steps/tier_assigner.rb
300
302
  - lib/legion/llm/pipeline/steps/token_budget.rb
@@ -319,6 +321,15 @@ files:
319
321
  - lib/legion/llm/scheduling.rb
320
322
  - lib/legion/llm/settings.rb
321
323
  - lib/legion/llm/shadow_eval.rb
324
+ - lib/legion/llm/skills.rb
325
+ - lib/legion/llm/skills/base.rb
326
+ - lib/legion/llm/skills/disk_loader.rb
327
+ - lib/legion/llm/skills/errors.rb
328
+ - lib/legion/llm/skills/external_discovery.rb
329
+ - lib/legion/llm/skills/registry.rb
330
+ - lib/legion/llm/skills/settings.rb
331
+ - lib/legion/llm/skills/skill_run_result.rb
332
+ - lib/legion/llm/skills/step_result.rb
322
333
  - lib/legion/llm/structured_output.rb
323
334
  - lib/legion/llm/token_tracker.rb
324
335
  - lib/legion/llm/tools/adapter.rb