legionio 1.7.26 → 1.7.30

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: 010545b8c47b9d866f1f5da8597e82f2e94bcd7c2a065f8aa97a9383f34325df
4
- data.tar.gz: 7e75584a9fdf08247ec2e8834584a25499f1150814d6888701adea1689a85c37
3
+ metadata.gz: 537bbc9c431e5e8b9fdaf96145ea124a0c4b8e84b4094673cbc4f6b07d4edb21
4
+ data.tar.gz: da76364c12123a27f644f4fdef175c14796b2542800a93b054b26cb5f27a5e79
5
5
  SHA512:
6
- metadata.gz: 582fd272567d6bcca36b3d9fa8eff39b123f8c664f557a3a26d832bde3756e9c5d32d94693533d31d6231ff9d3f77201d067c3b8e26c9fd416888245993c0dc7
7
- data.tar.gz: fc1f7f556c6f7cb5c4a24b8733fc23032a3cc54561ccb0d40fba8189190a424e58c84412824d5e195c870b05015ecc93f398fd74948f8fce837f842a63641e41
6
+ metadata.gz: bee0c19f368dffa21a46fc964a43ae7448356912cbc5f0ac6884c9bcd4e00660821bc66c37e2b8d2cc2e05c77ec0fbce17d2f5a6c70b5b2bfb0d5eb4c3c278a6
7
+ data.tar.gz: 60f48041416adb178c7aae384647c7fb15860aa2a25dde1418500ef947ac323ebf36340ab242e9abcdd2893e717e3bdef94bd62aea43955e927c26acf377aecf
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Legion Changelog
2
2
 
3
+ ## [1.7.30] - 2026-04-08
4
+
5
+ ### Added
6
+ - SSE streaming inference now emits real-time `tool-call`, `tool-result`, `tool-error`, and `model-fallback` events via `executor.tool_event_handler` as tools execute (with wall-clock `startedAt`/`finishedAt`/`durationMs` timing)
7
+ - `event: done` payload extended with `conversation_id`, `stop_reason`, `cache_read_tokens`, and `cache_write_tokens` fields (nil values compacted out)
8
+ - Post-hoc `model-fallback` events emitted from `pipeline_response.warnings` for non-streaming tool paths
9
+ - `admin purge-topology` CLI command to remove stale v2.0 `legion.*` AMQP exchanges that have `lex.*` counterparts
10
+ - Parallel tool execution in `CLI::Chat::DaemonChat`: all tools in a response now run concurrently via `Thread.new`, preserving original order for message replay
11
+ - `build_tool_result_object` now carries `tool_call_id`/`id` so the Interlink frontend can match results to tool calls by ID rather than name (fixes parallel same-type tool matching)
12
+
13
+ ### Changed
14
+ - SSE tool-call events now use camelCase keys (`toolCallId`, `toolName`, `args`) matching the Interlink wire protocol
15
+
16
+ ## [1.7.29] - 2026-04-07
17
+
18
+ ### Changed
19
+ - Skip secret resolution for all CLI commands that only need local settings: `config`, `mode`, `lex`, `doctor`, `auth`, `marketplace`, `debug`, `failover status` — eliminates noisy Vault/lease warnings on local-only operations
20
+
21
+ ## [1.7.28] - 2026-04-07
22
+
23
+ ### Fixed
24
+ - `legionio setup` pack marker and packs.json writes now rescue `Errno::EPERM`/`EACCES`, fixing Homebrew post-install crash when sandbox blocks writes to `~/.legionio/`
25
+
26
+ ## [1.7.27] - 2026-04-07
27
+
28
+ ### Changed
29
+ - `Connection.ensure_settings` accepts `resolve_secrets:` keyword (default `true`) to skip Vault/lease resolution for CLI commands that don't need infrastructure credentials
30
+ - `legionio update` now skips secret resolution, eliminating noisy "Vault not connected" and "LeaseManager not available" warnings
31
+
3
32
  ## [1.7.26] - 2026-04-07
4
33
 
5
34
  ### Added
@@ -306,6 +306,51 @@ module Legion
306
306
  'X-Accel-Buffering' => 'no'
307
307
 
308
308
  stream do |out|
309
+ # Wire up real-time tool-call / tool-result / tool-error / model-fallback SSE events.
310
+ # The executor fires tool_event_handler for each event as it happens,
311
+ # including accurate wall-clock startedAt/finishedAt/durationMs timing.
312
+ emitted_tool_call_ids = Set.new
313
+ executor.tool_event_handler = lambda do |event|
314
+ case event[:type]
315
+ when :tool_call
316
+ emitted_tool_call_ids << event[:tool_call_id] if event[:tool_call_id]
317
+ out << "event: tool-call\ndata: #{Legion::JSON.dump({
318
+ toolCallId: event[:tool_call_id],
319
+ toolName: event[:tool_name],
320
+ args: event[:arguments] || {},
321
+ startedAt: event[:started_at]&.iso8601(3),
322
+ timestamp: event[:started_at]&.iso8601(3) || Time.now.iso8601(3)
323
+ })}\n\n"
324
+ when :tool_result
325
+ out << "event: tool-result\ndata: #{Legion::JSON.dump({
326
+ toolCallId: event[:tool_call_id],
327
+ toolName: event[:tool_name],
328
+ result: event[:result],
329
+ startedAt: event[:started_at]&.iso8601(3),
330
+ finishedAt: event[:finished_at]&.iso8601(3) || Time.now.iso8601(3),
331
+ durationMs: event[:duration_ms],
332
+ timestamp: event[:finished_at]&.iso8601(3) || Time.now.iso8601(3)
333
+ })}\n\n"
334
+ when :tool_error
335
+ out << "event: tool-error\ndata: #{Legion::JSON.dump({
336
+ toolCallId: event[:tool_call_id],
337
+ toolName: event[:tool_name],
338
+ error: (event[:error] || event[:result]).to_s,
339
+ startedAt: event[:started_at]&.iso8601(3),
340
+ finishedAt: Time.now.iso8601(3),
341
+ timestamp: Time.now.iso8601(3)
342
+ })}\n\n"
343
+ when :model_fallback
344
+ out << "event: model-fallback\ndata: #{Legion::JSON.dump({
345
+ fromModel: event[:from_model],
346
+ toModel: event[:to_model],
347
+ toModelKey: event[:to_model],
348
+ error: event[:error] || 'Provider unavailable',
349
+ reason: event[:reason] || 'provider_fallback'
350
+ })}\n\n"
351
+ end
352
+ end
353
+
309
354
  full_text = +''
310
355
  pipeline_response = executor.call_stream do |chunk|
311
356
  text = chunk.respond_to?(:content) ? chunk.content.to_s : chunk.to_s
@@ -315,26 +360,53 @@ module Legion
315
360
  out << "event: text-delta\ndata: #{Legion::JSON.dump({ delta: text })}\n\n"
316
361
  end
317
362
 
363
+ # Post-hoc safety net: emit any tool-calls that weren't fired in real-time
364
+ # (e.g. non-streaming tool paths). Skip IDs already sent via tool_event_handler.
318
365
  if pipeline_response.tools.is_a?(Array) && !pipeline_response.tools.empty?
319
366
  pipeline_response.tools.each do |tc|
367
+ tc_id = tc.respond_to?(:id) ? tc.id : nil
368
+ next if tc_id && emitted_tool_call_ids.include?(tc_id)
369
+
320
370
  out << "event: tool-call\ndata: #{Legion::JSON.dump({
321
- id: tc.respond_to?(:id) ? tc.id : nil,
322
- name: tc.respond_to?(:name) ? tc.name : tc.to_s,
323
- arguments: tc.respond_to?(:arguments) ? tc.arguments : {}
371
+ toolCallId: tc_id,
372
+ toolName: tc.respond_to?(:name) ? tc.name : tc.to_s,
373
+ args: tc.respond_to?(:arguments) ? tc.arguments : {}
324
374
  })}\n\n"
325
375
  end
326
376
  end
327
377
 
378
+ # Emit any model-fallback warnings collected post-hoc
379
+ Array(pipeline_response.warnings).each do |w|
380
+ next unless w.is_a?(Hash) && w[:type] == :provider_fallback
381
+
382
+ fallback = w[:fallback].to_s
383
+ provider, model = fallback.split(':', 2)
384
+ resolved_model = (model || provider).to_s.strip
385
+ next if resolved_model.empty?
386
+
387
+ out << "event: model-fallback\ndata: #{Legion::JSON.dump({
388
+ fromModel: pipeline_response.routing&.dig(:model),
389
+ toModel: resolved_model,
390
+ toModelKey: resolved_model,
391
+ error: w[:original_error] || 'Provider unavailable',
392
+ reason: 'provider_fallback'
393
+ })}\n\n"
394
+ end
395
+
328
396
  enrichments = pipeline_response.enrichments
329
397
  out << "event: enrichment\ndata: #{Legion::JSON.dump(enrichments)}\n\n" if enrichments.is_a?(Hash) && !enrichments.empty?
330
398
 
331
399
  tokens = pipeline_response.tokens
332
400
  out << "event: done\ndata: #{Legion::JSON.dump({
333
- content: full_text,
334
- model: pipeline_response.routing&.dig(:model),
335
- input_tokens: tokens.respond_to?(:input_tokens) ? tokens.input_tokens : nil,
336
- output_tokens: tokens.respond_to?(:output_tokens) ? tokens.output_tokens : nil
337
- })}\n\n"
401
+ content: full_text,
402
+ model: pipeline_response.routing&.dig(:model),
403
+ conversation_id: pipeline_response.conversation_id,
404
+ stop_reason: pipeline_response.stop&.dig(:reason)&.to_s,
405
+ input_tokens: tokens.respond_to?(:input_tokens) ? tokens.input_tokens : nil,
406
+ output_tokens: tokens.respond_to?(:output_tokens) ? tokens.output_tokens : nil,
407
+ cache_read_tokens: tokens.respond_to?(:cache_read_tokens) ? tokens.cache_read_tokens : nil,
408
+ cache_write_tokens: tokens.respond_to?(:cache_write_tokens) ? tokens.cache_write_tokens : nil
409
+ }.compact)}\n\n"
338
410
  rescue StandardError => e
339
411
  Legion::Logging.log_exception(e, payload_summary: 'api/llm/inference stream failed', component_type: :api)
340
412
  out << "event: error\ndata: #{Legion::JSON.dump({ code: 'stream_error', message: e.message })}\n\n"
@@ -9,13 +9,15 @@ module Legion
9
9
  namespace :admin
10
10
 
11
11
  desc 'purge-topology', 'Remove old v2.0 AMQP exchanges (legion.* that have lex.* counterparts)'
12
- method_option :dry_run, type: :boolean, default: true, desc: 'List without deleting'
13
- method_option :execute, type: :boolean, default: false, desc: 'Actually delete exchanges'
14
- method_option :host, type: :string, default: 'localhost', desc: 'RabbitMQ management host'
15
- method_option :port, type: :numeric, default: 15_672, desc: 'RabbitMQ management port'
16
- method_option :user, type: :string, default: 'guest', desc: 'RabbitMQ management user'
17
- method_option :password, type: :string, default: 'guest', desc: 'RabbitMQ management password'
18
- method_option :vhost, type: :string, default: '/', desc: 'RabbitMQ vhost'
12
+ method_option :dry_run, type: :boolean, default: true, desc: 'List without deleting'
13
+ method_option :execute, type: :boolean, default: false, desc: 'Actually delete exchanges'
14
+ method_option :host, type: :string, default: 'localhost', desc: 'RabbitMQ management host'
15
+ method_option :port, type: :numeric, default: 15_672, desc: 'RabbitMQ management port'
16
+ method_option :user, type: :string, default: 'guest', desc: 'RabbitMQ management user'
17
+ method_option :password, type: :string, default: 'guest', desc: 'RabbitMQ management password'
18
+ method_option :vhost, type: :string, default: '/', desc: 'RabbitMQ vhost'
19
+ method_option :open_timeout, type: :numeric, default: 5, desc: 'HTTP open timeout in seconds'
20
+ method_option :read_timeout, type: :numeric, default: 30, desc: 'HTTP read timeout in seconds'
19
21
  def purge_topology
20
22
  exchanges = fetch_exchanges
21
23
  candidates = self.class.detect_old_exchanges(exchanges)
@@ -76,7 +78,9 @@ module Legion
76
78
  end
77
79
 
78
80
  def management_request(uri, method_class)
79
- Net::HTTP.start(uri.host, uri.port) do |http|
81
+ Net::HTTP.start(uri.host, uri.port,
82
+ open_timeout: options[:open_timeout],
83
+ read_timeout: options[:read_timeout]) do |http|
80
84
  req = method_class.new(uri)
81
85
  req.basic_auth(options[:user], options[:password])
82
86
  http.request(req)
@@ -20,7 +20,7 @@ module Legion
20
20
  method_option :scopes, type: :string, desc: 'OAuth scopes to request'
21
21
  def teams
22
22
  out = formatter
23
- Connection.ensure_settings
23
+ Connection.ensure_settings(resolve_secrets: false)
24
24
 
25
25
  port = begin
26
26
  Legion::Settings.dig(:api, :port) || 4567
@@ -32,6 +32,10 @@ module Legion
32
32
  end
33
33
  end
34
34
 
35
+ # Single shared struct class for tool result objects; avoids allocating
36
+ # an anonymous Struct class on every build_tool_result_object call.
37
+ ToolResult = Struct.new(:content, :tool_call_id, :id)
38
+
35
39
  attr_reader :model, :conversation_id, :caller_context
36
40
 
37
41
  def initialize(model: nil, provider: nil)
@@ -168,15 +172,24 @@ module Legion
168
172
  # Record the assistant turn with tool_calls before appending results.
169
173
  @messages << { role: 'assistant', content: assistant_content, tool_calls: tool_calls }
170
174
 
171
- tool_calls.each do |tc|
172
- tc = tc.transform_keys(&:to_sym) if tc.respond_to?(:transform_keys)
173
- tc_obj = build_tool_call_object(tc)
175
+ # Normalize all tool calls upfront so threads don't mutate shared state
176
+ normalized = tool_calls.map do |tc|
177
+ tc.respond_to?(:transform_keys) ? tc.transform_keys(&:to_sym) : tc
178
+ end
174
179
 
175
- @on_tool_call&.call(tc_obj)
180
+ # Fire on_tool_call callbacks immediately (serial — fast, just event emission)
181
+ normalized.each do |tc|
182
+ @on_tool_call&.call(build_tool_call_object(tc))
183
+ end
176
184
 
177
- result_text = run_tool(tc)
185
+ # Execute all tools in parallel, preserving original order for message replay
186
+ results = normalized.map do |tc|
187
+ Thread.new { [tc, run_tool(tc)] }
188
+ end.map(&:value)
178
189
 
179
- result_obj = build_tool_result_object(result_text)
190
+ # Collect results serially: fire callbacks and append messages in order
191
+ results.each do |tc, result_text|
192
+ result_obj = build_tool_result_object(result_text, tc[:id] || tc[:tool_call_id])
180
193
  @on_tool_result&.call(result_obj)
181
194
 
182
195
  @messages << {
@@ -195,8 +208,13 @@ module Legion
195
208
  )
196
209
  end
197
210
 
198
- def build_tool_result_object(text)
199
- Struct.new(:content).new(content: text.to_s)
211
+ # Carries both the result content AND the originating tool_call_id so the
212
+ # daemon-bridge-script serializer can include it in the tool-result event,
213
+ # allowing the Interlink frontend to match results back to the correct
214
+ # tool call by ID (rather than falling back to name-based matching which
215
+ # breaks when multiple tools of the same type run in parallel).
216
+ def build_tool_result_object(text, tool_call_id = nil)
217
+ ToolResult.new(text.to_s, tool_call_id, tool_call_id)
200
218
  end
201
219
 
202
220
  def run_tool(tool_call)
@@ -18,7 +18,7 @@ module Legion
18
18
  def show
19
19
  out = formatter
20
20
  Connection.config_dir = options[:config_dir] if options[:config_dir]
21
- Connection.ensure_settings
21
+ Connection.ensure_settings(resolve_secrets: false)
22
22
 
23
23
  settings = if Legion::Settings.respond_to?(:to_hash)
24
24
  Legion::Settings.to_hash
@@ -110,7 +110,7 @@ module Legion
110
110
 
111
111
  # Check settings load
112
112
  begin
113
- Connection.ensure_settings
113
+ Connection.ensure_settings(resolve_secrets: false)
114
114
  out.success('Settings loaded successfully') unless options[:json]
115
115
  rescue StandardError => e
116
116
  issues << "Settings failed to load: #{e.message}"
@@ -23,7 +23,7 @@ module Legion
23
23
  @logging_ready = true
24
24
  end
25
25
 
26
- def ensure_settings
26
+ def ensure_settings(resolve_secrets: true)
27
27
  return if @settings_ready
28
28
 
29
29
  ensure_logging
@@ -31,7 +31,7 @@ module Legion
31
31
 
32
32
  dir = resolve_config_dir
33
33
  Legion::Settings.load(config_dir: dir)
34
- Legion::Settings.resolve_secrets! if Legion::Settings.respond_to?(:resolve_secrets!)
34
+ Legion::Settings.resolve_secrets! if resolve_secrets && Legion::Settings.respond_to?(:resolve_secrets!)
35
35
  @settings_ready = true
36
36
  end
37
37
 
@@ -98,7 +98,7 @@ module Legion
98
98
  def load_settings
99
99
  Connection.config_dir = options[:config_dir] if options[:config_dir]
100
100
  Connection.log_level = 'error'
101
- Connection.ensure_settings
101
+ Connection.ensure_settings(resolve_secrets: false)
102
102
  rescue StandardError
103
103
  nil
104
104
  end
@@ -70,7 +70,7 @@ module Legion
70
70
  def diagnose
71
71
  out = formatter
72
72
  begin
73
- Connection.ensure_settings
73
+ Connection.ensure_settings(resolve_secrets: false)
74
74
  rescue StandardError => e
75
75
  Legion::Logging.debug("Doctor#diagnose settings load failed: #{e.message}") if defined?(Legion::Logging)
76
76
  end
@@ -72,7 +72,7 @@ module Legion
72
72
  private
73
73
 
74
74
  def ensure_settings
75
- Connection.ensure_settings
75
+ Connection.ensure_settings(resolve_secrets: false)
76
76
  end
77
77
 
78
78
  def run_dry_run(out, target)
@@ -23,6 +23,20 @@ module Legion
23
23
 
24
24
  desc 'team SUBCOMMAND', 'Team and multi-user management'
25
25
  subcommand 'team', Legion::CLI::Team
26
+
27
+ desc 'purge-topology', 'Remove old v2.0 AMQP exchanges (legion.* that have lex.* counterparts)'
28
+ method_option :dry_run, type: :boolean, default: true, desc: 'List without deleting'
29
+ method_option :execute, type: :boolean, default: false, desc: 'Actually delete exchanges'
30
+ method_option :host, type: :string, default: 'localhost', desc: 'RabbitMQ management host'
31
+ method_option :port, type: :numeric, default: 15_672, desc: 'RabbitMQ management port'
32
+ method_option :user, type: :string, default: 'guest', desc: 'RabbitMQ management user'
33
+ method_option :password, type: :string, default: 'guest', desc: 'RabbitMQ management password'
34
+ method_option :vhost, type: :string, default: '/', desc: 'RabbitMQ vhost'
35
+ method_option :open_timeout, type: :numeric, default: 5, desc: 'HTTP open timeout in seconds'
36
+ method_option :read_timeout, type: :numeric, default: 30, desc: 'HTTP read timeout in seconds'
37
+ def purge_topology
38
+ Legion::CLI::AdminCommand.new([], options).purge_topology
39
+ end
26
40
  end
27
41
  end
28
42
  end
@@ -160,7 +160,7 @@ module Legion
160
160
  desc 'enable NAME', 'Enable an extension in settings'
161
161
  def enable(name)
162
162
  out = formatter
163
- Connection.ensure_settings
163
+ Connection.ensure_settings(resolve_secrets: false)
164
164
 
165
165
  extensions = Legion::Settings[:extensions] || {}
166
166
  if extensions.key?(name.to_sym)
@@ -176,7 +176,7 @@ module Legion
176
176
  desc 'disable NAME', 'Disable an extension in settings'
177
177
  def disable(name)
178
178
  out = formatter
179
- Connection.ensure_settings
179
+ Connection.ensure_settings(resolve_secrets: false)
180
180
 
181
181
  extensions = Legion::Settings[:extensions] || {}
182
182
  if extensions.key?(name.to_sym)
@@ -349,7 +349,7 @@ module Legion
349
349
 
350
350
  # Load settings to check enabled/disabled state
351
351
  begin
352
- Connection.ensure_settings
352
+ Connection.ensure_settings(resolve_secrets: false)
353
353
  ext_settings = Legion::Settings[:extensions] || {}
354
354
  rescue StandardError => e
355
355
  Legion::Logging.warn("LexCommand#discover_all settings load failed: #{e.message}") if defined?(Legion::Logging)
@@ -248,7 +248,7 @@ module Legion
248
248
  end
249
249
 
250
250
  begin
251
- Connection.ensure_settings
251
+ Connection.ensure_settings(resolve_secrets: false)
252
252
  Legion::Extensions::GemSource.setup!
253
253
  rescue StandardError => e
254
254
  Legion::Logging.debug("marketplace install: settings not available: #{e.message}") if defined?(Legion::Logging)
@@ -31,7 +31,7 @@ module Legion
31
31
  desc 'show', 'Show current process role and extension profile'
32
32
  def show
33
33
  out = formatter
34
- Connection.ensure_settings
34
+ Connection.ensure_settings(resolve_secrets: false)
35
35
 
36
36
  process_role = Legion::ProcessRole.current
37
37
  profile = Legion::Settings.dig(:role, :profile)&.to_s || '(none — all extensions load)'
@@ -55,7 +55,7 @@ module Legion
55
55
  desc 'list', 'List available extension profiles and process roles'
56
56
  def list
57
57
  out = formatter
58
- Connection.ensure_settings
58
+ Connection.ensure_settings(resolve_secrets: false)
59
59
 
60
60
  if options[:json]
61
61
  out.json({ profiles: PROFILE_DESCRIPTIONS, process_roles: Legion::ProcessRole::ROLES.keys })
@@ -95,7 +95,7 @@ module Legion
95
95
  option :reload, type: :boolean, default: false, desc: 'Trigger daemon reload after writing config'
96
96
  def set(profile = nil)
97
97
  out = formatter
98
- Connection.ensure_settings
98
+ Connection.ensure_settings(resolve_secrets: false)
99
99
 
100
100
  validate_inputs!(out, profile)
101
101
 
@@ -304,6 +304,8 @@ module Legion
304
304
  marker = File.join(marker_dir, pack_name.to_s)
305
305
  File.write(marker, '') unless File.exist?(marker)
306
306
  update_packs_setting(pack_name)
307
+ rescue Errno::EPERM, Errno::EACCES => e
308
+ Legion::Logging.warn("Could not write pack marker: #{e.message}") if defined?(Legion::Logging)
307
309
  end
308
310
 
309
311
  def update_packs_setting(pack_name)
@@ -318,6 +320,8 @@ module Legion
318
320
  data['packs'] = packs.sort
319
321
  FileUtils.mkdir_p(File.dirname(settings_file))
320
322
  File.write(settings_file, ::JSON.pretty_generate(data))
323
+ rescue Errno::EPERM, Errno::EACCES => e
324
+ Legion::Logging.warn("Could not update packs setting: #{e.message}") if defined?(Legion::Logging)
321
325
  rescue ::JSON::ParserError
322
326
  data = { 'packs' => [pack_name.to_s] }
323
327
  File.write(settings_file, ::JSON.pretty_generate(data))
@@ -31,7 +31,7 @@ module Legion
31
31
  raise SystemExit, 1
32
32
  end
33
33
 
34
- Connection.ensure_settings
34
+ Connection.ensure_settings(resolve_secrets: false)
35
35
  Legion::Extensions::GemSource.setup!
36
36
 
37
37
  target_gems = discover_legion_gems
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Legion
4
- VERSION = '1.7.26'
4
+ VERSION = '1.7.30'
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.7.26
4
+ version: 1.7.30
5
5
  platform: ruby
6
6
  authors:
7
7
  - Esity