collavre_openclaw 0.4.0 → 0.6.0

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: 55f1fde91ceaf7db785f461e7463ae966ad015006ecba5bf8be22ac57f79d834
4
- data.tar.gz: 736ef6e4237b38fcc055d73a0d188d404389ed66b6ce9ed0a45e3ab0edf66b50
3
+ metadata.gz: ff18cc7843b54de3c5e86de861f69b9225aa47dd7e57aae94cd612d7698d1d55
4
+ data.tar.gz: 8b83ffc51769d3946de8660b0583ccc672079f08d80db9b93178c12c1c374279
5
5
  SHA512:
6
- metadata.gz: 5b249de9cc87980cf465d513af74900fead2c8ddb0c05262d578e821afec6830391e6bf8b30c7f57da0aa277945684c654a7435dfd081ef6da059f93bb7cacf7
7
- data.tar.gz: cd4f77c3f1536726ecd3d1078922c8ab3a335307fae35a34aa52352cca58376c95e6d9f8bc23a5ec8609c4935606ca803579f23e17d22ad62c5f3bc78e5405e2
6
+ metadata.gz: 54bf09a092ed87b269621100c55edadd8fd0591f41f25270672f27cf6837d3767dff33a3cff6bb6fe4315c90cbb5d596805b6ce6d2ec055eb5d0cd0f73e7d5d6
7
+ data.tar.gz: b967fc6beb11b41889b0574b937b94532910acba29e9b349e2ec40289fcb57bd9096ea4cf06756a5db05acdc5f9a13a51c1c3474fb8fe85709e1c68fafe703dc
@@ -66,6 +66,7 @@ module CollavreOpenclaw
66
66
  payload[:context][:creative_id] ||= pending.creative_id
67
67
  payload[:context][:comment_id] ||= pending.comment_id
68
68
  payload[:context][:thread_id] ||= pending.thread_id
69
+ payload[:context][:topic_id] ||= pending.thread_id
69
70
 
70
71
  # Merge any extra context stored in pending callback
71
72
  if pending.context.present?
@@ -3,11 +3,28 @@ module CollavreOpenclaw
3
3
  allow_unauthenticated_access only: :show
4
4
 
5
5
  def show
6
- render json: {
6
+ payload = {
7
7
  status: "ok",
8
8
  engine: "collavre_openclaw",
9
9
  version: CollavreOpenclaw::VERSION
10
10
  }
11
+
12
+ # WebSocket details only for authenticated requests
13
+ if authenticated?
14
+ payload[:transport] = CollavreOpenclaw.config.transport
15
+ payload[:websocket] = ConnectionManager.status_summary
16
+ payload[:reactor] = { running: EmReactor.running? }
17
+ end
18
+
19
+ render json: payload
20
+ end
21
+
22
+ private
23
+
24
+ def authenticated?
25
+ respond_to?(:current_user, true) && current_user.present?
26
+ rescue
27
+ false
11
28
  end
12
29
  end
13
30
  end
@@ -39,6 +39,8 @@ module CollavreOpenclaw
39
39
  comment_id = context[:comment_id]
40
40
  content = payload[:content] || payload[:message]
41
41
 
42
+ normalize_topic_context!(context)
43
+
42
44
  if comment_id.present?
43
45
  # Update existing comment (streaming completion)
44
46
  comment = Collavre::Comment.find_by(id: comment_id)
@@ -56,7 +58,7 @@ module CollavreOpenclaw
56
58
  def handle_proactive(payload)
57
59
  creative_id = payload[:creative_id] || payload.dig(:context, :creative_id)
58
60
  content = payload[:content] || payload[:message]
59
- thread_id = payload[:thread_id] || payload.dig(:context, :thread_id)
61
+ thread_id = payload[:thread_id] || payload[:topic_id] || payload.dig(:context, :thread_id) || payload.dig(:context, :topic_id)
60
62
  parent_comment_id = payload[:parent_comment_id] || payload.dig(:context, :parent_comment_id)
61
63
 
62
64
  unless creative_id.present?
@@ -94,6 +96,8 @@ module CollavreOpenclaw
94
96
  end
95
97
 
96
98
  def create_ai_comment(creative_id, content, context = {})
99
+ normalize_topic_context!(context)
100
+
97
101
  creative = Collavre::Creative.find_by(id: creative_id)
98
102
  unless creative
99
103
  Rails.logger.error("[CollavreOpenclaw] Creative not found: #{creative_id}")
@@ -135,5 +139,13 @@ module CollavreOpenclaw
135
139
  Rails.logger.error("[CollavreOpenclaw] Failed to create comment: #{e.message}")
136
140
  nil
137
141
  end
142
+
143
+ def normalize_topic_context!(context)
144
+ return unless context.is_a?(Hash)
145
+ return if context[:thread_id].present?
146
+
147
+ topic_id = context[:topic_id]
148
+ context[:thread_id] = topic_id if topic_id.present?
149
+ end
138
150
  end
139
151
  end
@@ -12,8 +12,11 @@ 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: }
16
+ # from MessageBuilder, 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]
@@ -31,13 +34,13 @@ module CollavreOpenclaw
31
34
  error_message = nil
32
35
 
33
36
  begin
34
- response_content = adapter.chat(contents, &block)
37
+ response_content = adapter.chat(messages_data, &block)
35
38
  rescue StandardError => e
36
39
  error_message = e.message
37
40
  raise
38
41
  ensure
39
42
  log_interaction(
40
- messages: Array(contents),
43
+ messages: messages_data[:messages],
41
44
  tools: [],
42
45
  response_content: response_content,
43
46
  error_message: error_message,
@@ -49,12 +52,24 @@ module CollavreOpenclaw
49
52
  return response_content
50
53
  end
51
54
 
52
- # Fall back to original implementation
53
- super
55
+ # Fall back to original RubyLLM implementation (expects Array)
56
+ super(messages_data[:messages], tools: tools, &block)
54
57
  end
55
58
 
56
59
  private
57
60
 
58
61
  attr_reader :vendor, :system_prompt, :context
62
+
63
+ # Wrap plain Array input (from standalone callers like CompressJob)
64
+ # into the Hash format expected by the adapter.
65
+ def normalize_messages_input(input)
66
+ return input if input.is_a?(Hash)
67
+
68
+ {
69
+ messages: Array(input).map { |m| m.merge(kind: :trigger) },
70
+ first_message: true,
71
+ context_changed: false
72
+ }
73
+ end
59
74
  end
60
75
  end
@@ -43,10 +43,17 @@ module CollavreOpenclaw
43
43
  client = @connections[gateway_url]
44
44
 
45
45
  if client.nil?
46
- client = WebsocketClient.new(user: user)
47
- client.on_proactive_message(&@proactive_handler) if @proactive_handler
48
- @connections[gateway_url] = client
49
- @gateway_users[gateway_url] = Set.new
46
+ client = create_client(user, gateway_url)
47
+ elsif client.user.llm_api_key != user.llm_api_key
48
+ # Same gateway_url but different API key. This is a configuration
49
+ # error: one Gateway = one API key. Log a warning so the admin
50
+ # notices, rather than silently ignoring the second user's key.
51
+ # In HTTP mode this would surface as a 401 on each request.
52
+ Rails.logger.warn(
53
+ "[CollavreOpenclaw::ConnectionManager] API key mismatch for gateway #{gateway_url}: " \
54
+ "user #{user.id} has a different key than connection owner #{client.user.id}. " \
55
+ "The connection uses the owner's key. Verify AI agent settings."
56
+ )
50
57
  end
51
58
 
52
59
  # Track this user as using this gateway
@@ -109,6 +116,17 @@ module CollavreOpenclaw
109
116
  end
110
117
  end
111
118
 
119
+ # Safe accessor: returns status without triggering singleton initialization.
120
+ # Use this from controllers/monitoring instead of instance_variable_get.
121
+ def self.status_summary
122
+ if instance_variable_defined?(:@singleton__instance__) && @singleton__instance__
123
+ instance.status
124
+ else
125
+ { total_connections: 0, total_users: 0,
126
+ connected: 0, connecting: 0, reconnecting: 0, disconnected: 0 }
127
+ end
128
+ end
129
+
112
130
  # Register a proactive message handler for all connections.
113
131
  # New connections will also get this handler.
114
132
  def on_proactive_message(&handler)
@@ -138,6 +156,35 @@ module CollavreOpenclaw
138
156
 
139
157
  private
140
158
 
159
+ # Create a new WebsocketClient and wire up handlers.
160
+ def create_client(user, gateway_url)
161
+ client = WebsocketClient.new(user: user)
162
+ client.on_proactive_message(&@proactive_handler) if @proactive_handler
163
+ client.on_fatal_close do |dead_client|
164
+ handle_fatal_close(gateway_url, dead_client)
165
+ end
166
+ @connections[gateway_url] = client
167
+ @gateway_users[gateway_url] ||= Set.new
168
+ client
169
+ end
170
+
171
+ # Called when a client receives a fatal close code (auth failure, etc.).
172
+ # Removes the dead client so the next connection_for call creates a fresh one.
173
+ #
174
+ # Guard: only deletes if the current mapping still points to dead_client.
175
+ # Without this, a late-arriving callback from an old client could wipe a
176
+ # new live connection that was already registered for the same gateway_url.
177
+ def handle_fatal_close(gateway_url, dead_client)
178
+ @mutex.synchronize do
179
+ return unless @connections[gateway_url].equal?(dead_client)
180
+
181
+ Rails.logger.warn("[CollavreOpenclaw::ConnectionManager] Fatal close for gateway #{gateway_url}, removing connection")
182
+ @connections.delete(gateway_url)
183
+ user_ids = @gateway_users.delete(gateway_url) || Set.new
184
+ user_ids.each { |uid| @user_gateways.delete(uid) }
185
+ end
186
+ end
187
+
141
188
  # Set up the default proactive message handler that dispatches
142
189
  # unsolicited chat events to CallbackProcessorJob.
143
190
  def setup_default_proactive_handler!
@@ -20,7 +20,10 @@ module CollavreOpenclaw
20
20
  @context = context
21
21
  end
22
22
 
23
- def chat(messages, &block)
23
+ # @param messages_data [Hash] { messages:, first_message:, context_changed: }
24
+ def chat(messages_data, &block)
25
+ parse_messages_data!(messages_data)
26
+
24
27
  unless @user&.gateway_url.present?
25
28
  Rails.logger.error("[CollavreOpenclaw] No Gateway URL configured for user #{@user&.id}")
26
29
  yield "Error: OpenClaw Gateway URL not configured" if block_given?
@@ -37,12 +40,12 @@ module CollavreOpenclaw
37
40
  # Try WebSocket first, fall back to HTTP
38
41
  # Set OPENCLAW_TRANSPORT=http to force HTTP-only mode
39
42
  if CollavreOpenclaw.config.transport == "http"
40
- Rails.logger.info("[CollavreOpenclaw] Using HTTP transport (forced by config)")
41
- chat_via_http(messages, &block)
43
+ Rails.logger.info("[CollavreOpenclaw::WS] TRANSPORT mode=http_forced")
44
+ chat_via_http(&block)
42
45
  elsif websocket_available?
43
- chat_via_websocket(messages, &block)
46
+ chat_via_websocket(&block)
44
47
  else
45
- chat_via_http(messages, &block)
48
+ chat_via_http(&block)
46
49
  end
47
50
  end
48
51
 
@@ -69,6 +72,27 @@ module CollavreOpenclaw
69
72
 
70
73
  private
71
74
 
75
+ CONTEXT_KINDS = %i[creative_context context_creative referenced_creative].freeze
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
+
83
+ def context_messages
84
+ @all_messages.select { |m| CONTEXT_KINDS.include?(m[:kind]) }
85
+ end
86
+
87
+ def trigger_message
88
+ @all_messages.find { |m| m[:kind] == :trigger }
89
+ end
90
+
91
+ # Send system prompt and context on first message or when they changed.
92
+ def include_full_context?
93
+ @first_message || @context_changed
94
+ end
95
+
72
96
  # ─────────────────────────────────────────────
73
97
  # WebSocket transport
74
98
  # ─────────────────────────────────────────────
@@ -83,18 +107,19 @@ module CollavreOpenclaw
83
107
  false
84
108
  end
85
109
 
86
- def chat_via_websocket(messages, &block)
110
+ def chat_via_websocket(&block)
87
111
  response_content = +""
88
112
 
89
113
  begin
90
114
  client = ConnectionManager.instance.connection_for(@user)
91
- message_text = format_message_for_ws(messages)
115
+ payload = build_ws_chat_payload
92
116
 
93
- Rails.logger.info("[CollavreOpenclaw] Sending via WebSocket (session: #{session_key})")
117
+ Rails.logger.info("[CollavreOpenclaw] Sending via WebSocket (session: #{session_key}, first: #{@first_message}, changed: #{@context_changed})")
94
118
 
95
119
  client.chat_send(
96
120
  session_key: session_key,
97
- message: message_text
121
+ message: payload[:message],
122
+ attachments: payload[:attachments]
98
123
  ) do |event|
99
124
  case event[:state]
100
125
  when "delta"
@@ -119,8 +144,8 @@ module CollavreOpenclaw
119
144
  response_content.presence
120
145
  rescue CollavreOpenclaw::ConnectionError,
121
146
  CollavreOpenclaw::TimeoutError => e
122
- Rails.logger.warn("[CollavreOpenclaw] WebSocket failed, falling back to HTTP: #{e.message}")
123
- chat_via_http(messages, &block)
147
+ Rails.logger.warn("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
148
+ chat_via_http(&block)
124
149
  rescue CollavreOpenclaw::ChatError, CollavreOpenclaw::RpcError => e
125
150
  Rails.logger.error("[CollavreOpenclaw] WebSocket chat error: #{e.message}")
126
151
  error_msg = "OpenClaw Error: #{e.message}"
@@ -129,64 +154,48 @@ module CollavreOpenclaw
129
154
  rescue StandardError => e
130
155
  Rails.logger.error("[CollavreOpenclaw] WebSocket unexpected error: #{e.message}\n" \
131
156
  "#{e.backtrace.first(5).join("\n")}")
132
- Rails.logger.info("[CollavreOpenclaw] Falling back to HTTP")
133
- chat_via_http(messages, &block)
134
- end
135
- end
136
-
137
- # Format messages for WebSocket chat.send (single message string).
138
- # Gateway manages session history, so we only send the latest user message
139
- # with optional context prefix on the FIRST message only.
140
- def format_message_for_ws(messages)
141
- formatted = Array(messages)
142
-
143
- # Extract the last user message
144
- last_user = formatted.reverse.find do |m|
145
- role = m[:role] || m["role"]
146
- role.to_s == "user"
147
- end
148
-
149
- return "" unless last_user
150
-
151
- text = extract_message_text(last_user)
152
-
153
- # Only prepend creative context on the first message in a session.
154
- # If there are prior assistant replies, the Gateway already has context.
155
- if first_message_in_session?(formatted)
156
- context_prefix = build_context_prefix(formatted)
157
- if context_prefix.present?
158
- return "#{context_prefix}\n\n#{text}"
159
- end
157
+ Rails.logger.info("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
158
+ chat_via_http(&block)
160
159
  end
161
-
162
- text.to_s
163
160
  end
164
161
 
165
- # Returns true when this looks like the first exchange in a session
166
- # (no prior assistant messages in the conversation history).
167
- def first_message_in_session?(messages)
168
- messages.none? do |m|
169
- role = (m[:role] || m["role"]).to_s
170
- # "model" is used by some providers (e.g. Gemini) as an alias for "assistant"
171
- role == "assistant" || role == "model"
172
- end
162
+ # Build WebSocket chat.send payload.
163
+ #
164
+ # Token optimization: only includes system prompt and creative context on
165
+ # the first message or when context has changed. The Gateway maintains its
166
+ # own session history, so chat history is never sent — only the trigger.
167
+ def build_ws_chat_payload
168
+ trigger = trigger_message
169
+ {
170
+ message: format_message_for_ws,
171
+ attachments: trigger ? extract_ws_attachments([ trigger ]).presence : nil
172
+ }
173
173
  end
174
174
 
175
- # Build a context prefix from system/context messages if present.
176
- # This includes creative tree markdown and other context that the Gateway
177
- # wouldn't have from its own agent config.
178
- def build_context_prefix(messages)
179
- # Find the first "user" message that looks like creative context
180
- # (typically starts with "Creative:\n")
181
- context_msg = messages.find do |m|
182
- role = m[:role] || m["role"]
183
- text = extract_message_text(m)
184
- role.to_s == "user" && text&.start_with?("Creative:")
175
+ # Format messages for WebSocket chat.send text payload.
176
+ #
177
+ # On first message (or context change):
178
+ # [system prompt] + [creative context] + [trigger]
179
+ # On subsequent messages:
180
+ # [trigger only]
181
+ #
182
+ # Chat history is NOT included — the Gateway's SessionManager tracks
183
+ # conversation turns automatically.
184
+ def format_message_for_ws
185
+ parts = []
186
+
187
+ if include_full_context?
188
+ parts << @system_prompt if @system_prompt.present?
189
+ context_messages.each do |m|
190
+ text = extract_message_text(m)
191
+ parts << text if text.present?
192
+ end
185
193
  end
186
194
 
187
- return nil unless context_msg
195
+ trigger = trigger_message
196
+ parts << extract_message_text(trigger) if trigger
188
197
 
189
- extract_message_text(context_msg)
198
+ parts.join("\n\n")
190
199
  end
191
200
 
192
201
  def extract_message_text(message)
@@ -205,6 +214,12 @@ module CollavreOpenclaw
205
214
  Array(parts).filter_map { |part| part[:image] || part["image"] }
206
215
  end
207
216
 
217
+ def extract_ws_attachments(messages)
218
+ Array(messages).flat_map do |message|
219
+ extract_image_sources(message).filter_map { |source| encode_image_source_for_ws(source) }
220
+ end
221
+ end
222
+
208
223
  def encode_image_source(source)
209
224
  if defined?(ActiveStorage) && source.is_a?(ActiveStorage::Blob)
210
225
  data = Base64.strict_encode64(source.download)
@@ -239,17 +254,54 @@ module CollavreOpenclaw
239
254
  nil
240
255
  end
241
256
 
257
+ def encode_image_source_for_ws(source)
258
+ if defined?(ActiveStorage) && source.is_a?(ActiveStorage::Blob)
259
+ {
260
+ type: "image",
261
+ mimeType: source.content_type,
262
+ fileName: source.filename.to_s,
263
+ content: Base64.strict_encode64(source.download)
264
+ }
265
+ elsif source.respond_to?(:download)
266
+ blob = source.respond_to?(:blob) ? source.blob : source
267
+ return nil unless blob
268
+
269
+ {
270
+ type: "image",
271
+ mimeType: blob.content_type,
272
+ fileName: blob.filename.to_s,
273
+ content: Base64.strict_encode64(blob.download)
274
+ }
275
+ elsif source.is_a?(String) && source.match?(%r{^https?://})
276
+ nil
277
+ elsif source.is_a?(String)
278
+ return nil unless File.exist?(source)
279
+
280
+ {
281
+ type: "image",
282
+ mimeType: Marcel::MimeType.for(Pathname.new(source)),
283
+ fileName: File.basename(source),
284
+ content: Base64.strict_encode64(File.binread(source))
285
+ }
286
+ else
287
+ nil
288
+ end
289
+ rescue StandardError => e
290
+ Rails.logger.warn("[CollavreOpenclaw] Failed to encode WS image attachment: #{e.message}")
291
+ nil
292
+ end
293
+
242
294
  # ─────────────────────────────────────────────
243
295
  # HTTP transport (fallback)
244
296
  # ─────────────────────────────────────────────
245
297
 
246
- def chat_via_http(messages, &block)
298
+ def chat_via_http(&block)
247
299
  response_content = +""
248
300
 
249
301
  begin
250
- payload = build_payload(messages)
302
+ payload = build_payload
251
303
 
252
- Rails.logger.info("[CollavreOpenclaw] Sending via HTTP to #{api_endpoint} (session: #{session_key})")
304
+ Rails.logger.info("[CollavreOpenclaw] Sending via HTTP to #{api_endpoint} (session: #{session_key}, first: #{@first_message}, changed: #{@context_changed})")
253
305
 
254
306
  stream_response(payload) do |chunk|
255
307
  response_content << chunk
@@ -281,7 +333,7 @@ module CollavreOpenclaw
281
333
  # Format: agent:<agent_id>:collavre:<user_id>:creative:<id>:topic:<id>
282
334
  def build_session_key
283
335
  creative_id = extract_id(@context, :creative) || @context[:creative_id]
284
- topic_id = @context[:thread_id] || @context[:topic_id]
336
+ topic_id = @context[:thread_id] || @context[:topic_id] || infer_topic_id
285
337
  agent_id = extract_agent_id_from_email || "main"
286
338
 
287
339
  parts = [ "agent", agent_id, "collavre", @user.id ]
@@ -295,22 +347,35 @@ module CollavreOpenclaw
295
347
  # HTTP payload building
296
348
  # ─────────────────────────────────────────────
297
349
 
298
- def build_payload(messages)
350
+ # Build HTTP payload with token optimization.
351
+ #
352
+ # On first message (or context change):
353
+ # system prompt + creative context + trigger
354
+ # On subsequent messages:
355
+ # trigger only
356
+ #
357
+ # Chat history is NOT included — the Gateway's SessionManager tracks
358
+ # conversation turns via the stable session key.
359
+ def build_payload
299
360
  agent_id = extract_agent_id_from_email
300
361
  model_value = agent_id.present? ? "openclaw:#{agent_id}" : "openclaw"
301
362
 
302
- payload = {
303
- model: model_value,
304
- messages: format_messages(messages),
305
- stream: true
306
- }
363
+ formatted = []
307
364
 
308
- if @system_prompt.present?
309
- payload[:messages].unshift({ role: "system", content: @system_prompt })
365
+ if include_full_context?
366
+ formatted << { role: "system", content: @system_prompt } if @system_prompt.present?
367
+ context_messages.each { |m| formatted << format_single_message(m) }
310
368
  end
311
369
 
312
- payload[:user] = build_user_context
313
- payload
370
+ trigger = trigger_message
371
+ formatted << format_single_message(trigger) if trigger
372
+
373
+ {
374
+ model: model_value,
375
+ messages: formatted,
376
+ stream: true,
377
+ user: build_user_context
378
+ }
314
379
  end
315
380
 
316
381
  def build_user_context
@@ -318,7 +383,7 @@ module CollavreOpenclaw
318
383
 
319
384
  creative_id = extract_id(@context, :creative) || @context[:creative_id]
320
385
  comment_id = extract_id(@context, :comment) || @context[:comment_id]
321
- topic_id = @context[:thread_id] || @context[:topic_id]
386
+ topic_id = @context[:thread_id] || @context[:topic_id] || infer_topic_id
322
387
 
323
388
  callback = callback_url
324
389
  if callback.present? && creative_id.present?
@@ -346,28 +411,26 @@ module CollavreOpenclaw
346
411
  end
347
412
  end
348
413
 
349
- def format_messages(messages)
350
- Array(messages).map do |msg|
351
- role = msg[:role] || msg["role"]
352
- text = extract_message_text(msg)
414
+ def format_single_message(msg)
415
+ role = msg[:role] || msg["role"]
416
+ text = extract_message_text(msg)
353
417
 
354
- sender_name = msg[:sender_name] || msg["sender_name"]
355
- if sender_name.present? && normalize_role(role) == "user"
356
- text = "[#{sender_name}]: #{text}"
357
- end
418
+ sender_name = msg[:sender_name] || msg["sender_name"]
419
+ if sender_name.present? && normalize_role(role) == "user"
420
+ text = "[#{sender_name}]: #{text}"
421
+ end
358
422
 
359
- image_sources = extract_image_sources(msg)
423
+ image_sources = extract_image_sources(msg)
360
424
 
361
- if image_sources.any?
362
- content_parts = [ { type: "text", text: text.to_s } ]
363
- image_sources.each do |source|
364
- image_data = encode_image_source(source)
365
- content_parts << image_data if image_data
366
- end
367
- { role: normalize_role(role), content: content_parts }
368
- else
369
- { role: normalize_role(role), content: text.to_s }
425
+ if image_sources.any?
426
+ content_parts = [ { type: "text", text: text.to_s } ]
427
+ image_sources.each do |source|
428
+ image_data = encode_image_source(source)
429
+ content_parts << image_data if image_data
370
430
  end
431
+ { role: normalize_role(role), content: content_parts }
432
+ else
433
+ { role: normalize_role(role), content: text.to_s }
371
434
  end
372
435
  end
373
436
 
@@ -539,6 +602,15 @@ module CollavreOpenclaw
539
602
  @user.email.split("@").first
540
603
  end
541
604
 
605
+ # Infer topic_id from the comment object in context when not explicitly provided.
606
+ # AiAgentService passes :comment (the reply or original comment) which carries topic_id.
607
+ def infer_topic_id
608
+ comment = @context[:comment]
609
+ return comment.topic_id if comment.respond_to?(:topic_id) && comment.topic_id.present?
610
+
611
+ nil
612
+ end
613
+
542
614
  def default_url_options
543
615
  options = Rails.application.config.action_mailer.default_url_options || {}
544
616
 
@@ -22,6 +22,20 @@ module CollavreOpenclaw
22
22
  COMPLETED_RUN_COOLDOWN = 5 # seconds to suppress late-arriving events for completed runs
23
23
  SEEN_EVENT_TTL = 30 # seconds to remember (runId, seq) pairs for dedup
24
24
 
25
+ # WebSocket close codes → reconnection policy
26
+ # :reconnect — schedule reconnect with exponential backoff
27
+ # :fatal — permanent failure (auth, forbidden), don't retry, propagate error
28
+ # :normal — clean shutdown, no reconnect, no error
29
+ CLOSE_POLICIES = {
30
+ 1000 => :normal, # Normal closure
31
+ 1001 => :reconnect, # Going away (server shutting down)
32
+ 1006 => :reconnect, # Abnormal closure (network issue)
33
+ 1008 => :fatal, # Policy violation (likely auth)
34
+ 1011 => :reconnect, # Internal server error
35
+ 4001 => :fatal, # Auth failure (OpenClaw)
36
+ 4003 => :fatal # Forbidden (OpenClaw)
37
+ }.freeze
38
+
25
39
  attr_reader :user, :state
26
40
 
27
41
  # States: :disconnected, :connecting, :connected, :reconnecting
@@ -39,8 +53,7 @@ module CollavreOpenclaw
39
53
  @proactive_handler = nil
40
54
  @reconnect_attempts = 0
41
55
  @last_activity_at = nil
42
- @tick_interval_ms = 15_000
43
- @tick_timer = nil
56
+ @rpc_run_registrations = {} # RPC request_id → run_queue (for EM-thread runId registration)
44
57
  end
45
58
 
46
59
  def connected?
@@ -52,7 +65,6 @@ module CollavreOpenclaw
52
65
  def connect!
53
66
  return if connected?
54
67
 
55
- initiator = false
56
68
  waiter_queue = nil
57
69
 
58
70
  @connect_mutex.synchronize do
@@ -64,7 +76,6 @@ module CollavreOpenclaw
64
76
  @connect_waiters << waiter_queue
65
77
  else
66
78
  @state = :connecting
67
- initiator = true
68
79
  end
69
80
  end
70
81
 
@@ -126,7 +137,6 @@ module CollavreOpenclaw
126
137
  def disconnect!
127
138
  @state = :disconnected
128
139
  EmReactor.next_tick do
129
- cancel_tick_timer!
130
140
  @ws&.close
131
141
  @ws = nil
132
142
  end
@@ -135,16 +145,18 @@ module CollavreOpenclaw
135
145
  @pending_requests.clear
136
146
  @pending_runs.each_value { |q| q.push({ done: true }) }
137
147
  @pending_runs.clear
148
+ @rpc_run_registrations.clear
138
149
  end
139
150
 
140
151
  # Send a chat message. Blocks and yields streaming events.
141
152
  #
142
153
  # @param session_key [String]
143
154
  # @param message [String]
155
+ # @param attachments [Array<Hash>, nil]
144
156
  # @param idempotency_key [String]
145
157
  # @yield [Hash] chat events with :state, :text, :message keys
146
158
  # @return [String, nil] final response text
147
- def chat_send(session_key:, message:, idempotency_key: nil, &block)
159
+ def chat_send(session_key:, message:, attachments: nil, idempotency_key: nil, &block)
148
160
  ensure_connected!
149
161
  touch_activity!
150
162
 
@@ -156,19 +168,33 @@ module CollavreOpenclaw
156
168
  # Pre-register with idempotency_key to catch early events
157
169
  @mutex.synchronize { @pending_runs[idempotency_key] = run_queue }
158
170
 
159
- # Send the RPC request to get the real runId
160
- response = send_rpc("chat.send", {
171
+ # Send the RPC request to get the real runId.
172
+ # IMPORTANT: We pass the run_queue via @rpc_run_registrations so that
173
+ # handle_response can register @pending_runs[actual_run_id] on the EM
174
+ # thread BEFORE any subsequent chat events arrive. This prevents a race
175
+ # condition where fast Gateway responses send events before the Rails
176
+ # thread can re-register with the actual runId.
177
+ rpc_request_id = SecureRandom.uuid
178
+ @mutex.synchronize { @rpc_run_registrations[rpc_request_id] = run_queue }
179
+
180
+ rpc_params = {
161
181
  sessionKey: session_key,
162
182
  message: message,
163
183
  idempotencyKey: idempotency_key
164
- })
184
+ }
185
+ rpc_params[:attachments] = attachments if attachments.present?
186
+
187
+ response = send_rpc("chat.send", rpc_params, request_id: rpc_request_id)
165
188
 
166
- # Re-register with the Gateway-assigned runId
189
+ # The EM thread already registered @pending_runs[actual_run_id] in
190
+ # handle_response. Clean up the idempotency_key entry if a different
191
+ # runId was assigned.
167
192
  actual_run_id = response&.dig(:runId) || idempotency_key
168
193
  if actual_run_id != idempotency_key
169
194
  @mutex.synchronize do
170
195
  @pending_runs.delete(idempotency_key)
171
- @pending_runs[actual_run_id] = run_queue
196
+ # Ensure runId is registered (may already be from handle_response)
197
+ @pending_runs[actual_run_id] ||= run_queue
172
198
  end
173
199
  end
174
200
 
@@ -185,14 +211,19 @@ module CollavreOpenclaw
185
211
  end
186
212
 
187
213
  # Gateway may broadcast + nodeSend the same event, causing
188
- # duplicates on the same WebSocket. Skip already-seen seqs.
214
+ # duplicates on the same WebSocket. Skip already-seen seqs for
215
+ # deltas only. Terminal events (final, error, aborted) must NEVER
216
+ # be skipped — they break the loop and unblock the caller.
189
217
  seq = event[:seq]
190
- if seq && last_seq && seq <= last_seq
218
+ event_state = event[:state]
219
+ is_terminal = event_state == "final" || event_state == "error" || event_state == "aborted"
220
+
221
+ if !is_terminal && seq && last_seq && seq <= last_seq
191
222
  next
192
223
  end
193
224
  last_seq = seq if seq
194
225
 
195
- case event[:state]
226
+ case event_state
196
227
  when "delta"
197
228
  text = extract_event_text(event)
198
229
  if text.present?
@@ -234,6 +265,21 @@ module CollavreOpenclaw
234
265
  end
235
266
  end
236
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
+
237
283
  # Fetch chat history for a session
238
284
  def chat_history(session_key:, limit: nil)
239
285
  ensure_connected!
@@ -260,6 +306,12 @@ module CollavreOpenclaw
260
306
  @proactive_handler = handler
261
307
  end
262
308
 
309
+ # Register a callback invoked when the connection dies with a fatal close code
310
+ # (auth failure, forbidden, etc.). ConnectionManager uses this to remove dead clients.
311
+ def on_fatal_close(&handler)
312
+ @on_fatal_close = handler
313
+ end
314
+
263
315
  # Time since last activity (for idle timeout)
264
316
  def idle_seconds
265
317
  return Float::INFINITY unless @last_activity_at
@@ -272,6 +324,23 @@ module CollavreOpenclaw
272
324
  CollavreOpenclaw.config
273
325
  end
274
326
 
327
+ # Determine reconnection policy for a WebSocket close code.
328
+ # Returns :reconnect, :fatal, or :normal.
329
+ def close_policy(code)
330
+ CLOSE_POLICIES[code] || (code.to_i >= 4000 ? :fatal : :reconnect)
331
+ end
332
+
333
+ # Drain all pending requests and streaming runs with an error message.
334
+ # Called on fatal close to unblock waiting Rails threads.
335
+ def drain_pending_with_error!(message)
336
+ @mutex.synchronize do
337
+ @pending_requests.each_value { |pr| pr[:queue]&.push({ error: message }) }
338
+ @pending_requests.clear
339
+ @pending_runs.each_value { |q| q.push({ error: message }) }
340
+ @pending_runs.clear
341
+ end
342
+ end
343
+
275
344
  def gateway_ws_url
276
345
  url = @user.gateway_url.to_s.strip
277
346
  return nil if url.blank?
@@ -301,8 +370,7 @@ module CollavreOpenclaw
301
370
  @handshake_done = false
302
371
 
303
372
  @ws.on :open do |_event|
304
- Rails.logger.info("[CollavreOpenclaw::WS] Connected to #{url}")
305
- # Wait for connect.challenge from gateway
373
+ Rails.logger.info("[CollavreOpenclaw::WS] CONNECT gateway=#{url} state=open")
306
374
  end
307
375
 
308
376
  @ws.on :message do |event|
@@ -312,18 +380,26 @@ module CollavreOpenclaw
312
380
  @ws.on :close do |event|
313
381
  code = event.code
314
382
  reason = event.reason
315
- Rails.logger.info("[CollavreOpenclaw::WS] Disconnected (code=#{code}, reason=#{reason})")
316
-
317
- cancel_tick_timer!
383
+ policy = close_policy(code)
384
+ Rails.logger.info("[CollavreOpenclaw::WS] DISCONNECT gateway=#{url} code=#{code} reason=#{reason} policy=#{policy}")
318
385
 
319
386
  unless @handshake_done
320
387
  @handshake_done = true
321
388
  @handshake_queue&.push({ error: "Connection closed during handshake (code=#{code})" })
322
389
  end
323
390
 
324
- if @state == :connected
325
- @state = :reconnecting
326
- schedule_reconnect!
391
+ case policy
392
+ when :reconnect
393
+ if @state == :connected || @state == :connecting
394
+ @state = :reconnecting
395
+ schedule_reconnect!
396
+ end
397
+ when :fatal
398
+ @state = :disconnected
399
+ drain_pending_with_error!("Connection closed with fatal code #{code}: #{reason}")
400
+ @on_fatal_close&.call(self)
401
+ when :normal
402
+ @state = :disconnected
327
403
  end
328
404
  end
329
405
  end
@@ -395,10 +471,8 @@ module CollavreOpenclaw
395
471
  if id == @connect_request_id && !@handshake_done
396
472
  @handshake_done = true
397
473
  if ok
398
- @tick_interval_ms = payload&.dig(:policy, :tickIntervalMs) || 15_000
399
474
  @state = :connected
400
475
  @reconnect_attempts = 0
401
- start_tick_timer!
402
476
  @handshake_queue&.push({ ok: true, payload: payload })
403
477
  else
404
478
  error_msg = error&.dig(:message) || error.to_s || "handshake failed"
@@ -411,6 +485,19 @@ module CollavreOpenclaw
411
485
  # Regular RPC response
412
486
  pending = @mutex.synchronize { @pending_requests.delete(id) }
413
487
  if pending
488
+ # If this RPC response contains a runId (chat.send response), register
489
+ # the run_queue under that runId NOW, on the EM thread, before any
490
+ # subsequent chat events arrive. This eliminates the race condition
491
+ # where fast events arrive before the Rails thread can re-register.
492
+ if ok && payload.is_a?(Hash) && payload[:runId]
493
+ run_queue = @mutex.synchronize { @rpc_run_registrations.delete(id) }
494
+ if run_queue
495
+ @mutex.synchronize { @pending_runs[payload[:runId]] = run_queue }
496
+ end
497
+ else
498
+ @mutex.synchronize { @rpc_run_registrations.delete(id) }
499
+ end
500
+
414
501
  if ok
415
502
  pending[:queue].push({ ok: true, payload: payload })
416
503
  else
@@ -425,9 +512,11 @@ module CollavreOpenclaw
425
512
  seq = payload[:seq]
426
513
 
427
514
  # Dedup: Gateway sends identical events via broadcast() + nodeSendToSession().
428
- # Skip if we've already seen this (runId, seq) pair.
429
- if run_id && seq && duplicate_chat_event?(run_id, seq)
430
- Rails.logger.debug("[CollavreOpenclaw::WS] Skipping duplicate chat event (runId=#{run_id}, seq=#{seq})")
515
+ # Key includes state so that a delta and final with the same seq are NOT
516
+ # treated as duplicates (they are different events that share a seq number).
517
+ state = payload[:state]
518
+ if run_id && seq && duplicate_chat_event?(run_id, seq, state)
519
+ Rails.logger.debug("[CollavreOpenclaw::WS] CHAT run=#{run_id} seq=#{seq} state=dedup_skipped")
431
520
  return
432
521
  end
433
522
 
@@ -439,21 +528,22 @@ module CollavreOpenclaw
439
528
  run_queue.push(payload)
440
529
  elsif recently_completed_run?(run_id)
441
530
  # Late-arriving event for a run we already finished — suppress it
442
- Rails.logger.info("[CollavreOpenclaw::WS] Suppressing late event for completed runId=#{run_id}")
531
+ Rails.logger.info("[CollavreOpenclaw::WS] CHAT run=#{run_id} state=late_suppressed")
443
532
  elsif @proactive_handler
444
533
  # Unknown run — proactive message from Gateway (cron/heartbeat)
445
- Rails.logger.info("[CollavreOpenclaw::WS] Proactive message received (runId=#{run_id})")
534
+ Rails.logger.info("[CollavreOpenclaw::WS] CHAT run=#{run_id} state=proactive")
446
535
  @proactive_handler.call(@user, payload)
447
536
  else
448
- Rails.logger.debug("[CollavreOpenclaw::WS] Ignoring chat event for unknown runId=#{run_id}")
537
+ Rails.logger.debug("[CollavreOpenclaw::WS] CHAT run=#{run_id} state=ignored_no_handler")
449
538
  end
450
539
  end
451
540
 
452
- # Check if this (runId, seq) pair was already seen. Records it if new.
541
+ # Check if this (runId, seq, state) triple was already seen. Records it if new.
453
542
  # Gateway emits each event via broadcast() AND nodeSendToSession(),
454
- # delivering the identical payload (same runId + seq) twice.
455
- def duplicate_chat_event?(run_id, seq)
456
- key = "#{run_id}:#{seq}"
543
+ # delivering the identical payload twice. Including state in the key
544
+ # ensures a delta and final with the same seq are treated as distinct events.
545
+ def duplicate_chat_event?(run_id, seq, state = nil)
546
+ key = "#{run_id}:#{seq}:#{state}"
457
547
  now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
458
548
 
459
549
  @mutex.synchronize do
@@ -485,38 +575,21 @@ module CollavreOpenclaw
485
575
  end
486
576
 
487
577
  def handle_tick(_payload)
488
- # Respond with a tick acknowledgment (poll response)
489
- send_frame({
490
- type: "req",
491
- id: SecureRandom.uuid,
492
- method: "poll",
493
- params: {}
494
- })
495
- end
496
-
497
- def start_tick_timer!
498
- cancel_tick_timer!
499
- interval = @tick_interval_ms / 1000.0
500
- @tick_timer = EM.add_periodic_timer(interval) do
501
- # Send keepalive poll if the server hasn't sent a tick
502
- send_frame({
503
- type: "req",
504
- id: SecureRandom.uuid,
505
- method: "poll",
506
- params: {}
507
- })
508
- end
509
- end
510
-
511
- def cancel_tick_timer!
512
- @tick_timer&.cancel
513
- @tick_timer = nil
578
+ # The tick event from Gateway is a keepalive heartbeat.
579
+ # Receiving it already refreshes our activity timer (via touch_activity!
580
+ # in handle_raw_message). No response is needed.
514
581
  end
515
582
 
516
583
  # Send an RPC request and block until the response.
517
584
  # Returns the response payload.
518
- def send_rpc(method, params)
519
- request_id = SecureRandom.uuid
585
+ #
586
+ # @param method [String] RPC method name
587
+ # @param params [Hash] RPC parameters
588
+ # @param request_id [String, nil] Pre-generated request ID. Used by chat_send
589
+ # to correlate the RPC response with the run_queue for EM-thread runId
590
+ # registration (see handle_response). If nil, a random UUID is generated.
591
+ def send_rpc(method, params, request_id: nil)
592
+ request_id ||= SecureRandom.uuid
520
593
  queue = Queue.new
521
594
 
522
595
  @mutex.synchronize do
@@ -566,7 +639,8 @@ module CollavreOpenclaw
566
639
  delay = config.ws_reconnect_base_delay * (2**(@reconnect_attempts - 1))
567
640
  delay = [ delay, 60 ].min # Cap at 60 seconds
568
641
 
569
- Rails.logger.info("[CollavreOpenclaw::WS] Reconnecting in #{delay}s (attempt #{@reconnect_attempts}/#{max})")
642
+ url = gateway_ws_url
643
+ Rails.logger.info("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} attempt=#{@reconnect_attempts}/#{max} delay=#{delay}s")
570
644
 
571
645
  EM.add_timer(delay) do
572
646
  next if @state == :disconnected # User explicitly disconnected
@@ -580,13 +654,13 @@ module CollavreOpenclaw
580
654
  EM.add_timer(config.ws_connect_timeout) do
581
655
  unless @handshake_done
582
656
  @handshake_done = true
583
- Rails.logger.warn("[CollavreOpenclaw::WS] Reconnect handshake timed out")
657
+ Rails.logger.warn("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} state=handshake_timeout")
584
658
  @ws&.close
585
659
  schedule_reconnect!
586
660
  end
587
661
  end
588
662
  rescue => e
589
- Rails.logger.error("[CollavreOpenclaw::WS] Reconnect failed: #{e.message}")
663
+ Rails.logger.error("[CollavreOpenclaw::WS] RECONNECT gateway=#{url} state=fail reason=#{e.message}")
590
664
  schedule_reconnect!
591
665
  end
592
666
  end
@@ -33,7 +33,7 @@ module CollavreOpenclaw
33
33
  @ws_reconnect_max = ENV.fetch("OPENCLAW_WS_RECONNECT_MAX", 10).to_i
34
34
  @ws_reconnect_base_delay = ENV.fetch("OPENCLAW_WS_RECONNECT_BASE", 1).to_f
35
35
  @ws_connect_timeout = ENV.fetch("OPENCLAW_WS_CONNECT_TIMEOUT", 10).to_i
36
- @transport = ENV.fetch("OPENCLAW_TRANSPORT", "http")
36
+ @transport = ENV.fetch("OPENCLAW_TRANSPORT", "auto")
37
37
  end
38
38
 
39
39
  # Legacy accessor for backward compatibility
@@ -1,3 +1,3 @@
1
1
  module CollavreOpenclaw
2
- VERSION = "0.4.0"
2
+ VERSION = "0.6.0"
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.4.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre