legionio 1.4.70 → 1.4.71

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: 6f924192ddb882e9dd08fa0ada9088c4f9f468ef8d782df7a06888774ebe5b9d
4
- data.tar.gz: f7eab58ceaf4f9119dad859f9b5d47945b57854a2e65e7f68484660da9c2f3a9
3
+ metadata.gz: 0e16727aaa70a3ffc2da0e6d3f3a6bce498e0052876e280c1d6cb42d50b3d594
4
+ data.tar.gz: 57042f5deb5a35dd8456850606e7a1bf42e0e4d5235385a4abb7987d13831894
5
5
  SHA512:
6
- metadata.gz: 5b120a8843d8626da5508cfef24fa2791539c135d090a83a1519e9db99d3fb78aac7b52fde48b2c844435687114dada9b9622d73f6ab204607e73f422f2a9b90
7
- data.tar.gz: 6965a20d9389f2b8e16be7e83fdac2835c6b18d47269bb9bf9f102588d03ed7df72b62cc119afeb1f7b0c29695efd0654e60af093bff4e00d3e2eefc76f76723
6
+ metadata.gz: b2c90fb0ba93a569c0dbd6c72f6e199fcebd6201fa4d003a114bc2d0f9bced639c28364ccb92aaf78f6ea1d226d1fa78b4a1c92538fa1002274f8656f1a8e0e1
7
+ data.tar.gz: 47dd1c952fcf6fd5f0f70d277b7c03ced2632dfa13cf9c47db2d1e0e03d6f90c005a8f13776a03ed900cbdf2c5e27aa7ec8d62e36751260f207c561878ef78ba
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.71] - 2026-03-19
4
+
5
+ ### Added
6
+ - `POST /api/llm/chat` daemon endpoint with async (202) and sync (201) response paths
7
+ - `ContextCompiler` module: categorizes 35 MCP tools into 9 groups with keyword matching
8
+ - `legion.do` meta-tool: natural language intent routing to best-matching MCP tool
9
+ - `legion.tools` meta-tool: compressed catalog, category browsing, and intent-matched discovery
10
+
11
+ ### Fixed
12
+ - `ContextCompiler.build_tool_index` now handles `MCP::Tool::InputSchema` objects (not just hashes)
13
+
3
14
  ## [1.4.70] - 2026-03-19
4
15
 
5
16
  ### Added
data/CLAUDE.md CHANGED
@@ -9,7 +9,7 @@ The primary gem for the LegionIO framework. An extensible async job engine for s
9
9
 
10
10
  **GitHub**: https://github.com/LegionIO/LegionIO
11
11
  **Gem**: `legionio`
12
- **Version**: 1.4.67
12
+ **Version**: 1.4.70
13
13
  **License**: Apache-2.0
14
14
  **Docker**: `legionio/legion`
15
15
  **Ruby**: >= 3.4
@@ -39,21 +39,26 @@ Before any Legion code loads, `exe/legion` applies three performance optimizatio
39
39
  ```
40
40
  Legion.start
41
41
  └── Legion::Service.new
42
- ├── 1. setup_logging (legion-logging)
43
- ├── 2. setup_settings (legion-settings, loads /etc/legionio, ~/legionio, ./settings)
44
- ├── 3. Legion::Crypt.start (legion-crypt, Vault connection)
45
- ├── 4. setup_transport (legion-transport, RabbitMQ connection)
46
- ├── 5. require legion-cache
47
- ├── 6. setup_data (legion-data, MySQL/SQLite + migrations, optional)
48
- ├── 7. setup_llm (legion-llm, optional)
49
- ├── 8. setup_supervision (process supervision)
50
- ├── 9. load_extensions (discover + load LEX gems, filtered by role profile)
51
- ├── 10. Legion::Crypt.cs (distribute cluster secret)
52
- └── 11. setup_api (start Sinatra/Puma on port 4567)
42
+ ├── 1. setup_logging (legion-logging)
43
+ ├── 2. setup_settings (legion-settings, loads /etc/legionio, ~/legionio, ./settings)
44
+ ├── 3. Legion::Crypt.start (legion-crypt, Vault connection)
45
+ ├── 4. setup_transport (legion-transport, RabbitMQ connection)
46
+ ├── 5. require legion-cache
47
+ ├── 6. setup_data (legion-data, MySQL/SQLite + migrations, optional)
48
+ ├── 7. setup_rbac (legion-rbac, optional)
49
+ ├── 8. setup_llm (legion-llm, optional)
50
+ ├── 9. setup_gaia (legion-gaia, cognitive layer, optional)
51
+ ├── 10. setup_telemetry (OpenTelemetry, optional)
52
+ ├── 11. setup_supervision (process supervision)
53
+ ├── 12. load_extensions (two-phase: require+autobuild all, then hook_all_actors)
54
+ ├── 13. Legion::Crypt.cs (distribute cluster secret)
55
+ └── 14. setup_api (start Sinatra/Puma on port 4567)
53
56
  ```
54
57
 
55
58
  Each phase calls `Legion::Readiness.mark_ready(:component)`. All phases are individually toggleable via `Service.new(transport: false, ...)`.
56
59
 
60
+ Extension loading is two-phase: all extensions are `require`d and `autobuild` runs first, collecting actors into `@pending_actors`. After all extensions are loaded, `hook_all_actors` starts AMQP subscriptions, timers, and other actor types. This prevents race conditions where early extensions start ticking while later ones haven't loaded yet.
61
+
57
62
  ### Reload Sequence
58
63
 
59
64
  `Legion.reload` shuts down all subsystems in reverse order, waits for them to drain, then re-runs setup from settings onward. Extensions and API are re-loaded fresh.
@@ -66,7 +71,7 @@ Legion (lib/legion.rb)
66
71
  │ # Entry points: Legion.start, .shutdown, .reload
67
72
  ├── Process # Daemonization: PID management, signal traps (SIGINT=quit), main loop
68
73
  ├── Readiness # Startup readiness tracking
69
- │ # COMPONENTS: settings, crypt, transport, cache, data, extensions, api
74
+ │ # COMPONENTS: settings, crypt, transport, cache, data, gaia, extensions, api
70
75
  │ # Readiness.ready? checks all; /api/ready returns JSON status
71
76
  ├── Events # In-process pub/sub event bus
72
77
  │ # Events.on(name) / .emit(name, **payload) / .once / .off
@@ -723,8 +728,8 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
723
728
 
724
729
  ```bash
725
730
  bundle install
726
- bundle exec rspec # 1379 examples, 0 failures
727
- bundle exec rubocop # 396 files, 0 offenses
731
+ bundle exec rspec # 1433 examples, 0 failures
732
+ bundle exec rubocop # 418 files, 0 offenses
728
733
  ```
729
734
 
730
735
  Specs use `rack-test` for API testing. `Legion::JSON.load` returns symbol keys — use `body[:data]` not `body['data']` in specs.
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+
5
+ module Legion
6
+ class API < Sinatra::Base
7
+ module Routes
8
+ module Llm
9
+ def self.registered(app)
10
+ app.helpers do
11
+ define_method(:require_llm!) do
12
+ return if defined?(Legion::LLM) &&
13
+ Legion::LLM.respond_to?(:started?) &&
14
+ Legion::LLM.started?
15
+
16
+ halt 503, { 'Content-Type' => 'application/json' },
17
+ Legion::JSON.dump({ error: { code: 'llm_unavailable',
18
+ message: 'LLM subsystem is not available' } })
19
+ end
20
+
21
+ define_method(:cache_available?) do
22
+ defined?(Legion::Cache) &&
23
+ Legion::Cache.respond_to?(:connected?) &&
24
+ Legion::Cache.connected?
25
+ end
26
+ end
27
+
28
+ register_chat(app)
29
+ end
30
+
31
+ def self.register_chat(app)
32
+ app.post '/api/llm/chat' do # rubocop:disable Metrics/BlockLength
33
+ require_llm!
34
+
35
+ body = parse_request_body
36
+ validate_required!(body, :message)
37
+
38
+ request_id = body[:request_id] || SecureRandom.uuid
39
+ message = body[:message]
40
+ model = body[:model]
41
+ provider = body[:provider]
42
+
43
+ if cache_available?
44
+ llm = Legion::LLM
45
+ rc = Legion::LLM::ResponseCache
46
+ rc.init_request(request_id)
47
+
48
+ Thread.new do
49
+ session = llm.chat_direct(model: model, provider: provider)
50
+ response = session.ask(message)
51
+ rc.complete(
52
+ request_id,
53
+ response: response.content,
54
+ meta: {
55
+ model: session.model.to_s,
56
+ tokens_in: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
57
+ tokens_out: response.respond_to?(:output_tokens) ? response.output_tokens : nil
58
+ }
59
+ )
60
+ rescue StandardError => e
61
+ rc.fail_request(request_id, code: 'llm_error', message: e.message)
62
+ end
63
+
64
+ json_response({ request_id: request_id, poll_key: "llm:#{request_id}:status" },
65
+ status_code: 202)
66
+ else
67
+ session = Legion::LLM.chat_direct(model: model, provider: provider)
68
+ response = session.ask(message)
69
+ json_response(
70
+ {
71
+ response: response.content,
72
+ meta: {
73
+ model: session.model.to_s,
74
+ tokens_in: response.respond_to?(:input_tokens) ? response.input_tokens : nil,
75
+ tokens_out: response.respond_to?(:output_tokens) ? response.output_tokens : nil
76
+ }
77
+ },
78
+ status_code: 201
79
+ )
80
+ end
81
+ end
82
+ end
83
+
84
+ class << self
85
+ private :register_chat
86
+ end
87
+ end
88
+ end
89
+ end
90
+ end
data/lib/legion/api.rb CHANGED
@@ -33,6 +33,7 @@ require_relative 'api/auth_kerberos'
33
33
  require_relative 'api/capacity'
34
34
  require_relative 'api/audit'
35
35
  require_relative 'api/metrics'
36
+ require_relative 'api/llm'
36
37
 
37
38
  module Legion
38
39
  class API < Sinatra::Base
@@ -108,6 +109,7 @@ module Legion
108
109
  register Routes::Capacity
109
110
  register Routes::Audit
110
111
  register Routes::Metrics
112
+ register Routes::Llm
111
113
 
112
114
  use Legion::Rbac::Middleware if defined?(Legion::Rbac::Middleware)
113
115
 
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module ContextCompiler
6
+ CATEGORIES = {
7
+ tasks: {
8
+ tools: %w[legion.run_task legion.list_tasks legion.get_task legion.delete_task legion.get_task_logs],
9
+ summary: 'Create, list, query, and delete tasks. Run functions via dot-notation task identifiers.'
10
+ },
11
+ chains: {
12
+ tools: %w[legion.list_chains legion.create_chain legion.update_chain legion.delete_chain],
13
+ summary: 'Manage task chains - ordered sequences of tasks that execute in series.'
14
+ },
15
+ relationships: {
16
+ tools: %w[legion.list_relationships legion.create_relationship legion.update_relationship
17
+ legion.delete_relationship],
18
+ summary: 'Manage trigger-action relationships between functions.'
19
+ },
20
+ extensions: {
21
+ tools: %w[legion.list_extensions legion.get_extension legion.enable_extension
22
+ legion.disable_extension],
23
+ summary: 'Manage LEX extensions - list installed, inspect details, enable/disable.'
24
+ },
25
+ schedules: {
26
+ tools: %w[legion.list_schedules legion.create_schedule legion.update_schedule legion.delete_schedule],
27
+ summary: 'Manage scheduled tasks - cron-style recurring task execution.'
28
+ },
29
+ workers: {
30
+ tools: %w[legion.list_workers legion.show_worker legion.worker_lifecycle legion.worker_costs],
31
+ summary: 'Manage digital workers - list, inspect, lifecycle transitions, cost tracking.'
32
+ },
33
+ rbac: {
34
+ tools: %w[legion.rbac_check legion.rbac_assignments legion.rbac_grants],
35
+ summary: 'Role-based access control - check permissions, view assignments and grants.'
36
+ },
37
+ status: {
38
+ tools: %w[legion.get_status legion.get_config legion.team_summary legion.routing_stats],
39
+ summary: 'System status, configuration, team overview, and routing statistics.'
40
+ },
41
+ describe: {
42
+ tools: %w[legion.describe_runner],
43
+ summary: 'Inspect a specific runner function - parameters, return type, metadata.'
44
+ }
45
+ }.freeze
46
+
47
+ module_function
48
+
49
+ # Returns a compressed summary of all categories with tool counts and tool name lists.
50
+ # @return [Array<Hash>] array of { category:, summary:, tool_count:, tools: }
51
+ def compressed_catalog
52
+ CATEGORIES.map do |category, config|
53
+ tool_names = config[:tools]
54
+ {
55
+ category: category,
56
+ summary: config[:summary],
57
+ tool_count: tool_names.length,
58
+ tools: tool_names
59
+ }
60
+ end
61
+ end
62
+
63
+ # Returns tools for a specific category, filtered to only those present in TOOL_CLASSES.
64
+ # @param category_sym [Symbol] one of the CATEGORIES keys
65
+ # @return [Hash, nil] { category:, summary:, tools: [{ name:, description:, params: }] } or nil
66
+ def category_tools(category_sym)
67
+ config = CATEGORIES[category_sym]
68
+ return nil unless config
69
+
70
+ index = tool_index
71
+ tools = config[:tools].filter_map { |name| index[name] }
72
+ return nil if tools.empty?
73
+
74
+ {
75
+ category: category_sym,
76
+ summary: config[:summary],
77
+ tools: tools
78
+ }
79
+ end
80
+
81
+ # Keyword-match intent against tool names and descriptions.
82
+ # @param intent_string [String] natural language intent
83
+ # @return [Class, nil] best matching tool CLASS from Server::TOOL_CLASSES or nil
84
+ def match_tool(intent_string)
85
+ scored = scored_tools(intent_string)
86
+ return nil if scored.empty?
87
+
88
+ best = scored.max_by { |entry| entry[:score] }
89
+ return nil if best[:score].zero?
90
+
91
+ Server::TOOL_CLASSES.find { |klass| klass.tool_name == best[:name] }
92
+ end
93
+
94
+ # Returns top N keyword-matched tools ranked by score.
95
+ # @param intent_string [String] natural language intent
96
+ # @param limit [Integer] max results (default 5)
97
+ # @return [Array<Hash>] array of { name:, description:, score: }
98
+ def match_tools(intent_string, limit: 5)
99
+ scored = scored_tools(intent_string)
100
+ .select { |entry| entry[:score].positive? }
101
+ .sort_by { |entry| -entry[:score] }
102
+ scored.first(limit)
103
+ end
104
+
105
+ # Returns a hash keyed by tool_name with compressed param info.
106
+ # Memoized — call reset! to clear.
107
+ # @return [Hash<String, Hash>] { name:, description:, params: [String] }
108
+ def tool_index
109
+ @tool_index ||= build_tool_index
110
+ end
111
+
112
+ # Clears the memoized tool_index.
113
+ def reset!
114
+ @tool_index = nil
115
+ end
116
+
117
+ def build_tool_index
118
+ Server::TOOL_CLASSES.each_with_object({}) do |klass, idx|
119
+ raw_schema = klass.input_schema
120
+ schema = raw_schema.is_a?(Hash) ? raw_schema : raw_schema.to_h
121
+ properties = schema[:properties] || {}
122
+ idx[klass.tool_name] = {
123
+ name: klass.tool_name,
124
+ description: klass.description,
125
+ params: properties.keys.map(&:to_s)
126
+ }
127
+ end
128
+ end
129
+
130
+ def scored_tools(intent_string)
131
+ keywords = intent_string.downcase.split
132
+ return [] if keywords.empty?
133
+
134
+ tool_index.values.map do |entry|
135
+ haystack = "#{entry[:name].downcase} #{entry[:description].downcase}"
136
+ score = keywords.count { |kw| haystack.include?(kw) }
137
+ { name: entry[:name], description: entry[:description], score: score }
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -33,6 +33,9 @@ require_relative 'tools/routing_stats'
33
33
  require_relative 'tools/rbac_check'
34
34
  require_relative 'tools/rbac_assignments'
35
35
  require_relative 'tools/rbac_grants'
36
+ require_relative 'context_compiler'
37
+ require_relative 'tools/do_action'
38
+ require_relative 'tools/discover_tools'
36
39
  require_relative 'resources/runner_catalog'
37
40
  require_relative 'resources/extension_info'
38
41
 
@@ -72,7 +75,9 @@ module Legion
72
75
  Tools::RoutingStats,
73
76
  Tools::RbacCheck,
74
77
  Tools::RbacAssignments,
75
- Tools::RbacGrants
78
+ Tools::RbacGrants,
79
+ Tools::DoAction,
80
+ Tools::DiscoverTools
76
81
  ].freeze
77
82
 
78
83
  class << self
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class DiscoverTools < ::MCP::Tool
7
+ tool_name 'legion.tools'
8
+ description 'Discover available Legion tools by category or intent. Returns compressed definitions to reduce context.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ category: {
13
+ type: 'string',
14
+ description: 'Tool category: tasks, chains, relationships, extensions, schedules, workers, rbac, status, describe'
15
+ },
16
+ intent: {
17
+ type: 'string',
18
+ description: 'Describe what you want to do and relevant tools will be ranked'
19
+ }
20
+ }
21
+ )
22
+
23
+ class << self
24
+ def call(category: nil, intent: nil)
25
+ if category
26
+ result = ContextCompiler.category_tools(category.to_sym)
27
+ return error_response("Unknown category: #{category}") if result.nil?
28
+
29
+ text_response(result)
30
+ elsif intent
31
+ results = ContextCompiler.match_tools(intent, limit: 5)
32
+ text_response({ matched_tools: results })
33
+ else
34
+ text_response(ContextCompiler.compressed_catalog)
35
+ end
36
+ rescue StandardError => e
37
+ error_response("Failed: #{e.message}")
38
+ end
39
+
40
+ private
41
+
42
+ def text_response(data)
43
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
44
+ end
45
+
46
+ def error_response(msg)
47
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module Tools
6
+ class DoAction < ::MCP::Tool
7
+ tool_name 'legion.do'
8
+ description 'Execute a Legion action by describing what you want to do in natural language. Routes to the best matching tool automatically.'
9
+
10
+ input_schema(
11
+ properties: {
12
+ intent: {
13
+ type: 'string',
14
+ description: 'Natural language description (e.g., "list all running tasks")'
15
+ },
16
+ params: {
17
+ type: 'object',
18
+ description: 'Parameters to pass to the matched tool',
19
+ additionalProperties: true
20
+ }
21
+ },
22
+ required: ['intent']
23
+ )
24
+
25
+ class << self
26
+ def call(intent:, params: {})
27
+ matched = ContextCompiler.match_tool(intent)
28
+ return error_response("No matching tool found for intent: #{intent}") if matched.nil?
29
+
30
+ Legion::MCP::Observer.record_intent(intent, matched) if defined?(Legion::MCP::Observer)
31
+
32
+ tool_params = params.transform_keys(&:to_sym)
33
+ if tool_params.empty?
34
+ matched.call
35
+ else
36
+ matched.call(**tool_params)
37
+ end
38
+ rescue StandardError => e
39
+ error_response("Failed: #{e.message}")
40
+ end
41
+
42
+ private
43
+
44
+ def text_response(data)
45
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump(data) }])
46
+ end
47
+
48
+ def error_response(msg)
49
+ ::MCP::Tool::Response.new([{ type: 'text', text: Legion::JSON.dump({ error: msg }) }], error: true)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.70'
4
+ VERSION = '1.4.71'
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.4.70
4
+ version: 1.4.71
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -353,6 +353,7 @@ files:
353
353
  - lib/legion/api/gaia.rb
354
354
  - lib/legion/api/helpers.rb
355
355
  - lib/legion/api/hooks.rb
356
+ - lib/legion/api/llm.rb
356
357
  - lib/legion/api/metrics.rb
357
358
  - lib/legion/api/middleware/api_version.rb
358
359
  - lib/legion/api/middleware/auth.rb
@@ -508,9 +509,6 @@ files:
508
509
  - lib/legion/cli/theme.rb
509
510
  - lib/legion/cli/trace_command.rb
510
511
  - lib/legion/cli/trigger.rb
511
- - lib/legion/cli/tty/chat_ui.rb
512
- - lib/legion/cli/tty/palette.rb
513
- - lib/legion/cli/tty/splash.rb
514
512
  - lib/legion/cli/tty_command.rb
515
513
  - lib/legion/cli/update_command.rb
516
514
  - lib/legion/cli/version.rb
@@ -560,6 +558,7 @@ files:
560
558
  - lib/legion/lex.rb
561
559
  - lib/legion/mcp.rb
562
560
  - lib/legion/mcp/auth.rb
561
+ - lib/legion/mcp/context_compiler.rb
563
562
  - lib/legion/mcp/resources/extension_info.rb
564
563
  - lib/legion/mcp/resources/runner_catalog.rb
565
564
  - lib/legion/mcp/server.rb
@@ -573,6 +572,8 @@ files:
573
572
  - lib/legion/mcp/tools/delete_task.rb
574
573
  - lib/legion/mcp/tools/describe_runner.rb
575
574
  - lib/legion/mcp/tools/disable_extension.rb
575
+ - lib/legion/mcp/tools/discover_tools.rb
576
+ - lib/legion/mcp/tools/do_action.rb
576
577
  - lib/legion/mcp/tools/enable_extension.rb
577
578
  - lib/legion/mcp/tools/get_config.rb
578
579
  - lib/legion/mcp/tools/get_extension.rb
@@ -1,220 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'tty-box'
4
- require 'tty-markdown'
5
- require 'tty-reader'
6
- require 'tty-screen'
7
- require 'tty-cursor'
8
- require_relative 'palette'
9
-
10
- module Legion
11
- module CLI
12
- module TTY
13
- module ChatUI
14
- SLASH_COMMANDS = {
15
- '/help' => 'Show available commands',
16
- '/quit' => 'Exit chat',
17
- '/clear' => 'Clear conversation',
18
- '/status' => 'Show system status',
19
- '/cost' => 'Show session token usage',
20
- '/model' => 'Switch model',
21
- '/compact' => 'Compact conversation history'
22
- }.freeze
23
-
24
- class << self
25
- def run
26
- p = Palette
27
- reader = ::TTY::Reader.new(interrupt: :exit, track_history: true)
28
-
29
- render_chat_header
30
- puts
31
- render_welcome
32
- puts
33
-
34
- token_count = 0
35
- turn = 0
36
-
37
- loop do
38
- prompt_text = " #{p.fg(:cardinal)}\u276f#{p.reset} "
39
- input = reader.read_line(prompt_text)&.strip
40
-
41
- break if input.nil?
42
- next if input.empty?
43
- break if input == '/quit'
44
-
45
- result = handle_slash_command(input, turn, token_count)
46
- if result
47
- turn = result[:turn] if result.key?(:turn)
48
- token_count = result[:token_count] if result.key?(:token_count)
49
- next
50
- end
51
-
52
- turn += 1
53
- token_count += input.split.size * 3
54
-
55
- response = simulate_response(input, turn)
56
- token_count += response.split.size * 4
57
-
58
- render_response(response)
59
- puts
60
- end
61
-
62
- puts
63
- puts " #{p.muted('Session ended.')} #{p.disabled("#{turn} turns, ~#{token_count} tokens")}"
64
- puts
65
- end
66
-
67
- private
68
-
69
- def handle_slash_command(input, turn, token_count)
70
- cursor = ::TTY::Cursor
71
- case input
72
- when '/help'
73
- render_help
74
- {}
75
- when '/clear'
76
- print cursor.clear_screen + cursor.move_to(0, 0)
77
- render_chat_header
78
- puts
79
- render_system_message('Conversation cleared.')
80
- puts
81
- { turn: 0, token_count: 0 }
82
- when '/status'
83
- render_status(turn, token_count)
84
- {}
85
- when '/cost'
86
- render_cost(token_count)
87
- {}
88
- else
89
- if input.start_with?('/')
90
- render_system_message("Unknown command: #{input}. Type /help for available commands.")
91
- puts
92
- {}
93
- end
94
- end
95
- end
96
-
97
- def render_chat_header
98
- p = Palette
99
- width = [::TTY::Screen.width, 80].min
100
-
101
- puts " #{p.border('─' * (width - 4))}"
102
- puts " #{p.heading('Legion Chat')} #{p.muted('(TTY Toolkit POC)')}"
103
- puts " #{p.border('─' * (width - 4))}"
104
- end
105
-
106
- def render_welcome
107
- p = Palette
108
- puts " #{p.body('Type a message to chat. Use')} #{p.accent('/help')} #{p.body('for commands.')}"
109
- end
110
-
111
- def render_help
112
- p = Palette
113
- puts
114
- puts " #{p.heading('Commands')}"
115
- puts
116
- SLASH_COMMANDS.each do |cmd, desc|
117
- puts " #{p.accent(cmd.ljust(12))} #{p.body(desc)}"
118
- end
119
- puts
120
- end
121
-
122
- def render_system_message(text)
123
- p = Palette
124
- puts " #{p.muted("\u00b7")} #{p.body(text)}"
125
- end
126
-
127
- def render_status(turn, tokens)
128
- p = Palette
129
- puts
130
-
131
- w = 48
132
- lines = [
133
- "#{p.label('Turns')} #{p.body(turn.to_s)}",
134
- "#{p.label('Tokens')} #{p.body("~#{tokens}")}",
135
- "#{p.label('Model')} #{p.body('claude-opus-4-6')}",
136
- "#{p.label('Provider')} #{p.body('anthropic')}",
137
- "#{p.label('Session')} #{p.success('active')}"
138
- ]
139
-
140
- puts " #{p.border('┌')} #{p.heading('Status')} #{p.border('─' * (w - 12))}#{p.border('┐')}"
141
- puts " #{p.border('│')}#{' ' * w}#{p.border('│')}"
142
- lines.each do |line|
143
- puts " #{p.border('│')} #{line}#{' ' * 4}#{p.border('│')}"
144
- end
145
- puts " #{p.border('│')}#{' ' * w}#{p.border('│')}"
146
- puts " #{p.border('└')}#{p.border('─' * w)}#{p.border('┘')}"
147
- puts
148
- end
149
-
150
- def render_cost(tokens)
151
- p = Palette
152
- cost_estimate = (tokens / 1000.0 * 0.015).round(4)
153
- puts
154
- puts " #{p.label('Tokens')} #{p.body("~#{tokens}")} #{p.muted('|')} #{p.label('Cost')} #{p.body("~$#{cost_estimate}")}"
155
- puts
156
- end
157
-
158
- def render_response(text)
159
- puts
160
-
161
- # Render as markdown
162
- rendered = ::TTY::Markdown.parse(
163
- text,
164
- width: [::TTY::Screen.width - 6, 74].min,
165
- theme: {
166
- em: :italic,
167
- header: %i[bold],
168
- hr: :dim,
169
- link: [:underline],
170
- list: [],
171
- strong: [:bold],
172
- table: [],
173
- quote: [:italic]
174
- }
175
- )
176
-
177
- rendered.each_line do |line|
178
- puts " #{line}"
179
- end
180
- end
181
-
182
- def simulate_response(_input, turn)
183
- responses = [
184
- "I can help with that. Here's what I found:\n\n" \
185
- 'The LegionIO extension system uses **auto-discovery** via `Bundler.load.specs` ' \
186
- "to find all `lex-*` gems. Each extension defines:\n\n" \
187
- "- **Runners** — the actual functions that execute\n" \
188
- "- **Actors** — execution modes (subscription, polling, interval)\n" \
189
- "- **Helpers** — shared utilities for the extension\n\n" \
190
- "```ruby\nmodule Legion::Extensions::MyExtension\n module Runners\n module Process\n " \
191
- "def handle(payload)\n # Your logic here\n end\n end\n end\nend\n```\n\n" \
192
- 'Would you like me to scaffold a new extension?',
193
-
194
- "Looking at the current GAIA tick cycle, here's the phase breakdown:\n\n" \
195
- "| Phase | Name | Purpose |\n" \
196
- "|-------|------|---------|\n" \
197
- "| 1 | sensory_input | Gather raw input signals |\n" \
198
- "| 2 | perception | Pattern recognition |\n" \
199
- "| 3 | memory_retrieval | Query lex-memory traces |\n" \
200
- "| 4 | knowledge_retrieval | Query Apollo knowledge base |\n" \
201
- "| 5 | working_memory | Integrate context |\n\n" \
202
- "The tick cycle runs at **configurable intervals** via `legion-gaia` settings.\n\n" \
203
- '> Note: Apollo knowledge retrieval requires a running PostgreSQL instance with pgvector.',
204
-
205
- "Here's a quick summary of what changed:\n\n" \
206
- "### Modified Files\n\n" \
207
- "1. `lib/legion/cli/tty/splash.rb` — New splash screen with TTY toolkit\n" \
208
- "2. `lib/legion/cli/tty/chat_ui.rb` — Chat mode proof of concept\n" \
209
- "3. `lib/legion/cli/tty/palette.rb` — Pastel-based palette wrapper\n\n" \
210
- "All rendering uses the **17-shade single-hue** palette. No colors outside the system.\n\n" \
211
- "```bash\nbundle exec exe/legion-tty\n```"
212
- ]
213
-
214
- responses[(turn - 1) % responses.length]
215
- end
216
- end
217
- end
218
- end
219
- end
220
- end
@@ -1,81 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Legion
4
- module CLI
5
- module TTY
6
- module Palette
7
- # LegionIO canonical palette: 17 shades, one hue, no exceptions.
8
- COLORS = {
9
- void: [7, 6, 15],
10
- background: [14, 13, 26],
11
- deep: [18, 16, 41],
12
- core_shell: [24, 22, 58],
13
- glow_center: [26, 22, 64],
14
- guide_rings: [30, 28, 58],
15
- core_mid: [33, 30, 80],
16
- skip: [42, 39, 96],
17
- inner_tier: [49, 46, 128],
18
- mid_arcs: [61, 56, 138],
19
- diagonal_nodes: [74, 68, 168],
20
- cardinal: [95, 87, 196],
21
- mid_nodes: [127, 119, 221],
22
- inner_nodes: [139, 131, 230],
23
- innermost: [160, 154, 232],
24
- near_white: [184, 178, 239],
25
- self_point: [197, 194, 245]
26
- }.freeze
27
-
28
- RESET = "\e[0m"
29
- BOLD = "\e[1m"
30
- DIM = "\e[2m"
31
-
32
- class << self
33
- def c(name, text)
34
- rgb = COLORS[name]
35
- return text.to_s unless rgb
36
-
37
- "#{fg(name)}#{text}#{RESET}"
38
- end
39
-
40
- def bold(name, text)
41
- rgb = COLORS[name]
42
- return text.to_s unless rgb
43
-
44
- "#{BOLD}#{fg(name)}#{text}#{RESET}"
45
- end
46
-
47
- def dim(name, text)
48
- rgb = COLORS[name]
49
- return text.to_s unless rgb
50
-
51
- "#{DIM}#{fg(name)}#{text}#{RESET}"
52
- end
53
-
54
- def fg(name)
55
- rgb = COLORS[name]
56
- return '' unless rgb
57
-
58
- "\e[38;2;#{rgb[0]};#{rgb[1]};#{rgb[2]}m"
59
- end
60
-
61
- def reset
62
- RESET
63
- end
64
-
65
- # Semantic shortcuts
66
- def title(text) = bold(:self_point, text)
67
- def heading(text) = bold(:near_white, text)
68
- def body(text) = c(:inner_nodes, text)
69
- def label(text) = c(:cardinal, text)
70
- def accent(text) = c(:mid_nodes, text)
71
- def muted(text) = c(:diagonal_nodes, text)
72
- def disabled(text) = c(:skip, text)
73
- def border(text) = c(:inner_tier, text)
74
- def success(text) = c(:cardinal, text)
75
- def caution(text) = c(:innermost, text)
76
- def critical(text) = bold(:self_point, text)
77
- end
78
- end
79
- end
80
- end
81
- end
@@ -1,121 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require 'tty-box'
4
- require 'tty-progressbar'
5
- require 'tty-screen'
6
- require 'tty-font'
7
- require 'tty-cursor'
8
- require_relative 'palette'
9
-
10
- module Legion
11
- module CLI
12
- module TTY
13
- module Splash
14
- BOOT_PHASES = [
15
- { name: 'settings', label: 'legion-settings', version: '1.3.2', delay: 0.15 },
16
- { name: 'crypt', label: 'legion-crypt', version: '1.4.3', delay: 0.20 },
17
- { name: 'transport', label: 'legion-transport', version: '1.2.1', delay: 0.30 },
18
- { name: 'cache', label: 'legion-cache', version: '1.3.0', delay: 0.15 },
19
- { name: 'data', label: 'legion-data', version: '1.4.2', delay: 0.20 },
20
- { name: 'llm', label: 'legion-llm', version: '0.3.3', delay: 0.15 },
21
- { name: 'gaia', label: 'legion-gaia', version: '0.8.0', delay: 0.10 }
22
- ].freeze
23
-
24
- EXTENSIONS = %w[
25
- lex-node lex-health lex-tasker lex-scheduler lex-telemetry
26
- lex-memory lex-coldstart lex-apollo lex-dream lex-reflection
27
- lex-perception lex-attention lex-emotion lex-motivation
28
- ].freeze
29
-
30
- class << self
31
- def run(version: '0.0.0')
32
- cursor = ::TTY::Cursor
33
- print cursor.hide
34
-
35
- render_banner(version)
36
- puts
37
- boot_core_libraries
38
- puts
39
- load_extensions
40
- puts
41
- render_ready_line(version)
42
- puts
43
-
44
- print cursor.show
45
- end
46
-
47
- private
48
-
49
- def render_banner(version)
50
- p = Palette
51
- width = [::TTY::Screen.width, 60].min
52
-
53
- font = ::TTY::Font.new(:standard)
54
- ascii_lines = font.write('LEGION').split("\n")
55
-
56
- # Gradient the ASCII art across palette shades
57
- gradient = %i[inner_tier cardinal mid_nodes inner_nodes innermost near_white]
58
-
59
- puts
60
- ascii_lines.each_with_index do |line, i|
61
- shade = gradient[i % gradient.size]
62
- puts " #{p.c(shade, line)}"
63
- end
64
-
65
- puts " #{p.border('─' * (width - 4))}"
66
- puts " #{p.accent('Async Job Engine & Cognitive Mesh')} #{p.muted("v#{version}")}"
67
- puts " #{p.border('─' * (width - 4))}"
68
- end
69
-
70
- def boot_core_libraries
71
- p = Palette
72
- puts " #{p.heading('Core Libraries')}"
73
- puts
74
-
75
- BOOT_PHASES.each do |phase|
76
- puts " #{p.success('✔')} #{p.label(phase[:label].ljust(20))} #{p.muted(phase[:version])} #{p.success('ready')}"
77
- end
78
- end
79
-
80
- def load_extensions
81
- p = Palette
82
- puts " #{p.heading('Extensions')} #{p.muted("(#{EXTENSIONS.size} discovered)")}"
83
- puts
84
-
85
- bar = ::TTY::ProgressBar.new(
86
- " #{p.fg(:cardinal)}:bar#{p.reset} :current/:total #{p.fg(:diagonal_nodes)}:eta#{p.reset}",
87
- total: EXTENSIONS.size,
88
- width: 30,
89
- complete: "\u2588",
90
- incomplete: "\u2591",
91
- head: "\u2588",
92
- output: $stdout
93
- )
94
-
95
- EXTENSIONS.each { |_ext| bar.advance(1) }
96
-
97
- puts
98
- EXTENSIONS.each_slice(4) do |group|
99
- line = group.map { |ext| p.muted(ext.ljust(18)) }.join
100
- puts " #{line}"
101
- end
102
- end
103
-
104
- def render_ready_line(version)
105
- p = Palette
106
- width = [::TTY::Screen.width, 60].min
107
-
108
- puts " #{p.border('─' * (width - 4))}"
109
-
110
- content = "#{p.success('Ready')} #{p.body("#{EXTENSIONS.size} extensions")} " \
111
- "#{p.muted('|')} #{p.body("#{BOOT_PHASES.size} libraries")} " \
112
- "#{p.muted('|')} #{p.accent("v#{version}")}"
113
- puts " #{p.border('┌')}#{p.border('─' * (width - 6))}#{p.border('┐')}"
114
- puts " #{p.border('│')} #{content} #{p.border('│')}"
115
- puts " #{p.border('└')}#{p.border('─' * (width - 6))}#{p.border('┘')}"
116
- end
117
- end
118
- end
119
- end
120
- end
121
- end