collavre_openclaw 0.2.0 → 0.2.2

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.
@@ -0,0 +1,554 @@
1
+ require "digest"
2
+ require "faye/websocket"
3
+ require "json"
4
+ require "securerandom"
5
+
6
+ module CollavreOpenclaw
7
+ # WebSocket client for a single user's OpenClaw Gateway connection.
8
+ #
9
+ # Handles:
10
+ # - Connection lifecycle (connect, disconnect, reconnect)
11
+ # - OpenClaw protocol handshake (connect.challenge → connect → hello-ok)
12
+ # - RPC request/response (chat.send, chat.history, chat.abort)
13
+ # - Event streaming (chat events with delta/final/error states)
14
+ # - Proactive message detection (unsolicited chat events)
15
+ # - Tick keepalive
16
+ #
17
+ # Thread model:
18
+ # - WebSocket runs in the shared EventMachine reactor thread
19
+ # - Rails threads call public methods which bridge via EM.next_tick + Queue
20
+ class WebsocketClient
21
+ PROTOCOL_VERSION = 3
22
+
23
+ attr_reader :user, :state
24
+
25
+ # States: :disconnected, :connecting, :connected, :reconnecting
26
+ def initialize(user:)
27
+ @user = user
28
+ @state = :disconnected
29
+ @ws = nil
30
+ @mutex = Mutex.new
31
+ @connect_mutex = Mutex.new
32
+ @connect_waiters = [] # Queues for threads waiting on in-progress connect
33
+ @pending_requests = {} # id → { queue:, timer: }
34
+ @pending_runs = {} # runId → Queue (for chat.send streaming)
35
+ @proactive_handler = nil
36
+ @reconnect_attempts = 0
37
+ @last_activity_at = nil
38
+ @tick_interval_ms = 15_000
39
+ @tick_timer = nil
40
+ end
41
+
42
+ def connected?
43
+ @state == :connected
44
+ end
45
+
46
+ # Connect to the Gateway. Blocks until connected or raises on failure.
47
+ # Thread-safe: concurrent callers all wait on the same handshake attempt.
48
+ def connect!
49
+ return if connected?
50
+
51
+ initiator = false
52
+ waiter_queue = nil
53
+
54
+ @connect_mutex.synchronize do
55
+ return if connected?
56
+
57
+ if @state == :connecting
58
+ # Another thread is already connecting — wait on the same handshake
59
+ waiter_queue = Queue.new
60
+ @connect_waiters << waiter_queue
61
+ else
62
+ @state = :connecting
63
+ initiator = true
64
+ end
65
+ end
66
+
67
+ if waiter_queue
68
+ # Wait for the initiating thread to finish handshake
69
+ result = wait_with_timeout(waiter_queue, config.ws_connect_timeout, "connect (waiting)")
70
+ if result[:error]
71
+ raise ConnectionError, result[:error]
72
+ end
73
+ return result
74
+ end
75
+
76
+ # This thread initiates the connection
77
+ queue = Queue.new
78
+
79
+ EmReactor.next_tick do
80
+ begin
81
+ do_connect!(queue)
82
+ rescue => e
83
+ queue.push({ error: e.message })
84
+ end
85
+ end
86
+
87
+ begin
88
+ result = wait_with_timeout(queue, config.ws_connect_timeout, "connect")
89
+ rescue TimeoutError, StandardError => e
90
+ # Timeout or unexpected error — reset state and wake all waiters
91
+ error_result = { error: e.message }
92
+ @connect_mutex.synchronize do
93
+ @state = :disconnected
94
+ @connect_waiters.each { |q| q.push(error_result) }
95
+ @connect_waiters.clear
96
+ end
97
+ raise ConnectionError, e.message
98
+ end
99
+
100
+ # Notify all waiting threads
101
+ @connect_mutex.synchronize do
102
+ if result[:error]
103
+ @state = :disconnected
104
+ else
105
+ @state = :connected
106
+ @reconnect_attempts = 0
107
+ touch_activity!
108
+ end
109
+
110
+ @connect_waiters.each { |q| q.push(result) }
111
+ @connect_waiters.clear
112
+ end
113
+
114
+ if result[:error]
115
+ raise ConnectionError, result[:error]
116
+ end
117
+
118
+ result
119
+ end
120
+
121
+ # Disconnect gracefully
122
+ def disconnect!
123
+ @state = :disconnected
124
+ EmReactor.next_tick do
125
+ cancel_tick_timer!
126
+ @ws&.close
127
+ @ws = nil
128
+ end
129
+ # Unblock any waiting requests
130
+ @pending_requests.each_value { |pr| pr[:queue]&.push({ error: "disconnected" }) }
131
+ @pending_requests.clear
132
+ @pending_runs.each_value { |q| q.push({ done: true }) }
133
+ @pending_runs.clear
134
+ end
135
+
136
+ # Send a chat message. Blocks and yields streaming events.
137
+ #
138
+ # @param session_key [String]
139
+ # @param message [String]
140
+ # @param idempotency_key [String]
141
+ # @yield [Hash] chat events with :state, :text, :message keys
142
+ # @return [String, nil] final response text
143
+ def chat_send(session_key:, message:, idempotency_key: nil, &block)
144
+ ensure_connected!
145
+ touch_activity!
146
+
147
+ idempotency_key ||= SecureRandom.uuid
148
+ actual_run_id = nil
149
+ run_queue = Queue.new
150
+ response_text = +""
151
+
152
+ # Pre-register with idempotency_key to catch early events
153
+ @mutex.synchronize { @pending_runs[idempotency_key] = run_queue }
154
+
155
+ # Send the RPC request to get the real runId
156
+ response = send_rpc("chat.send", {
157
+ sessionKey: session_key,
158
+ message: message,
159
+ idempotencyKey: idempotency_key
160
+ })
161
+
162
+ # Re-register with the Gateway-assigned runId
163
+ actual_run_id = response&.dig(:runId) || idempotency_key
164
+ if actual_run_id != idempotency_key
165
+ @mutex.synchronize do
166
+ @pending_runs.delete(idempotency_key)
167
+ @pending_runs[actual_run_id] = run_queue
168
+ end
169
+ end
170
+
171
+ # Stream events until final/error/aborted
172
+ loop do
173
+ event = wait_with_timeout(run_queue, config.read_timeout, "chat response")
174
+
175
+ break if event[:done]
176
+
177
+ if event[:error]
178
+ raise ChatError, event[:error]
179
+ end
180
+
181
+ case event[:state]
182
+ when "delta"
183
+ text = extract_event_text(event)
184
+ if text.present?
185
+ response_text << text
186
+ yield({ state: "delta", text: text }) if block_given?
187
+ end
188
+ when "final"
189
+ text = extract_event_text(event)
190
+ yield({ state: "final", text: text, message: event[:message] }) if block_given?
191
+ break
192
+ when "error"
193
+ error_msg = event[:errorMessage] || "Unknown error"
194
+ yield({ state: "error", text: error_msg }) if block_given?
195
+ raise ChatError, error_msg
196
+ when "aborted"
197
+ yield({ state: "aborted" }) if block_given?
198
+ break
199
+ end
200
+ end
201
+
202
+ response_text.presence
203
+ ensure
204
+ @mutex.synchronize do
205
+ @pending_runs.delete(actual_run_id) if actual_run_id
206
+ # Also clean up idempotency_key if send_rpc failed before we got a runId
207
+ @pending_runs.delete(idempotency_key) if idempotency_key
208
+ end
209
+ end
210
+
211
+ # Fetch chat history for a session
212
+ def chat_history(session_key:, limit: nil)
213
+ ensure_connected!
214
+ touch_activity!
215
+
216
+ params = { sessionKey: session_key }
217
+ params[:limit] = limit if limit
218
+ send_rpc("chat.history", params)
219
+ end
220
+
221
+ # Abort a running chat
222
+ def chat_abort(session_key:, run_id: nil)
223
+ ensure_connected!
224
+ touch_activity!
225
+
226
+ params = { sessionKey: session_key }
227
+ params[:runId] = run_id if run_id
228
+ send_rpc("chat.abort", params)
229
+ end
230
+
231
+ # Register a handler for proactive messages (unsolicited chat events).
232
+ # The handler receives (user, payload) where user is the connection owner.
233
+ def on_proactive_message(&handler)
234
+ @proactive_handler = handler
235
+ end
236
+
237
+ # Time since last activity (for idle timeout)
238
+ def idle_seconds
239
+ return Float::INFINITY unless @last_activity_at
240
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) - @last_activity_at
241
+ end
242
+
243
+ private
244
+
245
+ def config
246
+ CollavreOpenclaw.config
247
+ end
248
+
249
+ def gateway_ws_url
250
+ url = @user.gateway_url.to_s.strip
251
+ return nil if url.blank?
252
+
253
+ uri = URI.parse(url)
254
+ # Convert http(s) to ws(s)
255
+ case uri.scheme
256
+ when "https" then uri.scheme = "wss"
257
+ when "http" then uri.scheme = "ws"
258
+ when "ws", "wss" then # already correct
259
+ else
260
+ uri.scheme = "ws"
261
+ end
262
+ uri.path = "/" if uri.path.blank?
263
+ uri.to_s
264
+ end
265
+
266
+ def do_connect!(result_queue)
267
+ url = gateway_ws_url
268
+ unless url.present?
269
+ result_queue.push({ error: "No Gateway URL configured" })
270
+ return
271
+ end
272
+
273
+ @ws = Faye::WebSocket::Client.new(url)
274
+ @handshake_queue = result_queue
275
+ @handshake_done = false
276
+
277
+ @ws.on :open do |_event|
278
+ Rails.logger.info("[CollavreOpenclaw::WS] Connected to #{url}")
279
+ # Wait for connect.challenge from gateway
280
+ end
281
+
282
+ @ws.on :message do |event|
283
+ handle_raw_message(event.data)
284
+ end
285
+
286
+ @ws.on :close do |event|
287
+ code = event.code
288
+ reason = event.reason
289
+ Rails.logger.info("[CollavreOpenclaw::WS] Disconnected (code=#{code}, reason=#{reason})")
290
+
291
+ cancel_tick_timer!
292
+
293
+ unless @handshake_done
294
+ @handshake_done = true
295
+ @handshake_queue&.push({ error: "Connection closed during handshake (code=#{code})" })
296
+ end
297
+
298
+ if @state == :connected
299
+ @state = :reconnecting
300
+ schedule_reconnect!
301
+ end
302
+ end
303
+ end
304
+
305
+ def handle_raw_message(data)
306
+ touch_activity! # Refresh idle timer on any inbound traffic
307
+ frame = JSON.parse(data, symbolize_names: true)
308
+
309
+ case frame[:type]
310
+ when "event"
311
+ handle_event(frame[:event], frame[:payload])
312
+ when "res"
313
+ handle_response(frame[:id], frame[:ok], frame[:payload], frame[:error])
314
+ end
315
+ rescue JSON::ParserError => e
316
+ Rails.logger.warn("[CollavreOpenclaw::WS] Invalid JSON: #{e.message}")
317
+ end
318
+
319
+ def handle_event(event_name, payload)
320
+ case event_name
321
+ when "connect.challenge"
322
+ # Respond with connect request
323
+ send_connect_request(payload)
324
+ when "chat"
325
+ handle_chat_event(payload)
326
+ when "tick"
327
+ handle_tick(payload)
328
+ end
329
+ end
330
+
331
+ def send_connect_request(challenge_payload)
332
+ agent_id = extract_agent_id
333
+ device_id = "collavre-#{@user.id}-#{Digest::SHA256.hexdigest(@user.id.to_s)[0..7]}"
334
+
335
+ params = {
336
+ minProtocol: PROTOCOL_VERSION,
337
+ maxProtocol: PROTOCOL_VERSION,
338
+ client: {
339
+ id: "collavre",
340
+ version: CollavreOpenclaw::VERSION,
341
+ platform: "ruby",
342
+ mode: "operator"
343
+ },
344
+ role: "operator",
345
+ scopes: [ "operator.read", "operator.write" ],
346
+ caps: [],
347
+ commands: [],
348
+ permissions: {},
349
+ auth: { token: @user.llm_api_key },
350
+ locale: "en-US",
351
+ userAgent: "collavre-openclaw/#{CollavreOpenclaw::VERSION}",
352
+ device: {
353
+ id: device_id
354
+ }
355
+ }
356
+
357
+ request_id = SecureRandom.uuid
358
+ send_frame({
359
+ type: "req",
360
+ id: request_id,
361
+ method: "connect",
362
+ params: params
363
+ })
364
+
365
+ # Store the request so hello-ok resolves the handshake
366
+ @connect_request_id = request_id
367
+ end
368
+
369
+ def handle_response(id, ok, payload, error)
370
+ # Check if this is the connect handshake response
371
+ if id == @connect_request_id && !@handshake_done
372
+ @handshake_done = true
373
+ if ok
374
+ @tick_interval_ms = payload&.dig(:policy, :tickIntervalMs) || 15_000
375
+ @state = :connected
376
+ @reconnect_attempts = 0
377
+ start_tick_timer!
378
+ @handshake_queue&.push({ ok: true, payload: payload })
379
+ else
380
+ error_msg = error&.dig(:message) || error.to_s || "handshake failed"
381
+ @handshake_queue&.push({ error: error_msg })
382
+ end
383
+ @handshake_queue = nil
384
+ return
385
+ end
386
+
387
+ # Regular RPC response
388
+ pending = @mutex.synchronize { @pending_requests.delete(id) }
389
+ if pending
390
+ if ok
391
+ pending[:queue].push({ ok: true, payload: payload })
392
+ else
393
+ error_msg = error&.dig(:message) || error.to_s || "RPC error"
394
+ pending[:queue].push({ error: error_msg })
395
+ end
396
+ end
397
+ end
398
+
399
+ def handle_chat_event(payload)
400
+ run_id = payload[:runId]
401
+
402
+ # Check if this is a response to a pending chat.send
403
+ run_queue = @mutex.synchronize { @pending_runs[run_id] }
404
+
405
+ if run_queue
406
+ # Known run — forward to the waiting thread
407
+ run_queue.push(payload)
408
+ elsif @proactive_handler
409
+ # Unknown run — proactive message from Gateway (cron/heartbeat)
410
+ Rails.logger.info("[CollavreOpenclaw::WS] Proactive message received (runId=#{run_id})")
411
+ @proactive_handler.call(@user, payload)
412
+ else
413
+ Rails.logger.debug("[CollavreOpenclaw::WS] Ignoring chat event for unknown runId=#{run_id}")
414
+ end
415
+ end
416
+
417
+ def handle_tick(_payload)
418
+ # Respond with a tick acknowledgment (poll response)
419
+ send_frame({
420
+ type: "req",
421
+ id: SecureRandom.uuid,
422
+ method: "poll",
423
+ params: {}
424
+ })
425
+ end
426
+
427
+ def start_tick_timer!
428
+ cancel_tick_timer!
429
+ interval = @tick_interval_ms / 1000.0
430
+ @tick_timer = EM.add_periodic_timer(interval) do
431
+ # Send keepalive poll if the server hasn't sent a tick
432
+ send_frame({
433
+ type: "req",
434
+ id: SecureRandom.uuid,
435
+ method: "poll",
436
+ params: {}
437
+ })
438
+ end
439
+ end
440
+
441
+ def cancel_tick_timer!
442
+ @tick_timer&.cancel
443
+ @tick_timer = nil
444
+ end
445
+
446
+ # Send an RPC request and block until the response.
447
+ # Returns the response payload.
448
+ def send_rpc(method, params)
449
+ request_id = SecureRandom.uuid
450
+ queue = Queue.new
451
+
452
+ @mutex.synchronize do
453
+ @pending_requests[request_id] = { queue: queue }
454
+ end
455
+
456
+ EmReactor.next_tick do
457
+ send_frame({
458
+ type: "req",
459
+ id: request_id,
460
+ method: method,
461
+ params: params
462
+ })
463
+ end
464
+
465
+ result = wait_with_timeout(queue, config.read_timeout, method)
466
+ if result[:error]
467
+ raise RpcError, "#{method} failed: #{result[:error]}"
468
+ end
469
+ result[:payload]
470
+ ensure
471
+ @mutex.synchronize { @pending_requests.delete(request_id) }
472
+ end
473
+
474
+ def send_frame(frame)
475
+ return unless @ws
476
+
477
+ data = JSON.generate(frame)
478
+ @ws.send(data)
479
+ end
480
+
481
+ def ensure_connected!
482
+ unless connected?
483
+ connect!
484
+ end
485
+ end
486
+
487
+ def touch_activity!
488
+ @last_activity_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
489
+ end
490
+
491
+ def schedule_reconnect!
492
+ max = config.ws_reconnect_max
493
+ return if @reconnect_attempts >= max
494
+
495
+ @reconnect_attempts += 1
496
+ delay = config.ws_reconnect_base_delay * (2**(@reconnect_attempts - 1))
497
+ delay = [ delay, 60 ].min # Cap at 60 seconds
498
+
499
+ Rails.logger.info("[CollavreOpenclaw::WS] Reconnecting in #{delay}s (attempt #{@reconnect_attempts}/#{max})")
500
+
501
+ EM.add_timer(delay) do
502
+ next if @state == :disconnected # User explicitly disconnected
503
+
504
+ begin
505
+ queue = Queue.new
506
+ do_connect!(queue)
507
+
508
+ # Handshake result is handled by handle_response which sets @state.
509
+ # Add a timeout to retry if handshake doesn't complete.
510
+ EM.add_timer(config.ws_connect_timeout) do
511
+ unless @handshake_done
512
+ @handshake_done = true
513
+ Rails.logger.warn("[CollavreOpenclaw::WS] Reconnect handshake timed out")
514
+ @ws&.close
515
+ schedule_reconnect!
516
+ end
517
+ end
518
+ rescue => e
519
+ Rails.logger.error("[CollavreOpenclaw::WS] Reconnect failed: #{e.message}")
520
+ schedule_reconnect!
521
+ end
522
+ end
523
+ end
524
+
525
+ def extract_event_text(event)
526
+ message = event[:message]
527
+ return nil unless message.is_a?(Hash)
528
+
529
+ content = message[:content]
530
+ case content
531
+ when String
532
+ content
533
+ when Array
534
+ content.filter_map { |c| c[:text] if c[:type] == "text" }.join
535
+ else
536
+ nil
537
+ end
538
+ end
539
+
540
+ def extract_agent_id
541
+ return nil unless @user&.email.present?
542
+ @user.email.split("@").first
543
+ end
544
+
545
+ def wait_with_timeout(queue, timeout_seconds, operation)
546
+ # Use Queue#pop(timeout:) instead of Timeout.timeout to avoid Thread.raise corruption
547
+ result = queue.pop(timeout: timeout_seconds)
548
+ if result.nil? && queue.empty?
549
+ raise TimeoutError, "#{operation} timed out after #{timeout_seconds}s"
550
+ end
551
+ result
552
+ end
553
+ end
554
+ end
@@ -10,10 +10,26 @@ module CollavreOpenclaw
10
10
  # Max retries for transient failures
11
11
  attr_accessor :max_retries
12
12
 
13
+ # WebSocket idle timeout (seconds) - disconnect after inactivity
14
+ attr_accessor :ws_idle_timeout
15
+
16
+ # WebSocket max reconnect attempts before giving up
17
+ attr_accessor :ws_reconnect_max
18
+
19
+ # WebSocket reconnect base delay (seconds) - exponential backoff base
20
+ attr_accessor :ws_reconnect_base_delay
21
+
22
+ # WebSocket connect timeout (seconds)
23
+ attr_accessor :ws_connect_timeout
24
+
13
25
  def initialize
14
26
  @open_timeout = ENV.fetch("OPENCLAW_OPEN_TIMEOUT", 10).to_i
15
27
  @read_timeout = ENV.fetch("OPENCLAW_READ_TIMEOUT", 180).to_i # 3 minutes for AI responses
16
28
  @max_retries = ENV.fetch("OPENCLAW_MAX_RETRIES", 2).to_i
29
+ @ws_idle_timeout = ENV.fetch("OPENCLAW_WS_IDLE_TIMEOUT", 1800).to_i # 30 minutes
30
+ @ws_reconnect_max = ENV.fetch("OPENCLAW_WS_RECONNECT_MAX", 10).to_i
31
+ @ws_reconnect_base_delay = ENV.fetch("OPENCLAW_WS_RECONNECT_BASE", 1).to_f
32
+ @ws_connect_timeout = ENV.fetch("OPENCLAW_WS_CONNECT_TIMEOUT", 10).to_i
17
33
  end
18
34
 
19
35
  # Legacy accessor for backward compatibility
@@ -40,5 +40,19 @@ module CollavreOpenclaw
40
40
  end
41
41
  end
42
42
  end
43
+
44
+ # Graceful shutdown: disconnect all WebSocket connections.
45
+ # Only clean up if the singleton was already instantiated to avoid
46
+ # starting EM/threads during shutdown or test teardown.
47
+ config.after_initialize do
48
+ at_exit do
49
+ if ConnectionManager.instance_variable_get(:@singleton__instance__)
50
+ ConnectionManager.instance.disconnect_all
51
+ end
52
+ EmReactor.stop! if EmReactor.running?
53
+ rescue => e
54
+ Rails.logger.warn("[CollavreOpenclaw] Shutdown cleanup error: #{e.message}")
55
+ end
56
+ end
43
57
  end
44
58
  end
@@ -0,0 +1,6 @@
1
+ module CollavreOpenclaw
2
+ class ConnectionError < StandardError; end
3
+ class RpcError < StandardError; end
4
+ class ChatError < StandardError; end
5
+ class TimeoutError < StandardError; end
6
+ end
@@ -1,3 +1,3 @@
1
1
  module CollavreOpenclaw
2
- VERSION = "0.2.0"
2
+ VERSION = "0.2.2"
3
3
  end
@@ -1,5 +1,6 @@
1
1
  require "collavre_openclaw/version"
2
2
  require "collavre_openclaw/configuration"
3
+ require "collavre_openclaw/errors"
3
4
  require "collavre_openclaw/engine"
4
5
 
5
6
  module CollavreOpenclaw
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: collavre_openclaw
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.0
4
+ version: 0.2.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre
@@ -37,6 +37,34 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '2.0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: faye-websocket
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.11'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.11'
54
+ - !ruby/object:Gem::Dependency
55
+ name: eventmachine
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '1.2'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '1.2'
40
68
  description: Enables AI agents in Collavre to use OpenClaw as their LLM backend
41
69
  email:
42
70
  - support@collavre.com
@@ -54,7 +82,11 @@ files:
54
82
  - app/models/collavre_openclaw/application_record.rb
55
83
  - app/models/collavre_openclaw/pending_callback.rb
56
84
  - app/services/collavre_openclaw/ai_client_extension.rb
85
+ - app/services/collavre_openclaw/connection_manager.rb
86
+ - app/services/collavre_openclaw/em_reactor.rb
57
87
  - app/services/collavre_openclaw/openclaw_adapter.rb
88
+ - app/services/collavre_openclaw/proactive_message_handler.rb
89
+ - app/services/collavre_openclaw/websocket_client.rb
58
90
  - config/initializers/ai_client_extension.rb
59
91
  - config/locales/en.yml
60
92
  - config/locales/ko.yml
@@ -72,6 +104,7 @@ files:
72
104
  - lib/collavre_openclaw.rb
73
105
  - lib/collavre_openclaw/configuration.rb
74
106
  - lib/collavre_openclaw/engine.rb
107
+ - lib/collavre_openclaw/errors.rb
75
108
  - lib/collavre_openclaw/version.rb
76
109
  homepage: https://github.com/sh1nj1/plan42
77
110
  licenses: