collavre_openclaw 0.5.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: 47fb3ac9892c9b3c1ee7531645732dd47acdba3b7504d048cd207ff8ae4fa82e
4
- data.tar.gz: e6233d85c5bff7f28ad2201b0cb84df1fe6c62827cd23666de54ef64060f41d5
3
+ metadata.gz: ff18cc7843b54de3c5e86de861f69b9225aa47dd7e57aae94cd612d7698d1d55
4
+ data.tar.gz: 8b83ffc51769d3946de8660b0583ccc672079f08d80db9b93178c12c1c374279
5
5
  SHA512:
6
- metadata.gz: 1a8b34146dedfcec60bc2b0b78ac4f9f51448c8b2bd3ee9064e24d24ac7854271e8cb943b5083da143ec9a18e7470b869e927f2b680db1518ff6a324897a0dc9
7
- data.tar.gz: 6f97da70c3443dcda6087cfa114b5f068cb952e567b90849a07b27239d6df89c55cafa07f35e76a2c864337d02e56f9cc328e5bc33f8655af4fd87ec93c4dee7
6
+ metadata.gz: 54bf09a092ed87b269621100c55edadd8fd0591f41f25270672f27cf6837d3767dff33a3cff6bb6fe4315c90cbb5d596805b6ce6d2ec055eb5d0cd0f73e7d5d6
7
+ data.tar.gz: b967fc6beb11b41889b0574b937b94532910acba29e9b349e2ec40289fcb57bd9096ea4cf06756a5db05acdc5f9a13a51c1c3474fb8fe85709e1c68fafe703dc
@@ -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
@@ -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?
@@ -38,11 +41,11 @@ module CollavreOpenclaw
38
41
  # Set OPENCLAW_TRANSPORT=http to force HTTP-only mode
39
42
  if CollavreOpenclaw.config.transport == "http"
40
43
  Rails.logger.info("[CollavreOpenclaw::WS] TRANSPORT mode=http_forced")
41
- chat_via_http(messages, &block)
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,14 +107,14 @@ 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
- payload = build_ws_chat_payload(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,
@@ -121,7 +145,7 @@ module CollavreOpenclaw
121
145
  rescue CollavreOpenclaw::ConnectionError,
122
146
  CollavreOpenclaw::TimeoutError => e
123
147
  Rails.logger.warn("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
124
- chat_via_http(messages, &block)
148
+ chat_via_http(&block)
125
149
  rescue CollavreOpenclaw::ChatError, CollavreOpenclaw::RpcError => e
126
150
  Rails.logger.error("[CollavreOpenclaw] WebSocket chat error: #{e.message}")
127
151
  error_msg = "OpenClaw Error: #{e.message}"
@@ -131,72 +155,46 @@ module CollavreOpenclaw
131
155
  Rails.logger.error("[CollavreOpenclaw] WebSocket unexpected error: #{e.message}\n" \
132
156
  "#{e.backtrace.first(5).join("\n")}")
133
157
  Rails.logger.info("[CollavreOpenclaw::WS] FALLBACK gateway=#{@user.gateway_url} reason=#{e.class}:#{e.message}")
134
- chat_via_http(messages, &block)
158
+ chat_via_http(&block)
135
159
  end
136
160
  end
137
161
 
138
162
  # Build WebSocket chat.send payload.
139
163
  #
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)
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
143
169
  {
144
- message: format_message_for_ws(messages),
145
- attachments: extract_ws_attachments(messages).presence
170
+ message: format_message_for_ws,
171
+ attachments: trigger ? extract_ws_attachments([ trigger ]).presence : nil
146
172
  }
147
173
  end
148
174
 
149
175
  # Format messages for WebSocket chat.send text payload.
150
176
  #
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).
177
+ # On first message (or context change):
178
+ # [system prompt] + [creative context] + [trigger]
179
+ # On subsequent messages:
180
+ # [trigger only]
155
181
  #
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
-
182
+ # Chat history is NOT included — the Gateway's SessionManager tracks
183
+ # conversation turns automatically.
184
+ def format_message_for_ws
166
185
  parts = []
167
186
 
168
- # 1. System prompt (same as HTTP mode's build_payload)
169
- parts << @system_prompt if @system_prompt.present?
170
-
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
-
176
- 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}"
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?
197
192
  end
198
193
  end
199
194
 
195
+ trigger = trigger_message
196
+ parts << extract_message_text(trigger) if trigger
197
+
200
198
  parts.join("\n\n")
201
199
  end
202
200
 
@@ -297,13 +295,13 @@ module CollavreOpenclaw
297
295
  # HTTP transport (fallback)
298
296
  # ─────────────────────────────────────────────
299
297
 
300
- def chat_via_http(messages, &block)
298
+ def chat_via_http(&block)
301
299
  response_content = +""
302
300
 
303
301
  begin
304
- payload = build_payload(messages)
302
+ payload = build_payload
305
303
 
306
- 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})")
307
305
 
308
306
  stream_response(payload) do |chunk|
309
307
  response_content << chunk
@@ -349,22 +347,35 @@ module CollavreOpenclaw
349
347
  # HTTP payload building
350
348
  # ─────────────────────────────────────────────
351
349
 
352
- 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
353
360
  agent_id = extract_agent_id_from_email
354
361
  model_value = agent_id.present? ? "openclaw:#{agent_id}" : "openclaw"
355
362
 
356
- payload = {
357
- model: model_value,
358
- messages: format_messages(messages),
359
- stream: true
360
- }
363
+ formatted = []
361
364
 
362
- if @system_prompt.present?
363
- 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) }
364
368
  end
365
369
 
366
- payload[:user] = build_user_context
367
- 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
+ }
368
379
  end
369
380
 
370
381
  def build_user_context
@@ -400,28 +411,26 @@ module CollavreOpenclaw
400
411
  end
401
412
  end
402
413
 
403
- def format_messages(messages)
404
- Array(messages).map do |msg|
405
- role = msg[:role] || msg["role"]
406
- text = extract_message_text(msg)
414
+ def format_single_message(msg)
415
+ role = msg[:role] || msg["role"]
416
+ text = extract_message_text(msg)
407
417
 
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
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
412
422
 
413
- image_sources = extract_image_sources(msg)
423
+ image_sources = extract_image_sources(msg)
414
424
 
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 }
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
424
430
  end
431
+ { role: normalize_role(role), content: content_parts }
432
+ else
433
+ { role: normalize_role(role), content: text.to_s }
425
434
  end
426
435
  end
427
436
 
@@ -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!
@@ -1,3 +1,3 @@
1
1
  module CollavreOpenclaw
2
- VERSION = "0.5.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.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Collavre