openclacky 1.5.12 → 1.5.13

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.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +48 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/message_compressor_helper.rb +52 -1
  5. data/lib/clacky/agent/session_serializer.rb +35 -2
  6. data/lib/clacky/agent/time_machine.rb +9 -1
  7. data/lib/clacky/agent.rb +11 -7
  8. data/lib/clacky/agent_config.rb +40 -0
  9. data/lib/clacky/agent_profile.rb +5 -5
  10. data/lib/clacky/billing/platform_billing.rb +84 -1
  11. data/lib/clacky/brand_config.rb +4 -3
  12. data/lib/clacky/cli.rb +4 -2
  13. data/lib/clacky/client.rb +107 -0
  14. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
  15. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
  16. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  17. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
  18. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  19. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  20. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  21. data/lib/clacky/extension/packager.rb +4 -2
  22. data/lib/clacky/extension/verifier.rb +14 -1
  23. data/lib/clacky/message_format/open_ai.rb +5 -1
  24. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  25. data/lib/clacky/message_history.rb +6 -2
  26. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  27. data/lib/clacky/providers.rb +18 -6
  28. data/lib/clacky/server/dir_picker.rb +154 -0
  29. data/lib/clacky/server/git_panel.rb +61 -9
  30. data/lib/clacky/server/http_server.rb +147 -103
  31. data/lib/clacky/tools/terminal.rb +27 -3
  32. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  33. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  34. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  35. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  36. data/lib/clacky/utils/model_pricing.rb +70 -4
  37. data/lib/clacky/version.rb +1 -1
  38. data/lib/clacky/web/app.css +412 -108
  39. data/lib/clacky/web/components/composer.js +45 -7
  40. data/lib/clacky/web/components/custom-select.js +136 -0
  41. data/lib/clacky/web/components/mentions.js +58 -2
  42. data/lib/clacky/web/components/model-picker.js +527 -0
  43. data/lib/clacky/web/components/onboard.js +29 -55
  44. data/lib/clacky/web/core/ext.js +38 -0
  45. data/lib/clacky/web/features/billing/view.js +20 -6
  46. data/lib/clacky/web/features/extensions/view.js +22 -10
  47. data/lib/clacky/web/features/new-session/store.js +15 -1
  48. data/lib/clacky/web/features/new-session/view.js +31 -22
  49. data/lib/clacky/web/features/trash/view.js +31 -19
  50. data/lib/clacky/web/features/workspace/store.js +1 -0
  51. data/lib/clacky/web/features/workspace/view.js +54 -3
  52. data/lib/clacky/web/i18n.js +73 -11
  53. data/lib/clacky/web/index.html +92 -37
  54. data/lib/clacky/web/sessions.js +550 -592
  55. data/lib/clacky/web/settings.js +163 -190
  56. data/lib/clacky.rb +3 -0
  57. metadata +8 -1
@@ -0,0 +1,409 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module MessageFormat
5
+ # Static helpers for the OpenAI Responses API (/v1/responses).
6
+ #
7
+ # The Responses API uses a different request/response shape than the
8
+ # classic Chat Completions API:
9
+ #
10
+ # - Request: `input` array of typed items (not `messages`)
11
+ # - Response: `output` array of typed items (not `choices[].message`)
12
+ # - Token limit field: `max_output_tokens` (not `max_tokens`)
13
+ # - Tool calls are standalone `function_call` output items
14
+ # - Tool results are `function_call_output` input items
15
+ # - Usage: `input_tokens` / `output_tokens` (not `prompt_tokens` / `completion_tokens`)
16
+ #
17
+ # This module converts between the canonical internal format (which is
18
+ # Chat Completions-shaped) and the Responses API shape.
19
+ module OpenAIResponses
20
+ module_function
21
+
22
+ # ── Request building ──────────────────────────────────────────────────────
23
+
24
+ # Build a Responses API request body from canonical messages.
25
+ #
26
+ # @param messages [Array<Hash>] canonical messages (OpenAI chat format)
27
+ # @param model [String]
28
+ # @param tools [Array<Hash>] OpenAI-style tool definitions
29
+ # @param max_tokens [Integer]
30
+ # @param caching_enabled [Boolean] kept for signature compatibility; the
31
+ # Responses API has no content-level cache_control field, so Clacky's
32
+ # cache markers are intentionally not applied on this path
33
+ # @param vision_supported [Boolean] whether the target model accepts images
34
+ # @param reasoning_effort [String, nil] reasoning effort level
35
+ # @return [Hash] Responses API request body
36
+ def build_request_body(messages, model, tools, max_tokens, _caching_enabled, vision_supported: true, reasoning_effort: nil)
37
+ input_items = messages.flat_map { |msg| convert_message_to_input_items(msg, vision_supported: vision_supported) }
38
+
39
+ body = {
40
+ model: model,
41
+ input: input_items,
42
+ max_output_tokens: max_tokens
43
+ }
44
+
45
+ if tools&.any?
46
+ # No cache_control markers here: the Responses API has no
47
+ # content-level cache_control field (that is Anthropic syntax).
48
+ # OpenAI's Responses prompt caching is automatic server-side;
49
+ # the request-level prompt_cache_key is a routing hint that does
50
+ # not map to Clacky's breakpoint convention. caching_enabled is
51
+ # kept in the signature for compatibility but intentionally
52
+ # unused on this path.
53
+ body[:tools] = convert_tools_to_responses_format(tools)
54
+ end
55
+
56
+ OpenAI.apply_reasoning_params(body, model, reasoning_effort)
57
+
58
+ body
59
+ end
60
+
61
+ # ── Canonical message -> Responses API input items ────────────────────────
62
+
63
+ # Convert a single canonical message into one or more Responses API
64
+ # input items.
65
+ #
66
+ # System messages become {type:"message", role:"developer"} items.
67
+ # Assistant messages with tool_calls are split into a message item
68
+ # plus separate function_call items.
69
+ # Tool result messages become function_call_output items.
70
+ #
71
+ # @param msg [Hash] canonical message
72
+ # @param vision_supported [Boolean]
73
+ # @return [Array<Hash>] Responses API input items
74
+ def convert_message_to_input_items(msg, vision_supported:)
75
+ role = msg[:role].to_s
76
+
77
+ # Tool result message -> function_call_output item
78
+ if role == "tool" && msg[:tool_call_id]
79
+ content = msg[:content]
80
+ content = JSON.generate(content) if content.is_a?(Array) || content.is_a?(Hash)
81
+ return [{
82
+ type: "function_call_output",
83
+ call_id: msg[:tool_call_id],
84
+ output: content.to_s
85
+ }]
86
+ end
87
+
88
+ items = []
89
+
90
+ # Map system -> developer (Responses API convention; "system" also works
91
+ # but "developer" is the documented role).
92
+ api_role = role == "system" ? "developer" : role
93
+
94
+ # Build the message item (skip when assistant message has no text
95
+ # content but only tool_calls - the Responses API rejects null content)
96
+ content = msg[:content]
97
+ has_content = content && !(content.is_a?(String) && content.empty?) &&
98
+ !(content.is_a?(Array) && content.empty?)
99
+
100
+ if has_content || role != "assistant"
101
+ items << {
102
+ type: "message",
103
+ role: api_role,
104
+ content: normalize_content(content, vision_supported: vision_supported)
105
+ }
106
+ end
107
+
108
+ # If the assistant message has tool_calls, emit separate function_call items
109
+ if role == "assistant" && msg[:tool_calls]
110
+ msg[:tool_calls].each do |tc|
111
+ func = tc[:function] || tc # Handle both nested and flat formats
112
+ items << {
113
+ type: "function_call",
114
+ call_id: tc[:id],
115
+ name: func[:name],
116
+ arguments: serialize_arguments(func[:arguments])
117
+ }
118
+ end
119
+ end
120
+
121
+ items
122
+ end
123
+
124
+ # Normalize canonical content to Responses API content format.
125
+ #
126
+ # String content passes through as-is.
127
+ # Array content: text blocks -> input_text, image_url -> input_image
128
+ # (non-vision models get a text placeholder for images).
129
+ #
130
+ # @param content [String, Array, nil]
131
+ # @param vision_supported [Boolean]
132
+ # @return [String, Array]
133
+ def normalize_content(content, vision_supported:)
134
+ return content.to_s unless content.is_a?(Array)
135
+
136
+ blocks = content.map { |b| normalize_block(b, vision_supported: vision_supported) }.compact
137
+ blocks = [{ type: "input_text", text: "..." }] if blocks.empty?
138
+ blocks
139
+ end
140
+
141
+ # Normalize a single content block for the Responses API input side.
142
+ #
143
+ # @param block [Hash] canonical content block
144
+ # @param vision_supported [Boolean]
145
+ # @return [Hash, nil]
146
+ def normalize_block(block, vision_supported:)
147
+ return block unless block.is_a?(Hash)
148
+
149
+ case block[:type]
150
+ when "text"
151
+ text = block[:text]
152
+ return nil if text.nil? || text.empty?
153
+
154
+ # Any cache_control marker (Anthropic syntax) is dropped here:
155
+ # the Responses API does not recognize content-level cache_control.
156
+ { type: "input_text", text: text }
157
+ when "image_url"
158
+ if vision_supported
159
+ # Responses API uses input_image with image_url sub-field
160
+ { type: "input_image", image_url: block[:image_url] }
161
+ else
162
+ { type: "input_text", text: "[Image content removed - current model does not support vision input]" }
163
+ end
164
+ else
165
+ block
166
+ end
167
+ end
168
+
169
+ # ── Response parsing ──────────────────────────────────────────────────────
170
+
171
+ # Parse a Responses API response into canonical internal format.
172
+ #
173
+ # @param data [Hash] parsed JSON response body
174
+ # @return [Hash] canonical response: { content, tool_calls, finish_reason, usage, raw_api_usage }
175
+ def parse_response(data)
176
+ output = data["output"] || []
177
+
178
+ # Extract text content from message-type output items
179
+ text_content = extract_output_text(output)
180
+
181
+ # Extract function calls from function_call-type output items
182
+ tool_calls = extract_function_calls(output)
183
+
184
+ # Parse usage (field names differ from Chat Completions)
185
+ usage = data["usage"] || {}
186
+ raw_api_usage = usage.dup
187
+
188
+ usage_data = {
189
+ prompt_tokens: usage["input_tokens"],
190
+ completion_tokens: usage["output_tokens"],
191
+ total_tokens: usage["total_tokens"] || (usage["input_tokens"].to_i + usage["output_tokens"].to_i)
192
+ }
193
+
194
+ # Responses API stores cache info under input_tokens_details
195
+ if (details = usage["input_tokens_details"])
196
+ usage_data[:cache_read_input_tokens] = details["cached_tokens"] if details["cached_tokens"].to_i > 0
197
+ usage_data[:cache_creation_input_tokens] = details["cache_write_tokens"] if details["cache_write_tokens"].to_i > 0
198
+ end
199
+
200
+ # OpenRouter may also send output_tokens_details
201
+ if (out_details = usage["output_tokens_details"])
202
+ usage_data[:reasoning_tokens] = out_details["reasoning_tokens"] if out_details["reasoning_tokens"].to_i > 0
203
+ end
204
+
205
+ usage_data[:api_cost] = usage["cost"] if usage["cost"]
206
+
207
+ # Determine finish_reason from status + output content
208
+ finish_reason = determine_finish_reason(data, tool_calls)
209
+
210
+ result = {
211
+ content: text_content,
212
+ tool_calls: tool_calls.empty? ? nil : tool_calls,
213
+ finish_reason: finish_reason,
214
+ usage: usage_data,
215
+ raw_api_usage: raw_api_usage
216
+ }
217
+
218
+ # Preserve reasoning content if present in the output
219
+ reasoning = extract_reasoning(output)
220
+ result[:reasoning_content] = reasoning if reasoning
221
+
222
+ result
223
+ end
224
+
225
+ # ── Tool result formatting ────────────────────────────────────────────────
226
+
227
+ # Format tool results into canonical messages to append to @messages.
228
+ # Returns canonical format (role: "tool") - conversion to Responses API
229
+ # function_call_output items happens inside convert_message_to_input_items
230
+ # on the next request.
231
+ #
232
+ # @param response [Hash] canonical response from parse_response
233
+ # @param tool_results [Array<Hash>] tool execution results
234
+ # @return [Array<Hash>] canonical tool messages
235
+ def format_tool_results(response, tool_results)
236
+ results_map = tool_results.each_with_object({}) { |r, h| h[r[:id]] = r }
237
+
238
+ response[:tool_calls].map do |tc|
239
+ result = results_map[tc[:id]]
240
+ raw_content = result ? result[:content] : { error: "Tool result missing" }.to_json
241
+
242
+ content = raw_content.is_a?(Array) ? JSON.generate(raw_content) : raw_content
243
+
244
+ {
245
+ role: "tool",
246
+ tool_call_id: tc[:id],
247
+ content: content
248
+ }
249
+ end
250
+ end
251
+
252
+ # ── Private helpers ───────────────────────────────────────────────────────
253
+
254
+ # The Responses API expects function_call arguments as a JSON *string*.
255
+ # Canonical tool_calls normally carry a JSON string already, but a Hash
256
+ # or Array value must be JSON-encoded — #to_s would emit a Ruby literal
257
+ # (e.g. {:city=>"Tokyo"}) that the tool side cannot parse.
258
+ private_class_method def self.serialize_arguments(arguments)
259
+ return JSON.generate(arguments) if arguments.is_a?(Hash) || arguments.is_a?(Array)
260
+
261
+ arguments.to_s
262
+ end
263
+
264
+ # Extract text content from output items.
265
+ # Looks for message-type items with output_text content blocks.
266
+ #
267
+ # @param output [Array<Hash>] response output items
268
+ # @return [String, nil]
269
+ private_class_method def self.extract_output_text(output)
270
+ texts = []
271
+
272
+ output.each do |item|
273
+ next unless item["type"] == "message"
274
+
275
+ content = item["content"]
276
+ next unless content.is_a?(Array)
277
+
278
+ content.each do |block|
279
+ next unless block["type"] == "output_text"
280
+ texts << block["text"].to_s
281
+ end
282
+ end
283
+
284
+ texts.empty? ? nil : texts.join
285
+ end
286
+
287
+ # Extract reasoning content from output items.
288
+ # Supports both shapes seen in the wild:
289
+ # 1. OpenAI official — a "reasoning" block inside a message item,
290
+ # with summary[] and/or content[] of reasoning_text blocks.
291
+ # 2. DeepSeek — a top-level "reasoning" item whose content array
292
+ # carries {"type":"reasoning_text","text":...} blocks.
293
+ # Prefers full reasoning text (reasoning_text blocks); falls back to
294
+ # summary text when only a summary is present.
295
+ #
296
+ # @param output [Array<Hash>] response output items
297
+ # @return [String, nil]
298
+ private_class_method def self.extract_reasoning(output)
299
+ texts = []
300
+
301
+ output.each do |item|
302
+ content = item["content"]
303
+
304
+ case item["type"]
305
+ when "reasoning"
306
+ next unless content.is_a?(Array)
307
+ before = texts.length
308
+ collect_reasoning_texts(content, texts)
309
+ collect_summary_texts(content, texts) if texts.length == before
310
+ when "message"
311
+ next unless content.is_a?(Array)
312
+ content.each do |block|
313
+ case block["type"]
314
+ when "reasoning"
315
+ before = texts.length
316
+ collect_reasoning_texts(block["content"], texts)
317
+ collect_summary_texts(block["summary"], texts) if texts.length == before
318
+ when "output_text"
319
+ # Some providers surface reasoning as output_text blocks
320
+ # carrying a summary attribute.
321
+ collect_summary_texts(block["summary"], texts)
322
+ end
323
+ end
324
+ end
325
+ end
326
+
327
+ texts.empty? ? nil : texts.join
328
+ end
329
+
330
+ private_class_method def self.collect_reasoning_texts(blocks, texts)
331
+ return unless blocks.is_a?(Array)
332
+ blocks.each do |b|
333
+ texts << b["text"].to_s if b.is_a?(Hash) && b["type"] == "reasoning_text" && b["text"]
334
+ end
335
+ end
336
+
337
+ private_class_method def self.collect_summary_texts(summary, texts)
338
+ return unless summary.is_a?(Array)
339
+ summary.each do |b|
340
+ texts << b["text"].to_s if b.is_a?(Hash) && b["type"] == "summary_text" && b["text"]
341
+ end
342
+ end
343
+
344
+ # Extract function calls from output items.
345
+ # Converts Responses API function_call items to canonical tool_calls format.
346
+ #
347
+ # @param output [Array<Hash>] response output items
348
+ # @return [Array<Hash>] canonical tool_calls
349
+ private_class_method def self.extract_function_calls(output)
350
+ # each_with_object instead of filter_map to stay Ruby 2.6 compatible.
351
+ output.each_with_object([]) do |item, calls|
352
+ next unless item["type"] == "function_call"
353
+
354
+ calls << {
355
+ id: item["call_id"],
356
+ type: "function",
357
+ name: item["name"],
358
+ arguments: item["arguments"].to_s
359
+ }
360
+ end
361
+ end
362
+
363
+ # Determine the canonical finish_reason from Responses API status + output.
364
+ #
365
+ # @param data [Hash] full response body
366
+ # @param tool_calls [Array<Hash>] extracted tool calls
367
+ # @return [String]
368
+ private_class_method def self.determine_finish_reason(data, tool_calls)
369
+ # If there are tool calls, the model wants to call tools
370
+ return "tool_calls" unless tool_calls.empty?
371
+
372
+ status = data["status"]
373
+ case status
374
+ when "completed" then "stop"
375
+ when "incomplete" then incomplete_finish_reason(data)
376
+ else "stop"
377
+ end
378
+ end
379
+
380
+ # Map an "incomplete" response to a canonical finish_reason via
381
+ # incomplete_details.reason: "content_filter" means the response was
382
+ # blocked by safety filtering (typically empty) and must not be
383
+ # mislabelled as a mere token limit ("length").
384
+ #
385
+ # @param data [Hash] full response body
386
+ # @return [String]
387
+ private_class_method def self.incomplete_finish_reason(data)
388
+ reason = data.dig("incomplete_details", "reason")
389
+ reason == "content_filter" ? "content_filter" : "length"
390
+ end
391
+ # Convert Chat Completions tool definitions to Responses API format.
392
+ # Chat Completions: {type: "function", function: {name:, description:, parameters:}}
393
+ # Responses API: {type: "function", name:, description:, parameters:}
394
+ # Tools already in flat format (no "function" key) pass through unchanged.
395
+ private_class_method def self.convert_tools_to_responses_format(tools)
396
+ tools.map do |tool|
397
+ func = tool[:function] || tool["function"]
398
+ next tool unless func.is_a?(Hash)
399
+ {
400
+ type: tool[:type] || tool["type"] || "function",
401
+ name: func[:name] || func["name"],
402
+ description: func[:description] || func["description"],
403
+ parameters: func[:parameters] || func["parameters"]
404
+ }
405
+ end
406
+ end
407
+ end
408
+ end
409
+ end
@@ -15,9 +15,11 @@ module Clacky
15
15
  subagent_instructions subagent_result subagent_transcript token_usage
16
16
  compressed_summary chunk_path truncated transient
17
17
  chunk_index chunk_count ext_events skill_command skill_command_display
18
- display_references
18
+ display_files display_references
19
19
  ].freeze
20
20
 
21
+ INTERNAL_CONTENT_BLOCK_FIELDS = %i[image_path image_name].freeze
22
+
21
23
  # Cap on persisted ext_events per message. These are milestone events
22
24
  # (progress chatter is transient), so a handful per message is the norm —
23
25
  # the cap only exists to stop a runaway extension. Oldest are dropped first.
@@ -358,7 +360,9 @@ module Clacky
358
360
  next nil
359
361
  end
360
362
 
361
- block.key?(:image_path) ? block.reject { |k, _| k == :image_path } : block
363
+ next block if (block.keys & INTERNAL_CONTENT_BLOCK_FIELDS).empty?
364
+
365
+ block.reject { |key, _| INTERNAL_CONTENT_BLOCK_FIELDS.include?(key) }
362
366
  end
363
367
 
364
368
  return msg if cleaned == content