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.
- checksums.yaml +4 -4
- data/.clacky/skills/gem-release/SKILL.md +1 -1
- data/.clacky/skills/gem-release/scripts/release.sh +4 -1
- data/CHANGELOG.md +23 -0
- data/lib/clacky/agent/llm_caller.rb +40 -25
- data/lib/clacky/agent/memory_updater.rb +12 -0
- data/lib/clacky/agent/skill_auto_creator.rb +7 -4
- data/lib/clacky/agent/skill_evolution.rb +23 -5
- data/lib/clacky/agent/skill_manager.rb +86 -1
- data/lib/clacky/agent/skill_reflector.rb +18 -23
- data/lib/clacky/agent.rb +9 -1
- data/lib/clacky/agent_config.rb +59 -15
- data/lib/clacky/cli.rb +55 -0
- data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
- data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
- data/lib/clacky/idle_compression_timer.rb +1 -1
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/openai_stream_aggregator.rb +4 -1
- data/lib/clacky/providers.rb +40 -12
- data/lib/clacky/server/http_server.rb +117 -3
- data/lib/clacky/server/session_registry.rb +30 -8
- data/lib/clacky/server/web_ui_controller.rb +24 -1
- data/lib/clacky/session_manager.rb +120 -0
- data/lib/clacky/tools/web_search.rb +59 -8
- data/lib/clacky/ui2/layout_manager.rb +15 -5
- data/lib/clacky/ui2/progress_handle.rb +7 -1
- data/lib/clacky/ui2/ui_controller.rb +27 -0
- data/lib/clacky/ui_interface.rb +22 -0
- data/lib/clacky/utils/model_pricing.rb +96 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +209 -4
- data/lib/clacky/web/app.js +6 -5
- data/lib/clacky/web/i18n.js +18 -4
- data/lib/clacky/web/index.html +2 -1
- data/lib/clacky/web/sessions.js +408 -80
- data/lib/clacky/web/settings.js +213 -51
- data/lib/clacky/web/skills.js +5 -14
- data/lib/clacky/web/utils.js +57 -0
- data/lib/clacky/web/ws-dispatcher.js +136 -0
- 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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -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
|
data/lib/clacky/version.rb
CHANGED