legionio 1.7.36 → 1.7.37
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 +5 -1
- data/lib/legion/api/llm.rb +47 -47
- data/lib/legion/audit/siem_export.rb +1 -1
- data/lib/legion/extensions/builders/runners.rb +32 -29
- data/lib/legion/extensions/core.rb +4 -0
- data/lib/legion/service.rb +7 -0
- data/lib/legion/tools/base.rb +6 -0
- data/lib/legion/tools/discovery.rb +16 -8
- data/lib/legion/tools/trigger_index.rb +98 -0
- data/lib/legion/tools.rb +3 -1
- data/lib/legion/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2bafa8899bbfa7e80970cec50a67e3989fc259cc426ebbbf7bc531194bd8b0d8
|
|
4
|
+
data.tar.gz: 4e768d2523b3a9b962f8d1119061fe25d71c8e101acb071cef793b40695ebb8c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c20eb127ef11c1f60ad06c42619354a91072a3c79ff103189454c6f8080e60fa1e1b5ad9bdcb77703ccfd3308079117aadad6c7f063f59c5255a01b635d2c3dc
|
|
7
|
+
data.tar.gz: 0bcaa14c6e8c8a3fd0c9b5f47d2cb64abba2cee271cb09eadf812a460f0bfe5069147bb497b205e910fffa71da98702c3889805ef8d18f09cd7d7d8714020c38
|
data/CHANGELOG.md
CHANGED
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.7.37] - 2026-04-09
|
|
6
|
+
|
|
5
7
|
### Added
|
|
6
|
-
-
|
|
8
|
+
- Trigger word tool injection: extensions and runners declare trigger words that auto-promote deferred tools when detected in LLM messages
|
|
9
|
+
- `Legion::Tools::TriggerIndex` — Concurrent::Map-backed reverse index for O(1) trigger word lookup
|
|
10
|
+
- `trigger_words` DSL on Extensions::Core, runner modules, and Tools::Base
|
|
7
11
|
|
|
8
12
|
## [1.7.36] - 2026-04-09
|
|
9
13
|
|
data/lib/legion/api/llm.rb
CHANGED
|
@@ -314,40 +314,40 @@ module Legion
|
|
|
314
314
|
case event[:type]
|
|
315
315
|
when :tool_call
|
|
316
316
|
emitted_tool_call_ids << event[:tool_call_id] if event[:tool_call_id]
|
|
317
|
-
out << "event: tool-call\ndata: #{Legion::JSON.
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
317
|
+
out << "event: tool-call\ndata: #{Legion::JSON.generate({
|
|
318
|
+
toolCallId: event[:tool_call_id],
|
|
319
|
+
toolName: event[:tool_name],
|
|
320
|
+
args: event[:arguments] || {},
|
|
321
|
+
startedAt: event[:started_at]&.iso8601(3),
|
|
322
|
+
timestamp: event[:started_at]&.iso8601(3) || Time.now.iso8601(3)
|
|
323
|
+
})}\n\n"
|
|
324
324
|
when :tool_result
|
|
325
|
-
out << "event: tool-result\ndata: #{Legion::JSON.
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
325
|
+
out << "event: tool-result\ndata: #{Legion::JSON.generate({
|
|
326
|
+
toolCallId: event[:tool_call_id],
|
|
327
|
+
toolName: event[:tool_name],
|
|
328
|
+
result: event[:result],
|
|
329
|
+
startedAt: event[:started_at]&.iso8601(3),
|
|
330
|
+
finishedAt: event[:finished_at]&.iso8601(3) || Time.now.iso8601(3),
|
|
331
|
+
durationMs: event[:duration_ms],
|
|
332
|
+
timestamp: event[:finished_at]&.iso8601(3) || Time.now.iso8601(3)
|
|
333
|
+
})}\n\n"
|
|
334
334
|
when :tool_error
|
|
335
|
-
out << "event: tool-error\ndata: #{Legion::JSON.
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
})}\n\n"
|
|
343
|
-
when :model_fallback
|
|
344
|
-
out << "event: model-fallback\ndata: #{Legion::JSON.dump({
|
|
345
|
-
fromModel: event[:from_model],
|
|
346
|
-
toModel: event[:to_model],
|
|
347
|
-
toModelKey: event[:to_model],
|
|
348
|
-
error: event[:error] || 'Provider unavailable',
|
|
349
|
-
reason: event[:reason] || 'provider_fallback'
|
|
335
|
+
out << "event: tool-error\ndata: #{Legion::JSON.generate({
|
|
336
|
+
toolCallId: event[:tool_call_id],
|
|
337
|
+
toolName: event[:tool_name],
|
|
338
|
+
error: (event[:error] || event[:result]).to_s,
|
|
339
|
+
startedAt: event[:started_at]&.iso8601(3),
|
|
340
|
+
finishedAt: Time.now.iso8601(3),
|
|
341
|
+
timestamp: Time.now.iso8601(3)
|
|
350
342
|
})}\n\n"
|
|
343
|
+
when :model_fallback
|
|
344
|
+
out << "event: model-fallback\ndata: #{Legion::JSON.generate({
|
|
345
|
+
fromModel: event[:from_model],
|
|
346
|
+
toModel: event[:to_model],
|
|
347
|
+
toModelKey: event[:to_model],
|
|
348
|
+
error: event[:error] || 'Provider unavailable',
|
|
349
|
+
reason: event[:reason] || 'provider_fallback'
|
|
350
|
+
})}\n\n"
|
|
351
351
|
end
|
|
352
352
|
end
|
|
353
353
|
|
|
@@ -357,7 +357,7 @@ module Legion
|
|
|
357
357
|
next if text.empty?
|
|
358
358
|
|
|
359
359
|
full_text << text
|
|
360
|
-
out << "event: text-delta\ndata: #{Legion::JSON.
|
|
360
|
+
out << "event: text-delta\ndata: #{Legion::JSON.generate({ delta: text })}\n\n"
|
|
361
361
|
end
|
|
362
362
|
|
|
363
363
|
# Post-hoc safety net: emit any tool-calls that weren't fired in real-time
|
|
@@ -367,11 +367,11 @@ module Legion
|
|
|
367
367
|
tc_id = tc.respond_to?(:id) ? tc.id : nil
|
|
368
368
|
next if tc_id && emitted_tool_call_ids.include?(tc_id)
|
|
369
369
|
|
|
370
|
-
out << "event: tool-call\ndata: #{Legion::JSON.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
370
|
+
out << "event: tool-call\ndata: #{Legion::JSON.generate({
|
|
371
|
+
toolCallId: tc_id,
|
|
372
|
+
toolName: tc.respond_to?(:name) ? tc.name : tc.to_s,
|
|
373
|
+
args: tc.respond_to?(:arguments) ? tc.arguments : {}
|
|
374
|
+
})}\n\n"
|
|
375
375
|
end
|
|
376
376
|
end
|
|
377
377
|
|
|
@@ -384,20 +384,20 @@ module Legion
|
|
|
384
384
|
resolved_model = (model || provider).to_s.strip
|
|
385
385
|
next if resolved_model.empty?
|
|
386
386
|
|
|
387
|
-
out << "event: model-fallback\ndata: #{Legion::JSON.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
387
|
+
out << "event: model-fallback\ndata: #{Legion::JSON.generate({
|
|
388
|
+
fromModel: pipeline_response.routing&.dig(:model),
|
|
389
|
+
toModel: resolved_model,
|
|
390
|
+
toModelKey: resolved_model,
|
|
391
|
+
error: w[:original_error] || 'Provider unavailable',
|
|
392
|
+
reason: 'provider_fallback'
|
|
393
|
+
})}\n\n"
|
|
394
394
|
end
|
|
395
395
|
|
|
396
396
|
enrichments = pipeline_response.enrichments
|
|
397
|
-
out << "event: enrichment\ndata: #{Legion::JSON.
|
|
397
|
+
out << "event: enrichment\ndata: #{Legion::JSON.generate(enrichments)}\n\n" if enrichments.is_a?(Hash) && !enrichments.empty?
|
|
398
398
|
|
|
399
399
|
tokens = pipeline_response.tokens
|
|
400
|
-
out << "event: done\ndata: #{Legion::JSON.
|
|
400
|
+
out << "event: done\ndata: #{Legion::JSON.generate({
|
|
401
401
|
content: full_text,
|
|
402
402
|
model: pipeline_response.routing&.dig(:model),
|
|
403
403
|
conversation_id: pipeline_response.conversation_id,
|
|
@@ -409,7 +409,7 @@ module Legion
|
|
|
409
409
|
}.compact)}\n\n"
|
|
410
410
|
rescue StandardError => e
|
|
411
411
|
Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference stream failed', component_type: :api)
|
|
412
|
-
out << "event: error\ndata: #{Legion::JSON.
|
|
412
|
+
out << "event: error\ndata: #{Legion::JSON.generate({ code: 'stream_error', message: e.message })}\n\n"
|
|
413
413
|
end
|
|
414
414
|
else
|
|
415
415
|
pipeline_response = executor.call
|
|
@@ -21,41 +21,44 @@ module Legion
|
|
|
21
21
|
def build_runner_list
|
|
22
22
|
runner_files.each do |file|
|
|
23
23
|
runner_name = file.split('/').last.sub('.rb', '')
|
|
24
|
-
runner_class =
|
|
24
|
+
runner_class = "#{lex_class}::Runners::#{runner_name.split('_').collect(&:capitalize).join}"
|
|
25
25
|
loaded_runner = Kernel.const_get(runner_class)
|
|
26
26
|
loaded_runner.extend(Legion::Extensions::Definitions) unless loaded_runner.respond_to?(:definition)
|
|
27
27
|
Legion::Logging.debug "[Runners] registered: #{runner_class}" if defined?(Legion::Logging)
|
|
28
|
+
@runners[runner_name.to_sym] = build_runner_entry(runner_name, runner_class, loaded_runner, file)
|
|
29
|
+
populate_runner_methods(runner_name, loaded_runner)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
28
32
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
loaded_runner.public_instance_methods(false).each do |runner_method|
|
|
47
|
-
@runners[runner_name.to_sym][:class_methods][runner_method] = {
|
|
48
|
-
args: loaded_runner.instance_method(runner_method).parameters
|
|
49
|
-
}
|
|
50
|
-
end
|
|
33
|
+
def build_runner_entry(runner_name, runner_class, loaded_runner, file)
|
|
34
|
+
entry = {
|
|
35
|
+
extension: lex_class.to_s.downcase,
|
|
36
|
+
extension_name: extension_name,
|
|
37
|
+
extension_class: lex_class,
|
|
38
|
+
runner_name: runner_name,
|
|
39
|
+
runner_class: runner_class,
|
|
40
|
+
runner_module: loaded_runner,
|
|
41
|
+
runner_path: file,
|
|
42
|
+
class_methods: {}
|
|
43
|
+
}
|
|
44
|
+
entry[:scheduled_tasks] = loaded_runner.scheduled_tasks if loaded_runner.method_defined?(:scheduled_tasks)
|
|
45
|
+
entry[:trigger_words] = loaded_runner.trigger_words if loaded_runner.respond_to?(:trigger_words)
|
|
46
|
+
entry[:desc] = settings[:runners][runner_name.to_sym][:desc] if settings.key?(:runners) && settings[:runners].key?(runner_name.to_sym)
|
|
47
|
+
entry
|
|
48
|
+
end
|
|
51
49
|
|
|
52
|
-
|
|
53
|
-
|
|
50
|
+
def populate_runner_methods(runner_name, loaded_runner)
|
|
51
|
+
loaded_runner.public_instance_methods(false).each do |runner_method|
|
|
52
|
+
@runners[runner_name.to_sym][:class_methods][runner_method] = {
|
|
53
|
+
args: loaded_runner.instance_method(runner_method).parameters
|
|
54
|
+
}
|
|
55
|
+
end
|
|
56
|
+
loaded_runner.methods(false).each do |runner_method|
|
|
57
|
+
next if %i[scheduled_tasks runner_description].include?(runner_method)
|
|
54
58
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
end
|
|
59
|
+
@runners[runner_name.to_sym][:class_methods][runner_method] = {
|
|
60
|
+
args: loaded_runner.method(runner_method).parameters
|
|
61
|
+
}
|
|
59
62
|
end
|
|
60
63
|
end
|
|
61
64
|
|
|
@@ -120,6 +120,10 @@ module Legion
|
|
|
120
120
|
true
|
|
121
121
|
end
|
|
122
122
|
|
|
123
|
+
def trigger_words
|
|
124
|
+
[]
|
|
125
|
+
end
|
|
126
|
+
|
|
123
127
|
# Auto-generate AMQP message classes for each runner method that has a definition.
|
|
124
128
|
# Explicit Messages::* classes in the transport directory take precedence.
|
|
125
129
|
# Runs after build_runners so definitions are populated.
|
data/lib/legion/service.rb
CHANGED
|
@@ -935,6 +935,13 @@ module Legion
|
|
|
935
935
|
require 'legion/tools'
|
|
936
936
|
Legion::Tools.register_all
|
|
937
937
|
Legion::Tools::Discovery.discover_and_register
|
|
938
|
+
future = Legion::Tools::TriggerIndex.build_async!
|
|
939
|
+
if future.respond_to?(:rescue)
|
|
940
|
+
@trigger_index_build_future = future.rescue do |e|
|
|
941
|
+
handle_exception(e, level: :warn, operation: 'service.register_core_tools.trigger_index_build')
|
|
942
|
+
nil
|
|
943
|
+
end
|
|
944
|
+
end
|
|
938
945
|
Legion::Tools::EmbeddingCache.setup
|
|
939
946
|
|
|
940
947
|
log.info(
|
data/lib/legion/tools/base.rb
CHANGED
|
@@ -147,14 +147,15 @@ module Legion
|
|
|
147
147
|
ext_name = derive_extension_name(ext)
|
|
148
148
|
runner_snake = derive_runner_snake(runner_mod)
|
|
149
149
|
{
|
|
150
|
-
tool_name:
|
|
151
|
-
description:
|
|
152
|
-
input_schema:
|
|
153
|
-
mcp_category:
|
|
154
|
-
mcp_tier:
|
|
155
|
-
deferred:
|
|
156
|
-
ext_name:
|
|
157
|
-
runner_snake:
|
|
150
|
+
tool_name: defn&.dig(:mcp_prefix) || "legion-#{ext_name}-#{runner_snake}-#{func_name}",
|
|
151
|
+
description: meta[:desc] || defn&.dig(:desc) || "#{ext_name}##{func_name}",
|
|
152
|
+
input_schema: normalize_schema(meta[:options]),
|
|
153
|
+
mcp_category: defn&.dig(:mcp_category),
|
|
154
|
+
mcp_tier: defn&.dig(:mcp_tier),
|
|
155
|
+
deferred: deferred,
|
|
156
|
+
ext_name: ext_name,
|
|
157
|
+
runner_snake: runner_snake,
|
|
158
|
+
trigger_words: merge_trigger_words(ext, runner_mod)
|
|
158
159
|
}
|
|
159
160
|
end
|
|
160
161
|
|
|
@@ -168,6 +169,7 @@ module Legion
|
|
|
168
169
|
runner(attrs[:runner_snake])
|
|
169
170
|
mcp_category(attrs[:mcp_category]) if attrs[:mcp_category]
|
|
170
171
|
mcp_tier(attrs[:mcp_tier]) if attrs[:mcp_tier]
|
|
172
|
+
trigger_words(attrs[:trigger_words])
|
|
171
173
|
|
|
172
174
|
define_singleton_method(:call) do |**params|
|
|
173
175
|
if runner_ref.respond_to?(func_ref)
|
|
@@ -184,6 +186,12 @@ module Legion
|
|
|
184
186
|
end
|
|
185
187
|
end
|
|
186
188
|
|
|
189
|
+
def merge_trigger_words(ext, runner_mod)
|
|
190
|
+
ext_words = ext.respond_to?(:trigger_words) ? Array(ext.trigger_words) : []
|
|
191
|
+
runner_words = runner_mod.respond_to?(:trigger_words) ? Array(runner_mod.trigger_words) : []
|
|
192
|
+
(ext_words + runner_words).uniq
|
|
193
|
+
end
|
|
194
|
+
|
|
187
195
|
def derive_runner_snake(runner_mod)
|
|
188
196
|
mod_name = runner_mod.name
|
|
189
197
|
return 'unknown' unless mod_name
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Tools
|
|
5
|
+
module TriggerIndex
|
|
6
|
+
@index = if defined?(Concurrent::Map)
|
|
7
|
+
Concurrent::Map.new
|
|
8
|
+
else
|
|
9
|
+
{}
|
|
10
|
+
end
|
|
11
|
+
@mutex = Mutex.new unless defined?(Concurrent::Map)
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def build_from_registry
|
|
15
|
+
clear
|
|
16
|
+
Registry.all_tools.each do |tool_class|
|
|
17
|
+
words = Array(tool_class.trigger_words)
|
|
18
|
+
next if words.empty?
|
|
19
|
+
|
|
20
|
+
normalized = words.flat_map { |w| w.downcase.gsub(/[^a-z ]/, ' ').split }.uniq
|
|
21
|
+
normalized.each { |word| add_tool_for_word(word, tool_class) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def build_async!
|
|
26
|
+
if defined?(Concurrent::Promises)
|
|
27
|
+
Concurrent::Promises.future { build_from_registry }
|
|
28
|
+
else
|
|
29
|
+
build_from_registry
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def match(word_set)
|
|
34
|
+
matched = Set.new
|
|
35
|
+
per_word = {}
|
|
36
|
+
word_set.each do |word|
|
|
37
|
+
normalized = word.to_s.downcase.gsub(/[^a-z ]/, ' ').strip
|
|
38
|
+
next if normalized.empty?
|
|
39
|
+
|
|
40
|
+
tools = read_word(normalized)
|
|
41
|
+
next unless tools
|
|
42
|
+
|
|
43
|
+
per_word[normalized] = tools
|
|
44
|
+
matched.merge(tools)
|
|
45
|
+
end
|
|
46
|
+
[matched, per_word]
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def empty?
|
|
50
|
+
if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map)
|
|
51
|
+
@index.each_pair.none?
|
|
52
|
+
else
|
|
53
|
+
@index.empty?
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def size
|
|
58
|
+
if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map)
|
|
59
|
+
count = 0
|
|
60
|
+
@index.each_pair { count += 1 }
|
|
61
|
+
count
|
|
62
|
+
else
|
|
63
|
+
@index.size
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def clear
|
|
68
|
+
if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map)
|
|
69
|
+
@index = Concurrent::Map.new
|
|
70
|
+
else
|
|
71
|
+
@mutex.synchronize { @index = {} }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
def add_tool_for_word(word, tool_class)
|
|
78
|
+
if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map)
|
|
79
|
+
@index.compute(word) { |existing| ((existing || Set.new) + Set[tool_class]).freeze }
|
|
80
|
+
else
|
|
81
|
+
@mutex.synchronize do
|
|
82
|
+
@index[word] ||= Set.new
|
|
83
|
+
@index[word] = (@index[word] + Set[tool_class]).freeze
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def read_word(word)
|
|
89
|
+
if defined?(Concurrent::Map) && @index.is_a?(Concurrent::Map)
|
|
90
|
+
@index[word]
|
|
91
|
+
else
|
|
92
|
+
@mutex&.synchronize { @index[word] }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
data/lib/legion/tools.rb
CHANGED
|
@@ -29,7 +29,9 @@ require_relative 'tools/registry'
|
|
|
29
29
|
require_relative 'tools/base'
|
|
30
30
|
require_relative 'tools/discovery'
|
|
31
31
|
require_relative 'tools/embedding_cache'
|
|
32
|
+
require_relative 'tools/trigger_index'
|
|
32
33
|
|
|
33
34
|
Dir[File.join(__dir__, 'tools', '*.rb')].each do |f|
|
|
34
|
-
require f unless f.end_with?('/base.rb', '/registry.rb', '/discovery.rb', '/embedding_cache.rb'
|
|
35
|
+
require f unless f.end_with?('/base.rb', '/registry.rb', '/discovery.rb', '/embedding_cache.rb',
|
|
36
|
+
'/trigger_index.rb')
|
|
35
37
|
end
|
data/lib/legion/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: legionio
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.7.
|
|
4
|
+
version: 1.7.37
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -901,6 +901,7 @@ files:
|
|
|
901
901
|
- lib/legion/tools/embedding_cache/migrations/001_create_tool_embedding_cache.rb
|
|
902
902
|
- lib/legion/tools/registry.rb
|
|
903
903
|
- lib/legion/tools/status.rb
|
|
904
|
+
- lib/legion/tools/trigger_index.rb
|
|
904
905
|
- lib/legion/trace_search.rb
|
|
905
906
|
- lib/legion/trigger.rb
|
|
906
907
|
- lib/legion/trigger/envelope.rb
|