collavre_openclaw 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47fb3ac9892c9b3c1ee7531645732dd47acdba3b7504d048cd207ff8ae4fa82e
4
- data.tar.gz: e6233d85c5bff7f28ad2201b0cb84df1fe6c62827cd23666de54ef64060f41d5
3
+ metadata.gz: '09c2c291bce3fdf441d7c93dacf44883ca47804db6abb38d78b3ea70a412deaf'
4
+ data.tar.gz: 8bd10b6c57adedb5e655b23688088a1be0d8c2e97dca6300ef7470b1a28c8c33
5
5
  SHA512:
6
- metadata.gz: 1a8b34146dedfcec60bc2b0b78ac4f9f51448c8b2bd3ee9064e24d24ac7854271e8cb943b5083da143ec9a18e7470b869e927f2b680db1518ff6a324897a0dc9
7
- data.tar.gz: 6f97da70c3443dcda6087cfa114b5f068cb952e567b90849a07b27239d6df89c55cafa07f35e76a2c864337d02e56f9cc328e5bc33f8655af4fd87ec93c4dee7
6
+ metadata.gz: ba347bb7844c93e96b5601f728267df346ba28b78458d77e0ea8a8f83ceb77fc83127ddad0dd164704f8bad95701df1838ac636e8c59aaa356bab07c63e5d58d
7
+ data.tar.gz: 90b4555d3b69f2f0489dff54e6f326b4ee57404989e1ef7068a2b91d8761541ddf42be7154ed6e0a7b8e5f536d6408441b3412997731c3868c603fb3e25a7122
@@ -23,7 +23,7 @@ module CollavreOpenclaw
23
23
 
24
24
  def authenticated?
25
25
  respond_to?(:current_user, true) && current_user.present?
26
- rescue
26
+ rescue StandardError
27
27
  false
28
28
  end
29
29
  end
@@ -12,18 +12,24 @@ module CollavreOpenclaw
12
12
  end
13
13
  end
14
14
 
15
- def chat(contents, tools: [], &block)
15
+ # @param messages_input [Hash, Array] Hash { messages:, first_message:, context_changed:, system_prompt: }
16
+ # from SessionContextResolver, or a plain Array from standalone callers (e.g., CompressJob).
17
+ def chat(messages_input, tools: [], &block)
16
18
  normalized_vendor = vendor.to_s.downcase
19
+ messages_data = normalize_messages_input(messages_input)
17
20
 
18
21
  # Check if we have a custom adapter for this vendor
19
22
  adapter_class = self.class.adapter_registry[normalized_vendor]
20
23
 
21
24
  if adapter_class
22
25
  # Use the custom adapter (tools not supported for OpenClaw)
26
+ # Prefer resolved system_prompt from SessionContextResolver over instance default.
27
+ # key?(:system_prompt) distinguishes "not provided" (Array input) from "explicitly nil" (incremental session).
28
+ resolved_system_prompt = messages_data.key?(:system_prompt) ? messages_data[:system_prompt] : system_prompt
23
29
  user = context&.dig(:user)
24
30
  adapter = adapter_class.new(
25
31
  user: user,
26
- system_prompt: system_prompt,
32
+ system_prompt: resolved_system_prompt,
27
33
  context: context
28
34
  )
29
35
 
@@ -31,13 +37,13 @@ module CollavreOpenclaw
31
37
  error_message = nil
32
38
 
33
39
  begin
34
- response_content = adapter.chat(contents, &block)
40
+ response_content = adapter.chat(messages_data, &block)
35
41
  rescue StandardError => e
36
42
  error_message = e.message
37
43
  raise
38
44
  ensure
39
45
  log_interaction(
40
- messages: Array(contents),
46
+ messages: messages_data[:messages],
41
47
  tools: [],
42
48
  response_content: response_content,
43
49
  error_message: error_message,
@@ -49,12 +55,24 @@ module CollavreOpenclaw
49
55
  return response_content
50
56
  end
51
57
 
52
- # Fall back to original implementation
53
- super
58
+ # Fall back to original RubyLLM implementation (expects Array)
59
+ super(messages_data[:messages], tools: tools, &block)
54
60
  end
55
61
 
56
62
  private
57
63
 
58
64
  attr_reader :vendor, :system_prompt, :context
65
+
66
+ # Wrap plain Array input (from standalone callers like CompressJob)
67
+ # into the Hash format expected by the adapter.
68
+ def normalize_messages_input(input)
69
+ return input if input.is_a?(Hash)
70
+
71
+ {
72
+ messages: Array(input).map { |m| m.merge(kind: :trigger) },
73
+ first_message: true,
74
+ context_changed: false
75
+ }
76
+ end
59
77
  end
60
78
  end
@@ -205,7 +205,7 @@ module CollavreOpenclaw
205
205
  next unless @idle_check_counter >= 60 # Run check every ~60 seconds
206
206
  @idle_check_counter = 0
207
207
  check_idle_connections!
208
- rescue => e
208
+ rescue StandardError => e
209
209
  Rails.logger.error("[CollavreOpenclaw::ConnectionManager] Idle checker error: #{e.message}")
210
210
  end
211
211
  end
@@ -3,9 +3,11 @@ require "json"
3
3
 
4
4
  module CollavreOpenclaw
5
5
  class OpenclawAdapter
6
- # Adapter for OpenClaw AI Gateway
6
+ # Pure transport adapter for OpenClaw AI Gateway.
7
+ # Session context filtering (full vs incremental) is handled upstream
8
+ # by SessionContextResolver — this adapter sends exactly what it receives.
7
9
  #
8
- # Supports two transport modes:
10
+ # Transport modes:
9
11
  # 1. WebSocket (primary) - via faye-websocket + EventMachine
10
12
  # 2. HTTP (fallback) - via Faraday POST /v1/chat/completions
11
13
  #
@@ -20,7 +22,10 @@ module CollavreOpenclaw
20
22
  @context = context
21
23
  end
22
24
 
23
- def chat(messages, &block)
25
+ # @param messages_data [Hash] { messages:, first_message:, context_changed: }
26
+ def chat(messages_data, &block)
27
+ parse_messages_data!(messages_data)
28
+
24
29
  unless @user&.gateway_url.present?
25
30
  Rails.logger.error("[CollavreOpenclaw] No Gateway URL configured for user #{@user&.id}")
26
31
  yield "Error: OpenClaw Gateway URL not configured" if block_given?
@@ -38,11 +43,11 @@ module CollavreOpenclaw
38
43
  # Set OPENCLAW_TRANSPORT=http to force HTTP-only mode
39
44
  if CollavreOpenclaw.config.transport == "http"
40
45
  Rails.logger.info("[CollavreOpenclaw::WS] TRANSPORT mode=http_forced")
41
- chat_via_http(messages, &block)
46
+ chat_via_http(&block)
42
47
  elsif websocket_available?
43
- chat_via_websocket(messages, &block)
48
+ chat_via_websocket(&block)
44
49
  else
45
- chat_via_http(messages, &block)
50
+ chat_via_http(&block)
46
51
  end
47
52
  end
48
53
 
@@ -69,6 +74,12 @@ module CollavreOpenclaw
69
74
 
70
75
  private
71
76
 
77
+ def parse_messages_data!(data)
78
+ @all_messages = data[:messages] || []
79
+ @first_message = data[:first_message]
80
+ @context_changed = data[:context_changed]
81
+ end
82
+
72
83
  # ─────────────────────────────────────────────
73
84
  # WebSocket transport
74
85
  # ─────────────────────────────────────────────
@@ -83,14 +94,14 @@ module CollavreOpenclaw
83
94
  false
84
95
  end
85
96
 
86
- def chat_via_websocket(messages, &block)
97
+ def chat_via_websocket(&block)
87
98
  response_content = +""
88
99
 
89
100
  begin
90
101
  client = ConnectionManager.instance.connection_for(@user)
91
- payload = build_ws_chat_payload(messages)
102
+ payload = build_ws_chat_payload
92
103
 
93
- Rails.logger.info("[CollavreOpenclaw] Sending via WebSocket (session: #{session_key})")
104
+ Rails.logger.info("[CollavreOpenclaw] Sending via WebSocket (session: #{session_key}, first: #{@first_message}, changed: #{@context_changed})")
94
105
 
95
106
  client.chat_send(
96
107
  session_key: session_key,
@@ -121,7 +132,7 @@ module CollavreOpenclaw
121
132
  rescue CollavreOpenclaw::ConnectionError,
122
133
  CollavreOpenclaw::TimeoutError => e
123
134
  Rails.logger.warn("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
124
- chat_via_http(messages, &block)
135
+ chat_via_http(&block)
125
136
  rescue CollavreOpenclaw::ChatError, CollavreOpenclaw::RpcError => e
126
137
  Rails.logger.error("[CollavreOpenclaw] WebSocket chat error: #{e.message}")
127
138
  error_msg = "OpenClaw Error: #{e.message}"
@@ -131,70 +142,26 @@ module CollavreOpenclaw
131
142
  Rails.logger.error("[CollavreOpenclaw] WebSocket unexpected error: #{e.message}\n" \
132
143
  "#{e.backtrace.first(5).join("\n")}")
133
144
  Rails.logger.info("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
134
- chat_via_http(messages, &block)
145
+ chat_via_http(&block)
135
146
  end
136
147
  end
137
148
 
138
- # Build WebSocket chat.send payload.
139
- #
140
- # Includes the same full text context as HTTP mode plus optional base64
141
- # image attachments supported by the Gateway's chat.send.attachments field.
142
- def build_ws_chat_payload(messages)
149
+ def build_ws_chat_payload
143
150
  {
144
- message: format_message_for_ws(messages),
145
- attachments: extract_ws_attachments(messages).presence
151
+ message: format_message_for_ws,
152
+ attachments: extract_ws_attachments(@all_messages).presence
146
153
  }
147
154
  end
148
155
 
149
- # Format messages for WebSocket chat.send text payload.
150
- #
151
- # Includes full context on EVERY request, matching HTTP mode behavior.
152
- # The Gateway's WS session may be new (no prior history), so we cannot
153
- # assume context was sent before. This is consistent with how the HTTP
154
- # Chat Completions API works (full history on every request).
155
- #
156
- # Message structure sent:
157
- # [system prompt]
158
- # [creative context messages]
159
- # [context creative messages]
160
- # [chat history]
161
- # [latest user message]
162
- def format_message_for_ws(messages)
163
- formatted = Array(messages)
164
- return "" if formatted.empty?
165
-
156
+ # SessionContextResolver already decided what to include.
157
+ # We just format and send everything we received.
158
+ def format_message_for_ws
166
159
  parts = []
167
-
168
- # 1. System prompt (same as HTTP mode's build_payload)
169
160
  parts << @system_prompt if @system_prompt.present?
170
161
 
171
- # 2. All context messages (Creative:, Context Creative:, Referenced Creative:)
172
- formatted.each do |m|
173
- role = m[:role] || m["role"]
174
- next unless role.to_s == "user"
175
-
162
+ @all_messages.each do |m|
176
163
  text = extract_message_text(m)
177
- next unless text.present?
178
- next unless text.match?(/\A(Creative|Context Creative|Referenced Creative)\s*\(/)
179
-
180
- parts << text
181
- end
182
-
183
- # 3. Chat history (prior user/assistant exchanges)
184
- formatted.each do |m|
185
- role = (m[:role] || m["role"]).to_s
186
- text = extract_message_text(m)
187
- next unless text.present?
188
-
189
- # Skip context messages (already included above)
190
- next if text.match?(/\A(Creative|Context Creative|Referenced Creative)\s*\(/)
191
-
192
- case role
193
- when "user"
194
- parts << text
195
- when "assistant", "model"
196
- parts << "[Assistant]: #{text}"
197
- end
164
+ parts << text if text.present?
198
165
  end
199
166
 
200
167
  parts.join("\n\n")
@@ -297,13 +264,13 @@ module CollavreOpenclaw
297
264
  # HTTP transport (fallback)
298
265
  # ─────────────────────────────────────────────
299
266
 
300
- def chat_via_http(messages, &block)
267
+ def chat_via_http(&block)
301
268
  response_content = +""
302
269
 
303
270
  begin
304
- payload = build_payload(messages)
271
+ payload = build_payload
305
272
 
306
- Rails.logger.info("[CollavreOpenclaw] Sending via HTTP to #{api_endpoint} (session: #{session_key})")
273
+ Rails.logger.info("[CollavreOpenclaw] Sending via HTTP to #{api_endpoint} (session: #{session_key}, first: #{@first_message}, changed: #{@context_changed})")
307
274
 
308
275
  stream_response(payload) do |chunk|
309
276
  response_content << chunk
@@ -349,22 +316,21 @@ module CollavreOpenclaw
349
316
  # HTTP payload building
350
317
  # ─────────────────────────────────────────────
351
318
 
352
- def build_payload(messages)
319
+ # SessionContextResolver already decided what to include.
320
+ def build_payload
353
321
  agent_id = extract_agent_id_from_email
354
322
  model_value = agent_id.present? ? "openclaw:#{agent_id}" : "openclaw"
355
323
 
356
- payload = {
324
+ formatted = []
325
+ formatted << { role: "system", content: @system_prompt } if @system_prompt.present?
326
+ @all_messages.each { |m| formatted << format_single_message(m) }
327
+
328
+ {
357
329
  model: model_value,
358
- messages: format_messages(messages),
359
- stream: true
330
+ messages: formatted,
331
+ stream: true,
332
+ user: build_user_context
360
333
  }
361
-
362
- if @system_prompt.present?
363
- payload[:messages].unshift({ role: "system", content: @system_prompt })
364
- end
365
-
366
- payload[:user] = build_user_context
367
- payload
368
334
  end
369
335
 
370
336
  def build_user_context
@@ -400,28 +366,26 @@ module CollavreOpenclaw
400
366
  end
401
367
  end
402
368
 
403
- def format_messages(messages)
404
- Array(messages).map do |msg|
405
- role = msg[:role] || msg["role"]
406
- text = extract_message_text(msg)
369
+ def format_single_message(msg)
370
+ role = msg[:role] || msg["role"]
371
+ text = extract_message_text(msg)
407
372
 
408
- sender_name = msg[:sender_name] || msg["sender_name"]
409
- if sender_name.present? && normalize_role(role) == "user"
410
- text = "[#{sender_name}]: #{text}"
411
- end
373
+ sender_name = msg[:sender_name] || msg["sender_name"]
374
+ if sender_name.present? && normalize_role(role) == "user"
375
+ text = "[#{sender_name}]: #{text}"
376
+ end
412
377
 
413
- image_sources = extract_image_sources(msg)
378
+ image_sources = extract_image_sources(msg)
414
379
 
415
- if image_sources.any?
416
- content_parts = [ { type: "text", text: text.to_s } ]
417
- image_sources.each do |source|
418
- image_data = encode_image_source(source)
419
- content_parts << image_data if image_data
420
- end
421
- { role: normalize_role(role), content: content_parts }
422
- else
423
- { role: normalize_role(role), content: text.to_s }
380
+ if image_sources.any?
381
+ content_parts = [ { type: "text", text: text.to_s } ]
382
+ image_sources.each do |source|
383
+ image_data = encode_image_source(source)
384
+ content_parts << image_data if image_data
424
385
  end
386
+ { role: normalize_role(role), content: content_parts }
387
+ else
388
+ { role: normalize_role(role), content: text.to_s }
425
389
  end
426
390
  end
427
391
 
@@ -94,7 +94,7 @@ module CollavreOpenclaw
94
94
  EmReactor.next_tick do
95
95
  begin
96
96
  do_connect!(queue)
97
- rescue => e
97
+ rescue StandardError => e
98
98
  queue.push({ error: e.message })
99
99
  end
100
100
  end
@@ -265,6 +265,21 @@ module CollavreOpenclaw
265
265
  end
266
266
  end
267
267
 
268
+ # Inject an assistant message into a session transcript.
269
+ # Useful for pre-populating context without triggering an agent run.
270
+ #
271
+ # @param session_key [String]
272
+ # @param message [String] content to inject
273
+ # @param label [String, nil] optional label for the injected message
274
+ def chat_inject(session_key:, message:, label: nil)
275
+ ensure_connected!
276
+ touch_activity!
277
+
278
+ params = { sessionKey: session_key, message: message }
279
+ params[:label] = label if label
280
+ send_rpc("chat.inject", params)
281
+ end
282
+
268
283
  # Fetch chat history for a session
269
284
  def chat_history(session_key:, limit: nil)
270
285
  ensure_connected!
@@ -644,7 +659,7 @@ module CollavreOpenclaw
644
659
  schedule_reconnect!
645
660
  end
646
661
  end
647
- rescue => e
662
+ rescue StandardError => e
648
663
  Rails.logger.error("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} state=fail reason=#{e.message}")
649
664
  schedule_reconnect!
650
665
  end
@@ -27,7 +27,11 @@ module CollavreOpenclaw
27
27
 
28
28
  def initialize
29
29
  @open_timeout = ENV.fetch("OPENCLAW_OPEN_TIMEOUT", 10).to_i
30
- @read_timeout = ENV.fetch("OPENCLAW_READ_TIMEOUT", 180).to_i # 3 minutes for AI responses
30
+ @read_timeout = begin
31
+ Collavre::SystemSetting.llm_request_timeout_seconds
32
+ rescue StandardError
33
+ 1800
34
+ end
31
35
  @max_retries = ENV.fetch("OPENCLAW_MAX_RETRIES", 2).to_i
32
36
  @ws_idle_timeout = ENV.fetch("OPENCLAW_WS_IDLE_TIMEOUT", 1800).to_i # 30 minutes
33
37
  @ws_reconnect_max = ENV.fetch("OPENCLAW_WS_RECONNECT_MAX", 10).to_i
@@ -50,7 +50,7 @@ module CollavreOpenclaw
50
50
  ConnectionManager.instance.disconnect_all
51
51
  end
52
52
  EmReactor.stop! if EmReactor.running?
53
- rescue => e
53
+ rescue StandardError => e
54
54
  Rails.logger.warn("[CollavreOpenclaw] Shutdown cleanup error: #{e.message}")
55
55
  end
56
56
  end
@@ -1,3 +1,3 @@
1
1
  module CollavreOpenclaw
2
- VERSION = "0.5.0"
2
+ VERSION = "0.6.1"
3
3
  end
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.5.0
4
+ version: 0.6.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre