legionio 1.4.71 → 1.4.73

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: 0e16727aaa70a3ffc2da0e6d3f3a6bce498e0052876e280c1d6cb42d50b3d594
4
- data.tar.gz: 57042f5deb5a35dd8456850606e7a1bf42e0e4d5235385a4abb7987d13831894
3
+ metadata.gz: 7693068b5190222c508dd07a248424e8f9e1eff77e80059cbd8a4fc81e9e6325
4
+ data.tar.gz: 43cc0da2efe7d604101ab87426c21b5f8ce677a8c023aeec38e5b91018b515ae
5
5
  SHA512:
6
- metadata.gz: b2c90fb0ba93a569c0dbd6c72f6e199fcebd6201fa4d003a114bc2d0f9bced639c28364ccb92aaf78f6ea1d226d1fa78b4a1c92538fa1002274f8656f1a8e0e1
7
- data.tar.gz: 47dd1c952fcf6fd5f0f70d277b7c03ced2632dfa13cf9c47db2d1e0e03d6f90c005a8f13776a03ed900cbdf2c5e27aa7ec8d62e36751260f207c561878ef78ba
6
+ metadata.gz: a2d75a86b1646113be917a9f8ed17f8f7aa8ffb48ff371db78ee62fbfdba1929d85e675f2bf7d6a5469b077f10370f3d044eb6f5049db353d471f5be1c3cbab5
7
+ data.tar.gz: 2f00a29a6ddf7be18ee71839b97e1a8aeeb74f2cecd820932447bc574fcfeb595a6adce0d757909bf3e3d68909235fcbb32ad95182192b5471dea0a2f3d39a0e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.4.73] - 2026-03-19
4
+
5
+ ### Added
6
+ - TBI Phase 3: semantic tool retrieval via embedding vectors
7
+ - `Legion::MCP::EmbeddingIndex` module: in-memory embedding cache with pure-Ruby cosine similarity
8
+ - `ContextCompiler` semantic score blending: 60% semantic + 40% keyword when embeddings available, keyword-only fallback
9
+ - `Server.populate_embedding_index`: auto-populates tool embeddings on MCP server build (no-op if LLM unavailable)
10
+ - `legion observe embeddings` subcommand: index size, coverage, and populated status
11
+ - 61 new specs (1666 total): EmbeddingIndex unit, ContextCompiler semantic blending, integration wiring, CLI
12
+
13
+ ## [1.4.72] - 2026-03-19
14
+
15
+ ### Added
16
+ - TBI Phase 0+2: MCP tool observation pipeline and usage-based filtering
17
+ - `Legion::MCP::Observer` module: in-memory tool call recording with counters, ring buffer, and intent tracking
18
+ - `Legion::MCP::UsageFilter` module: scores tools by frequency, recency, and keyword match; prunes dead tools
19
+ - MCP `instrumentation_callback` wiring: automatically records all `tools/call` invocations via Observer
20
+ - MCP `tools_list_handler` wiring: dynamically filters and ranks tools per-request based on usage data
21
+ - `legion observe` CLI command: `stats`, `recent`, `reset` subcommands for MCP tool usage inspection
22
+ - 96 new specs covering Observer, UsageFilter, CLI command, and integration wiring
23
+
3
24
  ## [1.4.71] - 2026-03-19
4
25
 
5
26
  ### Added
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'thor'
4
+ require 'legion/mcp/observer'
5
+
6
+ module Legion
7
+ module CLI
8
+ class ObserveCommand < Thor
9
+ namespace :observe
10
+
11
+ desc 'stats', 'Show MCP tool usage statistics'
12
+ def stats
13
+ data = Legion::MCP::Observer.stats
14
+
15
+ if options['json']
16
+ puts ::JSON.pretty_generate(serialize_stats(data))
17
+ return
18
+ end
19
+
20
+ puts 'MCP Tool Observation Stats'
21
+ puts '=' * 40
22
+ puts "Total Calls: #{data[:total_calls]}"
23
+ puts "Tools Used: #{data[:tool_count]}"
24
+ puts "Failure Rate: #{(data[:failure_rate] * 100).round(1)}%"
25
+ puts "Since: #{data[:since]&.strftime('%Y-%m-%d %H:%M:%S')}"
26
+ puts
27
+
28
+ return if data[:top_tools].empty?
29
+
30
+ puts 'Top Tools:'
31
+ puts '-' * 60
32
+ puts 'Tool Calls Avg(ms) Fails'
33
+ puts '-' * 60
34
+ data[:top_tools].each do |tool|
35
+ puts format('%-30<name>s %6<calls>d %8<avg>d %6<fails>d',
36
+ name: tool[:name], calls: tool[:call_count],
37
+ avg: tool[:avg_latency_ms], fails: tool[:failure_count])
38
+ end
39
+ end
40
+
41
+ desc 'recent', 'Show recent MCP tool calls'
42
+ method_option :limit, type: :numeric, default: 20, aliases: '-n'
43
+ def recent
44
+ calls = Legion::MCP::Observer.recent(options['limit'] || 20)
45
+
46
+ if options['json']
47
+ puts ::JSON.pretty_generate(calls.map { |c| serialize_call(c) })
48
+ return
49
+ end
50
+
51
+ if calls.empty?
52
+ puts 'No recent tool calls recorded.'
53
+ return
54
+ end
55
+
56
+ puts 'Tool Duration Status Time'
57
+ puts '-' * 70
58
+ calls.reverse_each do |call|
59
+ status = call[:success] ? 'OK' : 'FAIL'
60
+ time = call[:timestamp]&.strftime('%H:%M:%S')
61
+ puts format('%-30<tool>s %6<dur>dms %7<st>s %<tm>s',
62
+ tool: call[:tool_name], dur: call[:duration_ms], st: status, tm: time)
63
+ end
64
+ end
65
+
66
+ desc 'reset', 'Clear all observation data'
67
+ def reset
68
+ print 'Clear all observation data? (yes/no): '
69
+ return unless $stdin.gets&.strip&.downcase == 'yes'
70
+
71
+ Legion::MCP::Observer.reset!
72
+ puts 'Observation data cleared.'
73
+ end
74
+
75
+ desc 'embeddings', 'Show MCP tool embedding index status'
76
+ def embeddings
77
+ require 'legion/mcp/embedding_index'
78
+ data = {
79
+ index_size: Legion::MCP::EmbeddingIndex.size,
80
+ coverage: Legion::MCP::EmbeddingIndex.coverage,
81
+ populated: Legion::MCP::EmbeddingIndex.populated?
82
+ }
83
+
84
+ if options['json']
85
+ puts ::JSON.pretty_generate(data.transform_keys(&:to_s))
86
+ return
87
+ end
88
+
89
+ puts 'MCP Embedding Index'
90
+ puts '=' * 40
91
+ puts "Index Size: #{data[:index_size]}"
92
+ puts "Coverage: #{(data[:coverage] * 100).round(1)}%"
93
+ puts "Populated: #{data[:populated]}"
94
+ end
95
+
96
+ private
97
+
98
+ def serialize_stats(data)
99
+ {
100
+ total_calls: data[:total_calls],
101
+ tool_count: data[:tool_count],
102
+ failure_rate: data[:failure_rate],
103
+ since: data[:since]&.iso8601,
104
+ top_tools: data[:top_tools].map { |t| t.transform_keys(&:to_s) }
105
+ }
106
+ end
107
+
108
+ def serialize_call(call)
109
+ call.transform_keys(&:to_s).tap do |c|
110
+ c['timestamp'] = c['timestamp']&.iso8601 if c['timestamp']
111
+ end
112
+ end
113
+ end
114
+ end
115
+ end
data/lib/legion/cli.rb CHANGED
@@ -43,7 +43,8 @@ module Legion
43
43
  autoload :Marketplace, 'legion/cli/marketplace_command'
44
44
  autoload :Notebook, 'legion/cli/notebook_command'
45
45
  autoload :Llm, 'legion/cli/llm_command'
46
- autoload :Tty, 'legion/cli/tty_command'
46
+ autoload :Tty, 'legion/cli/tty_command'
47
+ autoload :ObserveCommand, 'legion/cli/observe_command'
47
48
  autoload :Interactive, 'legion/cli/interactive'
48
49
 
49
50
  class Main < Thor
@@ -241,6 +242,9 @@ module Legion
241
242
  desc 'tty', 'Rich terminal UI (onboarding, AI chat, dashboard)'
242
243
  subcommand 'tty', Legion::CLI::Tty
243
244
 
245
+ desc 'observe SUBCOMMAND', 'MCP tool observation stats'
246
+ subcommand 'observe', Legion::CLI::ObserveCommand
247
+
244
248
  desc 'tree', 'Print a tree of all available commands'
245
249
  def tree
246
250
  legion_print_command_tree(self.class, 'legion', '')
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'embedding_index'
4
+
3
5
  module Legion
4
6
  module MCP
5
7
  module ContextCompiler
@@ -112,6 +114,7 @@ module Legion
112
114
  # Clears the memoized tool_index.
113
115
  def reset!
114
116
  @tool_index = nil
117
+ Legion::MCP::EmbeddingIndex.reset! if defined?(Legion::MCP::EmbeddingIndex)
115
118
  end
116
119
 
117
120
  def build_tool_index
@@ -131,10 +134,38 @@ module Legion
131
134
  keywords = intent_string.downcase.split
132
135
  return [] if keywords.empty?
133
136
 
137
+ kw_scores = keyword_score_map(keywords)
138
+ sem_scores = semantic_score_map(intent_string)
139
+ use_semantic = !sem_scores.empty?
140
+
134
141
  tool_index.values.map do |entry|
142
+ kw_raw = kw_scores[entry[:name]] || 0
143
+ if use_semantic
144
+ max_kw = kw_scores.values.max || 1
145
+ normalized_kw = max_kw.positive? ? kw_raw.to_f / max_kw : 0.0
146
+ sem = sem_scores[entry[:name]] || 0.0
147
+ blended = (normalized_kw * 0.4) + (sem * 0.6)
148
+ else
149
+ blended = kw_raw.to_f
150
+ end
151
+
152
+ { name: entry[:name], description: entry[:description], score: blended }
153
+ end
154
+ end
155
+
156
+ def keyword_score_map(keywords)
157
+ tool_index.values.to_h do |entry|
135
158
  haystack = "#{entry[:name].downcase} #{entry[:description].downcase}"
136
- score = keywords.count { |kw| haystack.include?(kw) }
137
- { name: entry[:name], description: entry[:description], score: score }
159
+ score = keywords.count { |kw| haystack.include?(kw) }
160
+ [entry[:name], score]
161
+ end
162
+ end
163
+
164
+ def semantic_score_map(intent_string)
165
+ return {} unless defined?(Legion::MCP::EmbeddingIndex) && Legion::MCP::EmbeddingIndex.populated?
166
+
167
+ Legion::MCP::EmbeddingIndex.semantic_match(intent_string, limit: tool_index.size).to_h do |result|
168
+ [result[:name], result[:score]]
138
169
  end
139
170
  end
140
171
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module EmbeddingIndex
6
+ module_function
7
+
8
+ def build_from_tool_data(tool_data, embedder: default_embedder)
9
+ @embedder = embedder
10
+ mutex.synchronize do
11
+ tool_data.each do |tool|
12
+ composite = build_composite(tool[:name], tool[:description], tool[:params])
13
+ vector = safe_embed(composite, embedder)
14
+ next unless vector
15
+
16
+ index[tool[:name]] = {
17
+ name: tool[:name],
18
+ composite_text: composite,
19
+ vector: vector,
20
+ built_at: Time.now
21
+ }
22
+ end
23
+ end
24
+ end
25
+
26
+ def semantic_match(intent, embedder: @embedder || default_embedder, limit: 5)
27
+ return [] if index.empty?
28
+
29
+ intent_vec = safe_embed(intent, embedder)
30
+ return [] unless intent_vec
31
+
32
+ scores = mutex.synchronize do
33
+ index.values.filter_map do |entry|
34
+ next unless entry[:vector]
35
+
36
+ score = cosine_similarity(intent_vec, entry[:vector])
37
+ { name: entry[:name], score: score }
38
+ end
39
+ end
40
+
41
+ scores.sort_by { |s| -s[:score] }.first(limit)
42
+ end
43
+
44
+ def cosine_similarity(vec_a, vec_b)
45
+ dot = vec_a.zip(vec_b).sum { |a, b| a * b }
46
+ mag_a = Math.sqrt(vec_a.sum { |x| x**2 })
47
+ mag_b = Math.sqrt(vec_b.sum { |x| x**2 })
48
+ return 0.0 if mag_a.zero? || mag_b.zero?
49
+
50
+ dot / (mag_a * mag_b)
51
+ end
52
+
53
+ def entry(tool_name)
54
+ mutex.synchronize { index[tool_name] }
55
+ end
56
+
57
+ def size
58
+ mutex.synchronize { index.size }
59
+ end
60
+
61
+ def populated?
62
+ mutex.synchronize { !index.empty? }
63
+ end
64
+
65
+ def coverage
66
+ mutex.synchronize do
67
+ return 0.0 if index.empty?
68
+
69
+ with_vectors = index.values.count { |e| e[:vector] }
70
+ with_vectors.to_f / index.size
71
+ end
72
+ end
73
+
74
+ def reset!
75
+ @embedder = nil
76
+ mutex.synchronize { index.clear }
77
+ end
78
+
79
+ def index
80
+ @index ||= {}
81
+ end
82
+
83
+ def mutex
84
+ @mutex ||= Mutex.new
85
+ end
86
+
87
+ def build_composite(name, description, params)
88
+ parts = [name, '--', description]
89
+ parts << "Params: #{params.join(', ')}" unless params.empty?
90
+ parts.join(' ')
91
+ end
92
+
93
+ def safe_embed(text, embedder)
94
+ return nil unless embedder
95
+
96
+ result = embedder.call(text)
97
+ return nil unless result.is_a?(Array) && !result.empty?
98
+
99
+ result
100
+ rescue StandardError
101
+ nil
102
+ end
103
+
104
+ def default_embedder
105
+ return nil unless defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
106
+
107
+ ->(text) { Legion::LLM.embed(text)[:vector] }
108
+ rescue StandardError
109
+ nil
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'concurrent-ruby'
4
+
5
+ module Legion
6
+ module MCP
7
+ module Observer
8
+ RING_BUFFER_MAX = 500
9
+ INTENT_BUFFER_MAX = 200
10
+
11
+ module_function
12
+
13
+ def record(tool_name:, duration_ms:, success:, params_keys: [], error: nil)
14
+ now = Time.now
15
+
16
+ counters_mutex.synchronize do
17
+ entry = counters[tool_name] || { call_count: 0, total_latency_ms: 0.0, failure_count: 0,
18
+ last_used: nil, last_error: nil }
19
+ counters[tool_name] = {
20
+ call_count: entry[:call_count] + 1,
21
+ total_latency_ms: entry[:total_latency_ms] + duration_ms.to_f,
22
+ failure_count: entry[:failure_count] + (success ? 0 : 1),
23
+ last_used: now,
24
+ last_error: success ? entry[:last_error] : error
25
+ }
26
+ end
27
+
28
+ buffer_mutex.synchronize do
29
+ ring_buffer << {
30
+ tool_name: tool_name,
31
+ duration_ms: duration_ms,
32
+ success: success,
33
+ params_keys: params_keys,
34
+ error: error,
35
+ recorded_at: now
36
+ }
37
+ ring_buffer.shift if ring_buffer.size > RING_BUFFER_MAX
38
+ end
39
+ end
40
+
41
+ def record_intent(intent, matched_tool_name)
42
+ intent_mutex.synchronize do
43
+ intent_buffer << { intent: intent, matched_tool: matched_tool_name, recorded_at: Time.now }
44
+ intent_buffer.shift if intent_buffer.size > INTENT_BUFFER_MAX
45
+ end
46
+ end
47
+
48
+ def tool_stats(tool_name)
49
+ entry = counters_mutex.synchronize { counters[tool_name] }
50
+ return nil unless entry
51
+
52
+ count = entry[:call_count]
53
+ avg = count.positive? ? (entry[:total_latency_ms] / count).round(2) : 0.0
54
+
55
+ {
56
+ name: tool_name,
57
+ call_count: count,
58
+ avg_latency_ms: avg,
59
+ failure_count: entry[:failure_count],
60
+ last_used: entry[:last_used],
61
+ last_error: entry[:last_error]
62
+ }
63
+ end
64
+
65
+ def all_tool_stats
66
+ names = counters_mutex.synchronize { counters.keys.dup }
67
+ names.to_h { |name| [name, tool_stats(name)] }
68
+ end
69
+
70
+ def stats
71
+ all_names = counters_mutex.synchronize { counters.keys.dup }
72
+ total = all_names.sum { |n| counters_mutex.synchronize { counters[n][:call_count] } }
73
+ failures = all_names.sum { |n| counters_mutex.synchronize { counters[n][:failure_count] } }
74
+ rate = total.positive? ? (failures.to_f / total).round(4) : 0.0
75
+
76
+ top = all_names
77
+ .map { |n| tool_stats(n) }
78
+ .sort_by { |s| -s[:call_count] }
79
+ .first(10)
80
+
81
+ {
82
+ total_calls: total,
83
+ tool_count: all_names.size,
84
+ failure_rate: rate,
85
+ top_tools: top,
86
+ since: started_at
87
+ }
88
+ end
89
+
90
+ def recent(limit = 10)
91
+ buffer_mutex.synchronize { ring_buffer.last(limit) }
92
+ end
93
+
94
+ def recent_intents(limit = 10)
95
+ intent_mutex.synchronize { intent_buffer.last(limit) }
96
+ end
97
+
98
+ def reset!
99
+ counters_mutex.synchronize { counters.clear }
100
+ buffer_mutex.synchronize { ring_buffer.clear }
101
+ intent_mutex.synchronize { intent_buffer.clear }
102
+ @started_at = Time.now
103
+ end
104
+
105
+ # Internal state accessors
106
+ def counters
107
+ @counters ||= {}
108
+ end
109
+
110
+ def counters_mutex
111
+ @counters_mutex ||= Mutex.new
112
+ end
113
+
114
+ def ring_buffer
115
+ @ring_buffer ||= []
116
+ end
117
+
118
+ def buffer_mutex
119
+ @buffer_mutex ||= Mutex.new
120
+ end
121
+
122
+ def intent_buffer
123
+ @intent_buffer ||= []
124
+ end
125
+
126
+ def intent_mutex
127
+ @intent_mutex ||= Mutex.new
128
+ end
129
+
130
+ def started_at
131
+ @started_at ||= Time.now
132
+ end
133
+ end
134
+ end
135
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'observer'
4
+ require_relative 'usage_filter'
3
5
  require_relative 'tools/run_task'
4
6
  require_relative 'tools/describe_runner'
5
7
  require_relative 'tools/list_tasks'
@@ -34,6 +36,7 @@ require_relative 'tools/rbac_check'
34
36
  require_relative 'tools/rbac_assignments'
35
37
  require_relative 'tools/rbac_grants'
36
38
  require_relative 'context_compiler'
39
+ require_relative 'embedding_index'
37
40
  require_relative 'tools/do_action'
38
41
  require_relative 'tools/discover_tools'
39
42
  require_relative 'resources/runner_catalog'
@@ -97,12 +100,54 @@ module Legion
97
100
  resource_templates: Resources::ExtensionInfo.resource_templates
98
101
  )
99
102
 
103
+ if defined?(Observer)
104
+ ::MCP.configure do |c|
105
+ c.instrumentation_callback = ->(idata) { Server.wire_observer(idata) }
106
+ end
107
+ end
108
+
109
+ server.tools_list_handler do |_params|
110
+ build_filtered_tool_list.map(&:to_h)
111
+ end
112
+
113
+ # Populate embedding index for semantic tool matching (lazy — no-op if LLM unavailable)
114
+ populate_embedding_index
115
+
100
116
  Resources::RunnerCatalog.register(server)
101
117
  Resources::ExtensionInfo.register_read_handler(server)
102
118
 
103
119
  server
104
120
  end
105
121
 
122
+ def populate_embedding_index(embedder: EmbeddingIndex.default_embedder)
123
+ return unless embedder
124
+
125
+ tool_data = ContextCompiler.tool_index.values
126
+ EmbeddingIndex.build_from_tool_data(tool_data, embedder: embedder)
127
+ end
128
+
129
+ def wire_observer(data)
130
+ return unless data[:method] == 'tools/call' && data[:tool_name]
131
+
132
+ duration_ms = (data[:duration].to_f * 1000).to_i
133
+ params_keys = data[:tool_arguments].respond_to?(:keys) ? data[:tool_arguments].keys : []
134
+ success = data[:error].nil?
135
+
136
+ Observer.record(
137
+ tool_name: data[:tool_name],
138
+ duration_ms: duration_ms,
139
+ success: success,
140
+ params_keys: params_keys,
141
+ error: data[:error]
142
+ )
143
+ end
144
+
145
+ def build_filtered_tool_list(keywords: [])
146
+ tool_names = TOOL_CLASSES.map { |tc| tc.respond_to?(:tool_name) ? tc.tool_name : tc.name }
147
+ ranked = UsageFilter.ranked_tools(tool_names, keywords: keywords)
148
+ ranked.filter_map { |name| TOOL_CLASSES.find { |tc| (tc.respond_to?(:tool_name) ? tc.tool_name : tc.name) == name } }
149
+ end
150
+
106
151
  private
107
152
 
108
153
  def instructions
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module UsageFilter
6
+ ESSENTIAL_TOOLS = %w[
7
+ legion.do legion.tools legion.run_task legion.get_status legion.describe_runner
8
+ ].freeze
9
+
10
+ FREQUENCY_WEIGHT = 0.5
11
+ RECENCY_WEIGHT = 0.3
12
+ KEYWORD_WEIGHT = 0.2
13
+ BASELINE_SCORE = 0.1
14
+
15
+ module_function
16
+
17
+ def score_tools(tool_names, keywords: [])
18
+ all_stats = Observer.all_tool_stats
19
+ call_counts = tool_names.map { |n| all_stats.dig(n, :call_count) || 0 }
20
+ max_calls = call_counts.max || 0
21
+
22
+ tool_names.each_with_object({}) do |name, hash|
23
+ stats = all_stats[name]
24
+
25
+ freq_score = if max_calls.positive? && stats
26
+ (stats[:call_count].to_f / max_calls) * FREQUENCY_WEIGHT
27
+ else
28
+ 0.0
29
+ end
30
+
31
+ rec_score = if stats&.dig(:last_used)
32
+ recency_decay(stats[:last_used]) * RECENCY_WEIGHT
33
+ else
34
+ 0.0
35
+ end
36
+
37
+ kw_score = keyword_match(name, keywords) * KEYWORD_WEIGHT
38
+
39
+ total = freq_score + rec_score + kw_score
40
+ total = BASELINE_SCORE if total.zero?
41
+
42
+ hash[name] = total.round(6)
43
+ end
44
+ end
45
+
46
+ def ranked_tools(tool_names, limit: nil, keywords: [])
47
+ scores = score_tools(tool_names, keywords: keywords)
48
+ ranked = tool_names.sort_by { |n| -scores.fetch(n, BASELINE_SCORE) }
49
+ limit ? ranked.first(limit) : ranked
50
+ end
51
+
52
+ def prune_dead_tools(tool_names, prune_after_seconds: 86_400 * 30)
53
+ stats = Observer.stats
54
+ window = stats[:since]
55
+ elapsed = window ? (Time.now - window) : 0
56
+
57
+ return tool_names if elapsed < prune_after_seconds
58
+
59
+ all_stats = Observer.all_tool_stats
60
+ tool_names.reject do |name|
61
+ next false if ESSENTIAL_TOOLS.include?(name)
62
+
63
+ calls = all_stats.dig(name, :call_count) || 0
64
+ calls.zero?
65
+ end
66
+ end
67
+
68
+ def recency_decay(last_used)
69
+ return 0.0 unless last_used
70
+
71
+ age_seconds = Time.now - last_used
72
+ return 1.0 if age_seconds <= 0
73
+
74
+ decay = 1.0 - (age_seconds / 86_400.0)
75
+ decay.clamp(0.0, 1.0)
76
+ end
77
+
78
+ def keyword_match(tool_name, keywords)
79
+ return 0.0 if keywords.nil? || keywords.empty?
80
+
81
+ hits = keywords.count { |kw| tool_name.include?(kw.to_s) }
82
+ hits.to_f / keywords.size
83
+ end
84
+ end
85
+ end
86
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.4.71'
4
+ VERSION = '1.4.73'
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.71
4
+ version: 1.4.73
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity
@@ -490,6 +490,7 @@ files:
490
490
  - lib/legion/cli/mcp_command.rb
491
491
  - lib/legion/cli/memory_command.rb
492
492
  - lib/legion/cli/notebook_command.rb
493
+ - lib/legion/cli/observe_command.rb
493
494
  - lib/legion/cli/openapi_command.rb
494
495
  - lib/legion/cli/output.rb
495
496
  - lib/legion/cli/plan_command.rb
@@ -559,6 +560,8 @@ files:
559
560
  - lib/legion/mcp.rb
560
561
  - lib/legion/mcp/auth.rb
561
562
  - lib/legion/mcp/context_compiler.rb
563
+ - lib/legion/mcp/embedding_index.rb
564
+ - lib/legion/mcp/observer.rb
562
565
  - lib/legion/mcp/resources/extension_info.rb
563
566
  - lib/legion/mcp/resources/runner_catalog.rb
564
567
  - lib/legion/mcp/server.rb
@@ -598,6 +601,7 @@ files:
598
601
  - lib/legion/mcp/tools/update_schedule.rb
599
602
  - lib/legion/mcp/tools/worker_costs.rb
600
603
  - lib/legion/mcp/tools/worker_lifecycle.rb
604
+ - lib/legion/mcp/usage_filter.rb
601
605
  - lib/legion/metrics.rb
602
606
  - lib/legion/process.rb
603
607
  - lib/legion/readiness.rb