legionio 1.4.29 → 1.4.43
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/.rubocop.yml +1 -0
- data/CHANGELOG.md +98 -0
- data/CLAUDE.md +8 -4
- data/lib/legion/api/auth.rb +82 -0
- data/lib/legion/api/gaia.rb +22 -0
- data/lib/legion/api/webhooks.rb +29 -0
- data/lib/legion/api.rb +2 -0
- data/lib/legion/audit/hash_chain.rb +32 -0
- data/lib/legion/audit/siem_export.rb +33 -0
- data/lib/legion/catalog.rb +47 -0
- data/lib/legion/chat/notification_bridge.rb +2 -0
- data/lib/legion/chat/skills.rb +49 -0
- data/lib/legion/cli/chat/progress_bar.rb +55 -0
- data/lib/legion/cli/chat/team.rb +43 -0
- data/lib/legion/cli/chat_command.rb +19 -1
- data/lib/legion/cli/cost/data_client.rb +53 -0
- data/lib/legion/cli/cost_command.rb +76 -0
- data/lib/legion/cli/lex_templates.rb +64 -0
- data/lib/legion/cli/marketplace_command.rb +79 -0
- data/lib/legion/cli/mcp_command.rb +12 -1
- data/lib/legion/cli/notebook_command.rb +69 -0
- data/lib/legion/cli/skill_command.rb +86 -0
- data/lib/legion/cli.rb +16 -0
- data/lib/legion/context.rb +53 -0
- data/lib/legion/docs/site_generator.rb +48 -0
- data/lib/legion/guardrails.rb +50 -0
- data/lib/legion/isolation.rb +49 -0
- data/lib/legion/mcp/auth.rb +50 -0
- data/lib/legion/mcp/server.rb +8 -2
- data/lib/legion/mcp/tool_governance.rb +77 -0
- data/lib/legion/mcp.rb +9 -0
- data/lib/legion/registry/security_scanner.rb +48 -0
- data/lib/legion/registry.rb +72 -0
- data/lib/legion/sandbox.rb +65 -0
- data/lib/legion/version.rb +1 -1
- data/lib/legion/webhooks.rb +124 -0
- metadata +25 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 51e90ef95b551c27c044e9f9dac0d784a529d4947835ec0d6501276f73281fb9
|
|
4
|
+
data.tar.gz: 533c1b9813327e486e33cb2c1d657907248517deb15935c68616a095be699280
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 298d051c13e5e658eb572971863a7039d3c08bd168b40b2e6806a3139ccae7261ff29c916bb84ab34fd2fea6c2e6905615f8689e80f5a415feafe75cbf0f56c5
|
|
7
|
+
data.tar.gz: 1f0005c08f21b456e5d64923020e662efbe88a714514ad7bd27b59caa0efc05676551f8b87a150d0226aa1bc9237681d0688bb6b42f1430624ea5ba81e420903
|
data/.rubocop.yml
CHANGED
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,103 @@
|
|
|
1
1
|
# Legion Changelog
|
|
2
2
|
|
|
3
|
+
## [1.4.43] - 2026-03-17
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
- Auth token exchange route used `Legion::Settings.dig` which doesn't exist — replaced with bracket access
|
|
7
|
+
- Auth spec required `legion/rbac` gem directly — replaced with inline stub for standalone test execution
|
|
8
|
+
|
|
9
|
+
## [1.4.42] - 2026-03-17
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
- `POST /api/auth/token`: Entra ID token exchange endpoint (validates external JWT via JWKS, maps claims via EntraClaimsMapper, issues Legion token)
|
|
13
|
+
|
|
14
|
+
## [1.4.41] - 2026-03-17
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
- `Legion::CLI::LexTemplates`: extension template registry (basic, llm-agent, service-integration, scheduled-task, webhook-handler)
|
|
18
|
+
- `Legion::Docs::SiteGenerator`: documentation site generation from existing markdown files
|
|
19
|
+
|
|
20
|
+
## [1.4.40] - 2026-03-17
|
|
21
|
+
|
|
22
|
+
### Added
|
|
23
|
+
- `Legion::Guardrails`: embedding similarity and RAG relevancy safety checks
|
|
24
|
+
- `Legion::Context`: session/user tracking with thread-local `SessionContext`
|
|
25
|
+
- `Legion::Catalog`: AI catalog registration for MCP tools and workers
|
|
26
|
+
|
|
27
|
+
## [1.4.39] - 2026-03-17
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
- `Legion::Webhooks`: outbound webhook dispatcher with HMAC-SHA256 signing
|
|
31
|
+
- Webhook registration, delivery tracking, and dead letter queue
|
|
32
|
+
- API routes: `GET/POST/DELETE /api/webhooks`
|
|
33
|
+
|
|
34
|
+
## [1.4.38] - 2026-03-17
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- `Legion::Isolation`: per-agent data and tool access enforcement with thread-local context
|
|
38
|
+
- `Isolation::Context`: tool allowlist, data filter, and risk tier per agent
|
|
39
|
+
|
|
40
|
+
## [1.4.37] - 2026-03-17
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
- `POST /api/channels/teams/webhook`: Bot Framework activity delivery to GAIA sensory buffer
|
|
44
|
+
|
|
45
|
+
## [1.4.36] - 2026-03-17
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
- `Audit::HashChain`: SHA-256 hash chain for tamper-evident audit records
|
|
49
|
+
- `Audit::SiemExport`: SIEM-compatible JSON and NDJSON export with integrity metadata
|
|
50
|
+
- `Audit::HashChain.verify_chain` validates hash chain between records
|
|
51
|
+
|
|
52
|
+
## [1.4.35] - 2026-03-17
|
|
53
|
+
|
|
54
|
+
### Added
|
|
55
|
+
- `Chat::Team`: multi-user context tracking with thread-local user, env detection
|
|
56
|
+
- `Chat::ProgressBar`: progress indicator for long-running operations with ETA
|
|
57
|
+
- `legion notebook read/export`: Jupyter notebook reading and export (markdown/script)
|
|
58
|
+
|
|
59
|
+
## [1.4.34] - 2026-03-17
|
|
60
|
+
|
|
61
|
+
### Added
|
|
62
|
+
- `Legion::Registry`: central extension metadata store with search, risk tier filtering, AIRB status
|
|
63
|
+
- `Legion::Sandbox`: capability-based extension sandboxing with enforcement toggle
|
|
64
|
+
- `Legion::Registry::SecurityScanner`: naming convention, checksum, and gemspec metadata validation
|
|
65
|
+
- `legion marketplace`: CLI for search, info, list, scan operations
|
|
66
|
+
|
|
67
|
+
## [1.4.33] - 2026-03-17
|
|
68
|
+
|
|
69
|
+
### Added
|
|
70
|
+
- `legion cost summary`: overall cost summary (today/week/month)
|
|
71
|
+
- `legion cost worker <id>`: per-worker cost breakdown
|
|
72
|
+
- `legion cost top`: top cost consumers ranked by spend
|
|
73
|
+
- `legion cost export`: export cost data as JSON or CSV
|
|
74
|
+
- `Legion::CLI::CostData::Client`: API client for cost data retrieval
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
- `Connection.resolve_config_dir` spec now correctly stubs `~/.legionio/settings` path
|
|
78
|
+
|
|
79
|
+
## [1.4.32] - 2026-03-17
|
|
80
|
+
|
|
81
|
+
### Fixed
|
|
82
|
+
- `NotificationBridge` missing `require_relative 'notification_queue'` causing `NameError` on `legion chat`
|
|
83
|
+
|
|
84
|
+
## [1.4.31] - 2026-03-16
|
|
85
|
+
|
|
86
|
+
### Added
|
|
87
|
+
- Skills system: `.legion/skills/` and `~/.legionio/skills/` YAML frontmatter markdown files
|
|
88
|
+
- `Legion::Chat::Skills`: discovery, parsing, and find for skill files
|
|
89
|
+
- `/skill-name` invocation in chat resolves user-defined skills
|
|
90
|
+
- `legion skill list`, `legion skill show`, `legion skill create`, `legion skill run` CLI subcommands
|
|
91
|
+
|
|
92
|
+
## [1.4.30] - 2026-03-16
|
|
93
|
+
|
|
94
|
+
### Added
|
|
95
|
+
- `MCP::Auth`: token-based MCP authentication (JWT + API key)
|
|
96
|
+
- `MCP::ToolGovernance`: risk-tier-aware tool filtering and invocation audit
|
|
97
|
+
- `MCP.server_for(token:)` builds identity-scoped MCP server instances
|
|
98
|
+
- HTTP transport auth: Bearer token validation with 401 response on failure
|
|
99
|
+
- MCP settings: `mcp.auth.enabled`, `mcp.auth.allowed_api_keys`, `mcp.governance.enabled`, `mcp.governance.tool_risk_tiers`
|
|
100
|
+
|
|
3
101
|
## [1.4.29] - 2026-03-16
|
|
4
102
|
|
|
5
103
|
### 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.
|
|
12
|
+
**Version**: 1.4.36
|
|
13
13
|
**License**: Apache-2.0
|
|
14
14
|
**Docker**: `legionio/legion`
|
|
15
15
|
**Ruby**: >= 3.4
|
|
@@ -481,8 +481,10 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
|
|
|
481
481
|
| `lib/legion/api/middleware/body_limit.rb` | BodyLimit: request body size limit (1MB max, returns 413) |
|
|
482
482
|
| `lib/legion/api/middleware/rate_limit.rb` | RateLimit: sliding-window rate limiting with per-IP/agent/tenant tiers |
|
|
483
483
|
| **MCP** | |
|
|
484
|
-
| `lib/legion/mcp.rb` | Entry point: `Legion::MCP.server` singleton factory |
|
|
485
|
-
| `lib/legion/mcp/
|
|
484
|
+
| `lib/legion/mcp.rb` | Entry point: `Legion::MCP.server` singleton factory, `server_for(token:)` |
|
|
485
|
+
| `lib/legion/mcp/auth.rb` | MCP authentication: JWT + API key verification |
|
|
486
|
+
| `lib/legion/mcp/tool_governance.rb` | Risk-tier tool filtering and invocation audit |
|
|
487
|
+
| `lib/legion/mcp/server.rb` | MCP::Server builder, TOOL_CLASSES array, governance-aware build |
|
|
486
488
|
| `lib/legion/digital_worker.rb` | DigitalWorker module entry point |
|
|
487
489
|
| `lib/legion/digital_worker/lifecycle.rb` | Worker state machine |
|
|
488
490
|
| `lib/legion/digital_worker/registry.rb` | In-process worker registry |
|
|
@@ -564,6 +566,8 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
|
|
|
564
566
|
| `API::Routes::Relationships` | Fully implemented (backed by legion-data migration 013) |
|
|
565
567
|
| `API::Routes::Chains` | 501 stub - no data model |
|
|
566
568
|
| `API::Middleware::Auth` | JWT Bearer auth middleware — real token validation and API key (`X-API-Key` header) auth both implemented |
|
|
569
|
+
| `MCP::Auth` | JWT + API key authentication for MCP server (HTTP transport) |
|
|
570
|
+
| `MCP::ToolGovernance` | Risk-tier tool filtering + audit — disabled by default, opt-in via settings |
|
|
567
571
|
| `legion-data` chains/relationships models | Not yet implemented |
|
|
568
572
|
|
|
569
573
|
## Rubocop Notes
|
|
@@ -577,7 +581,7 @@ rack-test, rake, rspec, rubocop, rubocop-rspec, simplecov
|
|
|
577
581
|
|
|
578
582
|
```bash
|
|
579
583
|
bundle install
|
|
580
|
-
bundle exec rspec #
|
|
584
|
+
bundle exec rspec # 1088 examples, 0 failures
|
|
581
585
|
bundle exec rubocop # 0 offenses
|
|
582
586
|
```
|
|
583
587
|
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
module Routes
|
|
6
|
+
module Auth
|
|
7
|
+
def self.registered(app)
|
|
8
|
+
register_token_exchange(app)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.register_token_exchange(app) # rubocop:disable Metrics/MethodLength
|
|
12
|
+
app.post '/api/auth/token' do
|
|
13
|
+
body = parse_request_body
|
|
14
|
+
grant_type = body[:grant_type]
|
|
15
|
+
subject_token = body[:subject_token]
|
|
16
|
+
|
|
17
|
+
unless grant_type == 'urn:ietf:params:oauth:grant-type:token-exchange'
|
|
18
|
+
halt 400, json_error('unsupported_grant_type', 'expected urn:ietf:params:oauth:grant-type:token-exchange',
|
|
19
|
+
status_code: 400)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
halt 400, json_error('missing_subject_token', 'subject_token is required', status_code: 400) unless subject_token
|
|
23
|
+
|
|
24
|
+
unless defined?(Legion::Crypt::JWT) && Legion::Crypt::JWT.respond_to?(:verify_with_jwks)
|
|
25
|
+
halt 501, json_error('jwks_validation_not_available', 'legion-crypt JWKS support not loaded',
|
|
26
|
+
status_code: 501)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
rbac_settings = (Legion::Settings[:rbac].is_a?(Hash) && Legion::Settings[:rbac][:entra]) || {}
|
|
30
|
+
tenant_id = rbac_settings[:tenant_id]
|
|
31
|
+
halt 500, json_error('entra_tenant_not_configured', 'rbac.entra.tenant_id not set', status_code: 500) unless tenant_id
|
|
32
|
+
|
|
33
|
+
jwks_url = "https://login.microsoftonline.com/#{tenant_id}/discovery/v2.0/keys"
|
|
34
|
+
issuer = "https://login.microsoftonline.com/#{tenant_id}/v2.0"
|
|
35
|
+
|
|
36
|
+
begin
|
|
37
|
+
entra_claims = Legion::Crypt::JWT.verify_with_jwks(
|
|
38
|
+
subject_token, jwks_url: jwks_url, issuers: [issuer]
|
|
39
|
+
)
|
|
40
|
+
rescue Legion::Crypt::JWT::ExpiredTokenError
|
|
41
|
+
halt 401, json_error('token_expired', 'Entra token has expired', status_code: 401)
|
|
42
|
+
rescue Legion::Crypt::JWT::InvalidTokenError => e
|
|
43
|
+
halt 401, json_error('invalid_token', e.message, status_code: 401)
|
|
44
|
+
rescue Legion::Crypt::JWT::Error => e
|
|
45
|
+
halt 502, json_error('identity_provider_unavailable', e.message, status_code: 502)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
unless defined?(Legion::Rbac::EntraClaimsMapper)
|
|
49
|
+
halt 501, json_error('claims_mapper_not_available', 'legion-rbac EntraClaimsMapper not loaded',
|
|
50
|
+
status_code: 501)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
mapped = Legion::Rbac::EntraClaimsMapper.map_claims(
|
|
54
|
+
entra_claims,
|
|
55
|
+
role_map: rbac_settings[:role_map] || Legion::Rbac::EntraClaimsMapper::DEFAULT_ROLE_MAP,
|
|
56
|
+
group_map: rbac_settings[:group_map] || {},
|
|
57
|
+
default_role: rbac_settings[:default_role] || 'worker'
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
ttl = 28_800
|
|
61
|
+
token = Legion::API::Token.issue_human_token(
|
|
62
|
+
msid: mapped[:sub], name: mapped[:name],
|
|
63
|
+
roles: mapped[:roles], ttl: ttl
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
json_response({
|
|
67
|
+
access_token: token,
|
|
68
|
+
token_type: 'Bearer',
|
|
69
|
+
expires_in: ttl,
|
|
70
|
+
roles: mapped[:roles],
|
|
71
|
+
team: mapped[:team]
|
|
72
|
+
})
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
class << self
|
|
77
|
+
private :register_token_exchange
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
data/lib/legion/api/gaia.rb
CHANGED
|
@@ -12,6 +12,28 @@ module Legion
|
|
|
12
12
|
json_response({ started: false }, status_code: 503)
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
|
+
|
|
16
|
+
app.post '/api/channels/teams/webhook' do
|
|
17
|
+
body = request.body.read
|
|
18
|
+
activity = Legion::JSON.load(body)
|
|
19
|
+
|
|
20
|
+
adapter = Routes::Gaia.teams_adapter
|
|
21
|
+
halt 503, json_response({ error: 'teams adapter not available' }, status_code: 503) unless adapter
|
|
22
|
+
|
|
23
|
+
input_frame = adapter.translate_inbound(activity)
|
|
24
|
+
Legion::Gaia.sensory_buffer&.push(input_frame) if defined?(Legion::Gaia)
|
|
25
|
+
|
|
26
|
+
json_response({ status: 'accepted', frame_id: input_frame&.id })
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.teams_adapter
|
|
31
|
+
return nil unless defined?(Legion::Gaia) && Legion::Gaia.respond_to?(:channel_registry)
|
|
32
|
+
return nil unless Legion::Gaia.channel_registry
|
|
33
|
+
|
|
34
|
+
Legion::Gaia.channel_registry.adapter_for(:teams)
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
15
37
|
end
|
|
16
38
|
end
|
|
17
39
|
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
class API < Sinatra::Base
|
|
5
|
+
module Routes
|
|
6
|
+
module Webhooks
|
|
7
|
+
def self.registered(app)
|
|
8
|
+
app.get '/api/webhooks' do
|
|
9
|
+
json_response(Legion::Webhooks.list)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
app.post '/api/webhooks' do
|
|
13
|
+
body = parse_request_body
|
|
14
|
+
result = Legion::Webhooks.register(
|
|
15
|
+
url: body[:url], secret: body[:secret],
|
|
16
|
+
event_types: body[:event_types] || ['*'],
|
|
17
|
+
max_retries: body[:max_retries] || 5
|
|
18
|
+
)
|
|
19
|
+
json_response(result, status_code: 201)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
app.delete '/api/webhooks/:id' do
|
|
23
|
+
json_response(Legion::Webhooks.unregister(id: params[:id].to_i))
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/legion/api.rb
CHANGED
|
@@ -26,6 +26,7 @@ require_relative 'api/gaia'
|
|
|
26
26
|
require_relative 'api/oauth'
|
|
27
27
|
require_relative 'api/openapi'
|
|
28
28
|
require_relative 'api/rbac'
|
|
29
|
+
require_relative 'api/auth'
|
|
29
30
|
require_relative 'api/audit'
|
|
30
31
|
require_relative 'api/metrics'
|
|
31
32
|
|
|
@@ -96,6 +97,7 @@ module Legion
|
|
|
96
97
|
register Routes::Gaia
|
|
97
98
|
register Routes::OAuth
|
|
98
99
|
register Routes::Rbac
|
|
100
|
+
register Routes::Auth
|
|
99
101
|
register Routes::Audit
|
|
100
102
|
register Routes::Metrics
|
|
101
103
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Audit
|
|
7
|
+
module HashChain
|
|
8
|
+
ALGORITHM = 'SHA256'
|
|
9
|
+
GENESIS_HASH = ('0' * 64).freeze
|
|
10
|
+
CANONICAL_FIELDS = %i[principal_id action resource source status detail created_at previous_hash].freeze
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def compute_hash(record)
|
|
15
|
+
payload = canonical_payload(record)
|
|
16
|
+
OpenSSL::Digest.new(ALGORITHM).hexdigest(payload)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def canonical_payload(record)
|
|
20
|
+
CANONICAL_FIELDS.map { |f| "#{f}:#{record[f]}" }.join('|')
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def verify_chain(records)
|
|
24
|
+
broken = []
|
|
25
|
+
records.each_cons(2) do |prev, curr|
|
|
26
|
+
broken << { id: curr[:id], expected: prev[:record_hash], got: curr[:previous_hash] } unless curr[:previous_hash] == prev[:record_hash]
|
|
27
|
+
end
|
|
28
|
+
{ valid: broken.empty?, broken_links: broken, records_checked: records.size }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module Audit
|
|
5
|
+
module SiemExport
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def export_batch(records)
|
|
9
|
+
records.map do |r|
|
|
10
|
+
{
|
|
11
|
+
timestamp: r[:created_at],
|
|
12
|
+
source: 'legion',
|
|
13
|
+
event_type: r[:event_type] || 'audit',
|
|
14
|
+
principal: r[:principal_id],
|
|
15
|
+
action: r[:action],
|
|
16
|
+
resource: r[:resource],
|
|
17
|
+
status: r[:status],
|
|
18
|
+
detail: r[:detail],
|
|
19
|
+
integrity: {
|
|
20
|
+
record_hash: r[:record_hash],
|
|
21
|
+
previous_hash: r[:previous_hash],
|
|
22
|
+
algorithm: 'SHA256'
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def to_ndjson(records)
|
|
29
|
+
export_batch(records).map { |r| Legion::JSON.dump(r) }.join("\n")
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
require 'uri'
|
|
5
|
+
|
|
6
|
+
module Legion
|
|
7
|
+
module Catalog
|
|
8
|
+
class << self
|
|
9
|
+
def register_tools(catalog_url:, api_key:)
|
|
10
|
+
tools = collect_mcp_tools
|
|
11
|
+
post_json("#{catalog_url}/api/tools", { tools: tools }, api_key)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def register_workers(catalog_url:, api_key:, workers:)
|
|
15
|
+
entries = workers.map do |w|
|
|
16
|
+
{ id: w[:worker_id], status: w[:status], capabilities: w[:capabilities] || [] }
|
|
17
|
+
end
|
|
18
|
+
post_json("#{catalog_url}/api/workers", { workers: entries }, api_key)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def collect_mcp_tools
|
|
22
|
+
return [] unless defined?(Legion::MCP) && Legion::MCP.respond_to?(:tools)
|
|
23
|
+
|
|
24
|
+
Legion::MCP.tools.map { |t| { name: t[:name], description: t[:description] } }
|
|
25
|
+
rescue StandardError
|
|
26
|
+
[]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def post_json(url, body, api_key)
|
|
32
|
+
uri = URI(url)
|
|
33
|
+
req = Net::HTTP::Post.new(uri)
|
|
34
|
+
req['Authorization'] = "Bearer #{api_key}"
|
|
35
|
+
req['Content-Type'] = 'application/json'
|
|
36
|
+
req.body = Legion::JSON.dump(body)
|
|
37
|
+
|
|
38
|
+
response = Net::HTTP.start(uri.hostname, uri.port, use_ssl: uri.scheme == 'https') do |http|
|
|
39
|
+
http.request(req)
|
|
40
|
+
end
|
|
41
|
+
{ status: response.code.to_i, body: response.body }
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
{ error: e.message }
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'yaml'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module Chat
|
|
7
|
+
module Skills
|
|
8
|
+
SKILL_DIRS = ['.legion/skills', '~/.legionio/skills'].freeze
|
|
9
|
+
|
|
10
|
+
class << self
|
|
11
|
+
def discover
|
|
12
|
+
SKILL_DIRS.flat_map do |dir|
|
|
13
|
+
expanded = File.expand_path(dir)
|
|
14
|
+
next [] unless Dir.exist?(expanded)
|
|
15
|
+
|
|
16
|
+
Dir.glob(File.join(expanded, '*.md')).filter_map { |f| parse(f) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def find(name)
|
|
21
|
+
discover.find { |s| s[:name] == name.to_s }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def parse(path)
|
|
25
|
+
content = File.read(path)
|
|
26
|
+
return nil unless content.start_with?('---')
|
|
27
|
+
|
|
28
|
+
parts = content.split(/^---\s*$/, 3)
|
|
29
|
+
return nil if parts.size < 3
|
|
30
|
+
|
|
31
|
+
frontmatter = YAML.safe_load(parts[1], permitted_classes: [Symbol])
|
|
32
|
+
body = parts[2]&.strip
|
|
33
|
+
|
|
34
|
+
{
|
|
35
|
+
name: frontmatter['name'] || File.basename(path, '.md'),
|
|
36
|
+
description: frontmatter['description'] || '',
|
|
37
|
+
model: frontmatter['model'],
|
|
38
|
+
tools: Array(frontmatter['tools']),
|
|
39
|
+
prompt: body,
|
|
40
|
+
path: path
|
|
41
|
+
}
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
Legion::Logging.warn "Skill parse error #{path}: #{e.message}" if defined?(Legion::Logging)
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module CLI
|
|
5
|
+
class Chat
|
|
6
|
+
class ProgressBar
|
|
7
|
+
attr_reader :total, :current
|
|
8
|
+
|
|
9
|
+
def initialize(total:, label: '', width: 40, output: $stdout)
|
|
10
|
+
@total = [total, 1].max
|
|
11
|
+
@current = 0
|
|
12
|
+
@label = label
|
|
13
|
+
@width = width
|
|
14
|
+
@output = output
|
|
15
|
+
@start_time = Time.now
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def advance(amount = 1)
|
|
19
|
+
@current = [@current + amount, @total].min
|
|
20
|
+
render
|
|
21
|
+
self
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def finish
|
|
25
|
+
@current = @total
|
|
26
|
+
render
|
|
27
|
+
@output.puts
|
|
28
|
+
self
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def percentage
|
|
32
|
+
(@current.to_f / @total * 100).round(1)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def elapsed
|
|
36
|
+
Time.now - @start_time
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def eta
|
|
40
|
+
return 0 if @current.zero? || @current >= @total
|
|
41
|
+
|
|
42
|
+
(elapsed / @current * (@total - @current)).round
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def render
|
|
48
|
+
filled = (@width * @current.to_f / @total).round
|
|
49
|
+
bar = ('#' * filled) + ('-' * [(@width - filled), 0].max)
|
|
50
|
+
@output.print "\r#{@label} [#{bar}] #{percentage}% (#{@current}/#{@total}) ETA: #{eta}s "
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Legion
|
|
4
|
+
module CLI
|
|
5
|
+
class Chat
|
|
6
|
+
module Team
|
|
7
|
+
class UserContext
|
|
8
|
+
attr_reader :user_id, :team_id, :display_name
|
|
9
|
+
|
|
10
|
+
def initialize(user_id:, team_id: nil, display_name: nil)
|
|
11
|
+
@user_id = user_id
|
|
12
|
+
@team_id = team_id
|
|
13
|
+
@display_name = display_name || user_id
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def to_h
|
|
17
|
+
{ user_id: user_id, team_id: team_id, display_name: display_name }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class << self
|
|
22
|
+
def current_user
|
|
23
|
+
Thread.current[:legion_chat_user]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def with_user(context)
|
|
27
|
+
previous = Thread.current[:legion_chat_user]
|
|
28
|
+
Thread.current[:legion_chat_user] = context
|
|
29
|
+
yield
|
|
30
|
+
ensure
|
|
31
|
+
Thread.current[:legion_chat_user] = previous
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def detect_user
|
|
35
|
+
user_id = ENV.fetch('LEGION_USER', ENV.fetch('USER', 'anonymous'))
|
|
36
|
+
team_id = ENV.fetch('LEGION_TEAM', nil)
|
|
37
|
+
UserContext.new(user_id: user_id, team_id: team_id)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -447,7 +447,13 @@ module Legion
|
|
|
447
447
|
when '/dream'
|
|
448
448
|
handle_dream_in_chat(out)
|
|
449
449
|
else
|
|
450
|
-
|
|
450
|
+
require 'legion/chat/skills'
|
|
451
|
+
skill = Legion::Chat::Skills.find(cmd.delete_prefix('/'))
|
|
452
|
+
if skill
|
|
453
|
+
handle_skill(skill, args.first, out)
|
|
454
|
+
else
|
|
455
|
+
out.warn("Unknown command: #{cmd}. Type /help for available commands.")
|
|
456
|
+
end
|
|
451
457
|
end
|
|
452
458
|
true
|
|
453
459
|
end
|
|
@@ -1104,6 +1110,18 @@ module Legion
|
|
|
1104
1110
|
out.error("Dream failed: #{e.message}")
|
|
1105
1111
|
end
|
|
1106
1112
|
|
|
1113
|
+
def handle_skill(skill, args_text, out)
|
|
1114
|
+
out.info("Running skill: #{skill[:name]}")
|
|
1115
|
+
user_input = args_text || ''
|
|
1116
|
+
system_prompt = skill[:prompt]
|
|
1117
|
+
|
|
1118
|
+
@session.chat.ask(user_input) do |msg|
|
|
1119
|
+
msg.system_prompt = system_prompt if system_prompt && msg.respond_to?(:system_prompt=)
|
|
1120
|
+
end
|
|
1121
|
+
rescue StandardError => e
|
|
1122
|
+
out.error("Skill error: #{e.message}")
|
|
1123
|
+
end
|
|
1124
|
+
|
|
1107
1125
|
def api_port_for_chat
|
|
1108
1126
|
4567
|
|
1109
1127
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'net/http'
|
|
4
|
+
|
|
5
|
+
module Legion
|
|
6
|
+
module CLI
|
|
7
|
+
module CostData
|
|
8
|
+
class Client
|
|
9
|
+
def initialize(base_url: 'http://localhost:4567')
|
|
10
|
+
@base_url = base_url
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def summary(period: 'month')
|
|
14
|
+
fetch("/api/costs/summary?period=#{period}") || default_summary
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def worker_cost(worker_id)
|
|
18
|
+
fetch("/api/workers/#{worker_id}/value") || {}
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def top_consumers(limit: 10)
|
|
22
|
+
workers = fetch('/api/workers') || []
|
|
23
|
+
workers = workers[:data] if workers.is_a?(Hash) && workers.key?(:data)
|
|
24
|
+
results = Array(workers).map do |w|
|
|
25
|
+
id = w[:worker_id] || w[:id]
|
|
26
|
+
cost = worker_cost(id)
|
|
27
|
+
{ worker_id: id, cost: cost }
|
|
28
|
+
end
|
|
29
|
+
results.sort_by { |w| -(w.dig(:cost, :total_cost_usd) || 0) }.first(limit)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def fetch(path)
|
|
35
|
+
uri = URI("#{@base_url}#{path}")
|
|
36
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
37
|
+
http.open_timeout = 5
|
|
38
|
+
http.read_timeout = 5
|
|
39
|
+
response = http.request(Net::HTTP::Get.new(uri))
|
|
40
|
+
return nil unless response.is_a?(Net::HTTPSuccess)
|
|
41
|
+
|
|
42
|
+
Legion::JSON.load(response.body)
|
|
43
|
+
rescue StandardError
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def default_summary
|
|
48
|
+
{ today: 0.0, week: 0.0, month: 0.0, workers: 0 }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|