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 +4 -4
- data/CHANGELOG.md +46 -1
- data/lib/legion/mcp/capability_generator.rb +109 -0
- data/lib/legion/mcp/cold_start.rb +31 -0
- data/lib/legion/mcp/gap_detector.rb +53 -0
- data/lib/legion/mcp/pattern_compiler.rb +51 -0
- data/lib/legion/mcp/pattern_exchange.rb +56 -0
- data/lib/legion/mcp/pattern_gossip.rb +60 -0
- data/lib/legion/mcp/pattern_schema.rb +80 -0
- data/lib/legion/mcp/pattern_store.rb +77 -1
- data/lib/legion/mcp/server.rb +32 -2
- data/lib/legion/mcp/tier_router.rb +1 -0
- data/lib/legion/mcp/tools/dataset_list.rb +48 -0
- data/lib/legion/mcp/tools/dataset_show.rb +54 -0
- data/lib/legion/mcp/tools/do_action.rb +49 -10
- data/lib/legion/mcp/tools/eval_list.rb +48 -0
- data/lib/legion/mcp/tools/eval_results.rb +77 -0
- data/lib/legion/mcp/tools/eval_run.rb +57 -0
- data/lib/legion/mcp/tools/experiment_results.rb +77 -0
- data/lib/legion/mcp/tools/plan_action.rb +59 -0
- data/lib/legion/mcp/tools/prompt_list.rb +48 -0
- data/lib/legion/mcp/tools/prompt_run.rb +59 -0
- data/lib/legion/mcp/tools/prompt_show.rb +55 -0
- data/lib/legion/mcp/version.rb +1 -1
- metadata +18 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 37f03ddf4778cc238c0e69b039b9cd6c55acc2ada6207ef32c777c844d60d5e1
|
|
4
|
+
data.tar.gz: ffe2b6b6da0930d795ca3965b86f5edabdf8f6b698232b110bd3ae6187ce0c20
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
##
|
|
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
|
data/lib/legion/mcp/server.rb
CHANGED
|
@@ -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
|
-
#
|
|
145
|
-
|
|
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],
|