legion-mcp 0.1.0 → 0.4.1

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: 9bef62b03828f3bda4594cb1eb2fa020da240b3df72db55412a65f854a515f40
4
- data.tar.gz: fb5272985978c3d78b03c20b4dafc724cd869f20e7cf25daabdbacae715a889a
3
+ metadata.gz: 37f03ddf4778cc238c0e69b039b9cd6c55acc2ada6207ef32c777c844d60d5e1
4
+ data.tar.gz: ffe2b6b6da0930d795ca3965b86f5edabdf8f6b698232b110bd3ae6187ce0c20
5
5
  SHA512:
6
- metadata.gz: 46793c8c4aa53e27f5831cbfec97984209fda9233325001b2fd2f87bf34502e1ee5a7196243ac90fb79cd5dd2351f2fc3078f952680ee25c735534150bc5b984
7
- data.tar.gz: 4cd1ff55314687a3d5aa7028b53393d3997c53e455bd170cf96b1a93a1486dd1b56dab51c4fba9481d194639068e44622ba991caa5b8497099f411b8b051870b
6
+ metadata.gz: a4c148c867a384494dd92847f6ec4f25b6010d73ce3f076e6d01c0f5ae47c8a7c6d8cfa909da58f067334612f7b7420e87e388c0910fad2edf604ea45a670206
7
+ data.tar.gz: eb8d5f6536df790298e4613a92f61f8a4e136d16d83ede2a99c92a9507ddfd8f7301fe52a12678f891b0227e1568660b77db008b09bd9e1a98f55644e64a56b2
data/CHANGELOG.md CHANGED
@@ -1,6 +1,51 @@
1
1
  # legion-mcp Changelog
2
2
 
3
- ## v0.1.0
3
+ ## [0.4.1] - 2026-03-19
4
+
5
+ ### Added
6
+ - `legion.prompt_list` — list all stored prompt templates via lex-prompt Client
7
+ - `legion.prompt_show` — fetch a prompt by name, version, or tag via lex-prompt Client
8
+ - `legion.prompt_run` — render a prompt template with ERB variable substitution via lex-prompt Client
9
+ - `legion.dataset_list` — list all stored datasets via lex-dataset Client
10
+ - `legion.dataset_show` — fetch a dataset with all rows, optionally version-pinned, via lex-dataset Client
11
+ - `legion.experiment_results` — retrieve per-row results and summary for a named experiment from lex-dataset
12
+ - `legion.eval_list` — list available evaluator templates via lex-eval Client
13
+ - `legion.eval_run` — run a single input/output pair through a named evaluator via lex-eval Client
14
+ - `legion.eval_results` — retrieve stored experiment results via lex-dataset experiment store
15
+ - All 9 tools registered in `TOOL_CLASSES`; total tool count raised from 36 to 45
16
+ - Specs for all 9 new tools
17
+
18
+ ## [0.4.0] - 2026-03-20
19
+
20
+ ### Added
21
+ - PatternSchema v1: portable pattern format with trust-level confidence capping on import/export
22
+ - PatternExchange: bulk import/export of patterns via JSON files with deduplication
23
+ - PatternGossip: AMQP-based pattern sharing between instances (org trust level)
24
+ - ColdStart: community pattern loading on first boot when PatternStore is empty
25
+
26
+ ## [0.3.0] - 2026-03-20
27
+
28
+ ### Added
29
+ - GapDetector: analyzes observations for repeated manual patterns and frequent unpatched intents
30
+ - PatternCompiler: generates compressed tool definitions and compiled workflows from promoted patterns
31
+ - CapabilityGenerator: autonomous function generation from detected gaps with LLM code generation
32
+ - Validation pipeline with Ruby syntax check and optional lex-eval integration
33
+
34
+ ## [0.2.0] - 2026-03-20
35
+
36
+ ### Fixed
37
+ - Observer feedback bug: records actual matched tool name instead of 'legion.do'
38
+ - wire_observer skips legion.do calls (feedback handled inside DoAction with correct tool name)
39
+
40
+ ### Added
41
+ - Boot-time L2 → L0 pattern hydration (`PatternStore.hydrate_from_l2`)
42
+ - Pattern confidence decay with archive threshold (`PatternStore.decay_all`)
43
+ - Tier 1 execution: pattern-hinted local/fleet LLM routing in DoAction
44
+ - Tier 2 execution: cloud LLM with compressed catalog context in DoAction
45
+ - `legion.plan` meta-tool for multi-step workflow planning (36 tools total)
46
+ - Response template learning from observed Tier 0 outputs (`PatternStore.learn_response_template`)
47
+
48
+ ## [0.1.0]
4
49
 
5
50
  ### Added
6
51
  - Initial extraction from LegionIO
@@ -0,0 +1,109 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module CapabilityGenerator
6
+ module_function
7
+
8
+ def generate_from_gap(gap)
9
+ name = infer_name(gap)
10
+ description = infer_description(gap)
11
+
12
+ proposal = {
13
+ name: name,
14
+ description: description,
15
+ source_gap: gap,
16
+ runner_code: nil,
17
+ spec_code: nil,
18
+ confidence: :sandbox,
19
+ generated_at: Time.now
20
+ }
21
+
22
+ if llm_available?
23
+ proposal[:runner_code] = generate_runner(name, description, gap)
24
+ proposal[:spec_code] = generate_spec(name, description)
25
+ end
26
+
27
+ proposal
28
+ rescue StandardError => e
29
+ { error: e.message, source_gap: gap }
30
+ end
31
+
32
+ def validate(runner_code:, spec_code:) # rubocop:disable Lint/UnusedMethodArgument
33
+ result = { syntax_valid: false, eval_score: nil }
34
+
35
+ result[:syntax_valid] = syntax_valid?(runner_code) if runner_code
36
+
37
+ if runner_code && defined?(Legion::Extensions::Eval::Client)
38
+ begin
39
+ client = Legion::Extensions::Eval::Client.new
40
+ eval_result = client.evaluate(code: runner_code, criteria: 'code_quality')
41
+ result[:eval_score] = eval_result[:score] if eval_result[:success]
42
+ rescue StandardError
43
+ nil
44
+ end
45
+ end
46
+
47
+ result
48
+ end
49
+
50
+ def infer_name(gap)
51
+ case gap[:type]
52
+ when :frequent_intent
53
+ gap[:sample_intents].first.to_s.gsub(/\s+/, '_').downcase.slice(0, 30)
54
+ when :repeated_chain
55
+ gap[:chain].join('_then_').slice(0, 30)
56
+ else
57
+ "generated_#{Time.now.to_i}"
58
+ end
59
+ end
60
+
61
+ def infer_description(gap)
62
+ case gap[:type]
63
+ when :frequent_intent
64
+ "Auto-generated from #{gap[:count]} observed intents: #{gap[:sample_intents].first(3).join(', ')}"
65
+ when :repeated_chain
66
+ "Auto-generated from #{gap[:count]} observed sequences: #{gap[:chain].join(' -> ')}"
67
+ else
68
+ 'Auto-generated capability'
69
+ end
70
+ end
71
+
72
+ def generate_runner(name, description, _gap)
73
+ return nil unless llm_available?
74
+
75
+ prompt = "Generate a Ruby module for a LegionIO runner named '#{name}'. " \
76
+ "Description: #{description}. " \
77
+ 'Follow the pattern: module with module_function methods returning hashes. ' \
78
+ 'Include proper error handling. Return ONLY the Ruby code.'
79
+
80
+ Legion::LLM.ask(prompt)
81
+ rescue StandardError
82
+ nil
83
+ end
84
+
85
+ def generate_spec(name, description)
86
+ return nil unless llm_available?
87
+
88
+ prompt = "Generate RSpec tests for a Ruby module named '#{name}'. " \
89
+ "Description: #{description}. " \
90
+ 'Use described_class pattern. Return ONLY the Ruby code.'
91
+
92
+ Legion::LLM.ask(prompt)
93
+ rescue StandardError
94
+ nil
95
+ end
96
+
97
+ def syntax_valid?(code)
98
+ RubyVM::InstructionSequence.compile(code)
99
+ true
100
+ rescue SyntaxError
101
+ false
102
+ end
103
+
104
+ def llm_available?
105
+ defined?(Legion::LLM) && Legion::LLM.respond_to?(:started?) && Legion::LLM.started?
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'pattern_store'
4
+ require_relative 'pattern_exchange'
5
+
6
+ module Legion
7
+ module MCP
8
+ module ColdStart
9
+ module_function
10
+
11
+ def load_community_patterns(path: nil)
12
+ return { skipped: true, reason: 'store not empty' } unless PatternStore.empty?
13
+
14
+ path ||= configured_path
15
+ return { skipped: true, reason: 'no path configured' } unless path
16
+
17
+ PatternExchange.import_from_file(path, trust_level: :community)
18
+ rescue StandardError => e
19
+ { error: e.message, imported: 0 }
20
+ end
21
+
22
+ def configured_path
23
+ return nil unless defined?(Legion::Settings)
24
+
25
+ Legion::Settings.dig(:mcp, :cold_start, :patterns_path)
26
+ rescue StandardError
27
+ nil
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Legion
6
+ module MCP
7
+ module GapDetector
8
+ FREQUENCY_THRESHOLD = 5
9
+ CHAIN_THRESHOLD = 3
10
+
11
+ module_function
12
+
13
+ def analyze
14
+ gaps = []
15
+ gaps.concat(detect_frequent_intents)
16
+ gaps.concat(detect_repeated_chains)
17
+ gaps
18
+ end
19
+
20
+ def detect_frequent_intents
21
+ intents = Observer.recent_intents(Observer::INTENT_BUFFER_MAX)
22
+ grouped = intents.group_by { |i| i[:matched_tool] }
23
+
24
+ grouped.filter_map do |tool, occurrences|
25
+ next if occurrences.size < FREQUENCY_THRESHOLD
26
+ next if PatternStore.pattern_exists?(Digest::SHA256.hexdigest(tool.to_s))
27
+
28
+ { type: :frequent_intent, tool: tool, count: occurrences.size,
29
+ sample_intents: occurrences.last(3).map { |o| o[:intent] } }
30
+ end
31
+ end
32
+
33
+ def detect_repeated_chains
34
+ recent = Observer.recent(Observer::RING_BUFFER_MAX)
35
+ chains = {}
36
+ recent.each_cons(2) do |a, b|
37
+ key = "#{a[:tool_name]}->#{b[:tool_name]}"
38
+ chains[key] = (chains[key] || 0) + 1
39
+ end
40
+
41
+ chains.filter_map do |chain, count|
42
+ next if count < CHAIN_THRESHOLD
43
+
44
+ { type: :repeated_chain, chain: chain.split('->'), count: count }
45
+ end
46
+ end
47
+
48
+ def reset!
49
+ # No persistent state to clear — analysis reads from Observer
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module MCP
5
+ module PatternCompiler
6
+ module_function
7
+
8
+ def compile_tool_definitions
9
+ return [] unless defined?(Legion::MCP::Server::TOOL_CLASSES)
10
+
11
+ Legion::MCP::Server::TOOL_CLASSES.map do |klass|
12
+ name = klass.respond_to?(:tool_name) ? klass.tool_name : klass.name
13
+ desc = klass.respond_to?(:description) ? klass.description : ''
14
+ params = extract_params(klass)
15
+
16
+ compressed = "#{name}(#{params.join(', ')}) -- #{desc.split('.').first}"
17
+ { name: name, compressed: compressed.slice(0, 200), full_description: desc }
18
+ end
19
+ end
20
+
21
+ def compile_workflows
22
+ PatternStore.patterns.filter_map do |_hash, pattern|
23
+ next if (pattern[:confidence] || 0) < 0.6
24
+
25
+ {
26
+ intent: pattern[:intent_text],
27
+ tools: pattern[:tool_chain],
28
+ confidence: pattern[:confidence],
29
+ template: pattern[:response_template]
30
+ }
31
+ end
32
+ end
33
+
34
+ def extract_params(klass)
35
+ return [] unless klass.respond_to?(:input_schema)
36
+
37
+ schema = klass.input_schema
38
+ props = if schema.is_a?(Hash)
39
+ schema[:properties] || schema['properties']
40
+ elsif schema.respond_to?(:to_h)
41
+ schema.to_h[:properties]
42
+ end
43
+ return [] unless props
44
+
45
+ props.keys.map(&:to_s)
46
+ rescue StandardError
47
+ []
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require_relative 'pattern_schema'
5
+ require_relative 'pattern_store'
6
+
7
+ module Legion
8
+ module MCP
9
+ module PatternExchange
10
+ module_function
11
+
12
+ def export_all(min_confidence: 0.5)
13
+ PatternStore.patterns.filter_map do |_hash, pattern|
14
+ next if (pattern[:confidence] || 0) < min_confidence
15
+
16
+ PatternSchema.export(pattern)
17
+ end
18
+ end
19
+
20
+ def import_all(patterns, trust_level: :community)
21
+ imported = 0
22
+ skipped = 0
23
+
24
+ Array(patterns).each do |external|
25
+ next unless PatternSchema.validate_schema(external)
26
+
27
+ internal = PatternSchema.import(external, trust_level: trust_level)
28
+ if PatternStore.pattern_exists?(internal[:intent_hash])
29
+ skipped += 1
30
+ next
31
+ end
32
+
33
+ PatternStore.store(internal)
34
+ imported += 1
35
+ end
36
+
37
+ { imported: imported, skipped: skipped }
38
+ end
39
+
40
+ def export_to_file(path, min_confidence: 0.5)
41
+ data = export_all(min_confidence: min_confidence)
42
+ File.write(path, ::JSON.pretty_generate(data))
43
+ { exported: data.size, path: path }
44
+ end
45
+
46
+ def import_from_file(path, trust_level: :community)
47
+ raw = File.read(path)
48
+ patterns = ::JSON.parse(raw, symbolize_names: true)
49
+ patterns = [patterns] if patterns.is_a?(Hash)
50
+ import_all(patterns, trust_level: trust_level)
51
+ rescue StandardError => e
52
+ { error: e.message, imported: 0 }
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'securerandom'
4
+ require_relative 'pattern_schema'
5
+
6
+ module Legion
7
+ module MCP
8
+ module PatternGossip
9
+ EXCHANGE_NAME = 'tbi.patterns'
10
+ ANNOUNCE_TTL = 86_400
11
+
12
+ module_function
13
+
14
+ def announce(pattern)
15
+ return nil unless transport_available?
16
+
17
+ exported = PatternSchema.export(pattern)
18
+ message = {
19
+ action: 'announce',
20
+ pattern_id: exported[:pattern_id],
21
+ pattern: exported,
22
+ origin: { instance_id: instance_id },
23
+ ttl: ANNOUNCE_TTL,
24
+ version: 1
25
+ }
26
+
27
+ Legion::Transport::Messages::Dynamic.new(
28
+ function: "#{EXCHANGE_NAME}.announce",
29
+ data: message
30
+ ).publish
31
+
32
+ { published: true, pattern_id: exported[:pattern_id] }
33
+ rescue StandardError
34
+ nil
35
+ end
36
+
37
+ def receive(message)
38
+ return nil unless message.is_a?(Hash) && message[:pattern]
39
+
40
+ PatternSchema.import(message[:pattern], trust_level: :org)
41
+ rescue StandardError
42
+ nil
43
+ end
44
+
45
+ def transport_available?
46
+ defined?(Legion::Transport) &&
47
+ Legion::Transport.respond_to?(:connected?) &&
48
+ Legion::Transport.connected?
49
+ end
50
+
51
+ def instance_id
52
+ @instance_id ||= SecureRandom.uuid
53
+ end
54
+
55
+ def reset!
56
+ @instance_id = nil
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Legion
6
+ module MCP
7
+ module PatternSchema
8
+ SCHEMA_VERSION = '1.0'
9
+ REQUIRED_FIELDS = %i[schema_version pattern_id intent capability_chain confidence metadata].freeze
10
+
11
+ TRUST_LEVELS = {
12
+ local: 0.5,
13
+ org: 0.4,
14
+ community: 0.3
15
+ }.freeze
16
+
17
+ module_function
18
+
19
+ def export(pattern)
20
+ {
21
+ schema_version: SCHEMA_VERSION,
22
+ pattern_id: pattern[:intent_hash],
23
+ intent: {
24
+ description: pattern[:intent_text],
25
+ keywords: extract_keywords(pattern[:intent_text])
26
+ },
27
+ capability_chain: Array(pattern[:tool_chain]).map { |t| { tool: t, params_template: {} } },
28
+ response_template: pattern[:response_template] ? { engine: 'mustache', template: pattern[:response_template] } : nil,
29
+ confidence: {
30
+ suggested_initial: [pattern[:confidence], 0.5].min,
31
+ source_hits: pattern[:hit_count] || 0,
32
+ source_misses: pattern[:miss_count] || 0
33
+ },
34
+ metadata: {
35
+ source: 'local',
36
+ sensitivity: 'public',
37
+ created_at: pattern[:created_at]&.iso8601
38
+ }
39
+ }
40
+ end
41
+
42
+ def import(external, trust_level: :community)
43
+ confidence = external.dig(:confidence, :suggested_initial) || TRUST_LEVELS.fetch(trust_level, 0.3)
44
+ confidence = [confidence, TRUST_LEVELS.fetch(trust_level, 0.3)].min
45
+
46
+ intent_text = external.dig(:intent, :description) || ''
47
+ intent_hash = external[:pattern_id] || Digest::SHA256.hexdigest(intent_text.downcase.strip)
48
+ tool_chain = Array(external[:capability_chain]).map { |c| c[:tool] || c.to_s }
49
+
50
+ template = external.dig(:response_template, :template)
51
+
52
+ {
53
+ intent_hash: intent_hash,
54
+ intent_text: intent_text,
55
+ intent_vector: nil,
56
+ tool_chain: tool_chain,
57
+ response_template: template,
58
+ confidence: confidence,
59
+ hit_count: 0,
60
+ miss_count: 0,
61
+ last_hit_at: nil,
62
+ created_at: Time.now,
63
+ context_requirements: nil
64
+ }
65
+ end
66
+
67
+ def validate_schema(data)
68
+ return false unless data.is_a?(Hash)
69
+
70
+ REQUIRED_FIELDS.all? { |f| data.key?(f) }
71
+ end
72
+
73
+ def extract_keywords(text)
74
+ return [] unless text
75
+
76
+ text.downcase.split(/\s+/).uniq.reject { |w| w.length < 3 }
77
+ end
78
+ end
79
+ end
80
+ end
@@ -4,10 +4,11 @@ require 'json'
4
4
 
5
5
  module Legion
6
6
  module MCP
7
- module PatternStore
7
+ module PatternStore # rubocop:disable Metrics/ModuleLength
8
8
  CONFIDENCE_SUCCESS_DELTA = 0.02
9
9
  CONFIDENCE_FAILURE_DELTA = -0.05
10
10
  SEEDED_CONFIDENCE = 0.5
11
+ DECAY_ARCHIVE_THRESHOLD = 0.1
11
12
 
12
13
  module_function
13
14
 
@@ -129,6 +130,10 @@ module Legion
129
130
  mutex.synchronize { patterns_l0.size }
130
131
  end
131
132
 
133
+ def empty?
134
+ size.zero?
135
+ end
136
+
132
137
  def stats
133
138
  total_hits = 0
134
139
  total_conf = 0.0
@@ -149,9 +154,59 @@ module Legion
149
154
  }
150
155
  end
151
156
 
157
+ def learn_response_template(intent_hash, result_data, threshold: 3)
158
+ return unless result_data.is_a?(Hash)
159
+
160
+ template_mutex.synchronize do
161
+ buffer = template_observations[intent_hash] ||= []
162
+ buffer << result_data.keys.sort
163
+ buffer.shift if buffer.size > 10
164
+
165
+ return unless buffer.size >= threshold
166
+
167
+ if buffer.last(threshold).uniq.size == 1
168
+ keys = buffer.last.sort
169
+ template = keys.map { |k| "#{k}: {{#{k}}}" }.join(', ')
170
+ mutex.synchronize do
171
+ pattern = patterns_l0[intent_hash]
172
+ pattern[:response_template] = template if pattern
173
+ end
174
+ sync_to_persistence(intent_hash)
175
+ end
176
+ end
177
+ end
178
+
179
+ def decay_all(factor: 0.998)
180
+ archived = []
181
+ mutex.synchronize do
182
+ patterns_l0.each do |hash, pattern|
183
+ pattern[:confidence] = (pattern[:confidence] * factor).clamp(0.0, 1.0)
184
+ archived << hash if pattern[:confidence] < DECAY_ARCHIVE_THRESHOLD
185
+ end
186
+ archived.each { |hash| patterns_l0.delete(hash) }
187
+ end
188
+
189
+ archived.each { |hash| archive_l2(hash) }
190
+ sync_all_to_persistence unless archived.empty?
191
+ end
192
+
193
+ def hydrate_from_l2
194
+ return unless local_db_available?
195
+
196
+ table = ensure_local_table
197
+ table.each do |row|
198
+ pattern = deserialize_pattern(row)
199
+ mutex.synchronize { patterns_l0[pattern[:intent_hash]] = pattern }
200
+ persist_l1(pattern[:intent_hash], pattern)
201
+ end
202
+ rescue StandardError
203
+ nil
204
+ end
205
+
152
206
  def reset!
153
207
  mutex.synchronize { patterns_l0.clear }
154
208
  candidates_mutex.synchronize { candidates_buffer.clear }
209
+ template_mutex.synchronize { template_observations.clear }
155
210
  end
156
211
 
157
212
  # --- Private helpers ---
@@ -224,6 +279,19 @@ module Legion
224
279
  persist_l2(intent_hash, pattern)
225
280
  end
226
281
 
282
+ def archive_l2(intent_hash)
283
+ return unless local_db_available?
284
+
285
+ table = ensure_local_table
286
+ table.where(intent_hash: intent_hash).delete
287
+ rescue StandardError
288
+ nil
289
+ end
290
+
291
+ def sync_all_to_persistence
292
+ mutex.synchronize { patterns_l0.keys.dup }.each { |h| sync_to_persistence(h) }
293
+ end
294
+
227
295
  def local_db_available?
228
296
  defined?(Legion::Data::Local) &&
229
297
  Legion::Data::Local.respond_to?(:connected?) &&
@@ -298,6 +366,14 @@ module Legion
298
366
  def candidates_mutex
299
367
  @candidates_mutex ||= Mutex.new
300
368
  end
369
+
370
+ def template_observations
371
+ @template_observations ||= {}
372
+ end
373
+
374
+ def template_mutex
375
+ @template_mutex ||= Mutex.new
376
+ end
301
377
  end
302
378
  end
303
379
  end
@@ -35,9 +35,20 @@ require_relative 'tools/routing_stats'
35
35
  require_relative 'tools/rbac_check'
36
36
  require_relative 'tools/rbac_assignments'
37
37
  require_relative 'tools/rbac_grants'
38
+ require_relative 'tools/prompt_list'
39
+ require_relative 'tools/prompt_show'
40
+ require_relative 'tools/prompt_run'
41
+ require_relative 'tools/dataset_list'
42
+ require_relative 'tools/dataset_show'
43
+ require_relative 'tools/experiment_results'
44
+ require_relative 'tools/eval_list'
45
+ require_relative 'tools/eval_run'
46
+ require_relative 'tools/eval_results'
38
47
  require_relative 'context_compiler'
39
48
  require_relative 'embedding_index'
49
+ require_relative 'cold_start'
40
50
  require_relative 'tools/do_action'
51
+ require_relative 'tools/plan_action'
41
52
  require_relative 'tools/discover_tools'
42
53
  require_relative 'resources/runner_catalog'
43
54
  require_relative 'resources/extension_info'
@@ -79,7 +90,17 @@ module Legion
79
90
  Tools::RbacCheck,
80
91
  Tools::RbacAssignments,
81
92
  Tools::RbacGrants,
93
+ Tools::PromptList,
94
+ Tools::PromptShow,
95
+ Tools::PromptRun,
96
+ Tools::DatasetList,
97
+ Tools::DatasetShow,
98
+ Tools::ExperimentResults,
99
+ Tools::EvalList,
100
+ Tools::EvalRun,
101
+ Tools::EvalResults,
82
102
  Tools::DoAction,
103
+ Tools::PlanAction,
83
104
  Tools::DiscoverTools
84
105
  ].freeze
85
106
 
@@ -110,6 +131,12 @@ module Legion
110
131
  build_filtered_tool_list.map(&:to_h)
111
132
  end
112
133
 
134
+ # Hydrate pattern store from L2 persistence (SQLite) on boot
135
+ PatternStore.hydrate_from_l2 if defined?(PatternStore)
136
+
137
+ # Cold-start: load community patterns if store is still empty after hydration
138
+ ColdStart.load_community_patterns if defined?(ColdStart)
139
+
113
140
  # Populate embedding index for semantic tool matching (lazy — no-op if LLM unavailable)
114
141
  populate_embedding_index
115
142
 
@@ -141,8 +168,11 @@ module Legion
141
168
  error: data[:error]
142
169
  )
143
170
 
144
- # Wire pattern promotion feedback loop for do_action calls
145
- return unless data[:tool_name] == 'legion.do' && data[:tool_arguments]&.dig(:intent)
171
+ # Pattern promotion for legion.do is handled inside DoAction itself
172
+ # (which knows the actual resolved tool name). For other tools called
173
+ # directly, we record the intent+result here if an intent is present.
174
+ return if data[:tool_name] == 'legion.do'
175
+ return unless data[:tool_arguments]&.dig(:intent)
146
176
 
147
177
  Observer.record_intent_with_result(
148
178
  intent: data[:tool_arguments][:intent],