legionio 1.6.44 → 1.6.45

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: 1d5e89a840a146657264710fe44bb2e60a3be0b7b44aece296a8fa1b7abe51df
4
- data.tar.gz: e49ca0b195a65c40647960f4c1c7e734b939106200c72d65e992210298a900c7
3
+ metadata.gz: 55adb4b5b957ffa41629f5d0cf48961096230993a9654f1f85d956a845773043
4
+ data.tar.gz: 41d97316c383dae8178b9730d2a3712d1669870180e01d55a966fbc3e79afbbc
5
5
  SHA512:
6
- metadata.gz: 0aacaf7a1d32a6c32bb0862ce0d2b569f1e528fcb31b77f4aa00e78ecbb021a0448ae1591ccff3251cfe61b41b9392a7415da6d40fcf56518b99f2305ddc422b
7
- data.tar.gz: a1aa3f9cacd5a88f4351a3014fc9c2a67b3fd225b594937585d64239eb4e349296896151b828d8ef5646994e08897b10378f070a42e4fbc0d186668c6011bd30
6
+ metadata.gz: 5d062ded2d5c0a87aa28864abd1c74a3d8aacf075f43fba8bd6267a5adcfeb0e74498df3583b5713921901cfbbd0ec96f57986a71dbeb70c48649541404dd484
7
+ data.tar.gz: afa0809aa907d8e9e508f257d20dd05db8e4c0ddad7dd9aeb989ec6cf135c4df48fcce0a35dda6bfbceb2ba7d1a4d6ad1a658342b026d044bb3262b2ed1d110c
data/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [1.6.45] - 2026-03-31
6
+
7
+ ### Added
8
+ - `Legion::CLI::ApiClient` shared module — extracts api_get/api_post/api_put/api_delete helpers into a reusable mixin for all CLI commands that talk to the daemon API
9
+ - `/api/knowledge/*` API routes — query, retrieve, ingest, status, health, maintain, quality, and monitor CRUD endpoints for lex-knowledge
10
+
11
+ ### Changed
12
+ - `legionio knowledge` commands now route through the local API instead of loading extension classes directly (fixes NameError when daemon not running)
13
+ - `legionio schedule` commands now route through the existing `/api/schedules/*` API instead of querying Sequel models directly
14
+ - `legionio codegen` commands now route through the existing `/api/codegen/*` API instead of checking `defined?` guards that always fail in CLI context
15
+ - `legionio absorb` commands now use the shared `ApiClient` module instead of inline HTTP helpers
16
+
5
17
  ## [1.6.44] - 2026-03-31
6
18
 
7
19
  ### Added
@@ -52,6 +52,30 @@ module Legion
52
52
  halt 503, json_error('scheduler_unavailable', 'lex-scheduler is not loaded', status_code: 503)
53
53
  end
54
54
 
55
+ def require_knowledge_query!
56
+ return if defined?(Legion::Extensions::Knowledge::Runners::Query)
57
+
58
+ halt 503, json_error('knowledge_unavailable', 'lex-knowledge is not loaded', status_code: 503)
59
+ end
60
+
61
+ def require_knowledge_ingest!
62
+ return if defined?(Legion::Extensions::Knowledge::Runners::Ingest)
63
+
64
+ halt 503, json_error('knowledge_unavailable', 'lex-knowledge is not loaded', status_code: 503)
65
+ end
66
+
67
+ def require_knowledge_maintenance!
68
+ return if defined?(Legion::Extensions::Knowledge::Runners::Maintenance)
69
+
70
+ halt 503, json_error('knowledge_unavailable', 'lex-knowledge is not loaded', status_code: 503)
71
+ end
72
+
73
+ def require_knowledge_monitor!
74
+ return if defined?(Legion::Extensions::Knowledge::Runners::Monitor)
75
+
76
+ halt 503, json_error('knowledge_unavailable', 'lex-knowledge is not loaded', status_code: 503)
77
+ end
78
+
55
79
  def require_trace_search!
56
80
  return if defined?(Legion::TraceSearch) && defined?(Legion::LLM)
57
81
 
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ class API < Sinatra::Base
5
+ module Routes
6
+ module Knowledge
7
+ def self.registered(app)
8
+ register_query_routes(app)
9
+ register_ingest_routes(app)
10
+ register_maintenance_routes(app)
11
+ register_monitor_routes(app)
12
+ end
13
+
14
+ def self.register_query_routes(app)
15
+ app.post '/api/knowledge/query' do
16
+ require_knowledge_query!
17
+ body = parse_request_body
18
+ result = Legion::Extensions::Knowledge::Runners::Query.query(
19
+ question: body[:question],
20
+ top_k: body[:top_k] || 5,
21
+ synthesize: body.fetch(:synthesize, true)
22
+ )
23
+ json_response(result)
24
+ end
25
+
26
+ app.post '/api/knowledge/retrieve' do
27
+ require_knowledge_query!
28
+ body = parse_request_body
29
+ result = Legion::Extensions::Knowledge::Runners::Query.retrieve(
30
+ question: body[:question],
31
+ top_k: body[:top_k] || 5
32
+ )
33
+ json_response(result)
34
+ end
35
+ end
36
+
37
+ def self.register_ingest_routes(app)
38
+ app.post '/api/knowledge/ingest' do
39
+ require_knowledge_ingest!
40
+ body = parse_request_body
41
+
42
+ result = if body[:content]
43
+ Legion::Extensions::Knowledge::Runners::Ingest.ingest_file(
44
+ content: body[:content],
45
+ tags: body[:tags] || [],
46
+ source: body[:source]
47
+ )
48
+ elsif body[:path]
49
+ if File.directory?(body[:path])
50
+ Legion::Extensions::Knowledge::Runners::Ingest.ingest_corpus(
51
+ path: body[:path],
52
+ force: body[:force] || false,
53
+ dry_run: body[:dry_run] || false
54
+ )
55
+ else
56
+ Legion::Extensions::Knowledge::Runners::Ingest.ingest_file(
57
+ file_path: body[:path],
58
+ force: body[:force] || false,
59
+ dry_run: body[:dry_run] || false
60
+ )
61
+ end
62
+ else
63
+ halt 400, json_error('missing_param', 'content or path is required')
64
+ end
65
+ json_response(result)
66
+ end
67
+
68
+ app.post '/api/knowledge/status' do
69
+ require_knowledge_ingest!
70
+ body = parse_request_body
71
+ path = body[:path] || Dir.pwd
72
+ result = Legion::Extensions::Knowledge::Runners::Ingest.scan_corpus(path: path)
73
+ json_response(result)
74
+ end
75
+ end
76
+
77
+ def self.register_maintenance_routes(app)
78
+ app.post '/api/knowledge/health' do
79
+ require_knowledge_maintenance!
80
+ body = parse_request_body
81
+ result = Legion::Extensions::Knowledge::Runners::Maintenance.health(path: body[:path])
82
+ json_response(result)
83
+ end
84
+
85
+ app.post '/api/knowledge/maintain' do
86
+ require_knowledge_maintenance!
87
+ body = parse_request_body
88
+ result = Legion::Extensions::Knowledge::Runners::Maintenance.cleanup_orphans(
89
+ path: body[:path],
90
+ dry_run: body.fetch(:dry_run, true)
91
+ )
92
+ json_response(result)
93
+ end
94
+
95
+ app.post '/api/knowledge/quality' do
96
+ require_knowledge_maintenance!
97
+ body = parse_request_body
98
+ result = Legion::Extensions::Knowledge::Runners::Maintenance.quality_report(
99
+ limit: body[:limit] || 10
100
+ )
101
+ json_response(result)
102
+ end
103
+ end
104
+
105
+ def self.register_monitor_routes(app)
106
+ app.get '/api/knowledge/monitors' do
107
+ require_knowledge_monitor!
108
+ monitors = Legion::Extensions::Knowledge::Runners::Monitor.list_monitors
109
+ json_response(monitors)
110
+ end
111
+
112
+ app.post '/api/knowledge/monitors' do
113
+ require_knowledge_monitor!
114
+ body = parse_request_body
115
+ result = Legion::Extensions::Knowledge::Runners::Monitor.add_monitor(
116
+ path: body[:path],
117
+ extensions: body[:extensions],
118
+ label: body[:label]
119
+ )
120
+ json_response(result, status_code: 201)
121
+ end
122
+
123
+ app.delete '/api/knowledge/monitors' do
124
+ require_knowledge_monitor!
125
+ body = parse_request_body
126
+ result = Legion::Extensions::Knowledge::Runners::Monitor.remove_monitor(
127
+ identifier: body[:identifier]
128
+ )
129
+ json_response(result)
130
+ end
131
+
132
+ app.get '/api/knowledge/monitors/status' do
133
+ require_knowledge_monitor!
134
+ result = Legion::Extensions::Knowledge::Runners::Monitor.monitor_status
135
+ json_response(result)
136
+ end
137
+ end
138
+
139
+ class << self
140
+ private :register_query_routes, :register_ingest_routes,
141
+ :register_maintenance_routes, :register_monitor_routes
142
+ end
143
+ end
144
+ end
145
+ end
146
+ end
data/lib/legion/api.rb CHANGED
@@ -47,6 +47,7 @@ require_relative 'api/traces'
47
47
  require_relative 'api/stats'
48
48
  require_relative 'api/absorbers'
49
49
  require_relative 'api/codegen'
50
+ require_relative 'api/knowledge'
50
51
  require_relative 'api/logs'
51
52
  require_relative 'api/router'
52
53
  require_relative 'api/library_routes'
@@ -176,6 +177,7 @@ module Legion
176
177
  register Routes::Stats
177
178
  register Routes::Absorbers
178
179
  register Routes::Codegen
180
+ register Routes::Knowledge
179
181
  register Routes::Logs
180
182
  register Routes::TbiPatterns
181
183
  register Routes::GraphQL if defined?(Routes::GraphQL)
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'net/http'
4
- require 'uri'
5
- require 'json'
3
+ require_relative 'api_client'
6
4
 
7
5
  module Legion
8
6
  module CLI
@@ -66,60 +64,12 @@ module Legion
66
64
  end
67
65
 
68
66
  no_commands do
67
+ include ApiClient
68
+
69
69
  def formatter
70
70
  @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
71
71
  end
72
72
 
73
- def api_port
74
- Connection.ensure_settings
75
- api_settings = Legion::Settings[:api]
76
- (api_settings.is_a?(Hash) && api_settings[:port]) || 4567
77
- rescue StandardError
78
- 4567
79
- end
80
-
81
- def api_post(path, **payload)
82
- uri = URI("http://127.0.0.1:#{api_port}#{path}")
83
- http = Net::HTTP.new(uri.host, uri.port)
84
- http.read_timeout = 300
85
- request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
86
- request.body = ::JSON.generate(payload)
87
- response = http.request(request)
88
- unless response.is_a?(Net::HTTPSuccess)
89
- formatter.error("API returned #{response.code} for #{path}")
90
- raise SystemExit, 1
91
- end
92
- body = ::JSON.parse(response.body, symbolize_names: true)
93
- body[:data]
94
- rescue Errno::ECONNREFUSED
95
- formatter.error('Daemon not running. Start with: legionio start')
96
- raise SystemExit, 1
97
- rescue SystemExit
98
- raise
99
- rescue StandardError => e
100
- formatter.error("API request failed: #{e.message}")
101
- raise SystemExit, 1
102
- end
103
-
104
- def api_get(path)
105
- uri = URI("http://127.0.0.1:#{api_port}#{path}")
106
- response = Net::HTTP.get_response(uri)
107
- unless response.is_a?(Net::HTTPSuccess)
108
- formatter.error("API returned #{response.code} for #{path}")
109
- raise SystemExit, 1
110
- end
111
- body = ::JSON.parse(response.body, symbolize_names: true)
112
- body[:data]
113
- rescue Errno::ECONNREFUSED
114
- formatter.error('Daemon not running. Start with: legionio start')
115
- raise SystemExit, 1
116
- rescue SystemExit
117
- raise
118
- rescue StandardError => e
119
- formatter.error("API request failed: #{e.message}")
120
- raise SystemExit, 1
121
- end
122
-
123
73
  def fetch_absorbers
124
74
  api_get('/api/absorbers')
125
75
  end
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'net/http'
4
+ require 'uri'
5
+ require 'json'
6
+
7
+ module Legion
8
+ module CLI
9
+ # Shared HTTP client for CLI commands that talk to the running daemon API.
10
+ # Include this module inside a Thor command's `no_commands` block, or
11
+ # extend it at the class level, to get api_get / api_post / api_put /
12
+ # api_delete helpers that target http://127.0.0.1:<port>/api/*.
13
+ module ApiClient
14
+ def api_port
15
+ Connection.ensure_settings
16
+ api_settings = Legion::Settings[:api]
17
+ (api_settings.is_a?(Hash) && api_settings[:port]) || 4567
18
+ rescue StandardError
19
+ 4567
20
+ end
21
+
22
+ def api_get(path)
23
+ uri = URI("http://127.0.0.1:#{api_port}#{path}")
24
+ http = build_http(uri)
25
+ response = http.get(uri.request_uri)
26
+ handle_response(response, path)
27
+ rescue Errno::ECONNREFUSED
28
+ daemon_not_running!
29
+ rescue SystemExit
30
+ raise
31
+ rescue StandardError => e
32
+ api_error!(e, path)
33
+ end
34
+
35
+ def api_post(path, **payload)
36
+ uri = URI("http://127.0.0.1:#{api_port}#{path}")
37
+ http = build_http(uri, read_timeout: 300)
38
+ request = Net::HTTP::Post.new(uri.path, 'Content-Type' => 'application/json')
39
+ request.body = ::JSON.generate(payload)
40
+ response = http.request(request)
41
+ handle_response(response, path)
42
+ rescue Errno::ECONNREFUSED
43
+ daemon_not_running!
44
+ rescue SystemExit
45
+ raise
46
+ rescue StandardError => e
47
+ api_error!(e, path)
48
+ end
49
+
50
+ def api_put(path, **payload)
51
+ uri = URI("http://127.0.0.1:#{api_port}#{path}")
52
+ http = build_http(uri)
53
+ request = Net::HTTP::Put.new(uri.path, 'Content-Type' => 'application/json')
54
+ request.body = ::JSON.generate(payload)
55
+ response = http.request(request)
56
+ handle_response(response, path)
57
+ rescue Errno::ECONNREFUSED
58
+ daemon_not_running!
59
+ rescue SystemExit
60
+ raise
61
+ rescue StandardError => e
62
+ api_error!(e, path)
63
+ end
64
+
65
+ def api_delete(path)
66
+ uri = URI("http://127.0.0.1:#{api_port}#{path}")
67
+ http = build_http(uri)
68
+ response = http.delete(uri.path)
69
+ handle_response(response, path)
70
+ rescue Errno::ECONNREFUSED
71
+ daemon_not_running!
72
+ rescue SystemExit
73
+ raise
74
+ rescue StandardError => e
75
+ api_error!(e, path)
76
+ end
77
+
78
+ private
79
+
80
+ def build_http(uri, read_timeout: 10)
81
+ http = Net::HTTP.new(uri.host, uri.port)
82
+ http.open_timeout = 3
83
+ http.read_timeout = read_timeout
84
+ http
85
+ end
86
+
87
+ def handle_response(response, path)
88
+ unless response.is_a?(Net::HTTPSuccess)
89
+ formatter.error("API returned #{response.code} for #{path}")
90
+ raise SystemExit, 1
91
+ end
92
+ body = ::JSON.parse(response.body, symbolize_names: true)
93
+ body[:data]
94
+ end
95
+
96
+ def daemon_not_running!
97
+ formatter.error('Daemon not running. Start with: legionio start')
98
+ raise SystemExit, 1
99
+ end
100
+
101
+ def api_error!(err, path)
102
+ formatter.error("API request failed (#{path}): #{err.message}")
103
+ raise SystemExit, 1
104
+ end
105
+ end
106
+ end
107
+ end
@@ -31,7 +31,7 @@ module Legion
31
31
  ['Job', 'jobTitle', :jobTitle]
32
32
  ].freeze
33
33
 
34
- def execute(query:, person: nil, domain: nil, trace_type: nil, limit: nil)
34
+ def execute(query:, person: nil, domain: nil, trace_type: nil, limit: nil, **) # rubocop:disable Metrics/ParameterLists
35
35
  return 'Memory trace system not available (lex-agentic-memory not loaded).' unless trace_store_available?
36
36
 
37
37
  limit = (limit || 20).clamp(1, 50)
@@ -1,103 +1,73 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'api_client'
4
+
3
5
  module Legion
4
6
  module CLI
5
7
  class CodegenCommand < Thor
6
8
  namespace :codegen
7
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
+
8
13
  desc 'status', 'Show codegen cycle stats, pending gaps, registry counts'
9
14
  def status
10
- if defined?(Legion::MCP::SelfGenerate)
11
- data = Legion::MCP::SelfGenerate.status
12
- say Legion::JSON.dump({ data: data })
13
- else
14
- say Legion::JSON.dump({ error: 'codegen not available' })
15
- end
15
+ data = api_get('/api/codegen/status')
16
+ formatter.json(data)
16
17
  end
17
18
 
18
19
  desc 'list', 'List generated functions'
19
20
  method_option :status, type: :string, desc: 'Filter by status'
20
21
  def list
21
- unless defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry)
22
- say Legion::JSON.dump({ error: 'codegen registry not available' })
23
- return
24
- end
25
-
26
- records = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.list(status: options[:status])
27
- say Legion::JSON.dump({ data: records })
22
+ path = '/api/codegen/generated'
23
+ path += "?status=#{options[:status]}" if options[:status]
24
+ data = api_get(path)
25
+ formatter.json(data)
28
26
  end
29
27
 
30
28
  desc 'show ID', 'Show details of a generated function'
31
29
  def show(id)
32
- unless defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry)
33
- say Legion::JSON.dump({ error: 'codegen registry not available' })
34
- return
35
- end
36
-
37
- record = Legion::Extensions::Codegen::Helpers::GeneratedRegistry.get(id: id)
38
- if record
39
- say Legion::JSON.dump({ data: record })
40
- else
41
- say Legion::JSON.dump({ error: 'not found' })
42
- end
30
+ data = api_get("/api/codegen/generated/#{id}")
31
+ formatter.json(data)
43
32
  end
44
33
 
45
34
  desc 'approve ID', 'Manually approve a parked generated function'
46
35
  def approve(id)
47
- unless defined?(Legion::Extensions::Codegen::Runners::ReviewHandler)
48
- say Legion::JSON.dump({ error: 'review handler not available' })
49
- return
50
- end
51
-
52
- result = Legion::Extensions::Codegen::Runners::ReviewHandler.handle_verdict(
53
- review: { generation_id: id, verdict: :approve, confidence: 1.0 }
54
- )
55
- say Legion::JSON.dump({ data: result })
36
+ data = api_post("/api/codegen/generated/#{id}/approve")
37
+ formatter.json(data)
56
38
  end
57
39
 
58
40
  desc 'reject ID', 'Manually reject a generated function'
59
41
  def reject(id)
60
- unless defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry)
61
- say Legion::JSON.dump({ error: 'codegen registry not available' })
62
- return
63
- end
64
-
65
- Legion::Extensions::Codegen::Helpers::GeneratedRegistry.update_status(id: id, status: 'rejected')
66
- say Legion::JSON.dump({ data: { id: id, status: 'rejected' } })
42
+ data = api_post("/api/codegen/generated/#{id}/reject")
43
+ formatter.json(data)
67
44
  end
68
45
 
69
46
  desc 'retry ID', 'Re-queue a generated function for regeneration'
70
47
  def retry_generation(id)
71
- unless defined?(Legion::Extensions::Codegen::Helpers::GeneratedRegistry)
72
- say Legion::JSON.dump({ error: 'codegen registry not available' })
73
- return
74
- end
75
-
76
- Legion::Extensions::Codegen::Helpers::GeneratedRegistry.update_status(id: id, status: 'pending')
77
- say Legion::JSON.dump({ data: { id: id, status: 'pending' } })
48
+ data = api_post("/api/codegen/generated/#{id}/retry")
49
+ formatter.json(data)
78
50
  end
79
51
  map 'retry' => :retry_generation
80
52
 
81
53
  desc 'gaps', 'List detected capability gaps with priorities'
82
54
  def gaps
83
- if defined?(Legion::MCP::GapDetector)
84
- detected = Legion::MCP::GapDetector.detect_gaps
85
- say Legion::JSON.dump({ data: detected })
86
- else
87
- say Legion::JSON.dump({ error: 'gap detector not available' })
88
- end
55
+ data = api_get('/api/codegen/gaps')
56
+ formatter.json(data)
89
57
  end
90
58
 
91
59
  desc 'cycle', 'Manually trigger a generation cycle (bypass cooldown)'
92
60
  def cycle
93
- unless defined?(Legion::MCP::SelfGenerate)
94
- say Legion::JSON.dump({ error: 'self_generate not available' })
95
- return
96
- end
61
+ data = api_post('/api/codegen/cycle')
62
+ formatter.json(data)
63
+ end
97
64
 
98
- Legion::MCP::SelfGenerate.instance_variable_set(:@last_cycle_at, nil)
99
- result = Legion::MCP::SelfGenerate.run_cycle
100
- say Legion::JSON.dump({ data: result })
65
+ no_commands do
66
+ include ApiClient
67
+
68
+ def formatter
69
+ @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
70
+ end
101
71
  end
102
72
  end
103
73
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'shellwords'
4
+ require_relative 'api_client'
4
5
 
5
6
  module Legion
6
7
  module CLI
@@ -16,14 +17,10 @@ module Legion
16
17
  option :extensions, type: :string, desc: 'Comma-separated file extensions to watch (e.g. md,rb)'
17
18
  option :label, type: :string, desc: 'Human-readable label for this monitor'
18
19
  def add(path)
19
- require_monitor!
20
- exts = options[:extensions]&.split(',')&.map(&:strip)
21
- result = Legion::Extensions::Knowledge::Runners::Monitor.add_monitor(
22
- path: path,
23
- extensions: exts,
24
- label: options[:label]
25
- )
26
20
  out = formatter
21
+ exts = options[:extensions]&.split(',')&.map(&:strip)
22
+ result = api_post('/api/knowledge/monitors', path: path, extensions: exts, label: options[:label])
23
+
27
24
  if options[:json]
28
25
  out.json(result)
29
26
  elsif result[:success]
@@ -35,9 +32,9 @@ module Legion
35
32
 
36
33
  desc 'list', 'List registered corpus monitors'
37
34
  def list
38
- require_monitor!
39
- monitors = Legion::Extensions::Knowledge::Runners::Monitor.list_monitors
40
35
  out = formatter
36
+ monitors = api_get('/api/knowledge/monitors')
37
+
41
38
  if options[:json]
42
39
  out.json(monitors)
43
40
  elsif monitors.nil? || monitors.empty?
@@ -56,9 +53,9 @@ module Legion
56
53
 
57
54
  desc 'remove IDENTIFIER', 'Remove a corpus monitor by path or label'
58
55
  def remove(identifier)
59
- require_monitor!
60
- result = Legion::Extensions::Knowledge::Runners::Monitor.remove_monitor(identifier:)
61
56
  out = formatter
57
+ result = api_delete("/api/knowledge/monitors?identifier=#{URI.encode_www_form_component(identifier)}")
58
+
62
59
  if options[:json]
63
60
  out.json(result)
64
61
  elsif result[:success]
@@ -70,9 +67,9 @@ module Legion
70
67
 
71
68
  desc 'status', 'Show monitor status (counts)'
72
69
  def status
73
- require_monitor!
74
- result = Legion::Extensions::Knowledge::Runners::Monitor.monitor_status
75
70
  out = formatter
71
+ result = api_get('/api/knowledge/monitors/status')
72
+
76
73
  if options[:json]
77
74
  out.json(result)
78
75
  else
@@ -85,13 +82,11 @@ module Legion
85
82
  end
86
83
 
87
84
  no_commands do
85
+ include ApiClient
86
+
88
87
  def formatter
89
88
  @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
90
89
  end
91
-
92
- def require_monitor!
93
- Connection.ensure_knowledge unless defined?(Legion::Extensions::Knowledge::Runners::Monitor)
94
- end
95
90
  end
96
91
  end
97
92
 
@@ -118,12 +113,7 @@ module Legion
118
113
  content = "Git commit: #{sha}\nSubject: #{subject}\n\nDiff stat:\n#{diff_stat}"
119
114
  tags = %w[git commit knowledge-capture]
120
115
 
121
- Connection.ensure_knowledge unless defined?(Legion::Extensions::Knowledge::Runners::Ingest)
122
- result = Legion::Extensions::Knowledge::Runners::Ingest.ingest_file(
123
- content: content,
124
- tags: tags,
125
- source: "git:#{sha}"
126
- )
116
+ result = api_post('/api/knowledge/ingest', content: content, tags: tags, source: "git:#{sha}")
127
117
 
128
118
  out = formatter
129
119
  if options[:json]
@@ -150,12 +140,8 @@ module Legion
150
140
  tags = ['session', 'knowledge-capture', ::Time.now.strftime('%Y-%m-%d')]
151
141
  tags << "repo:#{repo}" unless repo.empty?
152
142
 
153
- Connection.ensure_knowledge unless defined?(Legion::Extensions::Knowledge::Runners::Ingest)
154
- result = Legion::Extensions::Knowledge::Runners::Ingest.ingest_file(
155
- content: content,
156
- tags: tags,
157
- source: "session:#{::Time.now.iso8601}"
158
- )
143
+ result = api_post('/api/knowledge/ingest',
144
+ content: content, tags: tags, source: "session:#{::Time.now.iso8601}")
159
145
 
160
146
  out = formatter
161
147
  if options[:json]
@@ -201,11 +187,10 @@ module Legion
201
187
  content = format_turn(turn, idx + 1)
202
188
  next if content.strip.empty?
203
189
 
204
- result = ingest_content(
205
- content: content,
206
- tags: base_tags + ["turn:#{idx + 1}"],
207
- source: "claude-code:#{session_id}:turn-#{idx + 1}"
208
- )
190
+ result = api_post('/api/knowledge/ingest',
191
+ content: content,
192
+ tags: base_tags + ["turn:#{idx + 1}"],
193
+ source: "claude-code:#{session_id}:turn-#{idx + 1}")
209
194
  ingested += 1 if result[:success]
210
195
  end
211
196
 
@@ -217,7 +202,9 @@ module Legion
217
202
  end
218
203
  end
219
204
 
220
- no_commands do # rubocop:disable Metrics/BlockLength
205
+ no_commands do
206
+ include ApiClient
207
+
221
208
  def formatter
222
209
  @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
223
210
  end
@@ -279,18 +266,6 @@ module Legion
279
266
 
280
267
  "#{text.byteslice(0, max_bytes - 20)}\n\n[truncated]"
281
268
  end
282
-
283
- def ingest_content(content:, tags:, source:)
284
- if defined?(Legion::Extensions::Knowledge::Runners::Ingest)
285
- Legion::Extensions::Knowledge::Runners::Ingest.ingest_file(
286
- content: content, tags: tags, source: source
287
- )
288
- elsif defined?(Legion::Apollo)
289
- Legion::Apollo.ingest(content: content, tags: tags, source: source)
290
- else
291
- { success: false, error: 'neither lex-knowledge nor legion-apollo available' }
292
- end
293
- end
294
269
  end
295
270
  end
296
271
 
@@ -307,9 +282,8 @@ module Legion
307
282
  option :synthesize, type: :boolean, default: true, desc: 'Synthesize an LLM answer'
308
283
  option :verbose, type: :boolean, default: false, desc: 'Show full source metadata'
309
284
  def query(question)
310
- require_knowledge!
311
- result = knowledge_query.query(question: question, top_k: options[:top_k],
312
- synthesize: options[:synthesize])
285
+ result = api_post('/api/knowledge/query',
286
+ question: question, top_k: options[:top_k], synthesize: options[:synthesize])
313
287
  out = formatter
314
288
  if options[:json]
315
289
  out.json(result)
@@ -330,8 +304,7 @@ module Legion
330
304
  desc 'retrieve QUESTION', 'Retrieve source chunks without LLM synthesis'
331
305
  option :top_k, type: :numeric, default: 5, desc: 'Number of source chunks'
332
306
  def retrieve(question)
333
- require_knowledge!
334
- result = knowledge_query.retrieve(question: question, top_k: options[:top_k])
307
+ result = api_post('/api/knowledge/retrieve', question: question, top_k: options[:top_k])
335
308
  out = formatter
336
309
  if options[:json]
337
310
  out.json(result)
@@ -347,14 +320,8 @@ module Legion
347
320
  option :force, type: :boolean, default: false, desc: 'Re-ingest even unchanged files'
348
321
  option :dry_run, type: :boolean, default: false, desc: 'Preview without writing'
349
322
  def ingest(path)
350
- require_ingest!
351
- result = if ::File.directory?(path)
352
- knowledge_ingest.ingest_corpus(path: path, force: options[:force],
353
- dry_run: options[:dry_run])
354
- else
355
- knowledge_ingest.ingest_file(file_path: path, force: options[:force],
356
- dry_run: options[:dry_run])
357
- end
323
+ result = api_post('/api/knowledge/ingest',
324
+ path: ::File.expand_path(path), force: options[:force], dry_run: options[:dry_run])
358
325
  out = formatter
359
326
  if options[:json]
360
327
  out.json(result)
@@ -368,8 +335,7 @@ module Legion
368
335
 
369
336
  desc 'status', 'Show knowledge base status'
370
337
  def status
371
- require_ingest!
372
- result = knowledge_ingest.scan_corpus(path: ::Dir.pwd)
338
+ result = api_post('/api/knowledge/status', path: ::Dir.pwd)
373
339
  out = formatter
374
340
  if options[:json]
375
341
  out.json(result)
@@ -386,9 +352,7 @@ module Legion
386
352
  desc 'health', 'Show knowledge base health report (local, Apollo, sync)'
387
353
  option :corpus_path, type: :string, desc: 'Path to corpus directory (falls back to settings)'
388
354
  def health
389
- require_maintenance!
390
- path = resolve_corpus_path
391
- result = knowledge_maintenance.health(path: path)
355
+ result = api_post('/api/knowledge/health', path: options[:corpus_path])
392
356
  out = formatter
393
357
  if options[:json]
394
358
  out.json(result)
@@ -412,9 +376,8 @@ module Legion
412
376
  option :corpus_path, type: :string, desc: 'Path to corpus directory (falls back to settings)'
413
377
  option :dry_run, type: :boolean, default: true, desc: 'Preview without archiving (default: true)'
414
378
  def maintain
415
- require_maintenance!
416
- path = resolve_corpus_path
417
- result = knowledge_maintenance.cleanup_orphans(path: path, dry_run: options[:dry_run])
379
+ result = api_post('/api/knowledge/maintain',
380
+ path: options[:corpus_path], dry_run: options[:dry_run])
418
381
  out = formatter
419
382
  if options[:json]
420
383
  out.json(result)
@@ -434,8 +397,7 @@ module Legion
434
397
  desc 'quality', 'Show knowledge quality report (hot, cold, low-confidence chunks)'
435
398
  option :limit, type: :numeric, default: 10, desc: 'Max entries per category'
436
399
  def quality
437
- require_maintenance!
438
- result = knowledge_maintenance.quality_report(limit: options[:limit])
400
+ result = api_post('/api/knowledge/quality', limit: options[:limit])
439
401
  out = formatter
440
402
  if options[:json]
441
403
  out.json(result)
@@ -459,54 +421,13 @@ module Legion
459
421
  desc 'capture SUBCOMMAND', 'Capture knowledge from git commits or sessions'
460
422
  subcommand 'capture', CaptureCommand
461
423
 
462
- no_commands do # rubocop:disable Metrics/BlockLength
424
+ no_commands do
425
+ include ApiClient
426
+
463
427
  def formatter
464
428
  @formatter ||= Output::Formatter.new(json: options[:json], color: !options[:no_color])
465
429
  end
466
430
 
467
- def require_knowledge!
468
- Connection.ensure_knowledge unless defined?(Legion::Extensions::Knowledge::Runners::Query)
469
- end
470
-
471
- def require_ingest!
472
- Connection.ensure_knowledge unless defined?(Legion::Extensions::Knowledge::Runners::Ingest)
473
- end
474
-
475
- def require_maintenance!
476
- Connection.ensure_knowledge unless defined?(Legion::Extensions::Knowledge::Runners::Maintenance)
477
- end
478
-
479
- def knowledge_query
480
- Legion::Extensions::Knowledge::Runners::Query
481
- end
482
-
483
- def knowledge_ingest
484
- Legion::Extensions::Knowledge::Runners::Ingest
485
- end
486
-
487
- def knowledge_maintenance
488
- Legion::Extensions::Knowledge::Runners::Maintenance
489
- end
490
-
491
- def resolve_corpus_path
492
- if options[:corpus_path]
493
- options[:corpus_path]
494
- elsif defined?(Legion::Extensions::Knowledge::Runners::Monitor)
495
- monitors = Legion::Extensions::Knowledge::Runners::Monitor.resolve_monitors
496
- monitors.first&.dig(:path) || legacy_corpus_path || ::Dir.pwd
497
- elsif defined?(Legion::Settings)
498
- Legion::Settings.dig(:knowledge, :corpus_path) || ::Dir.pwd
499
- else
500
- ::Dir.pwd
501
- end
502
- end
503
-
504
- def legacy_corpus_path
505
- return unless defined?(Legion::Settings)
506
-
507
- Legion::Settings.dig(:knowledge, :corpus_path)
508
- end
509
-
510
431
  def print_sources(sources, out, verbose:)
511
432
  return out.warn('No sources found') if sources.empty?
512
433
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'api_client'
4
+
3
5
  module Legion
4
6
  module CLI
5
7
  class Schedule < Thor
@@ -17,22 +19,20 @@ module Legion
17
19
  option :limit, type: :numeric, default: 20, desc: 'Max results'
18
20
  def list
19
21
  out = formatter
20
- with_data do
21
- require_scheduler!
22
- ds = Legion::Extensions::Scheduler::Data::Model::Schedule.dataset
23
- ds = ds.where(active: true) if options[:active]
24
- schedules = ds.limit(options[:limit]).all
25
-
26
- if options[:json]
27
- out.json(schedules.map(&:values))
28
- else
29
- rows = schedules.map do |s|
30
- [s[:id], s[:function_id] || '-', s[:cron] || s[:interval] || '-',
31
- out.status(s[:active] ? 'active' : 'inactive'), s[:description] || '-']
32
- end
33
- out.table(%w[ID Function Schedule Status Description], rows)
34
- puts " #{schedules.size} schedule(s)"
22
+ query = "/api/schedules?limit=#{options[:limit]}"
23
+ query += '&active=true' if options[:active]
24
+ schedules = api_get(query)
25
+ schedules = [] if schedules.nil?
26
+
27
+ if options[:json]
28
+ out.json(schedules)
29
+ else
30
+ rows = Array(schedules).map do |s|
31
+ [s[:id], s[:function_id] || '-', s[:cron] || s[:interval] || '-',
32
+ out.status(s[:active] ? 'active' : 'inactive'), s[:description] || '-']
35
33
  end
34
+ out.table(%w[ID Function Schedule Status Description], rows)
35
+ puts " #{rows.size} schedule(s)"
36
36
  end
37
37
  end
38
38
  default_task :list
@@ -40,21 +40,14 @@ module Legion
40
40
  desc 'show ID', 'Show schedule details'
41
41
  def show(id)
42
42
  out = formatter
43
- with_data do
44
- require_scheduler!
45
- schedule = Legion::Extensions::Scheduler::Data::Model::Schedule[id.to_i]
46
- unless schedule
47
- out.error("Schedule not found: #{id}")
48
- return
49
- end
50
-
51
- if options[:json]
52
- out.json(schedule.values)
53
- else
54
- out.header("Schedule ##{id}")
55
- out.spacer
56
- out.detail(schedule.values.transform_keys(&:to_s))
57
- end
43
+ schedule = api_get("/api/schedules/#{id}")
44
+
45
+ if options[:json]
46
+ out.json(schedule)
47
+ else
48
+ out.header("Schedule ##{id}")
49
+ out.spacer
50
+ out.detail(schedule.transform_keys(&:to_s))
58
51
  end
59
52
  end
60
53
 
@@ -65,24 +58,22 @@ module Legion
65
58
  option :description, type: :string, desc: 'Schedule description'
66
59
  def add
67
60
  out = formatter
68
- with_data do
69
- require_scheduler!
70
- attrs = { function_id: options[:function_id], active: true, created_at: Time.now.utc }
71
- attrs[:cron] = options[:cron] if options[:cron]
72
- attrs[:interval] = options[:interval] if options[:interval]
73
- attrs[:description] = options[:description] if options[:description]
74
-
75
- unless attrs[:cron] || attrs[:interval]
76
- out.error('Either --cron or --interval is required')
77
- return
78
- end
79
61
 
80
- id = Legion::Extensions::Scheduler::Data::Model::Schedule.insert(attrs)
81
- if options[:json]
82
- out.json({ id: id, created: true })
83
- else
84
- out.success("Schedule ##{id} created")
85
- end
62
+ unless options[:cron] || options[:interval]
63
+ out.error('Either --cron or --interval is required')
64
+ return
65
+ end
66
+
67
+ payload = { function_id: options[:function_id], active: true }
68
+ payload[:cron] = options[:cron] if options[:cron]
69
+ payload[:interval] = options[:interval] if options[:interval]
70
+ payload[:description] = options[:description] if options[:description]
71
+
72
+ result = api_post('/api/schedules', **payload)
73
+ if options[:json]
74
+ out.json(result)
75
+ else
76
+ out.success("Schedule ##{result[:id]} created")
86
77
  end
87
78
  end
88
79
 
@@ -90,25 +81,17 @@ module Legion
90
81
  option :yes, type: :boolean, default: false, aliases: '-y', desc: 'Skip confirmation'
91
82
  def remove(id)
92
83
  out = formatter
93
- with_data do
94
- require_scheduler!
95
- schedule = Legion::Extensions::Scheduler::Data::Model::Schedule[id.to_i]
96
- unless schedule
97
- out.error("Schedule not found: #{id}")
98
- return
99
- end
100
84
 
101
- unless options[:yes]
102
- print "Delete schedule ##{id}? [y/N] "
103
- return unless $stdin.gets&.strip&.downcase == 'y'
104
- end
85
+ unless options[:yes]
86
+ print "Delete schedule ##{id}? [y/N] "
87
+ return unless $stdin.gets&.strip&.downcase == 'y'
88
+ end
105
89
 
106
- schedule.delete
107
- if options[:json]
108
- out.json({ id: id.to_i, deleted: true })
109
- else
110
- out.success("Schedule ##{id} deleted")
111
- end
90
+ result = api_delete("/api/schedules/#{id}")
91
+ if options[:json]
92
+ out.json({ id: id.to_i, deleted: true }.merge(result || {}))
93
+ else
94
+ out.success("Schedule ##{id} deleted")
112
95
  end
113
96
  end
114
97
 
@@ -116,58 +99,33 @@ module Legion
116
99
  option :limit, type: :numeric, default: 20, desc: 'Max results'
117
100
  def logs(id)
118
101
  out = formatter
119
- with_data do
120
- require_scheduler!
121
- schedule = Legion::Extensions::Scheduler::Data::Model::Schedule[id.to_i]
122
- unless schedule
123
- out.error("Schedule not found: #{id}")
124
- return
125
- end
126
-
127
- log_entries = Legion::Extensions::Scheduler::Data::Model::ScheduleLog
128
- .where(schedule_id: id.to_i)
129
- .order(Sequel.desc(:id))
130
- .limit(options[:limit]).all
131
-
132
- if options[:json]
133
- out.json(log_entries.map(&:values))
102
+ log_entries = api_get("/api/schedules/#{id}/logs?limit=#{options[:limit]}")
103
+ log_entries = [] if log_entries.nil?
104
+
105
+ if options[:json]
106
+ out.json(log_entries)
107
+ else
108
+ out.header("Logs for Schedule ##{id}")
109
+ if Array(log_entries).empty?
110
+ puts ' No logs found.'
134
111
  else
135
- out.header("Logs for Schedule ##{id}")
136
- if log_entries.empty?
137
- puts ' No logs found.'
138
- else
139
- rows = log_entries.map { |l| [l[:id], l[:status] || '-', l[:started_at]&.to_s || '-', l[:message] || '-'] }
140
- out.table(%w[ID Status Started Message], rows)
112
+ rows = Array(log_entries).map do |l|
113
+ [l[:id], l[:status] || '-', l[:started_at]&.to_s || '-', l[:message] || '-']
141
114
  end
115
+ out.table(%w[ID Status Started Message], rows)
142
116
  end
143
117
  end
144
118
  end
145
119
 
146
120
  no_commands do
121
+ include ApiClient
122
+
147
123
  def formatter
148
124
  @formatter ||= Output::Formatter.new(
149
125
  json: options[:json],
150
126
  color: !options[:no_color]
151
127
  )
152
128
  end
153
-
154
- def with_data
155
- Connection.config_dir = options[:config_dir] if options[:config_dir]
156
- Connection.log_level = options[:verbose] ? 'debug' : 'error'
157
- Connection.ensure_data
158
- yield
159
- rescue CLI::Error => e
160
- formatter.error(e.message)
161
- raise SystemExit, 1
162
- ensure
163
- Connection.shutdown
164
- end
165
-
166
- def require_scheduler!
167
- return if defined?(Legion::Extensions::Scheduler::Data::Model::Schedule)
168
-
169
- raise CLI::Error, 'lex-scheduler extension is not loaded. Install and enable it first.'
170
- end
171
129
  end
172
130
  end
173
131
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.6.44'
4
+ VERSION = '1.6.45'
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.6.44
4
+ version: 1.6.45
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -489,6 +489,7 @@ files:
489
489
  - lib/legion/api/graphql/types/task_type.rb
490
490
  - lib/legion/api/graphql/types/worker_type.rb
491
491
  - lib/legion/api/helpers.rb
492
+ - lib/legion/api/knowledge.rb
492
493
  - lib/legion/api/lex_dispatch.rb
493
494
  - lib/legion/api/library_routes.rb
494
495
  - lib/legion/api/llm.rb
@@ -540,6 +541,7 @@ files:
540
541
  - lib/legion/cli/acp_command.rb
541
542
  - lib/legion/cli/admin/purge_topology.rb
542
543
  - lib/legion/cli/admin_command.rb
544
+ - lib/legion/cli/api_client.rb
543
545
  - lib/legion/cli/apollo_command.rb
544
546
  - lib/legion/cli/audit_command.rb
545
547
  - lib/legion/cli/auth_command.rb