openclacky 1.2.12 → 1.2.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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +1 -1
  3. data/.clacky/skills/gem-release/scripts/release.sh +4 -1
  4. data/CHANGELOG.md +23 -0
  5. data/lib/clacky/agent/llm_caller.rb +40 -25
  6. data/lib/clacky/agent/memory_updater.rb +12 -0
  7. data/lib/clacky/agent/skill_auto_creator.rb +7 -4
  8. data/lib/clacky/agent/skill_evolution.rb +23 -5
  9. data/lib/clacky/agent/skill_manager.rb +86 -1
  10. data/lib/clacky/agent/skill_reflector.rb +18 -23
  11. data/lib/clacky/agent.rb +9 -1
  12. data/lib/clacky/agent_config.rb +59 -15
  13. data/lib/clacky/cli.rb +55 -0
  14. data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
  15. data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
  16. data/lib/clacky/idle_compression_timer.rb +1 -1
  17. data/lib/clacky/message_format/open_ai.rb +7 -1
  18. data/lib/clacky/openai_stream_aggregator.rb +4 -1
  19. data/lib/clacky/providers.rb +40 -12
  20. data/lib/clacky/server/http_server.rb +117 -3
  21. data/lib/clacky/server/session_registry.rb +30 -8
  22. data/lib/clacky/server/web_ui_controller.rb +24 -1
  23. data/lib/clacky/session_manager.rb +120 -0
  24. data/lib/clacky/tools/web_search.rb +59 -8
  25. data/lib/clacky/ui2/layout_manager.rb +15 -5
  26. data/lib/clacky/ui2/progress_handle.rb +7 -1
  27. data/lib/clacky/ui2/ui_controller.rb +27 -0
  28. data/lib/clacky/ui_interface.rb +22 -0
  29. data/lib/clacky/utils/model_pricing.rb +96 -0
  30. data/lib/clacky/version.rb +1 -1
  31. data/lib/clacky/web/app.css +209 -4
  32. data/lib/clacky/web/app.js +6 -5
  33. data/lib/clacky/web/i18n.js +18 -4
  34. data/lib/clacky/web/index.html +2 -1
  35. data/lib/clacky/web/sessions.js +408 -80
  36. data/lib/clacky/web/settings.js +213 -51
  37. data/lib/clacky/web/skills.js +5 -14
  38. data/lib/clacky/web/utils.js +57 -0
  39. data/lib/clacky/web/ws-dispatcher.js +136 -0
  40. metadata +4 -2
@@ -3,6 +3,7 @@
3
3
  require "json"
4
4
  require "fileutils"
5
5
  require "securerandom"
6
+ require "open3"
6
7
 
7
8
  module Clacky
8
9
  class SessionManager
@@ -51,6 +52,27 @@ module Clacky
51
52
  all_sessions.find { |s| s[:session_id].to_s.start_with?(session_id.to_s) }
52
53
  end
53
54
 
55
+ # Fork a session: create a copy with new id, "(copy)" name suffix, and reset stats.
56
+ # Returns the forked session data hash, or nil if the original is not found.
57
+ def fork(session_id)
58
+ original = load(session_id)
59
+ return nil unless original
60
+
61
+ forked = original.dup
62
+ forked[:session_id] = self.class.generate_id
63
+ forked[:created_at] = Time.now.iso8601
64
+ forked[:updated_at] = Time.now.iso8601
65
+ forked[:pinned] = false
66
+ forked[:name] = "#{original[:name] || "Unnamed session"} (copy)"
67
+ forked[:stats] = (original[:stats] || {}).merge(
68
+ total_tasks: 0, total_iterations: 0, total_cost_usd: 0.0,
69
+ last_status: nil, last_error: nil
70
+ )
71
+
72
+ save(forked)
73
+ forked
74
+ end
75
+
54
76
  # Soft-delete: move session JSON + chunks to the session trash directory.
55
77
  # Returns true if found and moved, false if not found.
56
78
  def delete(session_id)
@@ -158,6 +180,104 @@ module Clacky
158
180
  limit ? sessions.first(limit) : sessions
159
181
  end
160
182
 
183
+ # Full-text grep over session JSON + chunk MD files.
184
+ # Case-sensitive: BSD grep -i is ~30x slower; Chinese has no case.
185
+ # Returns Hash<short_id String => snippet String> (snippet around the first match).
186
+ def search_content(query, timeout: 5)
187
+ q = query.to_s
188
+ return {} if q.strip.length < 2
189
+
190
+ files = Dir.glob(File.join(@sessions_dir, "*.json")) +
191
+ Dir.glob(File.join(@sessions_dir, "*-chunk-*.md"))
192
+ return {} if files.empty?
193
+
194
+ result = {}
195
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
196
+ each_grep_batch(files) do |batch|
197
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
198
+ break if remaining <= 0
199
+ out = run_with_timeout({ "LC_ALL" => "C" },
200
+ "grep", "-H", "-F", "-m", "1", "--",
201
+ q, *batch,
202
+ timeout: remaining)
203
+ next unless out
204
+ out.each_line do |line|
205
+ path, _, rest = line.chomp.partition(":")
206
+ next if path.empty? || rest.empty?
207
+ sid = extract_short_id(File.basename(path))
208
+ next unless sid
209
+ next if result.key?(sid)
210
+ result[sid] = build_snippet(rest, q)
211
+ end
212
+ end
213
+ result
214
+ end
215
+
216
+ # Yield file batches whose joined argv length stays well under ARG_MAX.
217
+ # macOS ARG_MAX is ~256 KiB; we cap at 96 KiB to leave room for env.
218
+ private def each_grep_batch(files, max_bytes: 96 * 1024)
219
+ batch = []
220
+ size = 0
221
+ files.each do |f|
222
+ len = f.bytesize + 1
223
+ if size + len > max_bytes && !batch.empty?
224
+ yield batch
225
+ batch = []
226
+ size = 0
227
+ end
228
+ batch << f
229
+ size += len
230
+ end
231
+ yield batch unless batch.empty?
232
+ end
233
+
234
+ private def build_snippet(line, query, radius: 80)
235
+ bytes = line.b
236
+ q = query.b
237
+ idx = bytes.index(q)
238
+ if idx.nil?
239
+ head = bytes.byteslice(0, radius * 2).to_s
240
+ return head.force_encoding("UTF-8").scrub("?").gsub(/\s+/, " ").strip
241
+ end
242
+
243
+ start_byte = [idx - radius, 0].max
244
+ stop_byte = [idx + q.bytesize + radius, bytes.bytesize].min
245
+ snippet = bytes.byteslice(start_byte, stop_byte - start_byte).to_s
246
+ snippet = snippet.force_encoding("UTF-8").scrub("?")
247
+ snippet = "…" + snippet if start_byte > 0
248
+ snippet = snippet + "…" if stop_byte < bytes.bytesize
249
+ snippet.gsub(/\s+/, " ").strip
250
+ end
251
+
252
+ private def run_with_timeout(env, *cmd, timeout:)
253
+ Open3.popen3(env, *cmd) do |stdin, stdout, stderr, wait_thr|
254
+ stdin.close
255
+ out = +""
256
+ reader = Thread.new { out << stdout.read }
257
+ drain = Thread.new { stderr.read }
258
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
259
+ loop do
260
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
261
+ break if remaining <= 0
262
+ break if wait_thr.join(remaining)
263
+ end
264
+ if wait_thr.alive?
265
+ Process.kill("TERM", wait_thr.pid) rescue nil
266
+ wait_thr.join(0.5)
267
+ Process.kill("KILL", wait_thr.pid) rescue nil if wait_thr.alive?
268
+ reader.kill; drain.kill
269
+ return nil
270
+ end
271
+ reader.join; drain.join
272
+ out
273
+ end
274
+ end
275
+
276
+ private def extract_short_id(basename)
277
+ m = basename.match(/-([0-9a-f]{8})(?:-chunk-\d+)?\.(?:json|md)\z/)
278
+ m && m[1]
279
+ end
280
+
161
281
  # Return the most recent session for a given working directory, or nil.
162
282
  def latest_for_directory(working_dir)
163
283
  all_sessions(current_dir: working_dir).first
@@ -120,17 +120,67 @@ module Clacky
120
120
 
121
121
  # ── Bing ───────────────────────────────────────────────────────────────
122
122
 
123
+ BING_ENDPOINTS = [
124
+ ["cn.bing.com", "zh-CN,zh;q=0.9,en;q=0.8"],
125
+ ["www.bing.com", "en-US,en;q=0.9"]
126
+ ].freeze
127
+
128
+ # Race both Bing endpoints in parallel and return the first relevant result.
129
+ # cn.bing.com works best from mainland China; www.bing.com works best from
130
+ # overseas. Racing avoids guessing the network egress and recovers from
131
+ # one endpoint temporarily returning anti-scrape filler. If both return
132
+ # irrelevant garbage, fall back to whichever came back non-empty.
123
133
  private def search_bing(query, max_results)
124
- encoded_query = CGI.escape(query)
125
- # cn.bing.com redirects to www.bing.com for non-China IPs (e.g. GitHub CI);
126
- # follow_redirects ensures both environments work with the same code path.
127
- url = URI("https://cn.bing.com/search?q=#{encoded_query}&count=#{max_results}")
128
- response = http_get(url, accept_language: "zh-CN,zh;q=0.9,en;q=0.8", follow_redirects: 2)
134
+ queue = Queue.new
135
+ threads = BING_ENDPOINTS.map do |host, lang|
136
+ Thread.new do
137
+ results = bing_fetch(host, lang, query, max_results)
138
+ queue.push([host, results])
139
+ rescue StandardError
140
+ queue.push([host, []])
141
+ end
142
+ end
143
+
144
+ winner = nil
145
+ runner_up = nil
146
+ BING_ENDPOINTS.length.times do
147
+ _host, results = queue.pop
148
+ if bing_results_relevant?(results, query)
149
+ winner = results
150
+ break
151
+ elsif !results.empty? && runner_up.nil?
152
+ runner_up = results
153
+ end
154
+ end
155
+
156
+ threads.each(&:kill)
157
+ winner || runner_up || []
158
+ end
159
+
160
+ private def bing_fetch(host, lang, query, max_results)
161
+ url = URI("https://#{host}/search?q=#{CGI.escape(query)}&count=#{max_results}&form=QBLH")
162
+ response = http_get(url, accept_language: lang, follow_redirects: 2,
163
+ referer: "https://#{host}/")
129
164
  return [] unless response.is_a?(Net::HTTPSuccess)
130
165
 
131
166
  parse_bing_html(response.body, max_results)
132
167
  end
133
168
 
169
+ # A real Bing answer mentions at least one query token in the titles or
170
+ # snippets. The anti-scrape fallback returns top-domain filler (Yandex,
171
+ # Bunnings, WikiLeaks, …) that shares nothing with the query.
172
+ private def bing_results_relevant?(results, query)
173
+ return false if results.empty?
174
+
175
+ tokens = query.downcase.scan(/[\p{L}\p{N}]+/).reject { |t| t.length < 2 }
176
+ return true if tokens.empty?
177
+
178
+ results.any? do |r|
179
+ haystack = "#{r[:title]} #{r[:snippet]}".downcase
180
+ tokens.any? { |t| haystack.include?(t) }
181
+ end
182
+ end
183
+
134
184
  private def parse_bing_html(html, max_results)
135
185
  results = []
136
186
  html = Clacky::Utils::Encoding.to_utf8(html)
@@ -199,7 +249,7 @@ module Clacky
199
249
 
200
250
  # Shared browser-like GET request — no Accept-Encoding to avoid gzip/br
201
251
  # detection tricks used by Bing. Supports redirect following.
202
- private def http_get(url, accept_language: "en-US,en;q=0.9", follow_redirects: 0)
252
+ private def http_get(url, accept_language: "en-US,en;q=0.9", follow_redirects: 0, referer: nil)
203
253
  request = Net::HTTP::Get.new(url)
204
254
  request["User-Agent"] = USER_AGENTS.sample
205
255
  request["Accept"] = "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8"
@@ -208,8 +258,9 @@ module Clacky
208
258
  # a JS-only skeleton (~39KB) instead of the real HTML results (~120KB)
209
259
  request["Sec-Fetch-Dest"] = "document"
210
260
  request["Sec-Fetch-Mode"] = "navigate"
211
- request["Sec-Fetch-Site"] = "none"
261
+ request["Sec-Fetch-Site"] = referer ? "same-origin" : "none"
212
262
  request["Upgrade-Insecure-Requests"] = "1"
263
+ request["Referer"] = referer if referer
213
264
 
214
265
  response = Net::HTTP.start(url.hostname, url.port,
215
266
  use_ssl: url.scheme == "https",
@@ -220,7 +271,7 @@ module Clacky
220
271
  if follow_redirects > 0 && response.is_a?(Net::HTTPRedirection)
221
272
  location = response["location"]
222
273
  redirect_url = location.start_with?("http") ? URI(location) : URI("#{url.scheme}://#{url.hostname}#{location}")
223
- return http_get(redirect_url, accept_language: accept_language, follow_redirects: follow_redirects - 1)
274
+ return http_get(redirect_url, accept_language: accept_language, follow_redirects: follow_redirects - 1, referer: referer)
224
275
  end
225
276
 
226
277
  response
@@ -119,18 +119,29 @@ module Clacky
119
119
 
120
120
  @render_mutex.synchronize do
121
121
  entry = @buffer.entry_by_id(id)
122
- # Skip if gone, fully committed, or only partially visible (its
123
- # prefix is already in terminal scrollback and cannot be edited).
124
- return if entry.nil? || entry.committed
125
- return if (entry.committed_line_offset || 0) > 0
122
+ if entry.nil?
123
+ Clacky::Logger.warn("[ph_debug] replace_entry_nil", id: id, content: content.to_s[0, 120])
124
+ return
125
+ end
126
+ if entry.committed
127
+ Clacky::Logger.warn("[ph_debug] replace_entry_committed", id: id, content: content.to_s[0, 120])
128
+ return
129
+ end
130
+ if (entry.committed_line_offset || 0) > 0
131
+ Clacky::Logger.warn("[ph_debug] replace_entry_partial", id: id, offset: entry.committed_line_offset, content: content.to_s[0, 120])
132
+ return
133
+ end
126
134
 
127
135
  old_lines = entry.lines.dup
128
136
  new_lines = wrap_content_to_lines(content)
129
137
  if old_lines == new_lines
138
+ Clacky::Logger.warn("[ph_debug] replace_entry_same", id: id)
130
139
  screen.flush
131
140
  return
132
141
  end
133
142
  @buffer.replace(id, new_lines)
143
+ is_tail = @buffer.live_entries.last&.id == id
144
+ Clacky::Logger.warn("[ph_debug] replace_entry_paint", id: id, is_tail: is_tail, old_n: old_lines.length, new_n: new_lines.length, content: content.to_s[0, 120])
134
145
 
135
146
  unless @fullscreen_mode
136
147
  # repaint_entry_in_place relies on the entry being the tail of
@@ -147,7 +158,6 @@ module Clacky
147
158
  # For non-tail replaces, fall back to a full rebuild of the
148
159
  # output area from the buffer. Slower, but correct regardless
149
160
  # of where the entry lives.
150
- is_tail = @buffer.live_entries.last&.id == id
151
161
  if is_tail
152
162
  repaint_entry_in_place(entry, old_lines, new_lines)
153
163
  else
@@ -177,8 +177,12 @@ module Clacky
177
177
  # @param final_message [String, nil] Optional override for the last
178
178
  # frame. If nil, the handle composes "<message>… (<elapsed>s)".
179
179
  def finish(final_message: nil)
180
+ Clacky::Logger.warn("[ph_debug] finish_entry", oid: object_id, state: @state, msg: @message, eid: @entry_id)
180
181
  snapshot = @monitor.synchronize do
181
- return if @state != :running
182
+ if @state != :running
183
+ Clacky::Logger.warn("[ph_debug] finish_noop_state", oid: object_id, state: @state)
184
+ return
185
+ end
182
186
  @state = :closed
183
187
  { message: final_message || @message, elapsed: elapsed_seconds }
184
188
  end
@@ -194,7 +198,9 @@ module Clacky
194
198
  else
195
199
  compose_final_frame(snapshot[:message], snapshot[:elapsed])
196
200
  end
201
+ Clacky::Logger.warn("[ph_debug] finish_unregister", oid: object_id, eid: @entry_id, final_frame: final_frame.to_s[0, 200])
197
202
  @owner.unregister_progress(self, final_frame: final_frame)
203
+ Clacky::Logger.warn("[ph_debug] finish_done", oid: object_id)
198
204
  end
199
205
  alias_method :cancel, :finish
200
206
 
@@ -655,6 +655,7 @@ module Clacky
655
655
 
656
656
  # Called by ProgressHandle#finish.
657
657
  def unregister_progress(handle, final_frame:)
658
+ Clacky::Logger.warn("[ph_debug] unreg_entry", oid: handle.object_id, eid: handle.entry_id, top: @progress_stack.last == handle, stack_size: @progress_stack.size, ff: final_frame.to_s[0, 200])
658
659
  @progress_mutex.synchronize do
659
660
  # If this handle still holds its entry (it's currently top), we
660
661
  # render one last frame there and release the id. If it was
@@ -662,10 +663,14 @@ module Clacky
662
663
  # is already gone and the final_frame is simply dropped.
663
664
  if handle.entry_id
664
665
  if final_frame && !final_frame.to_s.strip.empty?
666
+ Clacky::Logger.warn("[ph_debug] unreg_update_entry", oid: handle.object_id, eid: handle.entry_id)
665
667
  update_entry(handle.entry_id, @renderer.render_progress(final_frame))
666
668
  else
669
+ Clacky::Logger.warn("[ph_debug] unreg_remove_entry", oid: handle.object_id, eid: handle.entry_id)
667
670
  remove_entry(handle.entry_id)
668
671
  end
672
+ else
673
+ Clacky::Logger.warn("[ph_debug] unreg_no_entry_id", oid: handle.object_id)
669
674
  end
670
675
 
671
676
  @progress_stack.delete(handle)
@@ -873,6 +878,28 @@ module Clacky
873
878
  append_output(output)
874
879
  end
875
880
 
881
+ def phase_start(kind:, label:)
882
+ phase_id = SecureRandom.uuid
883
+ @active_phases ||= {}
884
+ @active_phases[phase_id] = { kind: kind, label: label, started_at: Time.now }
885
+ Thread.current[:clacky_phase_id] = phase_id
886
+
887
+ banner = "──────── ▼ #{label} ────────"
888
+ append_output(@renderer.render_system_message(banner, prefix_newline: true))
889
+ phase_id
890
+ end
891
+
892
+ def phase_end(phase_id, summary: nil)
893
+ Thread.current[:clacky_phase_id] = nil
894
+ return unless @active_phases&.key?(phase_id)
895
+
896
+ info = @active_phases.delete(phase_id)
897
+ label = info[:label]
898
+ tail = summary && !summary.to_s.strip.empty? ? " — #{summary.to_s.strip}" : ""
899
+ banner = "──────── ▲ #{label} done#{tail} ────────"
900
+ append_output(@renderer.render_system_message(banner, prefix_newline: false))
901
+ end
902
+
876
903
  # Set workspace status to idle (called when agent stops working)
877
904
  def set_idle_status
878
905
  # Safety net: close any legacy progress slots that were opened via
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Clacky
4
6
  # UIInterface defines the standard interface between Agent/CLI and UI implementations.
5
7
  # All UI controllers (UIController, JsonUIController) must implement these methods.
@@ -136,5 +138,25 @@ module Clacky
136
138
  # === Path redaction (for encrypted brand skill tmpdirs) ===
137
139
  # === Lifecycle ===
138
140
  def stop(clear_screen: false); end
141
+
142
+ # === Phase grouping (optional, web UI uses this to fold subagent runs) ===
143
+ # Begin a logical phase. Events emitted between phase_start and phase_end
144
+ # carry the phase_id so the UI can group them visually.
145
+ # Returns the phase_id (caller is responsible for passing it to phase_end).
146
+ def phase_start(kind:, label: nil)
147
+ SecureRandom.uuid
148
+ end
149
+
150
+ def phase_end(phase_id, summary: nil); end
151
+
152
+ # Run block within a phase. Always closes via ensure.
153
+ def with_phase(kind:, label: nil)
154
+ pid = phase_start(kind: kind, label: label)
155
+ begin
156
+ yield pid
157
+ ensure
158
+ phase_end(pid)
159
+ end
160
+ end
139
161
  end
140
162
  end
@@ -145,6 +145,47 @@ module Clacky
145
145
  }
146
146
  },
147
147
 
148
+ # Xiaomi MiMo — USD per 1M tokens, international (海外) list price.
149
+ # Source: https://platform.xiaomimimo.com/docs/zh-CN/price/pay-as-you-go
150
+ # Effective 2026-05-27 (V2.5 launch price cut). Cache write is "limited-
151
+ # time free" per Xiaomi's notice; per the project's "displayed ≤ actual"
152
+ # convention we bill writes at the input-miss rate so that when the
153
+ # promo ends users won't see a cost spike. Cache hits use the explicit
154
+ # cache-hit rate.
155
+ #
156
+ # As of 2026-06-01, mimo-v2-pro/omni are forwarded to the V2.5 series
157
+ # and billed at V2.5 rates; mimo-v2-pro mirrors mimo-v2.5-pro and
158
+ # mimo-v2-omni mirrors mimo-v2.5. Both will be retired 2026-06-30.
159
+ "mimo-v2.5-pro" => {
160
+ input: { default: 0.435, over_200k: 0.435 },
161
+ output: { default: 0.87, over_200k: 0.87 },
162
+ cache: { write: 0.435, read: 0.0036 }
163
+ },
164
+
165
+ "mimo-v2.5" => {
166
+ input: { default: 0.14, over_200k: 0.14 },
167
+ output: { default: 0.28, over_200k: 0.28 },
168
+ cache: { write: 0.14, read: 0.0028 }
169
+ },
170
+
171
+ "mimo-v2-pro" => {
172
+ input: { default: 0.435, over_200k: 0.435 },
173
+ output: { default: 0.87, over_200k: 0.87 },
174
+ cache: { write: 0.435, read: 0.0036 }
175
+ },
176
+
177
+ "mimo-v2-omni" => {
178
+ input: { default: 0.14, over_200k: 0.14 },
179
+ output: { default: 0.28, over_200k: 0.28 },
180
+ cache: { write: 0.14, read: 0.0028 }
181
+ },
182
+
183
+ "mimo-v2-flash" => {
184
+ input: { default: 0.10, over_200k: 0.10 },
185
+ output: { default: 0.30, over_200k: 0.30 },
186
+ cache: { write: 0.10, read: 0.01 }
187
+ },
188
+
148
189
  # Kimi K2.5 / K2.6 multimodal models
149
190
  # Source: https://platform.moonshot.cn (USD / 1M tokens)
150
191
  # Kimi billing model (same shape as DeepSeek):
@@ -181,6 +222,38 @@ module Clacky
181
222
  }
182
223
  },
183
224
 
225
+ # Google Gemini 3 series (via Vertex AI). Tiered at 200K input tokens
226
+ # for Pro; Flash has flat pricing.
227
+ "gemini-3.1-pro" => {
228
+ input: {
229
+ default: 2.00,
230
+ over_200k: 4.00
231
+ },
232
+ output: {
233
+ default: 12.00,
234
+ over_200k: 18.00
235
+ },
236
+ cache: {
237
+ write: 2.00,
238
+ read: 0.50
239
+ }
240
+ },
241
+
242
+ "gemini-3-flash" => {
243
+ input: {
244
+ default: 0.50,
245
+ over_200k: 0.50
246
+ },
247
+ output: {
248
+ default: 3.00,
249
+ over_200k: 3.00
250
+ },
251
+ cache: {
252
+ write: 0.50,
253
+ read: 0.05
254
+ }
255
+ },
256
+
184
257
  # OpenAI GPT-5.5 / GPT-5.4 — breakpoint at 272K input tokens
185
258
  # Source: https://openai.com/api/pricing/ (USD / 1M tokens)
186
259
  # Note: OpenAI's actual tiered-pricing threshold is 272K, not the
@@ -581,6 +654,22 @@ module Clacky
581
654
  # non-thinking / thinking modes respectively. Bill at flash rates.
582
655
  when /^deepseek-chat$/i, /^deepseek-reasoner$/i
583
656
  "deepseek-v4-flash"
657
+ # Xiaomi MiMo — strict anchored match per registered model id in
658
+ # providers.rb (currently mimo-v2.5-pro / mimo-v2-pro / mimo-v2-omni).
659
+ # mimo-v2.5 / mimo-v2-flash are also priced ahead of provider-side
660
+ # registration. Per Xiaomi's 2026-06 schedule, mimo-v2-pro/omni are
661
+ # transparently routed to V2.5 — keys are listed independently so
662
+ # both old and new ids resolve to the right rate.
663
+ when /^mimo-v2\.?5-pro$/i
664
+ "mimo-v2.5-pro"
665
+ when /^mimo-v2\.?5$/i
666
+ "mimo-v2.5"
667
+ when /^mimo-v2-pro$/i
668
+ "mimo-v2-pro"
669
+ when /^mimo-v2-omni$/i
670
+ "mimo-v2-omni"
671
+ when /^mimo-v2-flash$/i
672
+ "mimo-v2-flash"
584
673
  # Kimi K2.5 / K2.6 — strict match only. K2 text-only models
585
674
  # (kimi-k2-0905-preview, kimi-k2-thinking, etc.) are not yet
586
675
  # registered in providers.rb and will be added in a follow-up
@@ -636,6 +725,13 @@ module Clacky
636
725
  when /^qwen3-vl-plus$/i
637
726
  "qwen3-vl-plus"
638
727
 
728
+ # Google Gemini 3 series. Match the platform aliases (or-gemini-*)
729
+ # and the bare upstream ids returned by Vertex.
730
+ when /^or-gemini-3-1-pro$/i, /^gemini-3\.1-pro(-preview)?$/i
731
+ "gemini-3.1-pro"
732
+ when /^or-gemini-3-5-flash$/i, /^gemini-3\.5-flash$/i, /^gemini-3-flash(-preview)?$/i
733
+ "gemini-3-flash"
734
+
639
735
  # OpenAI GPT-5.x models — match various dashed/dotted/compact forms
640
736
  # (e.g. "gpt-5.5", "gpt-5-5", "gpt5.5", "gpt55")
641
737
  when /^gpt-?5\.?5$/i, /^gpt-?5[\.-]?5$/i
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.12"
4
+ VERSION = "1.2.13"
5
5
  end