legionio 1.5.15 → 1.5.19

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: d66368a89ce7ef86f5c62277fe403c840caf722f8e6b840000251775ce74e4a4
4
- data.tar.gz: 8a44567875935c668bf2884e7fac6e0831f0dad9f1860f5e2bde28eedb0b7df5
3
+ metadata.gz: bb76821fb4802e78a19c4518c18f32a961a144cd96f0ef146ce0b582ef96f699
4
+ data.tar.gz: c9c6eb50bef1df98a3ede6ce943e4a45a9dc4c638ce682519708d3d0008eddd5
5
5
  SHA512:
6
- metadata.gz: 7e45f6aa1d7838ed5c5ddac267310ea8163f27f1e4c8efd190720da148add55ff9f50e958c8096691d6ce3a613064322f11eba64d8fc0eae79bea26fc1ee1a9c
7
- data.tar.gz: ce24287b1dd968ef9e33e053c358b7e182b92685c6696b0074fef7ab3d0447449452f9959a3086148a73b606fefc643444546ad34e596b8c8ea5039b4bf65c17
6
+ metadata.gz: 2e502641a0f4177baeab32e78b839f80fa163953fe55770cd0a31364b352b5eda156e0971e67057ad5263160cc5f874b7cdbfdafc1a7cc9799fdd7a9ebd73a65
7
+ data.tar.gz: 90ec056445d835e01d044dd1ca6272c7e30ef5cffa0e30c39d93d4c2e8004f0e4d3ff59d83e12753bc485484a68a2526e90ebe1ccfdd1fb39e12b89e549d01d1
data/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.5.19] - 2026-03-26
4
+
5
+ ### Added
6
+ - `legion knowledge` CLI subcommand: query, retrieve, ingest, status (closes #36)
7
+ - `legion knowledge query QUESTION` — synthesized LLM answer + ranked source chunks
8
+ - `legion knowledge retrieve QUESTION` — raw source chunks without synthesis
9
+ - `legion knowledge ingest PATH` — ingest file or directory corpus
10
+ - `legion knowledge status` — show corpus file count and size
11
+
12
+ ## [1.5.18] - 2026-03-25
13
+
14
+ ### Added
15
+ - `scope:` parameter on `Helpers::Knowledge` (`ingest_knowledge` and `query_knowledge`)
16
+ - Scope routing: `:local` -> `Apollo::Local`, `:global` -> `Apollo`, `:all` -> both with local-first dedup
17
+ - Default query scope configurable via `Settings[:apollo][:local][:default_query_scope]`
18
+ - `setup_apollo` now starts `Apollo::Local` when available
19
+
20
+ ## [1.5.17] - 2026-03-25
21
+
22
+ ### Added
23
+ - `Helpers::Knowledge` — universal `ingest_knowledge` and `query_knowledge` mixin for all extensions; included automatically in `Extensions::Core`
24
+ - Automatic file extraction via `Legion::Data::Extract` when a file path is passed to `ingest_knowledge`
25
+ - Graceful degradation when `Legion::Apollo` or `Legion::Data::Extract` are not available
26
+ - `setup_apollo` in `Service` boot sequence (between LLM and GAIA); wires `Legion::Apollo.start` with `LoadError`/`StandardError` rescue
27
+ - `:apollo` added to `Readiness::COMPONENTS` between `:llm` and `:gaia`
28
+ - `legion-apollo >= 0.2.1` dependency in gemspec
29
+ - `Helpers::LLM#llm_embed` in LegionIO now forwards all keyword arguments (`provider:`, `dimensions:`, etc.) via anonymous `**` forwarding
30
+
3
31
  ## [1.5.15] - 2026-03-25
4
32
 
5
33
  ### Removed
data/Gemfile CHANGED
@@ -11,6 +11,7 @@ gem 'legion-logging', path: '../legion-logging' if File.exist?(File.expand_path(
11
11
  gem 'legion-mcp', path: '../legion-mcp' if File.exist?(File.expand_path('../legion-mcp', __dir__))
12
12
  gem 'legion-settings', path: '../legion-settings' if File.exist?(File.expand_path('../legion-settings', __dir__))
13
13
 
14
+ gem 'legion-apollo', path: '../legion-apollo' if File.exist?(File.expand_path('../legion-apollo', __dir__))
14
15
  gem 'lex-agentic-memory', path: '../extensions-agentic/lex-agentic-memory' if File.exist?(File.expand_path('../extensions-agentic/lex-agentic-memory', __dir__))
15
16
  gem 'lex-llm-gateway', path: '../extensions-core/lex-llm-gateway' if File.exist?(File.expand_path('../extensions-core/lex-llm-gateway', __dir__))
16
17
  gem 'lex-microsoft_teams', path: '../extensions/lex-microsoft_teams' if File.exist?(File.expand_path('../extensions/lex-microsoft_teams', __dir__))
data/legionio.gemspec CHANGED
@@ -60,6 +60,7 @@ Gem::Specification.new do |spec|
60
60
  spec.add_dependency 'legion-settings', '>= 1.3.19'
61
61
  spec.add_dependency 'legion-transport', '>= 1.4.0'
62
62
 
63
+ spec.add_dependency 'legion-apollo', '>= 0.2.1'
63
64
  spec.add_dependency 'legion-gaia', '>= 0.9.24'
64
65
  spec.add_dependency 'legion-llm', '>= 0.5.8'
65
66
  spec.add_dependency 'legion-tty', '>= 0.4.35'
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module CLI
5
+ class Knowledge < Thor
6
+ def self.exit_on_failure?
7
+ true
8
+ end
9
+
10
+ class_option :json, type: :boolean, default: false, desc: 'Output as JSON'
11
+ class_option :no_color, type: :boolean, default: false, desc: 'Disable color output'
12
+
13
+ desc 'query QUESTION', 'Query the knowledge base with optional LLM synthesis'
14
+ option :top_k, type: :numeric, default: 5, desc: 'Number of source chunks'
15
+ option :synthesize, type: :boolean, default: true, desc: 'Synthesize an LLM answer'
16
+ option :verbose, type: :boolean, default: false, desc: 'Show full source metadata'
17
+ def query(question)
18
+ require_knowledge!
19
+ result = knowledge_query.query(question: question, top_k: options[:top_k],
20
+ synthesize: options[:synthesize])
21
+ out = formatter
22
+ if options[:json]
23
+ out.json(result)
24
+ elsif result[:success]
25
+ out.header('Knowledge Query')
26
+ if result[:answer]
27
+ out.spacer
28
+ puts result[:answer]
29
+ out.spacer
30
+ end
31
+ print_sources(result[:sources] || [], out, verbose: options[:verbose])
32
+ else
33
+ out.warn("Query failed: #{result[:error]}")
34
+ end
35
+ end
36
+ default_task :help
37
+
38
+ desc 'retrieve QUESTION', 'Retrieve source chunks without LLM synthesis'
39
+ option :top_k, type: :numeric, default: 5, desc: 'Number of source chunks'
40
+ def retrieve(question)
41
+ require_knowledge!
42
+ result = knowledge_query.retrieve(question: question, top_k: options[:top_k])
43
+ out = formatter
44
+ if options[:json]
45
+ out.json(result)
46
+ elsif result[:success]
47
+ out.header("Knowledge Retrieve (#{(result[:sources] || []).size} chunks)")
48
+ print_sources(result[:sources] || [], out, verbose: true)
49
+ else
50
+ out.warn("Retrieve failed: #{result[:error]}")
51
+ end
52
+ end
53
+
54
+ desc 'ingest PATH', 'Ingest a file or directory into the knowledge base'
55
+ option :force, type: :boolean, default: false, desc: 'Re-ingest even unchanged files'
56
+ option :dry_run, type: :boolean, default: false, desc: 'Preview without writing'
57
+ def ingest(path)
58
+ require_ingest!
59
+ result = if ::File.directory?(path)
60
+ knowledge_ingest.ingest_corpus(path: path, force: options[:force],
61
+ dry_run: options[:dry_run])
62
+ else
63
+ knowledge_ingest.ingest_file(file_path: path, force: options[:force],
64
+ dry_run: options[:dry_run])
65
+ end
66
+ out = formatter
67
+ if options[:json]
68
+ out.json(result)
69
+ elsif result[:success]
70
+ out.success('Ingest complete')
71
+ out.detail(result.except(:success))
72
+ else
73
+ out.warn("Ingest failed: #{result[:error]}")
74
+ end
75
+ end
76
+
77
+ desc 'status', 'Show knowledge base status'
78
+ def status
79
+ require_ingest!
80
+ result = knowledge_ingest.scan_corpus(path: ::Dir.pwd)
81
+ out = formatter
82
+ if options[:json]
83
+ out.json(result)
84
+ else
85
+ out.header('Knowledge Status')
86
+ out.detail({
87
+ 'Path' => result[:path].to_s,
88
+ 'Files' => result[:file_count].to_s,
89
+ 'Total size' => "#{result[:total_bytes]} bytes"
90
+ })
91
+ end
92
+ end
93
+
94
+ no_commands do
95
+ def formatter
96
+ @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
97
+ end
98
+
99
+ def require_knowledge!
100
+ return if defined?(Legion::Extensions::Knowledge::Runners::Query)
101
+
102
+ raise CLI::Error, 'lex-knowledge extension is not loaded. Install and enable it first.'
103
+ end
104
+
105
+ def require_ingest!
106
+ return if defined?(Legion::Extensions::Knowledge::Runners::Ingest)
107
+
108
+ raise CLI::Error, 'lex-knowledge extension is not loaded. Install and enable it first.'
109
+ end
110
+
111
+ def knowledge_query
112
+ Legion::Extensions::Knowledge::Runners::Query
113
+ end
114
+
115
+ def knowledge_ingest
116
+ Legion::Extensions::Knowledge::Runners::Ingest
117
+ end
118
+
119
+ def print_sources(sources, out, verbose:)
120
+ return out.warn('No sources found') if sources.empty?
121
+
122
+ out.header("Sources (#{sources.size})")
123
+ sources.each_with_index do |s, i|
124
+ score = format('%.2f', s[:score].to_f)
125
+ heading = s[:heading].to_s.empty? ? '' : " \u00a7 #{s[:heading]}"
126
+ puts " #{i + 1}. #{s[:source_file]}#{heading} score: #{score}"
127
+ puts " #{truncate(s[:content].to_s, 100)}" if verbose
128
+ end
129
+ end
130
+
131
+ def truncate(text, max)
132
+ return text if text.length <= max
133
+ return text[0, max] if max < 4
134
+
135
+ "#{text[0, max - 3]}..."
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
data/lib/legion/cli.rb CHANGED
@@ -42,6 +42,7 @@ module Legion
42
42
  autoload :Eval, 'legion/cli/eval_command'
43
43
  autoload :Update, 'legion/cli/update_command'
44
44
  autoload :Init, 'legion/cli/init_command'
45
+ autoload :Knowledge, 'legion/cli/knowledge_command'
45
46
  autoload :Setup, 'legion/cli/setup_command'
46
47
  autoload :Skill, 'legion/cli/skill_command'
47
48
  autoload :Prompt, 'legion/cli/prompt_command'
@@ -256,6 +257,9 @@ module Legion
256
257
  desc 'apollo SUBCOMMAND', 'Apollo knowledge graph'
257
258
  subcommand 'apollo', Legion::CLI::Apollo
258
259
 
260
+ desc 'knowledge SUBCOMMAND', 'Search and manage the document knowledge base'
261
+ subcommand 'knowledge', Legion::CLI::Knowledge
262
+
259
263
  desc 'schedule SUBCOMMAND', 'Manage schedules'
260
264
  subcommand 'schedule', Legion::CLI::Schedule
261
265
 
@@ -348,7 +352,7 @@ module Legion
348
352
 
349
353
  desc 'tree', 'Print a tree of all available commands'
350
354
  def tree
351
- legion_print_command_tree(self.class, 'legion', '')
355
+ legion_print_command_tree(self.class, ::File.basename($PROGRAM_NAME), '')
352
356
  end
353
357
 
354
358
  desc 'ask TEXT', 'Quick AI prompt (shortcut for chat prompt)'
@@ -21,6 +21,18 @@ rescue LoadError => e
21
21
  Legion::Logging.debug "Extensions::Core: legion-llm helpers not available: #{e.message}" if defined?(Legion::Logging)
22
22
  end
23
23
 
24
+ begin
25
+ require_relative 'helpers/llm'
26
+ rescue LoadError => e
27
+ Legion::Logging.debug "Extensions::Core: local llm helper not available: #{e.message}" if defined?(Legion::Logging)
28
+ end
29
+
30
+ begin
31
+ require_relative 'helpers/knowledge'
32
+ rescue LoadError => e
33
+ Legion::Logging.debug "Extensions::Core: knowledge helper not available: #{e.message}" if defined?(Legion::Logging)
34
+ end
35
+
24
36
  require_relative 'actors/base'
25
37
  require_relative 'actors/every'
26
38
  require_relative 'actors/loop'
@@ -35,6 +47,7 @@ module Legion
35
47
  module Core
36
48
  include Legion::Extensions::Helpers::Transport
37
49
  include Legion::Extensions::Helpers::Lex
50
+ include Legion::Extensions::Helpers::Knowledge if defined?(Legion::Extensions::Helpers::Knowledge)
38
51
 
39
52
  include Legion::Extensions::Builder::Runners
40
53
  include Legion::Extensions::Builder::Helpers
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Helpers
6
+ module Knowledge
7
+ def ingest_knowledge(content_or_path, type: :auto, tags: [], scope: :global, **opts)
8
+ target = resolve_ingest_target(scope)
9
+ return { success: false, error: :apollo_not_available } unless target
10
+
11
+ text, metadata = extract_if_needed(content_or_path, type: type)
12
+ return { success: false, error: :extraction_failed, detail: metadata } unless text
13
+
14
+ extraction_tags = metadata_to_tags(metadata) if metadata
15
+ all_tags = Array(tags) + Array(extraction_tags)
16
+
17
+ target.ingest(
18
+ content: text,
19
+ tags: all_tags,
20
+ source_channel: opts[:source_channel] || derive_lex_name,
21
+ **opts.except(:source_channel)
22
+ )
23
+ end
24
+
25
+ def query_knowledge(text:, limit: 5, scope: nil, **)
26
+ scope ||= default_query_scope
27
+
28
+ case scope.to_sym
29
+ when :local then query_local(text: text, limit: limit, **)
30
+ when :global then query_global(text: text, limit: limit, **)
31
+ else query_all(text: text, limit: limit, **)
32
+ end
33
+ end
34
+
35
+ private
36
+
37
+ def resolve_ingest_target(scope)
38
+ case scope.to_sym
39
+ when :local
40
+ local_available? ? Legion::Apollo::Local : nil
41
+ else
42
+ global_available? ? Legion::Apollo : nil
43
+ end
44
+ end
45
+
46
+ def query_local(text:, limit:, **)
47
+ unless local_available?
48
+ Legion::Logging.debug 'query_knowledge(:local) called but Apollo::Local is not available' if defined?(Legion::Logging)
49
+ return { success: false, error: :apollo_not_available }
50
+ end
51
+
52
+ Legion::Apollo::Local.query(text: text, limit: limit, **)
53
+ end
54
+
55
+ def query_global(text:, limit:, **)
56
+ unless global_available?
57
+ Legion::Logging.debug 'query_knowledge(:global) called but Apollo is not available' if defined?(Legion::Logging)
58
+ return { success: false, error: :apollo_not_available }
59
+ end
60
+
61
+ Legion::Apollo.query(text: text, limit: limit, **)
62
+ end
63
+
64
+ def query_all(text:, limit:, **)
65
+ local_results = local_available? ? Array((Legion::Apollo::Local.query(text: text, limit: limit, **) || {})[:results]) : []
66
+ global_results = global_available? ? Array((Legion::Apollo.query(text: text, limit: limit, **) || {})[:results]) : []
67
+
68
+ return { success: false, error: :apollo_not_available } if local_results.empty? && global_results.empty? && !local_available? && !global_available?
69
+
70
+ merged = merge_results(local_results, global_results)
71
+ { success: true, results: merged.first(limit), count: [merged.size, limit].min, mode: :all }
72
+ end
73
+
74
+ def merge_results(local_results, global_results)
75
+ seen = {}
76
+ merged = []
77
+
78
+ local_results.each do |r|
79
+ key = r[:content_hash] || r[:content]
80
+ seen[key] = true
81
+ merged << r
82
+ end
83
+
84
+ global_results.each do |r|
85
+ key = r[:content_hash] || r[:content]
86
+ merged << r unless seen[key]
87
+ end
88
+
89
+ merged
90
+ end
91
+
92
+ def global_available?
93
+ defined?(Legion::Apollo) && Legion::Apollo.started?
94
+ end
95
+
96
+ def local_available?
97
+ defined?(Legion::Apollo::Local) && Legion::Apollo::Local.started?
98
+ end
99
+
100
+ def default_query_scope
101
+ return :all unless defined?(Legion::Settings)
102
+
103
+ scope = Legion::Settings.dig(:apollo, :local, :default_query_scope)
104
+ scope ? scope.to_sym : :all
105
+ rescue StandardError
106
+ :all
107
+ end
108
+
109
+ def extract_if_needed(content_or_path, type:)
110
+ return extract_file(content_or_path, type: type) if content_or_path.is_a?(String) && File.exist?(content_or_path)
111
+ return extract_file(content_or_path, type: type) if content_or_path.respond_to?(:read)
112
+
113
+ [content_or_path.to_s, nil]
114
+ end
115
+
116
+ def extract_file(source, type:)
117
+ return [source.to_s, nil] unless defined?(Legion::Data::Extract)
118
+
119
+ result = Legion::Data::Extract.extract(source, type: type)
120
+ if result[:text]
121
+ [result[:text], result[:metadata]]
122
+ else
123
+ [nil, result]
124
+ end
125
+ end
126
+
127
+ def metadata_to_tags(metadata)
128
+ tags = []
129
+ tags << metadata[:type].to_s if metadata[:type]
130
+ tags << "pages:#{metadata[:pages]}" if metadata[:pages]
131
+ tags
132
+ end
133
+
134
+ def derive_lex_name
135
+ parts = self.class.name&.split('::')
136
+ parts && parts[2] ? parts[2].downcase : 'unknown'
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Extensions
5
+ module Helpers
6
+ module LLM
7
+ # Quick embed from any extension runner, forwarding all keyword arguments.
8
+ # Supports provider:, dimensions:, and any future parameters.
9
+ # @param text [String, Array<String>] text to embed
10
+ # @param kwargs [Hash] forwarded to Legion::LLM.embed (model:, provider:, dimensions:, etc.)
11
+ # @return [Hash] embedding result with :vector, :dimensions, :model, :provider
12
+ def llm_embed(text, **)
13
+ Legion::LLM.embed(text, **)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Legion
4
4
  module Readiness
5
- COMPONENTS = %i[settings crypt transport cache data rbac llm gaia extensions api].freeze
5
+ COMPONENTS = %i[settings crypt transport cache data rbac llm apollo gaia extensions api].freeze
6
6
  DRAIN_TIMEOUT = 5
7
7
 
8
8
  class << self
@@ -98,6 +98,15 @@ module Legion
98
98
  end
99
99
  end
100
100
 
101
+ begin
102
+ setup_apollo
103
+ Legion::Readiness.mark_ready(:apollo)
104
+ rescue LoadError
105
+ Legion::Logging.info 'Legion::Apollo gem is not installed, starting without Apollo'
106
+ rescue StandardError => e
107
+ Legion::Logging.warn "Legion::Apollo failed to load: #{e.message}"
108
+ end
109
+
101
110
  if gaia
102
111
  begin
103
112
  setup_gaia
@@ -317,6 +326,18 @@ module Legion
317
326
  Legion::Logging.warn "Legion::Gaia failed to load: #{e.message}"
318
327
  end
319
328
 
329
+ def setup_apollo
330
+ Legion::Logging.info 'Setting up Legion::Apollo'
331
+ require 'legion/apollo'
332
+ Legion::Apollo.start
333
+ Legion::Apollo::Local.start if defined?(Legion::Apollo::Local)
334
+ Legion::Logging.info 'Legion::Apollo started'
335
+ rescue LoadError
336
+ Legion::Logging.info 'Legion::Apollo gem is not installed, starting without Apollo'
337
+ rescue StandardError => e
338
+ Legion::Logging.warn "Legion::Apollo failed to load: #{e.message}"
339
+ end
340
+
320
341
  def setup_transport
321
342
  Legion::Logging.info 'Setting up Legion::Transport'
322
343
  require 'legion/transport'
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.5.15'
4
+ VERSION = '1.5.19'
5
5
  end
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.5.15
4
+ version: 1.5.19
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -317,6 +317,20 @@ dependencies:
317
317
  - - ">="
318
318
  - !ruby/object:Gem::Version
319
319
  version: 1.4.0
320
+ - !ruby/object:Gem::Dependency
321
+ name: legion-apollo
322
+ requirement: !ruby/object:Gem::Requirement
323
+ requirements:
324
+ - - ">="
325
+ - !ruby/object:Gem::Version
326
+ version: 0.2.1
327
+ type: :runtime
328
+ prerelease: false
329
+ version_requirements: !ruby/object:Gem::Requirement
330
+ requirements:
331
+ - - ">="
332
+ - !ruby/object:Gem::Version
333
+ version: 0.2.1
320
334
  - !ruby/object:Gem::Dependency
321
335
  name: legion-gaia
322
336
  requirement: !ruby/object:Gem::Requirement
@@ -616,6 +630,7 @@ files:
616
630
  - lib/legion/cli/init/environment_detector.rb
617
631
  - lib/legion/cli/init_command.rb
618
632
  - lib/legion/cli/interactive.rb
633
+ - lib/legion/cli/knowledge_command.rb
619
634
  - lib/legion/cli/lex/actor.rb
620
635
  - lib/legion/cli/lex/exchange.rb
621
636
  - lib/legion/cli/lex/message.rb
@@ -746,7 +761,9 @@ files:
746
761
  - lib/legion/extensions/helpers/cache.rb
747
762
  - lib/legion/extensions/helpers/core.rb
748
763
  - lib/legion/extensions/helpers/data.rb
764
+ - lib/legion/extensions/helpers/knowledge.rb
749
765
  - lib/legion/extensions/helpers/lex.rb
766
+ - lib/legion/extensions/helpers/llm.rb
750
767
  - lib/legion/extensions/helpers/logger.rb
751
768
  - lib/legion/extensions/helpers/segments.rb
752
769
  - lib/legion/extensions/helpers/task.rb