legionio 1.6.13 → 1.6.18
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/.github/workflows/ci-cd.yml +0 -27
- data/CHANGELOG.md +50 -0
- data/lib/legion/cli/absorb_command.rb +79 -0
- data/lib/legion/cli/chat/tools/run_command.rb +15 -6
- data/lib/legion/cli/check_command.rb +38 -0
- data/lib/legion/cli/config_command.rb +2 -1
- data/lib/legion/cli/connection.rb +1 -0
- data/lib/legion/cli/doctor_command.rb +8 -1
- data/lib/legion/cli/generate_command.rb +76 -0
- data/lib/legion/cli/image_command.rb +15 -1
- data/lib/legion/cli/llm_command.rb +20 -2
- data/lib/legion/cli/trace_command.rb +24 -2
- data/lib/legion/cli.rb +5 -1
- data/lib/legion/compliance.rb +59 -3
- data/lib/legion/extensions/absorbers/base.rb +119 -0
- data/lib/legion/extensions/absorbers/matchers/base.rb +41 -0
- data/lib/legion/extensions/absorbers/matchers/url.rb +65 -0
- data/lib/legion/extensions/absorbers/pattern_matcher.rb +52 -0
- data/lib/legion/extensions/absorbers.rb +13 -0
- data/lib/legion/extensions/actors/absorber_dispatch.rb +58 -0
- data/lib/legion/extensions/builders/absorbers.rb +50 -0
- data/lib/legion/extensions/capability.rb +16 -0
- data/lib/legion/extensions/core.rb +4 -0
- data/lib/legion/extensions.rb +20 -0
- data/lib/legion/service.rb +10 -0
- data/lib/legion/version.rb +1 -1
- metadata +9 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5796d0724836fec4f8426fa337d5c6de420fbdf81c94fa9d070ca2a1a6abd9c7
|
|
4
|
+
data.tar.gz: bbaa73b5ee1d36c1c8b7cb9536d1c7e4144464cacfe68c1b4fee55b9bfea08df
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 2c1a641e20d067fddcf0d949c3d2f11bbbcca141783b20e581f361bb916821c07e3538037e00c21f7866a12399d3a2c3ce56ad040f8af5713ff2969ea4ee1463
|
|
7
|
+
data.tar.gz: 568b3efc39291f9e2593b373eb3009db4d708fc7030b38237924d52acf2933c5a64e0aafb4e39265fe8997a7fd7a66aded647d969a4dcb3a8af1e3207afe0c29
|
data/.github/workflows/ci-cd.yml
CHANGED
|
@@ -4,33 +4,6 @@ on:
|
|
|
4
4
|
branches: [main]
|
|
5
5
|
|
|
6
6
|
jobs:
|
|
7
|
-
test:
|
|
8
|
-
name: Test
|
|
9
|
-
runs-on: ubuntu-latest
|
|
10
|
-
services:
|
|
11
|
-
rabbitmq:
|
|
12
|
-
image: rabbitmq:3.13-management
|
|
13
|
-
ports: ['5672:5672']
|
|
14
|
-
postgres:
|
|
15
|
-
image: postgres:16
|
|
16
|
-
env:
|
|
17
|
-
POSTGRES_PASSWORD: test
|
|
18
|
-
POSTGRES_DB: legion_test
|
|
19
|
-
ports: ['5432:5432']
|
|
20
|
-
options: >-
|
|
21
|
-
--health-cmd pg_isready
|
|
22
|
-
--health-interval 10s
|
|
23
|
-
--health-timeout 5s
|
|
24
|
-
--health-retries 5
|
|
25
|
-
steps:
|
|
26
|
-
- uses: actions/checkout@v4
|
|
27
|
-
- uses: ruby/setup-ruby@v1
|
|
28
|
-
with:
|
|
29
|
-
ruby-version: '3.4'
|
|
30
|
-
bundler-cache: true
|
|
31
|
-
- run: bundle install && bundle exec rspec
|
|
32
|
-
- run: bundle exec rubocop
|
|
33
|
-
|
|
34
7
|
helm-lint:
|
|
35
8
|
name: Helm Lint
|
|
36
9
|
runs-on: ubuntu-latest
|
data/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [1.6.18] - 2026-03-27
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- `legionio pipeline image analyze`: `call_llm` no longer passes unsupported `messages:` keyword to `Legion::LLM.chat`; now creates a chat object and sends multimodal content via `chat.ask`, returning a plain hash with `:content` and `:usage` keys
|
|
9
|
+
- `legionio ai trace search/summarize`: both commands now call `setup_connection` before invoking `TraceSearch`, ensuring `Legion::LLM` is booted so `TraceSearch.generate_filter` can use structured LLM output instead of returning "no filter generated"; added `class_option :config_dir` and `class_option :verbose` to `TraceCommand`
|
|
10
|
+
|
|
11
|
+
## [1.6.17] - 2026-03-27
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- `legionio check`: `resolve_secrets!` is now called after a successful crypt check so `lease://`, `vault://`, and `env://` credential URIs are resolved before transport/data checks attempt to connect
|
|
15
|
+
- `legionio check transport`: raises an early descriptive error when transport credentials are still unresolved URI references (Vault lease pending), instead of failing with a confusing connection error
|
|
16
|
+
- `legionio check data`: raises an early descriptive error when database credentials are still unresolved URI references (Vault lease pending)
|
|
17
|
+
- `legionio llm status/providers/models`: `boot_llm_settings` now calls `resolve_secrets!` so `env://` and `vault://` API key references are resolved before provider enabled-state is evaluated
|
|
18
|
+
- `legionio llm providers`: providers with unresolved credential URIs are now shown as `deferred (credentials pending Vault)` in yellow instead of incorrectly `disabled`
|
|
19
|
+
- `Connection.ensure_settings`: calls `resolve_secrets!` after loading settings so `env://` references are resolved in all CLI commands that use the lazy connection manager
|
|
20
|
+
|
|
21
|
+
## [1.6.16] - 2026-03-27
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- `config validate` transport host check now reads from `transport.connection.host` instead of `transport.host` (correct config nesting)
|
|
25
|
+
- `doctor diagnose` now loads settings via `Connection.ensure_settings` before running checks, so cache/database/vault/extensions checks no longer skip due to `Legion::Settings` being undefined; also adds `ensure Connection.shutdown` for clean teardown
|
|
26
|
+
|
|
27
|
+
## [1.6.15] - 2026-03-27
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- Absorbers: new LEX component type for pattern-matched content acquisition
|
|
31
|
+
- `Absorbers::Base` class with `pattern`/`description` DSL and knowledge helpers (`absorb_to_knowledge`, `absorb_raw`, `translate`, `report_progress`)
|
|
32
|
+
- `Absorbers::Matchers::Base` auto-registering matcher interface with `Matchers::Url` for URL glob matching
|
|
33
|
+
- `Absorbers::PatternMatcher` for thread-safe input-to-absorber resolution with priority-based dispatch
|
|
34
|
+
- `Builders::Absorbers` for auto-discovery of absorber classes during extension boot
|
|
35
|
+
- `Capability.from_absorber` factory method for Capability Registry integration
|
|
36
|
+
- `AbsorberDispatch` module for pattern resolution and handler execution
|
|
37
|
+
- `legionio absorb` CLI command with `url`, `list`, and `resolve` subcommands
|
|
38
|
+
- `legionio dev generate absorber` scaffolding template
|
|
39
|
+
|
|
40
|
+
## [1.6.14] - 2026-03-27
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- `Legion::Compliance` module rewritten with DEFAULTS hash, `merge_settings` registration, and clean API
|
|
44
|
+
- `Compliance.setup` registers max-classification defaults: PHI, PCI, PII, FedRAMP all enabled by default
|
|
45
|
+
- `Compliance.enabled?`, `.phi_enabled?`, `.pci_enabled?`, `.pii_enabled?`, `.fedramp_enabled?` convenience methods
|
|
46
|
+
- `Compliance.classification_level` returns `'confidential'` by default (highest level)
|
|
47
|
+
- `Compliance.profile` returns a hash with all compliance flags for downstream consumers
|
|
48
|
+
- `setup_compliance` wired into Service boot sequence after settings load
|
|
49
|
+
- Compliance profile spec (8 examples)
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
- `Compliance.phi_enabled?` now uses `Settings.dig(:compliance, :phi_enabled)` instead of chaining `[]` calls
|
|
53
|
+
- Existing PhiTag and PhiAccessLog specs updated to use `merge_settings` instead of stubbing `Settings.[]`
|
|
54
|
+
|
|
5
55
|
## [1.6.13] - 2026-03-27
|
|
6
56
|
|
|
7
57
|
### Added
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'legion/extensions/absorbers'
|
|
4
|
+
require 'legion/extensions/absorbers/pattern_matcher'
|
|
5
|
+
require 'legion/extensions/actors/absorber_dispatch'
|
|
6
|
+
|
|
7
|
+
module Legion
|
|
8
|
+
module CLI
|
|
9
|
+
class AbsorbCommand < Thor
|
|
10
|
+
def self.exit_on_failure?
|
|
11
|
+
true
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
|
|
15
|
+
class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
|
|
16
|
+
|
|
17
|
+
desc 'url URL', 'Absorb content from a URL'
|
|
18
|
+
option :scope, type: :string, default: 'global', desc: 'Knowledge scope (global/local/all)'
|
|
19
|
+
def url(input_url)
|
|
20
|
+
Connection.ensure_settings
|
|
21
|
+
out = formatter
|
|
22
|
+
result = Legion::Extensions::Actors::AbsorberDispatch.dispatch(
|
|
23
|
+
input: input_url,
|
|
24
|
+
context: { scope: options[:scope]&.to_sym }
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
if options[:json]
|
|
28
|
+
out.json(result)
|
|
29
|
+
elsif result[:success]
|
|
30
|
+
out.success("Absorbed: #{input_url}")
|
|
31
|
+
out.detail(absorber: result[:absorber], job_id: result[:job_id])
|
|
32
|
+
else
|
|
33
|
+
out.warn("Failed: #{result[:error]}")
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
desc 'list', 'List registered absorber patterns'
|
|
38
|
+
def list
|
|
39
|
+
Connection.ensure_settings
|
|
40
|
+
out = formatter
|
|
41
|
+
patterns = Legion::Extensions::Absorbers::PatternMatcher.list
|
|
42
|
+
|
|
43
|
+
if options[:json]
|
|
44
|
+
out.json(patterns.map { |p| { type: p[:type], value: p[:value], description: p[:description] } })
|
|
45
|
+
elsif patterns.empty?
|
|
46
|
+
out.warn('No absorbers registered')
|
|
47
|
+
else
|
|
48
|
+
headers = %w[Type Pattern Description]
|
|
49
|
+
rows = patterns.map do |p|
|
|
50
|
+
[p[:type].to_s, p[:value], p[:description] || '']
|
|
51
|
+
end
|
|
52
|
+
out.header('Registered Absorbers')
|
|
53
|
+
out.table(headers, rows)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
desc 'resolve URL', 'Show which absorber would handle a URL (dry run)'
|
|
58
|
+
def resolve(input_url)
|
|
59
|
+
Connection.ensure_settings
|
|
60
|
+
out = formatter
|
|
61
|
+
absorber = Legion::Extensions::Absorbers::PatternMatcher.resolve(input_url)
|
|
62
|
+
|
|
63
|
+
if options[:json]
|
|
64
|
+
out.json({ input: input_url, absorber: absorber&.name, match: !absorber.nil? })
|
|
65
|
+
elsif absorber
|
|
66
|
+
out.success("#{input_url} -> #{absorber.name}")
|
|
67
|
+
else
|
|
68
|
+
out.warn("No absorber registered for: #{input_url}")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
no_commands do
|
|
73
|
+
def formatter
|
|
74
|
+
@formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -18,9 +18,20 @@ module Legion
|
|
|
18
18
|
def execute(command:, timeout: 120, working_directory: nil)
|
|
19
19
|
dir = working_directory ? File.expand_path(working_directory) : Dir.pwd
|
|
20
20
|
|
|
21
|
-
stdout, stderr, status =
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
stdout, stderr, status = Open3.popen3(command, chdir: dir) do |stdin, out, err, wait_thr|
|
|
22
|
+
stdin.close
|
|
23
|
+
out_reader = Thread.new { out.read }
|
|
24
|
+
err_reader = Thread.new { err.read }
|
|
25
|
+
|
|
26
|
+
unless wait_thr.join(timeout)
|
|
27
|
+
::Process.kill('TERM', wait_thr.pid)
|
|
28
|
+
wait_thr.join(5) || ::Process.kill('KILL', wait_thr.pid)
|
|
29
|
+
out_reader.kill
|
|
30
|
+
err_reader.kill
|
|
31
|
+
raise ::Timeout::Error, "command timed out after #{timeout}s"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
[out_reader.value, err_reader.value, wait_thr.value]
|
|
24
35
|
end
|
|
25
36
|
|
|
26
37
|
output = String.new
|
|
@@ -29,11 +40,9 @@ module Legion
|
|
|
29
40
|
output << stderr unless stderr.empty?
|
|
30
41
|
output << "\n[exit code: #{status.exitstatus}]"
|
|
31
42
|
output
|
|
32
|
-
rescue ::Timeout::Error
|
|
33
|
-
Legion::Logging.warn("RunCommand#execute timed out after #{timeout}s for command #{command}: #{e.message}") if defined?(Legion::Logging)
|
|
43
|
+
rescue ::Timeout::Error
|
|
34
44
|
"[command timed out after #{timeout}s]: #{command}"
|
|
35
45
|
rescue StandardError => e
|
|
36
|
-
Legion::Logging.warn("RunCommand#execute failed for command #{command}: #{e.message}") if defined?(Legion::Logging)
|
|
37
46
|
"Error executing command: #{e.message}"
|
|
38
47
|
end
|
|
39
48
|
end
|
|
@@ -107,6 +107,7 @@ module Legion
|
|
|
107
107
|
|
|
108
108
|
results[name] = run_check(name, options)
|
|
109
109
|
started << name if results[name][:status] == 'pass'
|
|
110
|
+
resolve_secrets_after_crypt(name, results[name])
|
|
110
111
|
print_result(formatter, name, results[name], options) unless options[:json]
|
|
111
112
|
end
|
|
112
113
|
|
|
@@ -123,6 +124,15 @@ module Legion
|
|
|
123
124
|
Legion::Logging.setup(log_level: log_level, level: log_level, trace: false)
|
|
124
125
|
end
|
|
125
126
|
|
|
127
|
+
def resolve_secrets_after_crypt(name, result)
|
|
128
|
+
return unless name == :crypt && result[:status] == 'pass'
|
|
129
|
+
return unless Legion::Settings.respond_to?(:resolve_secrets!)
|
|
130
|
+
|
|
131
|
+
Legion::Settings.resolve_secrets!
|
|
132
|
+
rescue StandardError => e
|
|
133
|
+
Legion::Logging.warn("Check#run secret resolution failed: #{e.message}") if defined?(Legion::Logging)
|
|
134
|
+
end
|
|
135
|
+
|
|
126
136
|
def run_check(name, options)
|
|
127
137
|
start = ::Process.clock_gettime(::Process::CLOCK_MONOTONIC)
|
|
128
138
|
detail = send(:"check_#{name}", options)
|
|
@@ -151,6 +161,15 @@ module Legion
|
|
|
151
161
|
def check_transport(_options)
|
|
152
162
|
require 'legion/transport'
|
|
153
163
|
Legion::Settings.merge_settings('transport', Legion::Transport::Settings.default)
|
|
164
|
+
conn = Legion::Settings[:transport][:connection] || {}
|
|
165
|
+
user = conn[:user].to_s
|
|
166
|
+
pass = conn[:password].to_s
|
|
167
|
+
if user.start_with?('lease://', 'vault://') || pass.start_with?('lease://', 'vault://')
|
|
168
|
+
scheme = user[%r{\A[^:]+://}]
|
|
169
|
+
redacted = scheme ? "#{scheme}..." : '(unresolved)'
|
|
170
|
+
raise "credentials not resolved (Vault lease pending) — user: #{redacted}"
|
|
171
|
+
end
|
|
172
|
+
|
|
154
173
|
Legion::Transport::Connection.setup
|
|
155
174
|
if Legion::Transport::Connection.lite_mode?
|
|
156
175
|
'InProcess (lite mode)'
|
|
@@ -189,6 +208,11 @@ module Legion
|
|
|
189
208
|
def check_data(_options)
|
|
190
209
|
require 'legion/data'
|
|
191
210
|
Legion::Settings.merge_settings(:data, Legion::Data::Settings.default)
|
|
211
|
+
creds = Legion::Settings[:data][:creds] || Legion::Settings[:data] || {}
|
|
212
|
+
db_user = (creds[:user] || creds[:username]).to_s
|
|
213
|
+
db_pass = creds[:password].to_s
|
|
214
|
+
raise_if_unresolved_data_creds(db_user, db_pass)
|
|
215
|
+
|
|
192
216
|
Legion::Data.setup
|
|
193
217
|
ds = Legion::Settings[:data] || {}
|
|
194
218
|
adapter = ds[:adapter] || 'sqlite'
|
|
@@ -203,6 +227,20 @@ module Legion
|
|
|
203
227
|
end
|
|
204
228
|
end
|
|
205
229
|
|
|
230
|
+
def raise_if_unresolved_data_creds(db_user, db_pass)
|
|
231
|
+
return unless db_user.start_with?('lease://', 'vault://') || db_pass.start_with?('lease://', 'vault://')
|
|
232
|
+
|
|
233
|
+
unresolved_fields = []
|
|
234
|
+
unresolved_fields << 'user' if db_user.start_with?('lease://', 'vault://')
|
|
235
|
+
unresolved_fields << 'password' if db_pass.start_with?('lease://', 'vault://')
|
|
236
|
+
scheme_hints = []
|
|
237
|
+
scheme_hints << 'lease://...' if db_user.start_with?('lease://') || db_pass.start_with?('lease://')
|
|
238
|
+
scheme_hints << 'vault://...' if db_user.start_with?('vault://') || db_pass.start_with?('vault://')
|
|
239
|
+
details = "unresolved fields: #{unresolved_fields.join(', ')}"
|
|
240
|
+
details += " (#{scheme_hints.join(', ')})" unless scheme_hints.empty?
|
|
241
|
+
raise "credentials not resolved (Vault lease pending) — #{details}"
|
|
242
|
+
end
|
|
243
|
+
|
|
206
244
|
def check_data_local(_options)
|
|
207
245
|
if defined?(Legion::Data::Local) && Legion::Data::Local.respond_to?(:setup)
|
|
208
246
|
Legion::Data::Local.setup unless Legion::Data::Local.respond_to?(:connected?) && Legion::Data::Local.connected?
|
|
@@ -119,7 +119,8 @@ module Legion
|
|
|
119
119
|
# Check transport config
|
|
120
120
|
if Connection.settings?
|
|
121
121
|
transport = Legion::Settings[:transport] || {}
|
|
122
|
-
|
|
122
|
+
transport_host = transport.dig(:connection, :host)
|
|
123
|
+
warnings << 'Transport host not configured (RabbitMQ will use default localhost)' if transport_host.nil? || transport_host.to_s.empty?
|
|
123
124
|
|
|
124
125
|
# Check data config
|
|
125
126
|
data = Legion::Settings[:data] || {}
|
|
@@ -42,7 +42,12 @@ module Legion
|
|
|
42
42
|
desc 'diagnose', 'Check environment health and suggest fixes'
|
|
43
43
|
method_option :fix, type: :boolean, default: false, desc: 'Auto-fix issues where possible'
|
|
44
44
|
def diagnose
|
|
45
|
-
out
|
|
45
|
+
out = formatter
|
|
46
|
+
begin
|
|
47
|
+
Connection.ensure_settings
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
Legion::Logging.debug("Doctor#diagnose settings load failed: #{e.message}") if defined?(Legion::Logging)
|
|
50
|
+
end
|
|
46
51
|
results = run_all_checks
|
|
47
52
|
|
|
48
53
|
if options[:json]
|
|
@@ -54,6 +59,8 @@ module Legion
|
|
|
54
59
|
auto_fix(results) if options[:fix]
|
|
55
60
|
|
|
56
61
|
exit(1) if results.any?(&:fail?)
|
|
62
|
+
ensure
|
|
63
|
+
Connection.shutdown
|
|
57
64
|
end
|
|
58
65
|
|
|
59
66
|
default_task :diagnose
|
|
@@ -125,6 +125,30 @@ module Legion
|
|
|
125
125
|
out.success("Created #{message_path}")
|
|
126
126
|
end
|
|
127
127
|
|
|
128
|
+
desc 'absorber NAME', 'Add an absorber to the current LEX'
|
|
129
|
+
option :url_pattern, type: :string, default: 'example.com/path/*', desc: 'URL pattern to match'
|
|
130
|
+
def absorber(name)
|
|
131
|
+
out = formatter
|
|
132
|
+
lex = detect_lex(out)
|
|
133
|
+
|
|
134
|
+
snake = name.downcase.gsub(/[^a-z0-9]/, '_')
|
|
135
|
+
class_name = snake.split('_').map(&:capitalize).join
|
|
136
|
+
lex_class = lex.split('_').map(&:capitalize).join
|
|
137
|
+
url_pat = options[:url_pattern]
|
|
138
|
+
|
|
139
|
+
absorber_path = "lib/legion/extensions/#{lex}/absorbers/#{snake}.rb"
|
|
140
|
+
spec_path = "spec/absorbers/#{snake}_spec.rb"
|
|
141
|
+
|
|
142
|
+
ensure_dir(File.dirname(absorber_path))
|
|
143
|
+
ensure_dir(File.dirname(spec_path))
|
|
144
|
+
|
|
145
|
+
File.write(absorber_path, absorber_template(lex_class, class_name, url_pat))
|
|
146
|
+
File.write(spec_path, absorber_spec_template(lex_class, class_name, url_pat))
|
|
147
|
+
|
|
148
|
+
out.success("Created #{absorber_path}")
|
|
149
|
+
out.success("Created #{spec_path}")
|
|
150
|
+
end
|
|
151
|
+
|
|
128
152
|
desc 'tool NAME', 'Add a chat tool to the current LEX'
|
|
129
153
|
def tool(name)
|
|
130
154
|
out = formatter
|
|
@@ -360,6 +384,58 @@ module Legion
|
|
|
360
384
|
end
|
|
361
385
|
RUBY
|
|
362
386
|
end
|
|
387
|
+
|
|
388
|
+
def absorber_template(lex_class, class_name, url_pat)
|
|
389
|
+
escaped_pat = url_pat.inspect
|
|
390
|
+
<<~RUBY
|
|
391
|
+
# frozen_string_literal: true
|
|
392
|
+
|
|
393
|
+
module Legion
|
|
394
|
+
module Extensions
|
|
395
|
+
module #{lex_class}
|
|
396
|
+
module Absorbers
|
|
397
|
+
class #{class_name} < Legion::Extensions::Absorbers::Base
|
|
398
|
+
pattern :url, #{escaped_pat}
|
|
399
|
+
description 'TODO: describe what this absorber handles'
|
|
400
|
+
|
|
401
|
+
def handle(url: nil, content: nil, metadata: {}, context: {})
|
|
402
|
+
report_progress(message: 'starting absorption')
|
|
403
|
+
|
|
404
|
+
# TODO: implement content acquisition and processing
|
|
405
|
+
# absorb_to_knowledge(content: text, tags: ['tag'])
|
|
406
|
+
|
|
407
|
+
report_progress(message: 'done', percent: 100)
|
|
408
|
+
{ success: true }
|
|
409
|
+
end
|
|
410
|
+
end
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
RUBY
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def absorber_spec_template(lex_class, class_name, url_pat)
|
|
419
|
+
test_url = url_pat.gsub('*', 'test')
|
|
420
|
+
<<~RUBY
|
|
421
|
+
# frozen_string_literal: true
|
|
422
|
+
|
|
423
|
+
RSpec.describe Legion::Extensions::#{lex_class}::Absorbers::#{class_name} do
|
|
424
|
+
describe '.patterns' do
|
|
425
|
+
it 'has registered patterns' do
|
|
426
|
+
expect(described_class.patterns).not_to be_empty
|
|
427
|
+
end
|
|
428
|
+
end
|
|
429
|
+
|
|
430
|
+
describe '#handle' do
|
|
431
|
+
it 'returns success' do
|
|
432
|
+
result = described_class.new.handle(url: 'https://#{test_url}')
|
|
433
|
+
expect(result[:success]).to be true
|
|
434
|
+
end
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
RUBY
|
|
438
|
+
end
|
|
363
439
|
end
|
|
364
440
|
end
|
|
365
441
|
end
|
|
@@ -136,12 +136,26 @@ module Legion
|
|
|
136
136
|
llm_kwargs[:model] = options[:model] if options[:model]
|
|
137
137
|
llm_kwargs[:provider] = options[:provider].to_sym if options[:provider]
|
|
138
138
|
|
|
139
|
-
Legion::LLM.chat(
|
|
139
|
+
chat = Legion::LLM.chat(**llm_kwargs)
|
|
140
|
+
user_msg = messages.first
|
|
141
|
+
response = chat.ask(user_msg[:content])
|
|
142
|
+
{ content: response.content, usage: extract_usage(response) }
|
|
140
143
|
rescue StandardError => e
|
|
141
144
|
out.error("LLM call failed: #{e.message}")
|
|
142
145
|
raise SystemExit, 1
|
|
143
146
|
end
|
|
144
147
|
|
|
148
|
+
def extract_usage(response)
|
|
149
|
+
return {} unless response.respond_to?(:usage) && response.usage
|
|
150
|
+
|
|
151
|
+
{
|
|
152
|
+
input_tokens: response.usage.input_tokens,
|
|
153
|
+
output_tokens: response.usage.output_tokens
|
|
154
|
+
}
|
|
155
|
+
rescue StandardError
|
|
156
|
+
{}
|
|
157
|
+
end
|
|
158
|
+
|
|
145
159
|
def render_response(out, response, meta)
|
|
146
160
|
content = response[:content].to_s
|
|
147
161
|
usage = response[:usage] || {}
|
|
@@ -84,6 +84,7 @@ module Legion
|
|
|
84
84
|
Connection.config_dir = options[:config_dir] if options[:config_dir]
|
|
85
85
|
Connection.log_level = options[:verbose] ? 'debug' : 'error'
|
|
86
86
|
Connection.ensure_settings
|
|
87
|
+
Legion::Settings.resolve_secrets! if Legion::Settings.respond_to?(:resolve_secrets!)
|
|
87
88
|
require 'legion/llm'
|
|
88
89
|
Legion::Settings.merge_settings(:llm, Legion::LLM::Settings.default)
|
|
89
90
|
end
|
|
@@ -121,15 +122,24 @@ module Legion
|
|
|
121
122
|
def collect_providers
|
|
122
123
|
providers_cfg = llm_settings[:providers] || {}
|
|
123
124
|
providers_cfg.map do |name, cfg|
|
|
125
|
+
enabled = cfg[:enabled] == true
|
|
124
126
|
{
|
|
125
127
|
name: name,
|
|
126
|
-
enabled:
|
|
128
|
+
enabled: enabled,
|
|
129
|
+
deferred: !enabled && unresolved_credentials?(cfg),
|
|
127
130
|
default_model: cfg[:default_model],
|
|
128
131
|
reachable: check_reachable(name, cfg)
|
|
129
132
|
}
|
|
130
133
|
end
|
|
131
134
|
end
|
|
132
135
|
|
|
136
|
+
def unresolved_credentials?(cfg)
|
|
137
|
+
%i[api_key secret_key bearer_token password].any? do |key|
|
|
138
|
+
val = cfg[key].to_s
|
|
139
|
+
val.start_with?('vault://', 'lease://', 'env://')
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
|
|
133
143
|
def check_reachable(name, cfg)
|
|
134
144
|
case name
|
|
135
145
|
when :ollama
|
|
@@ -293,11 +303,19 @@ module Legion
|
|
|
293
303
|
when false then 'enabled, unreachable'
|
|
294
304
|
else 'enabled'
|
|
295
305
|
end
|
|
306
|
+
elsif p[:deferred]
|
|
307
|
+
'deferred (credentials pending Vault)'
|
|
296
308
|
else
|
|
297
309
|
'disabled'
|
|
298
310
|
end
|
|
299
311
|
|
|
300
|
-
color = p[:enabled]
|
|
312
|
+
color = if p[:enabled]
|
|
313
|
+
:green
|
|
314
|
+
elsif p[:deferred]
|
|
315
|
+
:yellow
|
|
316
|
+
else
|
|
317
|
+
:muted
|
|
318
|
+
end
|
|
301
319
|
name_str = p[:name].to_s.ljust(12)
|
|
302
320
|
model_str = p[:default_model] ? " (#{p[:default_model]})" : ''
|
|
303
321
|
puts " #{out.colorize(name_str, :label)}#{out.colorize(status, color)}#{model_str}"
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require 'thor'
|
|
4
4
|
require 'legion/cli/output'
|
|
5
|
+
require 'legion/cli/connection'
|
|
5
6
|
|
|
6
7
|
module Legion
|
|
7
8
|
module CLI
|
|
@@ -12,12 +13,16 @@ module Legion
|
|
|
12
13
|
true
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
class_option :json,
|
|
16
|
-
class_option :no_color,
|
|
16
|
+
class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
|
|
17
|
+
class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
|
|
18
|
+
class_option :verbose, type: :boolean, default: false, aliases: ['-V'], desc: 'Verbose logging'
|
|
19
|
+
class_option :config_dir, type: :string, desc: 'Config directory path'
|
|
17
20
|
|
|
18
21
|
desc 'search QUERY', 'Search traces with natural language'
|
|
19
22
|
option :limit, type: :numeric, default: 50, desc: 'Max results to return'
|
|
20
23
|
def search(*query_parts)
|
|
24
|
+
return unless setup_connection
|
|
25
|
+
|
|
21
26
|
require 'legion/trace_search'
|
|
22
27
|
query = query_parts.join(' ')
|
|
23
28
|
out = formatter
|
|
@@ -38,10 +43,14 @@ module Legion
|
|
|
38
43
|
end
|
|
39
44
|
|
|
40
45
|
display_results(out, result)
|
|
46
|
+
ensure
|
|
47
|
+
Legion::CLI::Connection.shutdown
|
|
41
48
|
end
|
|
42
49
|
|
|
43
50
|
desc 'summarize QUERY', 'Show aggregate statistics for matching traces'
|
|
44
51
|
def summarize(*query_parts)
|
|
52
|
+
return unless setup_connection
|
|
53
|
+
|
|
45
54
|
require 'legion/trace_search'
|
|
46
55
|
query = query_parts.join(' ')
|
|
47
56
|
out = formatter
|
|
@@ -62,6 +71,8 @@ module Legion
|
|
|
62
71
|
end
|
|
63
72
|
|
|
64
73
|
display_summary(out, result)
|
|
74
|
+
ensure
|
|
75
|
+
Legion::CLI::Connection.shutdown
|
|
65
76
|
end
|
|
66
77
|
|
|
67
78
|
default_task :search
|
|
@@ -71,6 +82,17 @@ module Legion
|
|
|
71
82
|
@formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
|
|
72
83
|
end
|
|
73
84
|
|
|
85
|
+
def setup_connection
|
|
86
|
+
Legion::CLI::Connection.config_dir = options[:config_dir] if options[:config_dir]
|
|
87
|
+
Legion::CLI::Connection.log_level = options[:verbose] ? 'debug' : 'error'
|
|
88
|
+
Legion::CLI::Connection.ensure_llm
|
|
89
|
+
Legion::CLI::Connection.ensure_data
|
|
90
|
+
true
|
|
91
|
+
rescue CLI::Error => e
|
|
92
|
+
formatter.error("Setup failed: #{e.message}")
|
|
93
|
+
false
|
|
94
|
+
end
|
|
95
|
+
|
|
74
96
|
private
|
|
75
97
|
|
|
76
98
|
def display_results(out, result)
|
data/lib/legion/cli.rb
CHANGED
|
@@ -60,7 +60,8 @@ module Legion
|
|
|
60
60
|
autoload :Interactive, 'legion/cli/interactive'
|
|
61
61
|
autoload :Docs, 'legion/cli/docs_command'
|
|
62
62
|
autoload :Failover, 'legion/cli/failover_command'
|
|
63
|
-
autoload :
|
|
63
|
+
autoload :AbsorbCommand, 'legion/cli/absorb_command'
|
|
64
|
+
autoload :Apollo, 'legion/cli/apollo_command'
|
|
64
65
|
autoload :TraceCommand, 'legion/cli/trace_command'
|
|
65
66
|
autoload :Features, 'legion/cli/features_command'
|
|
66
67
|
autoload :Debug, 'legion/cli/debug_command'
|
|
@@ -276,6 +277,9 @@ module Legion
|
|
|
276
277
|
desc 'dev SUBCOMMAND', 'Generators, docs, marketplace, and shell completion'
|
|
277
278
|
subcommand 'dev', Legion::CLI::Groups::Dev
|
|
278
279
|
|
|
280
|
+
desc 'absorb SUBCOMMAND', 'Absorb content from external sources'
|
|
281
|
+
subcommand 'absorb', AbsorbCommand
|
|
282
|
+
|
|
279
283
|
desc 'broker SUBCOMMAND', 'RabbitMQ broker management (stats, cleanup)'
|
|
280
284
|
subcommand 'broker', Legion::CLI::Broker
|
|
281
285
|
|
data/lib/legion/compliance.rb
CHANGED
|
@@ -6,13 +6,69 @@ require 'legion/compliance/phi_erasure'
|
|
|
6
6
|
|
|
7
7
|
module Legion
|
|
8
8
|
module Compliance
|
|
9
|
+
DEFAULTS = {
|
|
10
|
+
enabled: true,
|
|
11
|
+
classification_level: 'confidential',
|
|
12
|
+
phi_enabled: true,
|
|
13
|
+
pci_enabled: true,
|
|
14
|
+
pii_enabled: true,
|
|
15
|
+
fedramp_enabled: true,
|
|
16
|
+
log_redaction: true,
|
|
17
|
+
cache_phi_max_ttl: 3600
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
9
20
|
class << self
|
|
21
|
+
def setup
|
|
22
|
+
return unless defined?(Legion::Settings)
|
|
23
|
+
|
|
24
|
+
Legion::Settings.merge_settings(:compliance, DEFAULTS)
|
|
25
|
+
Legion::Logging.info('[Compliance] max-classification profile active') if defined?(Legion::Logging)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def enabled?
|
|
29
|
+
setting(:enabled) == true
|
|
30
|
+
end
|
|
31
|
+
|
|
10
32
|
def phi_enabled?
|
|
11
|
-
|
|
33
|
+
setting(:phi_enabled) == true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def pci_enabled?
|
|
37
|
+
setting(:pci_enabled) == true
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def pii_enabled?
|
|
41
|
+
setting(:pii_enabled) == true
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def fedramp_enabled?
|
|
45
|
+
setting(:fedramp_enabled) == true
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def classification_level
|
|
49
|
+
setting(:classification_level) || 'confidential'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def profile
|
|
53
|
+
{
|
|
54
|
+
classification_level: classification_level,
|
|
55
|
+
phi: phi_enabled?,
|
|
56
|
+
pci: pci_enabled?,
|
|
57
|
+
pii: pii_enabled?,
|
|
58
|
+
fedramp: fedramp_enabled?,
|
|
59
|
+
log_redaction: setting(:log_redaction) == true,
|
|
60
|
+
cache_phi_max_ttl: setting(:cache_phi_max_ttl) || 3600
|
|
61
|
+
}
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def setting(key)
|
|
67
|
+
return nil unless defined?(Legion::Settings)
|
|
12
68
|
|
|
13
|
-
Legion::Settings
|
|
69
|
+
Legion::Settings.dig(:compliance, key)
|
|
14
70
|
rescue StandardError
|
|
15
|
-
|
|
71
|
+
nil
|
|
16
72
|
end
|
|
17
73
|
end
|
|
18
74
|
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Absorbers
|
|
6
|
+
class Base
|
|
7
|
+
attr_accessor :job_id, :runners
|
|
8
|
+
|
|
9
|
+
class << self
|
|
10
|
+
def pattern(type, value, priority: 100)
|
|
11
|
+
@patterns ||= []
|
|
12
|
+
@patterns << { type: type, value: value, priority: priority }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def patterns
|
|
16
|
+
@patterns || []
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def description(text = nil)
|
|
20
|
+
text ? @description = text : @description
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def handle(url: nil, content: nil, metadata: {}, context: {})
|
|
25
|
+
raise NotImplementedError, "#{self.class.name} must implement #handle"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def absorb_to_knowledge(content:, tags: [], scope: :global, **opts)
|
|
29
|
+
return fallback_absorb(:chunker, content, tags, scope, opts) unless chunker_available?
|
|
30
|
+
return fallback_absorb(:apollo, content, tags, scope, opts) unless apollo_available?
|
|
31
|
+
|
|
32
|
+
sections = [{ heading: opts.delete(:heading) || 'absorbed',
|
|
33
|
+
content: content,
|
|
34
|
+
section_path: opts.delete(:section_path) || 'absorbed',
|
|
35
|
+
source_file: opts.delete(:source_file) || 'absorber' }]
|
|
36
|
+
chunks = Legion::Extensions::Knowledge::Helpers::Chunker.chunk(sections: sections)
|
|
37
|
+
embeddings = fetch_embeddings(chunks)
|
|
38
|
+
ingest_chunks(chunks, embeddings, tags, scope, opts)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def absorb_raw(content:, tags: [], scope: :global, **)
|
|
42
|
+
if apollo_available?
|
|
43
|
+
Legion::Apollo.ingest(content: content, tags: Array(tags), scope: scope, **)
|
|
44
|
+
else
|
|
45
|
+
Legion::Logging.warn('absorb_raw: Apollo not available') if defined?(Legion::Logging)
|
|
46
|
+
{ success: false, error: :apollo_not_available }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def translate(source, type: :auto)
|
|
51
|
+
raise 'legion-data is required for translate — add it to your Gemfile' unless defined?(Legion::Data::Extract)
|
|
52
|
+
|
|
53
|
+
Legion::Data::Extract.extract(source, type: type)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def report_progress(message:, percent: nil)
|
|
57
|
+
return unless job_id
|
|
58
|
+
return unless defined?(Legion::Logging)
|
|
59
|
+
|
|
60
|
+
Legion::Logging.info("absorb[#{job_id}] #{"#{percent}% " if percent}#{message}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def chunker_available?
|
|
66
|
+
defined?(Legion::Extensions::Knowledge::Helpers::Chunker)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def apollo_available?
|
|
70
|
+
defined?(Legion::Apollo) &&
|
|
71
|
+
Legion::Apollo.respond_to?(:ingest) &&
|
|
72
|
+
(!Legion::Apollo.respond_to?(:started?) || Legion::Apollo.started?)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def fallback_absorb(reason, content, tags, scope, opts)
|
|
76
|
+
if defined?(Legion::Logging)
|
|
77
|
+
label = reason == :chunker ? 'lex-knowledge not available' : 'Apollo not available'
|
|
78
|
+
Legion::Logging.warn("absorb_to_knowledge: #{label}, falling back to absorb_raw")
|
|
79
|
+
end
|
|
80
|
+
absorb_raw(content: content, tags: tags, scope: scope, **opts)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fetch_embeddings(chunks)
|
|
84
|
+
return [] unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:embed_batch)
|
|
85
|
+
|
|
86
|
+
Legion::LLM.embed_batch(chunks.map { |c| c[:content] })
|
|
87
|
+
rescue StandardError
|
|
88
|
+
[]
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def ingest_chunks(chunks, embeddings, tags, scope, opts)
|
|
92
|
+
chunks.each_with_index do |chunk, idx|
|
|
93
|
+
vector = embeddings.is_a?(Array) ? embeddings.dig(idx, :vector) : nil
|
|
94
|
+
payload = build_chunk_payload(chunk, tags, opts)
|
|
95
|
+
payload[:embedding] = vector if vector
|
|
96
|
+
Legion::Apollo.ingest(content: payload[:content], tags: payload[:tags],
|
|
97
|
+
scope: scope, **payload.except(:content, :tags))
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def build_chunk_payload(chunk, tags, opts)
|
|
102
|
+
{
|
|
103
|
+
content: chunk[:content],
|
|
104
|
+
content_type: opts[:content_type] || 'absorbed_chunk',
|
|
105
|
+
content_hash: chunk[:content_hash],
|
|
106
|
+
tags: (Array(tags) + [chunk[:heading], 'absorbed']).compact.uniq,
|
|
107
|
+
metadata: {
|
|
108
|
+
source_file: chunk[:source_file],
|
|
109
|
+
heading: chunk[:heading],
|
|
110
|
+
section_path: chunk[:section_path],
|
|
111
|
+
chunk_index: chunk[:chunk_index],
|
|
112
|
+
token_count: chunk[:token_count]
|
|
113
|
+
}.merge(opts.fetch(:metadata, {}))
|
|
114
|
+
}
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Absorbers
|
|
6
|
+
module Matchers
|
|
7
|
+
class Base
|
|
8
|
+
@registry = {}
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
attr_reader :registry
|
|
12
|
+
|
|
13
|
+
def inherited(subclass)
|
|
14
|
+
super
|
|
15
|
+
TracePoint.new(:end) do |tp|
|
|
16
|
+
if tp.self == subclass
|
|
17
|
+
register(subclass) if subclass.respond_to?(:type) && subclass.type
|
|
18
|
+
tp.disable
|
|
19
|
+
end
|
|
20
|
+
end.enable
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def register(matcher_class)
|
|
24
|
+
@registry[matcher_class.type] = matcher_class
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def for_type(type)
|
|
28
|
+
@registry[type&.to_sym]
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def type = nil
|
|
32
|
+
|
|
33
|
+
def match?(_pattern, _input)
|
|
34
|
+
raise NotImplementedError, "#{name} must implement .match?"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'uri'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Absorbers
|
|
8
|
+
module Matchers
|
|
9
|
+
class Url < Base
|
|
10
|
+
def self.type = :url
|
|
11
|
+
|
|
12
|
+
def self.match?(pattern, input)
|
|
13
|
+
uri = parse_uri(input)
|
|
14
|
+
return false unless uri
|
|
15
|
+
|
|
16
|
+
host_pattern, path_pattern = split_pattern(pattern)
|
|
17
|
+
return false unless host_matches?(host_pattern, uri.host)
|
|
18
|
+
|
|
19
|
+
path_matches?(path_pattern || '**', uri.path)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def parse_uri(input)
|
|
26
|
+
str = input.to_s.strip
|
|
27
|
+
str = "https://#{str}" unless str.match?(%r{\A\w+://})
|
|
28
|
+
uri = URI.parse(str)
|
|
29
|
+
return nil unless uri.is_a?(URI::HTTP) && uri.host
|
|
30
|
+
|
|
31
|
+
uri
|
|
32
|
+
rescue URI::InvalidURIError
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def split_pattern(pattern)
|
|
37
|
+
clean = pattern.sub(%r{\A\w+://}, '')
|
|
38
|
+
parts = clean.split('/', 2)
|
|
39
|
+
[parts[0], parts[1]]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def host_matches?(pattern, host)
|
|
43
|
+
return false unless host
|
|
44
|
+
|
|
45
|
+
regex = Regexp.new(
|
|
46
|
+
"\\A#{Regexp.escape(pattern).gsub('\\*', '[^.]+')}\\z",
|
|
47
|
+
Regexp::IGNORECASE
|
|
48
|
+
)
|
|
49
|
+
regex.match?(host)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def path_matches?(pattern, path)
|
|
53
|
+
path = path.to_s.sub(%r{\A/}, '')
|
|
54
|
+
escaped = Regexp.escape(pattern)
|
|
55
|
+
.gsub('\\*\\*', '__.DOUBLE_STAR__.')
|
|
56
|
+
.gsub('\\*', '[^/]*')
|
|
57
|
+
.gsub('__.DOUBLE_STAR__.', '.*')
|
|
58
|
+
Regexp.new("\\A#{escaped}\\z").match?(path)
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Extensions
|
|
5
|
+
module Absorbers
|
|
6
|
+
module PatternMatcher
|
|
7
|
+
@registrations = []
|
|
8
|
+
@mutex = Mutex.new
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def register(absorber_class)
|
|
13
|
+
@mutex.synchronize do
|
|
14
|
+
absorber_class.patterns.each do |pat|
|
|
15
|
+
@registrations << {
|
|
16
|
+
type: pat[:type],
|
|
17
|
+
value: pat[:value],
|
|
18
|
+
priority: pat[:priority],
|
|
19
|
+
absorber_class: absorber_class,
|
|
20
|
+
description: absorber_class.description
|
|
21
|
+
}
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def resolve(input)
|
|
27
|
+
matches = @mutex.synchronize { @registrations.dup }.select do |reg|
|
|
28
|
+
matcher = Matchers::Base.for_type(reg[:type])
|
|
29
|
+
next false unless matcher
|
|
30
|
+
|
|
31
|
+
matcher.match?(reg[:value], input)
|
|
32
|
+
end
|
|
33
|
+
return nil if matches.empty?
|
|
34
|
+
|
|
35
|
+
matches.min_by { |m| [m[:priority], -m[:value].gsub('*', '').length] }&.dig(:absorber_class)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def list
|
|
39
|
+
@mutex.synchronize { @registrations.dup }
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def registrations
|
|
43
|
+
@mutex.synchronize { @registrations.dup }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def reset!
|
|
47
|
+
@mutex.synchronize { @registrations.clear }
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'absorbers/matchers/base'
|
|
4
|
+
require_relative 'absorbers/matchers/url'
|
|
5
|
+
require_relative 'absorbers/base'
|
|
6
|
+
require_relative 'absorbers/pattern_matcher'
|
|
7
|
+
|
|
8
|
+
module Legion
|
|
9
|
+
module Extensions
|
|
10
|
+
module Absorbers
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'securerandom'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Actors
|
|
8
|
+
module AbsorberDispatch
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def dispatch(input:, job_id: nil, context: {})
|
|
12
|
+
job_id ||= SecureRandom.hex(8)
|
|
13
|
+
absorber_class = Absorbers::PatternMatcher.resolve(input)
|
|
14
|
+
|
|
15
|
+
unless absorber_class
|
|
16
|
+
publish_event("absorb.failed.#{job_id}", job_id: job_id, error: 'no handler found for input')
|
|
17
|
+
return { success: false, error: 'no handler found for input', job_id: job_id }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
absorber = absorber_class.new
|
|
21
|
+
absorber.job_id = job_id
|
|
22
|
+
result = absorber.handle(url: input, content: context[:content],
|
|
23
|
+
metadata: context[:metadata] || {}, context: context)
|
|
24
|
+
publish_event("absorb.complete.#{job_id}", job_id: job_id, absorber: absorber_class.name,
|
|
25
|
+
result: result)
|
|
26
|
+
{ success: true, job_id: job_id, absorber: absorber_class.name, result: result }
|
|
27
|
+
rescue StandardError => e
|
|
28
|
+
Legion::Logging.error("AbsorberDispatch failed: #{e.message}") if defined?(Legion::Logging)
|
|
29
|
+
publish_event("absorb.failed.#{job_id}", job_id: job_id, error: e.message)
|
|
30
|
+
{ success: false, job_id: job_id, error: e.message }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def publish_event(routing_key, **payload)
|
|
34
|
+
return unless defined?(Legion::Transport)
|
|
35
|
+
|
|
36
|
+
session = Legion::Transport.respond_to?(:session) ? Legion::Transport.session : nil
|
|
37
|
+
if session.respond_to?(:open?)
|
|
38
|
+
return unless session.open?
|
|
39
|
+
elsif session.nil?
|
|
40
|
+
return
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
message_class =
|
|
44
|
+
if defined?(Legion::Transport::Messages::Dynamic)
|
|
45
|
+
Legion::Transport::Messages::Dynamic
|
|
46
|
+
elsif defined?(Legion::Transport::Message)
|
|
47
|
+
Legion::Transport::Message
|
|
48
|
+
end
|
|
49
|
+
return unless message_class
|
|
50
|
+
|
|
51
|
+
message_class.new(routing_key: routing_key, **payload).publish
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
Legion::Logging.warn("AbsorberDispatch publish failed: #{e.message}") if defined?(Legion::Logging)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'base'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Extensions
|
|
7
|
+
module Builder
|
|
8
|
+
module Absorbers
|
|
9
|
+
include Legion::Extensions::Builder::Base
|
|
10
|
+
|
|
11
|
+
def build_absorbers
|
|
12
|
+
@absorbers = {}
|
|
13
|
+
absorber_files = find_files('absorbers')
|
|
14
|
+
return if absorber_files.empty?
|
|
15
|
+
|
|
16
|
+
require_files(absorber_files)
|
|
17
|
+
|
|
18
|
+
absorber_files.each do |file|
|
|
19
|
+
snake_name = file.split('/').last.sub('.rb', '')
|
|
20
|
+
class_name = snake_name.split('_').collect(&:capitalize).join
|
|
21
|
+
absorber_class = "#{lex_class}::Absorbers::#{class_name}"
|
|
22
|
+
|
|
23
|
+
next unless Kernel.const_defined?(absorber_class)
|
|
24
|
+
|
|
25
|
+
klass = Kernel.const_get(absorber_class)
|
|
26
|
+
next unless klass < Legion::Extensions::Absorbers::Base
|
|
27
|
+
|
|
28
|
+
@absorbers[snake_name.to_sym] = {
|
|
29
|
+
extension: lex_name,
|
|
30
|
+
extension_class: lex_class,
|
|
31
|
+
absorber_name: snake_name,
|
|
32
|
+
absorber_class: absorber_class,
|
|
33
|
+
absorber_module: klass,
|
|
34
|
+
patterns: klass.patterns,
|
|
35
|
+
description: klass.description
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
Legion::Extensions::Absorbers::PatternMatcher.register(klass)
|
|
39
|
+
end
|
|
40
|
+
rescue StandardError => e
|
|
41
|
+
Legion::Logging.error("Failed to build absorbers: #{e.message}") if defined?(Legion::Logging)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def absorbers
|
|
45
|
+
@absorbers || {}
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -6,6 +6,22 @@ module Legion
|
|
|
6
6
|
:name, :extension, :runner, :function,
|
|
7
7
|
:description, :parameters, :tags, :loaded_at
|
|
8
8
|
) do
|
|
9
|
+
def self.from_absorber(extension:, absorber:, patterns: [], description: nil)
|
|
10
|
+
absorber_name = absorber.name&.split('::')&.last || absorber.object_id.to_s
|
|
11
|
+
snake = absorber_name.gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase
|
|
12
|
+
canonical = "#{extension}:absorber:#{snake}"
|
|
13
|
+
new(
|
|
14
|
+
name: canonical,
|
|
15
|
+
extension: extension,
|
|
16
|
+
runner: 'Absorber',
|
|
17
|
+
function: absorber_name,
|
|
18
|
+
description: description,
|
|
19
|
+
parameters: { input: { type: :string, required: true } },
|
|
20
|
+
tags: ['absorber'] + patterns.map { |p| "pattern:#{p[:type]}:#{p[:value]}" },
|
|
21
|
+
loaded_at: Time.now
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
9
25
|
def self.from_runner(extension:, runner:, function:, **opts)
|
|
10
26
|
canonical = "#{extension}:#{runner.to_s.gsub(/([A-Z])/, '_\1').sub(/^_/, '').downcase}:#{function}"
|
|
11
27
|
new(
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative 'absorbers'
|
|
4
|
+
require_relative 'builders/absorbers'
|
|
3
5
|
require_relative 'builders/actors'
|
|
4
6
|
require_relative 'builders/helpers'
|
|
5
7
|
require_relative 'builders/hooks'
|
|
@@ -49,6 +51,7 @@ module Legion
|
|
|
49
51
|
include Legion::Extensions::Helpers::Lex
|
|
50
52
|
include Legion::Extensions::Helpers::Knowledge if defined?(Legion::Extensions::Helpers::Knowledge)
|
|
51
53
|
|
|
54
|
+
include Legion::Extensions::Builder::Absorbers
|
|
52
55
|
include Legion::Extensions::Builder::Runners
|
|
53
56
|
include Legion::Extensions::Builder::Helpers
|
|
54
57
|
include Legion::Extensions::Builder::Actors
|
|
@@ -73,6 +76,7 @@ module Legion
|
|
|
73
76
|
end
|
|
74
77
|
build_helpers
|
|
75
78
|
build_runners
|
|
79
|
+
build_absorbers
|
|
76
80
|
build_actors
|
|
77
81
|
build_hooks
|
|
78
82
|
build_routes
|
data/lib/legion/extensions.rb
CHANGED
|
@@ -229,6 +229,7 @@ module Legion
|
|
|
229
229
|
Legion::Transport::Messages::LexRegister.new(function: 'save', opts: extension.runners).publish
|
|
230
230
|
|
|
231
231
|
register_capabilities(entry[:gem_name], extension.runners) if extension.respond_to?(:runners)
|
|
232
|
+
register_absorber_capabilities(entry[:gem_name], extension.absorbers) if extension.respond_to?(:absorbers)
|
|
232
233
|
|
|
233
234
|
if extension.respond_to?(:meta_actors) && extension.meta_actors.is_a?(Hash)
|
|
234
235
|
extension.meta_actors.each_value do |actor|
|
|
@@ -502,6 +503,25 @@ module Legion
|
|
|
502
503
|
Extensions::Catalog::Registry.unregister_extension(gem_name)
|
|
503
504
|
end
|
|
504
505
|
|
|
506
|
+
def register_absorber_capabilities(gem_name, absorbers)
|
|
507
|
+
absorbers.each_value do |absorber_meta|
|
|
508
|
+
cap = Extensions::Capability.from_absorber(
|
|
509
|
+
extension: gem_name,
|
|
510
|
+
absorber: absorber_meta[:absorber_module],
|
|
511
|
+
patterns: absorber_meta[:patterns],
|
|
512
|
+
description: absorber_meta[:description]
|
|
513
|
+
)
|
|
514
|
+
Extensions::Catalog::Registry.register(cap)
|
|
515
|
+
rescue StandardError => e
|
|
516
|
+
if defined?(Legion::Logging)
|
|
517
|
+
Legion::Logging.warn(
|
|
518
|
+
"Absorber catalog registration error for #{gem_name} " \
|
|
519
|
+
"(#{absorber_meta[:absorber_module]}): #{e.message}"
|
|
520
|
+
)
|
|
521
|
+
end
|
|
522
|
+
end
|
|
523
|
+
end
|
|
524
|
+
|
|
505
525
|
def register_capabilities(gem_name, runners)
|
|
506
526
|
runners.each_value do |runner_meta|
|
|
507
527
|
runner_name = runner_meta[:runner_name]
|
data/lib/legion/service.rb
CHANGED
|
@@ -31,6 +31,7 @@ module Legion
|
|
|
31
31
|
Legion::Logging.debug('Starting Legion::Service')
|
|
32
32
|
setup_settings
|
|
33
33
|
apply_cli_overrides(http_port: http_port)
|
|
34
|
+
setup_compliance
|
|
34
35
|
setup_local_mode
|
|
35
36
|
reconfigure_logging(log_level)
|
|
36
37
|
Legion::Logging.info("node name: #{Legion::Settings[:client][:name]}")
|
|
@@ -225,6 +226,15 @@ module Legion
|
|
|
225
226
|
self.class.log_privacy_mode_status
|
|
226
227
|
end
|
|
227
228
|
|
|
229
|
+
def setup_compliance
|
|
230
|
+
require 'legion/compliance'
|
|
231
|
+
Legion::Compliance.setup
|
|
232
|
+
rescue LoadError => e
|
|
233
|
+
Legion::Logging.debug "Compliance module not available: #{e.message}"
|
|
234
|
+
rescue StandardError => e
|
|
235
|
+
Legion::Logging.warn "Compliance setup failed: #{e.message}"
|
|
236
|
+
end
|
|
237
|
+
|
|
228
238
|
def apply_cli_overrides(http_port: nil)
|
|
229
239
|
return unless http_port
|
|
230
240
|
|
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.6.
|
|
4
|
+
version: 1.6.18
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Esity
|
|
@@ -509,6 +509,7 @@ files:
|
|
|
509
509
|
- lib/legion/chat/notification_queue.rb
|
|
510
510
|
- lib/legion/chat/skills.rb
|
|
511
511
|
- lib/legion/cli.rb
|
|
512
|
+
- lib/legion/cli/absorb_command.rb
|
|
512
513
|
- lib/legion/cli/acp_command.rb
|
|
513
514
|
- lib/legion/cli/apollo_command.rb
|
|
514
515
|
- lib/legion/cli/audit_command.rb
|
|
@@ -740,6 +741,12 @@ files:
|
|
|
740
741
|
- lib/legion/docs/site_generator.rb
|
|
741
742
|
- lib/legion/events.rb
|
|
742
743
|
- lib/legion/extensions.rb
|
|
744
|
+
- lib/legion/extensions/absorbers.rb
|
|
745
|
+
- lib/legion/extensions/absorbers/base.rb
|
|
746
|
+
- lib/legion/extensions/absorbers/matchers/base.rb
|
|
747
|
+
- lib/legion/extensions/absorbers/matchers/url.rb
|
|
748
|
+
- lib/legion/extensions/absorbers/pattern_matcher.rb
|
|
749
|
+
- lib/legion/extensions/actors/absorber_dispatch.rb
|
|
743
750
|
- lib/legion/extensions/actors/base.rb
|
|
744
751
|
- lib/legion/extensions/actors/defaults.rb
|
|
745
752
|
- lib/legion/extensions/actors/every.rb
|
|
@@ -750,6 +757,7 @@ files:
|
|
|
750
757
|
- lib/legion/extensions/actors/poll.rb
|
|
751
758
|
- lib/legion/extensions/actors/singleton.rb
|
|
752
759
|
- lib/legion/extensions/actors/subscription.rb
|
|
760
|
+
- lib/legion/extensions/builders/absorbers.rb
|
|
753
761
|
- lib/legion/extensions/builders/actors.rb
|
|
754
762
|
- lib/legion/extensions/builders/base.rb
|
|
755
763
|
- lib/legion/extensions/builders/helpers.rb
|