openclacky 1.5.11 → 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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. metadata +12 -1
@@ -0,0 +1,231 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require_relative "../platform_http_client"
5
+
6
+ module Clacky
7
+ module Billing
8
+ # Authoritative usage data from the OpenClacky platform.
9
+ #
10
+ # The gateway records usage against real upstream model ids, while the
11
+ # client persists the user-facing alias (e.g. "dsk-deepseek-v4-pro" is
12
+ # stored as "deepseek-v4-pro" upstream). These tables bridge the two so
13
+ # the billing UI can merge platform data with local data under one name.
14
+ module PlatformBilling
15
+ # alias → real upstream model id, used to translate the model filter
16
+ # before querying the platform API. Vertex ids are preferred as the
17
+ # "primary" id where the gateway can dispatch an alias two ways.
18
+ ALIAS_TO_REAL = {
19
+ # deepseek (dsk-)
20
+ "dsk-deepseek-v4-pro" => "deepseek-v4-pro",
21
+ "dsk-deepseek-v4-flash" => "deepseek-v4-flash",
22
+ "dsk-deepseek-v4-flash-vision-exp" => "deepseek-v4-flash-vision-exp",
23
+ # claude via bedrock (abs-)
24
+ "abs-claude-fable-5-1" => "global.anthropic.claude-fable-5-1",
25
+ "abs-claude-fable-5" => "global.anthropic.claude-fable-5",
26
+ "abs-claude-opus-5" => "global.anthropic.claude-opus-5",
27
+ "abs-claude-opus-4-8" => "global.anthropic.claude-opus-4-8",
28
+ "abs-claude-opus-4-7" => "global.anthropic.claude-opus-4-7",
29
+ "abs-claude-opus-4-6" => "global.anthropic.claude-opus-4-6",
30
+ "abs-claude-sonnet-5" => "global.anthropic.claude-sonnet-5",
31
+ "abs-claude-sonnet-4-6" => "global.anthropic.claude-sonnet-4-6",
32
+ "abs-claude-sonnet-4-5" => "global.anthropic.claude-sonnet-4-5",
33
+ "abs-claude-haiku-4-5" => "global.anthropic.claude-haiku-4-5",
34
+ # gemini chat (or-)
35
+ "or-gemini-3-1-pro" => "gemini-3.1-pro-preview",
36
+ "or-gemini-3-8-flash" => "gemini-3.8-flash",
37
+ "or-gemini-3-7-flash" => "gemini-3.7-flash",
38
+ "or-gemini-3-6-flash" => "gemini-3.6-flash",
39
+ "or-gemini-3-5-flash" => "gemini-3.5-flash",
40
+ # image generation (or-)
41
+ "or-gemini-3-pro-image" => "gemini-3-pro-image",
42
+ "or-gemini-3-1-flash-image" => "gemini-3.1-flash-image",
43
+ "or-gpt-image-2" => "openai/gpt-5.4-image-2",
44
+ # video generation (or-)
45
+ "or-veo-3" => "veo-3.0-generate-001",
46
+ "or-veo-3-fast" => "veo-3.0-fast-generate-001",
47
+ "or-veo-3-1" => "veo-3.1-generate-001",
48
+ "or-veo-3-1-fast" => "veo-3.1-fast-generate-001",
49
+ # text-to-speech (or-)
50
+ "or-tts-gemini-2-5-flash" => "gemini-2.5-flash-tts",
51
+ "or-tts-gemini-2-5-pro" => "gemini-2.5-pro-tts",
52
+ # speech-to-text (or-)
53
+ "or-stt-gemini-3-8-flash" => "gemini-3.8-flash",
54
+ "or-stt-gemini-3-7-flash" => "gemini-3.7-flash",
55
+ "or-stt-gemini-3-6-flash" => "gemini-3.6-flash",
56
+ "or-stt-gemini-3-5-flash" => "gemini-3.5-flash",
57
+ "or-stt-gemini-1-5-pro" => "gemini-1.5-pro-002"
58
+ }.freeze
59
+
60
+ # real upstream model id → alias, for display. Includes the OpenRouter
61
+ # fallback ids (google/…-preview) the gateway records when Vertex is
62
+ # disabled, in addition to the primary Vertex ids in ALIAS_TO_REAL.
63
+ REAL_TO_ALIAS = ALIAS_TO_REAL.invert.merge(
64
+ "google/gemini-3.1-pro-preview" => "or-gemini-3-1-pro",
65
+ "google/gemini-3-pro-image-preview" => "or-gemini-3-pro-image",
66
+ # STT aliases reuse the chat real id; Hash#invert keeps the later STT
67
+ # key, so pin these back to the chat alias for display.
68
+ "gemini-3.8-flash" => "or-gemini-3-8-flash",
69
+ "gemini-3.7-flash" => "or-gemini-3-7-flash",
70
+ "gemini-3.6-flash" => "or-gemini-3-6-flash",
71
+ "gemini-3.5-flash" => "or-gemini-3-5-flash"
72
+ ).freeze
73
+
74
+ class << self
75
+ # Translate a real upstream model id back to the user-facing alias.
76
+ # Unknown ids pass through unchanged (other providers' local records).
77
+ def display_model(real_id)
78
+ REAL_TO_ALIAS.fetch(real_id, real_id)
79
+ end
80
+
81
+ # Translate a user-facing alias to the primary real upstream model id.
82
+ # Returns nil when the alias is not an openclacky model.
83
+ def real_model(alias_name)
84
+ ALIAS_TO_REAL[alias_name]
85
+ end
86
+
87
+ # Fetch and merge usage summaries across multiple openclacky keys
88
+ # (e.g. several accounts). The platform bills per key, so summing
89
+ # per-key results yields the combined total. Failing keys
90
+ # (invalid/revoked) are skipped; returns nil only when every key fails.
91
+ def fetch_summary_merged(api_keys, period:, model: nil)
92
+ summaries = Array(api_keys).uniq.map { |key| fetch_summary(key, period: period, model: model) }.compact
93
+ return nil if summaries.empty?
94
+
95
+ summaries.reduce { |acc, summary| merge_summaries(acc, summary) }
96
+ end
97
+
98
+ # Fetch and merge daily breakdowns across multiple openclacky keys.
99
+ # Same skip-on-failure semantics as fetch_summary_merged.
100
+ def fetch_daily_merged(api_keys, days:, model: nil)
101
+ dailies = Array(api_keys).uniq.map { |key| fetch_daily(key, days: days, model: model) }.compact
102
+ return nil if dailies.empty?
103
+
104
+ { days: merge_daily_entries(dailies.flat_map { |daily| daily[:days] }) }
105
+ end
106
+
107
+ # Fetch authoritative usage summary for the platform (openclacky).
108
+ # Returns a hash shaped like BillingStore#summary, or nil on failure.
109
+ def fetch_summary(api_key, period:, model: nil)
110
+ path = "/api/v1/usage/summary?period=#{period}"
111
+ path += "&model=#{URI.encode_www_form_component(model)}" if model && !model.empty?
112
+
113
+ data = request(api_key, path)
114
+ data && normalize_summary(data)
115
+ end
116
+
117
+ # Fetch authoritative daily breakdown for the platform (openclacky).
118
+ # Returns { days: [...] } or nil on failure.
119
+ def fetch_daily(api_key, days:, model: nil)
120
+ path = "/api/v1/usage/daily?days=#{days}"
121
+ path += "&model=#{URI.encode_www_form_component(model)}" if model && !model.empty?
122
+
123
+ data = request(api_key, path)
124
+ return nil unless data.is_a?(Hash)
125
+
126
+ days_data = data["days"] || data[:days] || []
127
+ days_data = days_data.map do |d|
128
+ next d unless d.is_a?(Hash)
129
+
130
+ align_prompt_tokens(d.transform_keys(&:to_sym))
131
+ end
132
+ { days: days_data }
133
+ end
134
+
135
+ private def request(api_key, path)
136
+ client = Clacky::PlatformHttpClient.new
137
+ result = client.get(path, headers: { "Authorization" => "Bearer #{api_key}" })
138
+ result[:success] ? result[:data] : nil
139
+ rescue StandardError
140
+ nil
141
+ end
142
+
143
+ private def merge_summaries(a, b)
144
+ merged = {}
145
+ (a.keys | b.keys).each do |key|
146
+ merged[key] =
147
+ case key
148
+ when :by_model then merge_by_model(a[:by_model], b[:by_model])
149
+ when :by_day then merge_by_day(a[:by_day], b[:by_day])
150
+ else merge_value(a[key], b[key])
151
+ end
152
+ end
153
+ merged
154
+ end
155
+
156
+ private def merge_by_model(a, b)
157
+ merged = {}
158
+ (a || {}).each { |model, entry| merged[model] = entry }
159
+ (b || {}).each do |model, entry|
160
+ merged[model] = merged.key?(model) ? merge_entries(merged[model], entry) : entry
161
+ end
162
+ merged
163
+ end
164
+
165
+ private def merge_by_day(a, b)
166
+ merged = {}
167
+ (a || {}).each { |date, cost| merged[date] = cost.to_f }
168
+ (b || {}).each { |date, cost| merged[date] = (merged[date] || 0.0) + cost.to_f }
169
+ merged
170
+ end
171
+
172
+ private def merge_daily_entries(entries)
173
+ by_date = {}
174
+ entries.each do |entry|
175
+ next unless entry.is_a?(Hash)
176
+
177
+ date = entry[:date] || entry["date"]
178
+ next if date.nil?
179
+
180
+ by_date[date] = by_date.key?(date) ? merge_entries(by_date[date], entry) : entry
181
+ end
182
+ by_date.values.sort_by { |entry| entry[:date].to_s }
183
+ end
184
+
185
+ private def merge_entries(a, b)
186
+ merged = {}
187
+ (a.keys | b.keys).each { |key| merged[key] = merge_value(a[key], b[key]) }
188
+ merged
189
+ end
190
+
191
+ # Sum numeric leaf fields. Non-numeric fields (period, from/to,
192
+ # source) are identical across keys — they come from the same query
193
+ # shape — so the first value wins.
194
+ private def merge_value(a, b)
195
+ return b if a.nil?
196
+ return a if b.nil?
197
+ return a + b if a.is_a?(Numeric) && b.is_a?(Numeric)
198
+
199
+ a
200
+ end
201
+
202
+ # Normalise the platform summary (string keys) to the same shape as
203
+ # BillingStore#summary (symbol keys, symbol-keyed by_model entries).
204
+ private def normalize_summary(data)
205
+ return nil unless data.is_a?(Hash)
206
+
207
+ normalized = data.transform_keys(&:to_sym)
208
+ if normalized[:by_model].is_a?(Hash)
209
+ normalized[:by_model] = normalized[:by_model].transform_values do |entry|
210
+ entry.is_a?(Hash) ? entry.transform_keys(&:to_sym) : entry
211
+ end
212
+ end
213
+ align_prompt_tokens(normalized)
214
+ normalized
215
+ end
216
+
217
+ # The platform reports prompt_tokens as Anthropic's input_tokens (the
218
+ # post-cache tail), while local records store prompt_tokens with
219
+ # cache_read already folded in. Fold cache_read back so prompt/total
220
+ # line up and the UI's `prompt - cache_read` never goes negative.
221
+ private def align_prompt_tokens(entry)
222
+ cache_read = entry[:cache_read_tokens].to_i
223
+ entry[:prompt_tokens] = entry[:prompt_tokens].to_i + cache_read
224
+ entry[:total_tokens] = entry[:total_tokens].to_i + cache_read if entry.key?(:total_tokens)
225
+ entry[:tokens] = entry[:tokens].to_i + cache_read if entry.key?(:tokens)
226
+ entry
227
+ end
228
+ end
229
+ end
230
+ end
231
+ end
@@ -912,14 +912,15 @@ module Clacky
912
912
  { success: false, error: e.message }
913
913
  end
914
914
 
915
- # Synchronise brand extensions in the background for activated installs.
915
+ # Synchronise brand extensions in the background for activated consumer
916
+ # installs. Brand administrators manage extensions through ext-studio and
917
+ # must not auto-install the extensions they publish for consumers.
916
918
  # Mirrors sync_brand_skills_async! but installs into the extension layer.
917
- # Unlike brand skills, new extensions are auto-installed because a bundled
918
- # extension is chosen by the brand administrator, not the end user.
919
919
  #
920
920
  # @return [Thread, nil]
921
921
  def sync_brand_extensions_async!(on_complete: nil)
922
922
  return nil unless activated?
923
+ return nil if user_licensed?
923
924
  return nil if ENV["CLACKY_TEST"] == "1"
924
925
 
925
926
  Clacky::ThreadRegistry.spawn(name: "brand-fetch-extensions") do
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ # A one-way, thread-safe cancellation flag shared across a parent agent and
5
+ # the subagents it forks. Fan-out runs each subagent on its own worker thread;
6
+ # when the parent is interrupted the worker threads must learn to stop, but
7
+ # they cannot see the parent's thread-local task epoch. Flipping this flag is
8
+ # the shared signal every subagent polls at its safe checkpoints.
9
+ class CancelFlag
10
+ def initialize
11
+ @mutex = Mutex.new
12
+ @cancelled = false
13
+ end
14
+
15
+ def cancel!
16
+ @mutex.synchronize { @cancelled = true }
17
+ end
18
+
19
+ def cancelled?
20
+ @mutex.synchronize { @cancelled }
21
+ end
22
+ end
23
+ end
data/lib/clacky/cli.rb CHANGED
@@ -1349,8 +1349,8 @@ module Clacky
1349
1349
 
1350
1350
  # ── Security gate ──────────────────────────────────────────────────────
1351
1351
  # Binding to 0.0.0.0 exposes the server to the public network.
1352
- # Refuse to start unless CLACKY_ACCESS_KEY env var is set.
1353
- if options[:host] == "0.0.0.0" && !ENV.key?("CLACKY_ACCESS_KEY")
1352
+ # Refuse to start unless an access key is available.
1353
+ if options[:host] == "0.0.0.0" && !ENV.key?("CLACKY_ACCESS_KEY") && Clacky::AccessKey.from_file.nil?
1354
1354
  puts <<~MSG
1355
1355
  ╔══════════════════════════════════════════════════════════════╗
1356
1356
  ║ ⚠️ Security Warning: Refusing to start ║
@@ -1365,6 +1365,8 @@ module Clacky
1365
1365
  ║ Then export it: ║
1366
1366
  ║ export CLACKY_ACCESS_KEY=<your-generated-key> ║
1367
1367
  ║ ║
1368
+ ║ Or write it to ~/.clacky/access_key ║
1369
+ ║ ║
1368
1370
  ╚══════════════════════════════════════════════════════════════╝
1369
1371
  MSG
1370
1372
  exit(1)
data/lib/clacky/client.rb CHANGED
@@ -44,6 +44,7 @@ module Clacky
44
44
  effective_api_format ||= "anthropic-messages" if anthropic_format
45
45
  resolved_type = Providers.api_type_for_model(provider_id, @model, user_override: effective_api_format)
46
46
  @use_anthropic_format = resolved_type == "anthropic-messages"
47
+ @use_responses_format = resolved_type == "openai-responses"
47
48
 
48
49
  # Remember the provider id so we can tune connection headers below
49
50
  # (OpenRouter's /v1/messages accepts either Bearer or x-api-key, but
@@ -66,6 +67,12 @@ module Clacky
66
67
  @use_anthropic_format && !@use_bedrock
67
68
  end
68
69
 
70
+ # Returns true when the client talks to the OpenAI Responses API
71
+ # (/v1/responses) instead of Chat Completions.
72
+ def responses_format?(model = nil)
73
+ @use_responses_format && !@use_bedrock
74
+ end
75
+
69
76
  # ── Connection test ───────────────────────────────────────────────────────
70
77
 
71
78
  # Test API connection by sending a minimal request.
@@ -81,6 +88,11 @@ module Clacky
81
88
  minimal_body = { model: api_model, max_tokens: 16,
82
89
  messages: [{ role: "user", content: "hi" }] }.to_json
83
90
  response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = minimal_body }
91
+ elsif responses_format?
92
+ minimal_body = MessageFormat::OpenAIResponses.build_request_body(
93
+ [{ role: "user", content: "hi" }], api_model, [], 16, false
94
+ ).to_json
95
+ response = openai_connection.post("responses") { |r| r.body = minimal_body }
84
96
  else
85
97
  minimal_body = { model: api_model, max_tokens: 16,
86
98
  messages: [{ role: "user", content: "hi" }] }.to_json
@@ -113,6 +125,10 @@ module Clacky
113
125
  body = MessageFormat::Anthropic.build_request_body(messages, api_model, [], max_tokens, false)
114
126
  response = anthropic_connection.post(anthropic_messages_path) { |r| r.body = body.to_json }
115
127
  parse_simple_anthropic_response(response)
128
+ elsif responses_format?
129
+ body = MessageFormat::OpenAIResponses.build_request_body(messages, api_model, [], max_tokens, false)
130
+ response = openai_connection.post("responses") { |r| r.body = body.to_json }
131
+ parse_simple_openai_responses_response(response)
116
132
  else
117
133
  body = MessageFormat::OpenAI.build_request_body(messages, api_model, [], max_tokens, false, reasoning_effort: reasoning_effort)
118
134
  response = openai_connection.post("chat/completions") { |r| r.body = body.to_json }
@@ -164,6 +180,9 @@ module Clacky
164
180
  elsif anthropic_format?
165
181
  streaming_used = !on_chunk.nil?
166
182
  send_anthropic_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk)
183
+ elsif responses_format?
184
+ streaming_used = !on_chunk.nil?
185
+ send_openai_responses_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk, capability_model: model)
167
186
  else
168
187
  streaming_used = !on_chunk.nil?
169
188
  send_openai_request(cloned, api_model, tools, max_tokens, caching_enabled, reasoning_effort: reasoning_effort, on_chunk: wrapped_on_chunk, capability_model: model)
@@ -206,6 +225,8 @@ module Clacky
206
225
  MessageFormat::Bedrock.format_tool_results(response, tool_results)
207
226
  elsif anthropic_format?
208
227
  MessageFormat::Anthropic.format_tool_results(response, tool_results)
228
+ elsif responses_format?
229
+ MessageFormat::OpenAIResponses.format_tool_results(response, tool_results)
209
230
  else
210
231
  MessageFormat::OpenAI.format_tool_results(response, tool_results)
211
232
  end
@@ -460,6 +481,92 @@ module Clacky
460
481
  content
461
482
  end
462
483
 
484
+ # ── OpenAI Responses API request / response ───────────────────────────────
485
+
486
+ def send_openai_responses_request(messages, model, tools, max_tokens, caching_enabled,
487
+ reasoning_effort: nil, on_chunk: nil, capability_model: nil)
488
+ # Override max_tokens when the model declares a higher output ceiling
489
+ model_for_limit = capability_model || model
490
+ model_limit = Providers.max_output_for(model_for_limit)
491
+ max_tokens = model_limit if model_limit
492
+
493
+ # Deliberately no apply_message_caching here: the Responses API does
494
+ # not recognize Anthropic-style cache_control markers, and OpenAI's
495
+ # Responses prompt caching is automatic server-side. Injecting
496
+ # cache_control would be silently ignored (or rejected by stricter
497
+ # endpoints).
498
+
499
+ cap_model = capability_model || model
500
+ body = MessageFormat::OpenAIResponses.build_request_body(
501
+ messages, model, tools, max_tokens, caching_enabled,
502
+ vision_supported: Providers.supports?(@provider_id, :vision, model_name: cap_model),
503
+ reasoning_effort: reasoning_effort
504
+ )
505
+ return send_openai_responses_stream_request(body, on_chunk) if on_chunk
506
+
507
+ response = openai_connection.post("responses") { |r| r.body = body.to_json }
508
+
509
+ raise_error(response) unless response.status == 200
510
+ check_html_response(response)
511
+
512
+ parsed_body = safe_json_parse(response.body, context: "LLM response")
513
+ MessageFormat::OpenAIResponses.parse_response(parsed_body)
514
+ end
515
+
516
+ # Streaming variant for the OpenAI Responses API.
517
+ # Posts to the "responses" endpoint with stream:true; the upstream returns
518
+ # typed SSE events (response.output_text.delta,
519
+ # response.function_call_arguments.delta, response.completed, etc.) that
520
+ # the aggregator reassembles into the non-streaming response shape.
521
+ private def send_openai_responses_stream_request(body, on_chunk)
522
+ stream_body = body.merge(stream: true)
523
+ aggregator = OpenAIResponsesStreamAggregator.new(on_chunk: on_chunk)
524
+ sse_buf = +""
525
+
526
+ response = openai_connection.post("responses") do |req|
527
+ req.headers["Accept"] = "text/event-stream"
528
+ req.body = stream_body.to_json
529
+ req.options.on_data = proc do |chunk, _bytes_received, _env|
530
+ sse_buf << chunk
531
+ drain_sse_frames(sse_buf) { |_event, data| aggregator.handle(data) }
532
+ end
533
+ end
534
+
535
+ unless response.status == 200
536
+ response.env.body = sse_buf if response.body.to_s.empty?
537
+ raise_error(response)
538
+ end
539
+
540
+ result = aggregator.to_h
541
+ log_stream_summary("openai-responses", aggregator, aggregator.saw_done? ? "completed" : nil)
542
+ # A complete Responses API stream always terminates with a
543
+ # response.completed / response.done (or response.incomplete) event.
544
+ # Its absence means the upstream cut the stream mid-response; retry
545
+ # rather than accept a silently truncated answer.
546
+ unless aggregator.saw_done?
547
+ raise Clacky::UpstreamTruncatedError,
548
+ "[LLM] Streaming response ended without response.completed (upstream cut the stream). Retrying..."
549
+ end
550
+ MessageFormat::OpenAIResponses.parse_response(result)
551
+ end
552
+
553
+ def parse_simple_openai_responses_response(response)
554
+ raise_error(response) unless response.status == 200
555
+ parsed_body = safe_json_parse(response.body, context: "LLM response")
556
+ result = MessageFormat::OpenAIResponses.parse_response(parsed_body)
557
+ content = result[:content]
558
+ if content.nil?
559
+ snippet = response.body.to_s[0, 1200]
560
+ if defined?(Clacky::Logger)
561
+ Clacky::Logger.warn("[parse_simple_openai_responses_response] no content. status=#{response.status} body=#{snippet}")
562
+ end
563
+ raise RetryableError,
564
+ "Upstream Responses API response missing text content. " \
565
+ "Body snippet: #{snippet}"
566
+ end
567
+ content
568
+ end
569
+
463
570
  # ── Prompt caching helpers ────────────────────────────────────────────────
464
571
 
465
572
  # Add cache_control markers to the last 2 messages in the array.
@@ -7,29 +7,35 @@ choose from.
7
7
 
8
8
  ## Output format (strict)
9
9
 
10
- Output exactly 3 options, one per line, in this format:
11
-
12
- - [full instruction] reason
13
-
14
- - full instruction: a complete, ready-to-send instruction for the agent — the
15
- user should be able to paste it into the input box and send it as-is, no
16
- editing. Write it as an imperative sentence.
17
- - reason: one short sentence (under ~20 words).
10
+ Respond with ONLY a JSON array of exactly 3 objects. No prose before or after,
11
+ no markdown code fence:
12
+
13
+ [{"action": "...", "reason": "..."}, {"action": "...", "reason": "..."}, {"action": "...", "reason": "..."}]
14
+
15
+ - action: a complete, ready-to-send instruction for the agent — the user
16
+ should be able to paste it into the input box and send it as-is, no
17
+ editing. Write it in the USER's voice, speaking TO the agent (e.g. "Run
18
+ the test suite and show me the failures"), never in the agent's voice
19
+ speaking to the user (e.g. "Tell me what you'd like to do" is wrong — the
20
+ user would be asking the agent to ask them). Put NOTHING here but the
21
+ instruction itself: no leading dash, no rationale, no trailing commentary.
22
+ - reason: one short sentence (under ~20 words) explaining why you suggest it.
18
23
  - The 3 options must be *mutually distinct directions* — e.g. one continue /
19
24
  fix path, one verification path, one wrap-up path. Do not rephrase the same
20
25
  idea three times.
21
- - If no reasonable options exist at all, output a single line: none
26
+ - If no reasonable options exist at all, respond with an empty array: []
22
27
 
23
28
  ## Hard rules
24
29
 
25
30
  1. Base every option ONLY on facts present in the brief. Never invent file
26
31
  names, tasks, or project content that the brief does not mention.
27
32
  2. When the user's latest message is vague and there were no tool calls
28
- (conversation just started, e.g. "hi"): your FIRST option must ask the user
29
- what they want to do (e.g. "Tell me what you'd like me to build or help
30
- with"). The other options may gently offer starting points, but only if
31
- the brief shows real content to start from — never fabricate an exploration
32
- target.
33
+ (conversation just started, e.g. "hi"): offer low-risk openers the user
34
+ could send right now, grounded in what the brief actually shows — e.g.
35
+ "Summarize what this project does and where its entry points are" when the
36
+ brief names a project. If the brief shows nothing to work from, respond
37
+ with an empty array rather than inventing an exploration target. Never
38
+ turn an option into a question aimed at the user.
33
39
  3. When the brief shows a clear task in progress (recent tool calls, files
34
40
  written, errors, tests), recommend directions tied to THAT task: continue
35
41
  the work, verify it (run the relevant tests), or wrap it up (review the
@@ -38,9 +44,9 @@ Output exactly 3 options, one per line, in this format:
38
44
 
39
45
  ## Judgement guide
40
46
 
41
- 1. Conversation just started, no tool calls → ask what the user wants first;
42
- optionally suggest a grounded starting point (only if the brief shows
43
- actual project content).
47
+ 1. Conversation just started, no tool calls → suggest grounded openers the
48
+ user can send as-is (only if the brief shows actual project content);
49
+ otherwise return [].
44
50
  2. Brief shows errors or tool failures → include a fix/continue option.
45
51
  3. Files were written but no tests have run → include a verification option.
46
52
  4. Task looks complete → include a wrap-up option (review diff, run the full
@@ -29,7 +29,25 @@ module Clacky
29
29
  CONVERSATION_TURNS = 8
30
30
  SUMMARY_LIMIT = 200
31
31
  MESSAGE_LIMIT = 200
32
-
32
+ MAX_OPTIONS = 3
33
+ ACTION_LIMIT = 500
34
+ REASON_LIMIT = 200
35
+
36
+ # Models copy the panel's own " · " separator when they drop the JSON
37
+ # format. Deliberately structural: a localized "Reason:" label would need
38
+ # one pattern per language, so those lines keep the label inside the action.
39
+ FALLBACK_REASON_SEPARATOR = /\s+·\s+/.freeze
40
+
41
+ # Some providers keep thinking inline in `content` (e.g. MiniMax), and the
42
+ # closing tag is missing whenever the reply gets cut short.
43
+ THINK_BLOCK = %r{<think>.*?</think>}m.freeze
44
+ UNCLOSED_THINK = /<think>[\s\S]*\z/.freeze
45
+
46
+ # A degraded line only counts as an option when the model marked it up as
47
+ # one: a list bullet or a [action] wrapper. Without this, reasoning prose
48
+ # turns into three bogus suggestions.
49
+ FALLBACK_LIST_MARKER = /\A(?:[-*•]|\d+[.)])\s+/.freeze
50
+ FALLBACK_BRACKETED = /\A\[([^\]]+)\]\s*(.*)\z/m.freeze
33
51
  class << self
34
52
  def enabled_for?(agent)
35
53
  return false if agent.instance_variable_get(:@is_subagent)
@@ -137,14 +155,16 @@ module Clacky
137
155
  private def analyze(snapshot)
138
156
  cfg = Advisor.config_for(@agent)
139
157
  advice = generate_advice(cfg, snapshot)
158
+ options = parse_options(advice)
140
159
  Clacky::Logger.info("[Advisor] analyze",
141
160
  session: @agent.session_id.to_s,
142
161
  len: advice.length,
162
+ options: options.size,
143
163
  head: advice[0, 100].to_s)
144
- if advice.empty? || advice.strip == "none"
164
+ if options.empty?
145
165
  @agent.emit_event("ext.advisor.done", reason: "empty")
146
166
  else
147
- push_advice(advice)
167
+ push_advice(options)
148
168
  end
149
169
  rescue StandardError => e
150
170
  warn_error("analyze", e)
@@ -217,8 +237,87 @@ module Clacky
217
237
  nil
218
238
  end
219
239
 
220
- private def push_advice(advice)
221
- @agent.emit_event("ext.advisor.recommendations", content: advice)
240
+ # Turn the raw model reply into [{ action:, reason: }, ...]. The JSON path
241
+ # is authoritative; the line-based path exists because weak models still
242
+ # drift out of the requested format, and a rough clickable option beats
243
+ # showing nothing.
244
+ private def parse_options(raw)
245
+ text = strip_thinking(raw.to_s).strip
246
+ return [] if text.empty?
247
+
248
+ array = extract_json_array(text)
249
+ return normalize_options(array) if array
250
+
251
+ fallback_options(text)
252
+ end
253
+
254
+ private def strip_thinking(text)
255
+ text.gsub(THINK_BLOCK, "").sub(UNCLOSED_THINK, "")
256
+ end
257
+
258
+ private def extract_json_array(text)
259
+ body = text
260
+ body = body.gsub(/\A```[a-zA-Z]*\n?/, "").gsub(/```\z/, "").strip if body.start_with?("```")
261
+ parsed = begin
262
+ JSON.parse(body)
263
+ rescue JSON::ParserError
264
+ # Models still wrap the array in a sentence or a stray code fence.
265
+ match = body.match(/\[[\s\S]*\]/)
266
+ match && begin
267
+ JSON.parse(match[0])
268
+ rescue JSON::ParserError
269
+ nil
270
+ end
271
+ end
272
+ parsed.is_a?(Array) ? parsed : nil
273
+ end
274
+
275
+ private def normalize_options(array)
276
+ options = []
277
+ array.each do |entry|
278
+ next unless entry.is_a?(Hash)
279
+
280
+ action = entry["action"].to_s.strip
281
+ next if action.empty?
282
+
283
+ options << { action: action[0, ACTION_LIMIT], reason: entry["reason"].to_s.strip[0, REASON_LIMIT] }
284
+ break if options.size >= MAX_OPTIONS
285
+ end
286
+ options
287
+ end
288
+
289
+ # One option per line, splitting the reason off so it never leaks into
290
+ # `action` — that text is sent verbatim to the agent when clicked. Only
291
+ # list items qualify, so a reply that is plain prose yields nothing.
292
+ private def fallback_options(text)
293
+ options = []
294
+ text.split("\n").each do |line|
295
+ action, reason = split_fallback_line(line)
296
+ next if action.empty?
297
+
298
+ options << { action: action[0, ACTION_LIMIT], reason: reason[0, REASON_LIMIT] }
299
+ break if options.size >= MAX_OPTIONS
300
+ end
301
+ options
302
+ end
303
+
304
+ private def split_fallback_line(line)
305
+ stripped = line.strip
306
+ listed = FALLBACK_LIST_MARKER.match(stripped)
307
+ body = listed ? stripped.sub(FALLBACK_LIST_MARKER, "") : stripped
308
+ return ["", ""] if body.empty?
309
+
310
+ bracketed = body.match(FALLBACK_BRACKETED)
311
+ return [bracketed[1].strip, bracketed[2].strip] if bracketed
312
+
313
+ return listed ? [body, ""] : ["", ""] unless FALLBACK_REASON_SEPARATOR.match(body)
314
+
315
+ action, reason = body.split(FALLBACK_REASON_SEPARATOR, 2)
316
+ [action.to_s.strip, reason.to_s.strip]
317
+ end
318
+
319
+ private def push_advice(options)
320
+ @agent.emit_event("ext.advisor.recommendations", options: options)
222
321
  end
223
322
 
224
323
  private def advisor_model_name(cfg)