openclacky 1.2.5 → 1.2.6

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.
@@ -39,6 +39,17 @@ module Clacky
39
39
  msg[:content].select { |b| b[:type] == "tool_result" }.map { |b| b[:tool_use_id] }
40
40
  end
41
41
 
42
+ # Anthropic requires tool_use.id to match ^[a-zA-Z0-9_-]+$ (max 128 chars).
43
+ # Some OpenAI-compatible upstreams (e.g. kimi-k2.6) return ids like "tool_name:0"
44
+ # — fine for OpenAI, rejected by Anthropic. We replace illegal chars with "_"
45
+ # at the format boundary so ids stay self-consistent across use/result pairs
46
+ # (pure function → same input maps to same output in both directions).
47
+ def sanitize_tool_use_id(id)
48
+ s = id.to_s
49
+ s = s.gsub(/[^a-zA-Z0-9_-]/, "_")
50
+ s.length > 128 ? s[0, 128] : s
51
+ end
52
+
42
53
  # ── Request building ──────────────────────────────────────────────────────
43
54
 
44
55
  # Convert canonical @messages + tools into an Anthropic API request body.
@@ -156,7 +167,6 @@ module Clacky
156
167
  end
157
168
 
158
169
  # ── Tool result formatting ────────────────────────────────────────────────
159
-
160
170
  # Format tool results into canonical messages to append to @messages.
161
171
  # Input: response (canonical, has :tool_calls), tool_results array
162
172
  # Output: canonical messages: [{ role: "tool", tool_call_id:, content: }]
@@ -211,7 +221,7 @@ module Clacky
211
221
  else
212
222
  raw_args
213
223
  end
214
- blocks << { type: "tool_use", id: tc[:id], name: name, input: input || {} }
224
+ blocks << { type: "tool_use", id: sanitize_tool_use_id(tc[:id]), name: name, input: input || {} }
215
225
  end
216
226
 
217
227
  return { role: "assistant", content: blocks }
@@ -250,7 +260,7 @@ module Clacky
250
260
  else
251
261
  raw_content
252
262
  end
253
- block = { type: "tool_result", tool_use_id: msg[:tool_call_id], content: tool_content }
263
+ block = { type: "tool_result", tool_use_id: sanitize_tool_use_id(msg[:tool_call_id]), content: tool_content }
254
264
  block[:cache_control] = hoisted_cache_control if hoisted_cache_control
255
265
  return { role: "user", content: [block] }
256
266
  end
@@ -187,7 +187,7 @@ module Clacky
187
187
  name = func[:name] || tc[:name]
188
188
  raw_args = func[:arguments] || tc[:arguments]
189
189
  input = raw_args.is_a?(String) ? (JSON.parse(raw_args) rescue {}) : (raw_args || {})
190
- blocks << { toolUse: { toolUseId: tc[:id], name: name, input: input } }
190
+ blocks << { toolUse: { toolUseId: Anthropic.sanitize_tool_use_id(tc[:id]), name: name, input: input } }
191
191
  end
192
192
 
193
193
  return { role: "assistant", content: blocks }
@@ -208,7 +208,7 @@ module Clacky
208
208
  end
209
209
  return {
210
210
  role: "user",
211
- content: [{ toolResult: { toolUseId: msg[:tool_call_id], content: result_blocks } }]
211
+ content: [{ toolResult: { toolUseId: Anthropic.sanitize_tool_use_id(msg[:tool_call_id]), content: result_blocks } }]
212
212
  }
213
213
  end
214
214
 
@@ -228,12 +228,17 @@ module Clacky
228
228
  h.request(req) do |resp|
229
229
  case resp.code.to_i
230
230
  when 200
231
+ expected_len = resp["content-length"]&.to_i
231
232
  File.open(dest, "wb") do |f|
232
233
  resp.read_body do |chunk|
233
234
  f.write(chunk)
234
235
  written += chunk.bytesize
235
236
  end
236
237
  end
238
+ if expected_len && expected_len > 0 && written != expected_len
239
+ raise RetryableNetworkError,
240
+ "Truncated download: got #{written} bytes, expected #{expected_len}"
241
+ end
237
242
  when 301, 302, 303, 307, 308
238
243
  location = resp["location"]
239
244
  raise RetryableNetworkError, "Redirect with no Location header" if location.nil? || location.empty?
@@ -354,13 +359,35 @@ module Clacky
354
359
  { success: true, data: body["data"] || body }
355
360
  else
356
361
  error_code = body["code"]
362
+ server_msg = extract_server_error_message(body)
357
363
  error_msg = API_ERROR_MESSAGES[error_code] ||
358
- body["error"] ||
364
+ server_msg ||
359
365
  "Request failed (HTTP #{code}#{error_code ? ", code: #{error_code}" : ""}). Please contact support."
360
366
  { success: false, error: error_msg, data: body }
361
367
  end
362
368
  end
363
369
 
370
+ # Server error messages can come back under different keys / shapes:
371
+ # { "error": "msg" } — single string
372
+ # { "errors": ["msg1", "msg2"] } — array of strings (Rails .errors.full_messages)
373
+ # { "errors": "msg" } — string (less common)
374
+ # { "message": "msg" } — alternative key
375
+ # Returns the first non-blank human-readable string, or nil if none.
376
+ private def extract_server_error_message(body)
377
+ return nil unless body.is_a?(Hash)
378
+
379
+ [body["error"], body["errors"], body["message"]].each do |val|
380
+ case val
381
+ when String
382
+ return val unless val.strip.empty?
383
+ when Array
384
+ joined = val.compact.map(&:to_s).reject(&:empty?).join("; ")
385
+ return joined unless joined.empty?
386
+ end
387
+ end
388
+ nil
389
+ end
390
+
364
391
  # Raised for transient failures that should be retried (timeouts, conn resets, SSL errors).
365
392
  class RetryableNetworkError < StandardError; end
366
393
  end
@@ -237,33 +237,6 @@ module Clacky
237
237
  "website_url" => "https://console.anthropic.com/settings/keys"
238
238
  }.freeze,
239
239
 
240
- "clackyai-sea" => {
241
- "name" => "ClackyAI(Sea)",
242
- "base_url" => "https://api.clacky.ai",
243
- "api" => "bedrock",
244
- "default_model" => "abs-claude-sonnet-4-5",
245
- "models" => [
246
- "abs-claude-opus-4-6",
247
- "abs-claude-sonnet-4-6",
248
- "abs-claude-sonnet-4-5",
249
- "abs-claude-haiku-4-5"
250
- ],
251
- # Claude family — all vision-capable.
252
- "capabilities" => { "vision" => true }.freeze,
253
- # Per-primary lite pairing — see openclacky preset for rationale.
254
- "lite_models" => {
255
- "abs-claude-opus-4-6" => "abs-claude-haiku-4-5",
256
- "abs-claude-sonnet-4-6" => "abs-claude-haiku-4-5",
257
- "abs-claude-sonnet-4-5" => "abs-claude-haiku-4-5"
258
- },
259
- # Fallback chain: if a model is unavailable, try the next one in order.
260
- # Keys are primary model names; values are the fallback model to use instead.
261
- "fallback_models" => {
262
- "abs-claude-sonnet-4-6" => "abs-claude-sonnet-4-5"
263
- },
264
- "website_url" => "https://clacky.ai"
265
- }.freeze,
266
-
267
240
  "mimo" => {
268
241
  "name" => "MiMo (Xiaomi)",
269
242
  "base_url" => "https://api.xiaomimimo.com/v1",
@@ -436,6 +436,7 @@ module Clacky
436
436
  when ["GET", "/api/billing/summary"] then api_billing_summary(req, res)
437
437
  when ["GET", "/api/billing/daily"] then api_billing_daily(req, res)
438
438
  when ["GET", "/api/billing/records"] then api_billing_records(req, res)
439
+ when ["DELETE", "/api/billing/clear"] then api_billing_clear(req, res)
439
440
  when ["PATCH", "/api/sessions/:id/model"] then api_switch_session_model(req, res)
440
441
  when ["PATCH", "/api/sessions/:id/working_dir"] then api_change_session_working_dir(req, res)
441
442
  else
@@ -1113,30 +1114,32 @@ module Clacky
1113
1114
 
1114
1115
  # GET /api/billing/summary
1115
1116
  # Returns billing summary for a time period
1116
- # Query params: period (day|week|month|year|all, default: month)
1117
+ # Query params: period (day|week|month|year|all, default: month), model (optional)
1117
1118
  def api_billing_summary(req, res)
1118
1119
  require_relative "../billing/billing_store"
1119
1120
 
1120
1121
  query = URI.decode_www_form(req.query_string.to_s).to_h
1121
1122
  period = (query["period"] || "month").to_sym
1123
+ model = query["model"]
1122
1124
 
1123
1125
  store = Clacky::Billing::BillingStore.new
1124
- summary = store.summary(period: period)
1126
+ summary = store.summary(period: period, model: model)
1125
1127
 
1126
1128
  json_response(res, 200, summary)
1127
1129
  end
1128
1130
 
1129
1131
  # GET /api/billing/daily
1130
1132
  # Returns daily cost breakdown
1131
- # Query params: days (default: 30)
1133
+ # Query params: days (default: 30), model (optional)
1132
1134
  def api_billing_daily(req, res)
1133
1135
  require_relative "../billing/billing_store"
1134
1136
 
1135
1137
  query = URI.decode_www_form(req.query_string.to_s).to_h
1136
1138
  days = [(query["days"] || "30").to_i, 90].min
1139
+ model = query["model"]
1137
1140
 
1138
1141
  store = Clacky::Billing::BillingStore.new
1139
- daily = store.daily_breakdown(days: days)
1142
+ daily = store.daily_breakdown(days: days, model: model)
1140
1143
 
1141
1144
  json_response(res, 200, { days: daily })
1142
1145
  end
@@ -1161,6 +1164,23 @@ module Clacky
1161
1164
  })
1162
1165
  end
1163
1166
 
1167
+ # DELETE /api/billing/clear
1168
+ # Clears billing records
1169
+ # Query params: scope (today|all, default: today)
1170
+ def api_billing_clear(req, res)
1171
+ require_relative "../billing/billing_store"
1172
+
1173
+ query = URI.decode_www_form(req.query_string.to_s).to_h
1174
+ scope = query["scope"] || "today"
1175
+
1176
+ store = Clacky::Billing::BillingStore.new
1177
+ deleted = store.clear(scope: scope.to_sym)
1178
+
1179
+ json_response(res, 200, { ok: true, deleted: deleted, scope: scope })
1180
+ rescue => e
1181
+ json_response(res, 500, { error: e.message })
1182
+ end
1183
+
1164
1184
  # GET /api/version
1165
1185
  # Returns current version and latest version from RubyGems (cached for 1 hour).
1166
1186
  def api_get_version(res)
@@ -35,7 +35,7 @@ module Clacky
35
35
  Workflow: open → snapshot(interactive:true) → act(ref=...). New tab from `open` is auto-selected; only use `focus` to switch back to a previously-opened tab.
36
36
  snapshot: returns hierarchical a11y tree truncated to ~8KB. Use query="text" to seek, or offset=N to page.
37
37
  act kinds: click | dblclick | type | fill | press | hover | scroll | drag | select | wait | evaluate | click_at
38
- evaluate: `js` is a function body, e.g. `return document.title` or `const x=...; return x;` result is JSON-encoded.
38
+ evaluate: `js` is a JS expression, e.g. "document.title". For multi-line / async logic use an IIFE: "(async () => { const r = await fetch(...); return r.status })()". Result is JSON-encoded.
39
39
  screenshot: expensive — pass `ref` to capture one element instead of the whole page.
40
40
  DESC
41
41
  self.tool_category = "web"
@@ -58,7 +58,7 @@ module Clacky
58
58
  amount: { type: "integer", description: "act scroll pixels (default 300)" },
59
59
  ms: { type: "integer", description: "act wait ms" },
60
60
  selector: { type: "string", description: "act wait: text or CSS selector" },
61
- js: { type: "string", description: "act evaluate: JS function body (use return)" },
61
+ js: { type: "string", description: "act evaluate: JS expression (use IIFE for multi-line/async)" },
62
62
  target_ref: { type: "string", description: "act drag destination ref" },
63
63
  values: { type: "array", items: { type: "string" }, description: "act select options" },
64
64
  x: { type: "number", description: "click_at x px" },
@@ -264,7 +264,7 @@ module Clacky
264
264
  url = require_url(opts)
265
265
  return url if url.is_a?(Hash)
266
266
  invalidate_page_cache!
267
- mcp_call("navigate_page", { type: "url", url: url })
267
+ mcp_call("navigate_page", with_page({ type: "url", url: url }))
268
268
  wait_for_page_ready
269
269
  { action: "navigate", success: true, profile: "user", url: url, output: "Navigated to: #{url}" }
270
270
 
@@ -385,19 +385,10 @@ module Clacky
385
385
  pid ? args.merge(pageId: pid) : args
386
386
  end
387
387
 
388
- # Wrap user-supplied JS as a Chrome-DevTools-MCP `function` argument.
389
- # We treat `js` as a function body so users can write `const x = ...; return x;`
390
- # naturally. For pure expressions ("document.title"), we auto-prepend `return`
391
- # so the result still flows back. Detection is conservative — the presence of
392
- # `return` or any top-level statement keyword skips the auto-return.
393
388
  private def build_evaluate_function(js)
394
389
  body = js.to_s.strip
395
390
  return "() => {}" if body.empty?
396
-
397
- looks_like_statement = body.match?(/(^|[\s;{])(return|const|let|var|if|for|while|throw|try|switch|function|class|do|await|async)\b/) ||
398
- body.include?(";")
399
- body = "return (#{body})" unless looks_like_statement
400
- "() => { #{body} }"
391
+ "() => (#{body})"
401
392
  end
402
393
 
403
394
  SCREENSHOT_MAX_WIDTH = 800
@@ -1513,6 +1513,7 @@ module Clacky
1513
1513
  # Detect .exe invocations and redirect stdin from /dev/null unless
1514
1514
  # the command already has an explicit stdin redirect.
1515
1515
  private def redirect_exe_stdin(command)
1516
+ return command unless Clacky::Utils::EnvironmentDetector.wsl?
1516
1517
  return command unless command =~ /\.exe\b/i
1517
1518
  return command if command =~ /<\s*[^\s|&;]/
1518
1519
 
@@ -1531,8 +1532,7 @@ module Clacky
1531
1532
  # `???`. Inject UTF-8 setup into the user's PowerShell command so the
1532
1533
  # shell emits UTF-8 bytes regardless of host locale.
1533
1534
  POWERSHELL_PREAMBLE =
1534
- "[Console]::OutputEncoding=[Text.Encoding]::UTF8;" \
1535
- "$OutputEncoding=[Text.Encoding]::UTF8;"
1535
+ "[Console]::OutputEncoding=[Text.Encoding]::UTF8;"
1536
1536
 
1537
1537
  # Only rewrites simple `powershell[.exe]` / `pwsh[.exe]` invocations.
1538
1538
  # Skips -File / -EncodedCommand / commands already handling encoding /
@@ -43,10 +43,12 @@ module Clacky
43
43
  .mp3 .mp4 .avi .mov .mkv .wav .flac
44
44
  .ttf .otf .woff .woff2
45
45
  .db .sqlite .bin .dat
46
+ .wps .et .dps
46
47
  ].freeze
47
48
 
48
49
  GLOB_ALLOWED_BINARY_EXTENSIONS = %w[
49
50
  .pdf .doc .docx .ppt .pptx .xls .xlsx .odt .odp .ods
51
+ .wps .et .dps
50
52
  ].freeze
51
53
 
52
54
  LLM_BINARY_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp .pdf].freeze
@@ -64,6 +66,7 @@ module Clacky
64
66
  ".docx" => :document, ".doc" => :document,
65
67
  ".xlsx" => :spreadsheet, ".xls" => :spreadsheet,
66
68
  ".pptx" => :presentation, ".ppt" => :presentation,
69
+ ".wps" => :document, ".et" => :spreadsheet, ".dps" => :presentation,
67
70
  ".pdf" => :pdf,
68
71
  ".zip" => :zip, ".gz" => :zip, ".tgz" => :zip, ".tar" => :zip, ".rar" => :zip, ".7z" => :zip,
69
72
  ".png" => :image, ".jpg" => :image, ".jpeg" => :image,
@@ -30,6 +30,9 @@ module Clacky
30
30
  ".xls" => "xlsx_parser.rb",
31
31
  ".pptx" => "pptx_parser.rb",
32
32
  ".ppt" => "pptx_parser.rb",
33
+ ".wps" => "wps_parser.rb",
34
+ ".et" => "wps_parser.rb",
35
+ ".dps" => "wps_parser.rb",
33
36
  }.freeze
34
37
 
35
38
  # Ensure ~/.clacky/parsers/ exists and all default parsers are present.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.2.5"
4
+ VERSION = "1.2.6"
5
5
  end