openclacky 1.5.13 → 1.5.14
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/CHANGELOG.md +51 -0
- data/lib/clacky/agent/chunk_index.rb +83 -0
- data/lib/clacky/agent/history_navigation.rb +239 -0
- data/lib/clacky/agent/session_serializer.rb +38 -97
- data/lib/clacky/agent.rb +281 -276
- data/lib/clacky/agent_config.rb +1 -1
- data/lib/clacky/billing/billing_store.rb +2 -2
- data/lib/clacky/billing/platform_billing.rb +6 -0
- data/lib/clacky/brand_config.rb +13 -2
- data/lib/clacky/cli.rb +27 -0
- data/lib/clacky/client.rb +2 -2
- data/lib/clacky/default_extensions/ext-studio/agents/ext-developer/system_prompt.md +70 -134
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +91 -5
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +179 -577
- data/lib/clacky/default_extensions/git/panels/git/view.js +34 -14
- data/lib/clacky/default_extensions/preview/ext.yml +20 -0
- data/lib/clacky/default_extensions/preview/panels/preview/view.js +475 -0
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +1 -10
- data/lib/clacky/extension/api_extension.rb +1 -1
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/openai_compat.rb +70 -30
- data/lib/clacky/message_format/bedrock.rb +6 -1
- data/lib/clacky/prompts/base.md +1 -1
- data/lib/clacky/providers.rb +38 -3
- data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
- data/lib/clacky/search_config.rb +3 -3
- data/lib/clacky/server/channel/channel_manager.rb +8 -5
- data/lib/clacky/server/git_panel.rb +10 -2
- data/lib/clacky/server/http_server.rb +161 -14
- data/lib/clacky/server/preview.rb +351 -0
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/skill.rb +48 -0
- data/lib/clacky/tools/browser.rb +176 -19
- data/lib/clacky/tools/web_search.rb +79 -8
- data/lib/clacky/ui2/components/command_suggestions.rb +2 -1
- data/lib/clacky/ui2/components/input_area.rb +22 -2
- data/lib/clacky/ui2/components/modal_component.rb +35 -6
- data/lib/clacky/ui2/ui_controller.rb +44 -7
- data/lib/clacky/utils/file_processor.rb +19 -7
- data/lib/clacky/utils/mac_app_detector.rb +186 -0
- data/lib/clacky/utils/model_pricing.rb +114 -66
- data/lib/clacky/utils/windows_app_detector.rb +334 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +596 -130
- data/lib/clacky/web/components/chat-navigator.js +489 -202
- data/lib/clacky/web/components/code-editor.js +191 -5
- data/lib/clacky/web/components/model-picker.js +2 -1
- data/lib/clacky/web/components/quote-select.js +235 -0
- data/lib/clacky/web/core/aside.js +157 -8
- data/lib/clacky/web/core/ext.js +16 -0
- data/lib/clacky/web/features/billing/view.js +75 -21
- data/lib/clacky/web/features/skills/store.js +18 -1
- data/lib/clacky/web/features/skills/view.js +128 -7
- data/lib/clacky/web/features/workspace/store.js +80 -7
- data/lib/clacky/web/features/workspace/view.js +392 -40
- data/lib/clacky/web/i18n.js +90 -5
- data/lib/clacky/web/index.html +15 -6
- data/lib/clacky/web/sessions.js +193 -38
- data/lib/clacky/web/utils.js +34 -0
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +24 -19
- data/lib/clacky/web/vendor/codemirror/entry.js +130 -0
- data/lib/clacky/web/vendor/codemirror/package.json +29 -0
- data/lib/clacky/web/ws-dispatcher.js +20 -0
- data/lib/clacky.rb +2 -0
- metadata +11 -1
|
@@ -31,6 +31,7 @@ module Clacky
|
|
|
31
31
|
working_dir: config[:working_dir],
|
|
32
32
|
mode: config[:mode],
|
|
33
33
|
model: config[:model],
|
|
34
|
+
reasoning_effort: config[:reasoning_effort],
|
|
34
35
|
theme: config[:theme]
|
|
35
36
|
}
|
|
36
37
|
|
|
@@ -81,6 +82,7 @@ module Clacky
|
|
|
81
82
|
working_dir: @config[:working_dir],
|
|
82
83
|
mode: @config[:mode],
|
|
83
84
|
model: @config[:model],
|
|
85
|
+
reasoning_effort: @config[:reasoning_effort],
|
|
84
86
|
tasks: @tasks_count,
|
|
85
87
|
cost: @total_cost
|
|
86
88
|
)
|
|
@@ -124,6 +126,7 @@ module Clacky
|
|
|
124
126
|
working_dir: @config[:working_dir],
|
|
125
127
|
mode: @config[:mode],
|
|
126
128
|
model: @config[:model],
|
|
129
|
+
reasoning_effort: @config[:reasoning_effort],
|
|
127
130
|
tasks: @tasks_count,
|
|
128
131
|
cost: @total_cost,
|
|
129
132
|
cost_source: cost_source,
|
|
@@ -1271,6 +1274,7 @@ module Clacky
|
|
|
1271
1274
|
"",
|
|
1272
1275
|
theme.format_text("Commands:", :info),
|
|
1273
1276
|
" #{theme.format_text("/model", :success)} - Quickly switch the current model",
|
|
1277
|
+
" #{theme.format_text("/think", :success)} - Set the thinking (reasoning) effort level",
|
|
1274
1278
|
" #{theme.format_text("/config", :success)} - Configure models, API keys, settings",
|
|
1275
1279
|
" #{theme.format_text("/goal", :success)} - Set a standing goal for autonomous work",
|
|
1276
1280
|
" #{theme.format_text("/goal <text>", :dim)} Set a goal and start working toward it",
|
|
@@ -1803,6 +1807,7 @@ module Clacky
|
|
|
1803
1807
|
working_dir: @config[:working_dir],
|
|
1804
1808
|
mode: @config[:mode],
|
|
1805
1809
|
model: @config[:model],
|
|
1810
|
+
reasoning_effort: @config[:reasoning_effort],
|
|
1806
1811
|
tasks: @tasks_count,
|
|
1807
1812
|
cost: @total_cost
|
|
1808
1813
|
)
|
|
@@ -1871,14 +1876,15 @@ module Clacky
|
|
|
1871
1876
|
}
|
|
1872
1877
|
end
|
|
1873
1878
|
|
|
1874
|
-
# Add action buttons
|
|
1875
|
-
|
|
1876
|
-
choices << { name: "
|
|
1879
|
+
# Add action buttons (sticky: always visible below the scrollable
|
|
1880
|
+
# model list, so a long model list can't push them off screen)
|
|
1881
|
+
choices << { name: "─" * 50, disabled: true, sticky: true }
|
|
1882
|
+
choices << { name: "[+] Add New Model", value: { action: :add }, sticky: true }
|
|
1877
1883
|
if current_config.models.length > 0
|
|
1878
|
-
choices << { name: "[*] Edit Current Model", value: { action: :edit } }
|
|
1879
|
-
choices << { name: "[-] Delete Model", value: { action: :delete } } if current_config.models.length > 1
|
|
1884
|
+
choices << { name: "[*] Edit Current Model", value: { action: :edit }, sticky: true }
|
|
1885
|
+
choices << { name: "[-] Delete Model", value: { action: :delete }, sticky: true } if current_config.models.length > 1
|
|
1880
1886
|
end
|
|
1881
|
-
choices << { name: "[X] Close", value: { action: :close } }
|
|
1887
|
+
choices << { name: "[X] Close", value: { action: :close }, sticky: true }
|
|
1882
1888
|
|
|
1883
1889
|
# Show menu
|
|
1884
1890
|
result = modal.show(
|
|
@@ -1903,7 +1909,7 @@ module Clacky
|
|
|
1903
1909
|
if new_model
|
|
1904
1910
|
# Determine anthropic_format based on provider
|
|
1905
1911
|
# For Anthropic provider, use Anthropic API format
|
|
1906
|
-
anthropic_format = new_model[:provider] ==
|
|
1912
|
+
anthropic_format = new_model[:provider] == Clacky::Providers::ANTHROPIC_ID
|
|
1907
1913
|
|
|
1908
1914
|
current_config.add_model(
|
|
1909
1915
|
model: new_model[:model],
|
|
@@ -2121,6 +2127,37 @@ module Clacky
|
|
|
2121
2127
|
result # Return selected task_id or nil
|
|
2122
2128
|
end
|
|
2123
2129
|
|
|
2130
|
+
# Show a reasoning-effort picker for the `/think` command.
|
|
2131
|
+
# @param current_effort [String, nil] the agent's current effort level
|
|
2132
|
+
# @return [String, nil] the chosen level ("off"/"low"/.../"max") or nil if cancelled
|
|
2133
|
+
public def show_reasoning_effort_menu(current_effort)
|
|
2134
|
+
modal = Components::ModalComponent.new
|
|
2135
|
+
|
|
2136
|
+
descriptions = {
|
|
2137
|
+
"off" => "Provider default (no effort override)",
|
|
2138
|
+
"low" => "Light reasoning - fastest",
|
|
2139
|
+
"medium" => "Balanced reasoning",
|
|
2140
|
+
"high" => "Deep reasoning",
|
|
2141
|
+
"xhigh" => "Extra deep reasoning",
|
|
2142
|
+
"max" => "Maximum reasoning - slowest"
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
active_index = nil
|
|
2146
|
+
choices = descriptions.keys.each_with_index.map do |level, idx|
|
|
2147
|
+
is_current = current_effort.nil? ? level == "off" : level == current_effort
|
|
2148
|
+
active_index = idx if is_current
|
|
2149
|
+
marker = is_current ? "● " : " "
|
|
2150
|
+
{ name: "#{marker}#{level} - #{descriptions[level]}", value: level }
|
|
2151
|
+
end
|
|
2152
|
+
|
|
2153
|
+
modal.show(
|
|
2154
|
+
title: "Thinking Level - Set Reasoning Effort",
|
|
2155
|
+
choices: choices,
|
|
2156
|
+
initial_index: active_index,
|
|
2157
|
+
on_close: -> { @layout.rerender_all }
|
|
2158
|
+
)
|
|
2159
|
+
end
|
|
2160
|
+
|
|
2124
2161
|
# Show form for editing a model
|
|
2125
2162
|
# @param model [Hash, nil] Existing model hash or nil for new model
|
|
2126
2163
|
# @return [Hash, nil] Updated model hash or nil if cancelled
|
|
@@ -299,7 +299,9 @@ module Clacky
|
|
|
299
299
|
Zip::InputStream.open(StringIO.new(body)) do |zis|
|
|
300
300
|
while (entry = zis.get_next_entry)
|
|
301
301
|
size = entry.size ? " (#{entry.size} bytes)" : ""
|
|
302
|
-
|
|
302
|
+
# rubyzip returns names as raw ASCII-8BIT bytes regardless of the zip's
|
|
303
|
+
# UTF-8 flag; convert so the listing never carries binary encoding.
|
|
304
|
+
lines << "- #{Clacky::Utils::Encoding.to_utf8(entry.name)}#{size}"
|
|
303
305
|
end
|
|
304
306
|
end
|
|
305
307
|
lines.join("\n")
|
|
@@ -325,7 +327,7 @@ module Clacky
|
|
|
325
327
|
tar.each do |entry|
|
|
326
328
|
kind = entry.directory? ? "[dir] " : ""
|
|
327
329
|
size = entry.header.size ? " (#{entry.header.size} bytes)" : ""
|
|
328
|
-
lines << "- #{kind}#{entry.full_name}#{size}"
|
|
330
|
+
lines << "- #{kind}#{Clacky::Utils::Encoding.to_utf8(entry.full_name)}#{size}"
|
|
329
331
|
end
|
|
330
332
|
end
|
|
331
333
|
end
|
|
@@ -339,7 +341,7 @@ module Clacky
|
|
|
339
341
|
tar.each do |entry|
|
|
340
342
|
kind = entry.directory? ? "[dir] " : ""
|
|
341
343
|
size = entry.header.size ? " (#{entry.header.size} bytes)" : ""
|
|
342
|
-
lines << "- #{kind}#{entry.full_name}#{size}"
|
|
344
|
+
lines << "- #{kind}#{Clacky::Utils::Encoding.to_utf8(entry.full_name)}#{size}"
|
|
343
345
|
end
|
|
344
346
|
end
|
|
345
347
|
end
|
|
@@ -359,7 +361,7 @@ module Clacky
|
|
|
359
361
|
found_tar = true
|
|
360
362
|
kind = entry.directory? ? "[dir] " : ""
|
|
361
363
|
size = entry.header.size ? " (#{entry.header.size} bytes)" : ""
|
|
362
|
-
lines << "- #{kind}#{entry.full_name}#{size}"
|
|
364
|
+
lines << "- #{kind}#{Clacky::Utils::Encoding.to_utf8(entry.full_name)}#{size}"
|
|
363
365
|
end
|
|
364
366
|
end
|
|
365
367
|
end
|
|
@@ -388,7 +390,8 @@ module Clacky
|
|
|
388
390
|
end
|
|
389
391
|
end
|
|
390
392
|
lines = ["# GZIP Contents\n"]
|
|
391
|
-
|
|
393
|
+
display_name = original_name ? Clacky::Utils::Encoding.to_utf8(original_name) : "(not recorded)"
|
|
394
|
+
lines << "- Original filename: #{display_name}"
|
|
392
395
|
lines << "- Compressed size: #{File.size(path)} bytes"
|
|
393
396
|
lines << "- Uncompressed size: #{uncompressed} bytes#{uncompressed && uncompressed > 64 * 1024 * 1024 ? " (truncated)" : ""}"
|
|
394
397
|
lines.join("\n")
|
|
@@ -406,7 +409,14 @@ module Clacky
|
|
|
406
409
|
FileUtils.mkdir_p(UPLOAD_DIR)
|
|
407
410
|
safe_name = File.basename(original_path.to_s).gsub(/[\/\:\*?"<>|\x00]/, "_")
|
|
408
411
|
dest = File.join(UPLOAD_DIR, "#{SecureRandom.hex(8)}_#{safe_name}.preview.md")
|
|
409
|
-
File.write
|
|
412
|
+
# File.write transcodes to the default external encoding and raises
|
|
413
|
+
# "\xE8 from ASCII-8BIT to UTF-8" on binary (ASCII-8BIT) content, which
|
|
414
|
+
# can leak in from archive entry names / external parsers.
|
|
415
|
+
text = content.to_s
|
|
416
|
+
unless text.encoding == ::Encoding::UTF_8 && text.valid_encoding?
|
|
417
|
+
text = Clacky::Utils::Encoding.to_utf8(text)
|
|
418
|
+
end
|
|
419
|
+
File.write(dest, text)
|
|
410
420
|
dest
|
|
411
421
|
end
|
|
412
422
|
|
|
@@ -540,7 +550,9 @@ module Clacky
|
|
|
540
550
|
LOCAL_IMAGE_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp].freeze
|
|
541
551
|
LOCAL_VIDEO_EXTENSIONS = %w[.mp4 .webm .mov].freeze
|
|
542
552
|
LOCAL_AUDIO_EXTENSIONS = %w[.wav .mp3 .ogg .aac .flac .m4a].freeze
|
|
543
|
-
|
|
553
|
+
# .pdf: served by /api/local-image so the WebUI file viewer can embed it
|
|
554
|
+
# in an iframe (browser-native PDF rendering, no extra dependency).
|
|
555
|
+
LOCAL_MEDIA_EXTENSIONS = (LOCAL_IMAGE_EXTENSIONS + LOCAL_VIDEO_EXTENSIONS + LOCAL_AUDIO_EXTENSIONS + %w[.pdf]).freeze
|
|
544
556
|
|
|
545
557
|
# Replace local image paths in markdown content with base64 data URLs.
|
|
546
558
|
#
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "digest"
|
|
5
|
+
require_relative "environment_detector"
|
|
6
|
+
|
|
7
|
+
module Clacky
|
|
8
|
+
module Utils
|
|
9
|
+
# Detects installed macOS applications able to open a given file type.
|
|
10
|
+
#
|
|
11
|
+
# Builds an extension → apps index by scanning application bundles and
|
|
12
|
+
# parsing their Info.plist document declarations (CFBundleDocumentTypes),
|
|
13
|
+
# then applies a static supplement for well-known apps whose cross-format
|
|
14
|
+
# abilities are not declared in the plist — e.g. Keynote opens ppt/pptx
|
|
15
|
+
# without listing them. Non-macOS hosts always answer with an empty list.
|
|
16
|
+
module MacAppDetector
|
|
17
|
+
SEARCH_DIRS = [
|
|
18
|
+
"/Applications",
|
|
19
|
+
"/System/Applications",
|
|
20
|
+
File.expand_path("~/Applications")
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
# bundle name (without .app) => extensions it handles but does not
|
|
24
|
+
# declare in CFBundleDocumentTypes.
|
|
25
|
+
EXTRA_ABILITIES = {
|
|
26
|
+
"Keynote" => %w[key kth ppt pptx pot pps ppsx],
|
|
27
|
+
"Pages" => %w[pages doc docx dot rtf],
|
|
28
|
+
"Numbers" => %w[numbers xls xlsx csv],
|
|
29
|
+
"Preview" => %w[pdf png jpg jpeg gif tiff tif heic webp svg bmp],
|
|
30
|
+
"Microsoft PowerPoint" => %w[ppt pptx pot pps ppsx],
|
|
31
|
+
"Microsoft Word" => %w[doc docx dot rtf],
|
|
32
|
+
"Microsoft Excel" => %w[xls xlsx csv],
|
|
33
|
+
}.freeze
|
|
34
|
+
|
|
35
|
+
# @param ext [String] file extension, with or without leading dot
|
|
36
|
+
# @return [Array<Hash>] [{ "name" => ..., "path" => bundle path }]
|
|
37
|
+
def self.apps_for_ext(ext)
|
|
38
|
+
return [] unless Utils::EnvironmentDetector.os_type == :macos
|
|
39
|
+
|
|
40
|
+
ext = ext.to_s.downcase.sub(/\A\./, "")
|
|
41
|
+
return [] if ext.empty?
|
|
42
|
+
|
|
43
|
+
index[ext].sort_by { |a| a["name"] }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Resolve an app name or bundle path to a scanned bundle path.
|
|
47
|
+
# @return [String, nil]
|
|
48
|
+
def self.app_path(name_or_path)
|
|
49
|
+
return nil unless Utils::EnvironmentDetector.os_type == :macos
|
|
50
|
+
|
|
51
|
+
all_apps[name_or_path.to_s] || all_apps.key(name_or_path.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Return the application macOS would use to open `path` (the handler the
|
|
55
|
+
# plain `open` command would pick). Cached per file extension.
|
|
56
|
+
# @param path [String] absolute file path
|
|
57
|
+
# @return [Hash, nil] { "name" => ..., "path" => bundle path }
|
|
58
|
+
def self.default_app_for(path)
|
|
59
|
+
return nil unless Utils::EnvironmentDetector.os_type == :macos
|
|
60
|
+
|
|
61
|
+
ext = File.extname(path).downcase.sub(/\A\./, "")
|
|
62
|
+
return nil if ext.empty?
|
|
63
|
+
|
|
64
|
+
@default_app_cache ||= {}
|
|
65
|
+
return @default_app_cache[ext] if @default_app_cache.key?(ext)
|
|
66
|
+
|
|
67
|
+
bundle = default_app_bundle(path)
|
|
68
|
+
@default_app_cache[ext] =
|
|
69
|
+
if bundle && File.directory?(bundle)
|
|
70
|
+
{ "name" => File.basename(bundle, ".app"), "path" => bundle }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Query LaunchServices for the default handler of `path` via NSWorkspace.
|
|
75
|
+
# @return [String, nil] bundle path (…/*.app)
|
|
76
|
+
def self.default_app_bundle(path)
|
|
77
|
+
script = <<~JXA
|
|
78
|
+
(function() {
|
|
79
|
+
ObjC.import("AppKit");
|
|
80
|
+
var url = $.NSURL.fileURLWithPath(#{path.to_json});
|
|
81
|
+
var app = $.NSWorkspace.sharedWorkspace.URLForApplicationToOpenURL(url);
|
|
82
|
+
return app ? app.path.js : "";
|
|
83
|
+
})()
|
|
84
|
+
JXA
|
|
85
|
+
out, _err, _status = Open3.capture3("osascript", "-l", "JavaScript", "-e", script)
|
|
86
|
+
result = out.strip
|
|
87
|
+
result.empty? ? nil : result
|
|
88
|
+
rescue StandardError
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Convert an application bundle icon (.icns) to a cached 64px PNG via the
|
|
93
|
+
# system `sips` tool. Used by the Web UI "open with" dropdown.
|
|
94
|
+
# @param bundle_path [String] application bundle path (…/*.app)
|
|
95
|
+
# @return [String, nil] path to the cached PNG file
|
|
96
|
+
def self.icon_png(bundle_path)
|
|
97
|
+
return nil unless Utils::EnvironmentDetector.os_type == :macos
|
|
98
|
+
|
|
99
|
+
bundle = bundle_path.to_s
|
|
100
|
+
return nil unless bundle.end_with?(".app") && File.directory?(bundle)
|
|
101
|
+
|
|
102
|
+
cache_dir = File.join(Dir.home, "Library", "Caches", "openclacky", "app-icons")
|
|
103
|
+
cache = File.join(cache_dir, Digest::MD5.hexdigest(bundle) + ".png")
|
|
104
|
+
return cache if File.exist?(cache)
|
|
105
|
+
|
|
106
|
+
info = read_plist(File.join(bundle, "Contents", "Info.plist"))
|
|
107
|
+
return nil unless info
|
|
108
|
+
|
|
109
|
+
icon = info["CFBundleIconFile"].to_s
|
|
110
|
+
return nil if icon.empty?
|
|
111
|
+
icon += ".icns" unless icon =~ /\.icns\z/i
|
|
112
|
+
icns = File.join(bundle, "Contents", "Resources", icon)
|
|
113
|
+
return nil unless File.exist?(icns)
|
|
114
|
+
|
|
115
|
+
require "fileutils"
|
|
116
|
+
FileUtils.mkdir_p(cache_dir)
|
|
117
|
+
ok = system("sips", "-s", "format", "png", "--resampleWidth", "64",
|
|
118
|
+
icns, "--out", cache, out: File::NULL, err: File::NULL)
|
|
119
|
+
return nil unless ok && File.exist?(cache)
|
|
120
|
+
cache
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Open a file with a specific installed application (macOS only).
|
|
124
|
+
# @param path [String] absolute file path
|
|
125
|
+
# @param app [String] app name or bundle path, must be a scanned app
|
|
126
|
+
# @return [Boolean, nil] system() result, nil when the app is unknown
|
|
127
|
+
def self.open_with(path, app)
|
|
128
|
+
bundle = app_path(app.to_s)
|
|
129
|
+
return nil unless bundle
|
|
130
|
+
system("open", "-a", bundle, path)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def self.all_apps
|
|
134
|
+
return @all_apps if defined?(@all_apps)
|
|
135
|
+
|
|
136
|
+
@all_apps = {}
|
|
137
|
+
SEARCH_DIRS.each do |dir|
|
|
138
|
+
next unless File.directory?(dir)
|
|
139
|
+
Dir.glob(File.join(dir, "*.app")).each do |bundle|
|
|
140
|
+
@all_apps[File.basename(bundle, ".app")] = bundle
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
@all_apps
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.index
|
|
147
|
+
return @index if defined?(@index)
|
|
148
|
+
|
|
149
|
+
@index = Hash.new { |h, k| h[k] = [] }
|
|
150
|
+
|
|
151
|
+
all_apps.each do |name, bundle|
|
|
152
|
+
info = read_plist(File.join(bundle, "Contents", "Info.plist"))
|
|
153
|
+
next unless info
|
|
154
|
+
(info["CFBundleDocumentTypes"] || []).each do |dt|
|
|
155
|
+
(dt["CFBundleTypeExtensions"] || []).each do |e|
|
|
156
|
+
add(@index, e.to_s.downcase, name, bundle)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
EXTRA_ABILITIES.each do |name, exts|
|
|
162
|
+
bundle = all_apps[name]
|
|
163
|
+
next unless bundle
|
|
164
|
+
exts.each { |e| add(@index, e, name, bundle) }
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
@index
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def self.add(index, ext, name, bundle)
|
|
171
|
+
return if ext.empty?
|
|
172
|
+
list = index[ext]
|
|
173
|
+
list << { "name" => name, "path" => bundle } unless list.any? { |a| a["path"] == bundle }
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def self.read_plist(path)
|
|
177
|
+
out, _err, _status = Open3.capture3("plutil", "-convert", "json", "-o", "-", path)
|
|
178
|
+
return nil if out.nil? || out.empty?
|
|
179
|
+
require "json"
|
|
180
|
+
JSON.parse(out)
|
|
181
|
+
rescue StandardError
|
|
182
|
+
nil
|
|
183
|
+
end
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
@@ -176,28 +176,40 @@ module Clacky
|
|
|
176
176
|
# - "cache miss input" = regular prompt_tokens rate
|
|
177
177
|
# - "cache hit input" = cache_read rate (DeepSeek has no separate cache-write charge)
|
|
178
178
|
# - No tiered pricing (single rate regardless of context length)
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
#
|
|
182
|
-
#
|
|
183
|
-
#
|
|
184
|
-
#
|
|
185
|
-
|
|
179
|
+
# Peak/off-peak billing (off-peak = half of peak; peak = 01:00-04:00 &
|
|
180
|
+
# 06:00-10:00 UTC). Weekends (Sat/Sun, Beijing time) are billed entirely
|
|
181
|
+
# at off-peak rates regardless of hour.
|
|
182
|
+
# v4-pro keeps its own rates until 2026-09-14, when it is routed to
|
|
183
|
+
# V4.1 Flash and billed at the flash rates (see flash_peak/flash_off_peak).
|
|
184
|
+
# Each entry carries peak/off_peak tiers; calculate_cost resolves the
|
|
185
|
+
# active tier from the request time.
|
|
186
|
+
# V4.1 Flash — new canonical model id. Native multimodal; the retired
|
|
187
|
+
# v4-flash / v4-flash-vision-exp ids are routed here for compatibility.
|
|
188
|
+
"deepseek-flash" => {
|
|
186
189
|
deepseek: true,
|
|
187
|
-
|
|
188
|
-
input: { default: 0.
|
|
189
|
-
output: { default:
|
|
190
|
-
cache: { write: 0.
|
|
190
|
+
peak: {
|
|
191
|
+
input: { default: 0.30, over_200k: 0.30 },
|
|
192
|
+
output: { default: 1.20, over_200k: 1.20 },
|
|
193
|
+
cache: { write: 0.30, read: 0.006 }
|
|
191
194
|
},
|
|
195
|
+
off_peak: {
|
|
196
|
+
input: { default: 0.15, over_200k: 0.15 },
|
|
197
|
+
output: { default: 0.60, over_200k: 0.60 },
|
|
198
|
+
cache: { write: 0.15, read: 0.003 }
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
|
|
202
|
+
"deepseek-v4-flash" => {
|
|
203
|
+
deepseek: true,
|
|
192
204
|
peak: {
|
|
193
|
-
input: { default: 0.
|
|
194
|
-
output: { default: 1.
|
|
195
|
-
cache: { write: 0.
|
|
205
|
+
input: { default: 0.30, over_200k: 0.30 }, # $0.30/MTok cache miss (peak)
|
|
206
|
+
output: { default: 1.20, over_200k: 1.20 }, # $1.20/MTok
|
|
207
|
+
cache: { write: 0.30, read: 0.006 } # $0.006/MTok cache hit
|
|
196
208
|
},
|
|
197
209
|
off_peak: {
|
|
198
|
-
input: { default: 0.
|
|
199
|
-
output: { default: 0.
|
|
200
|
-
cache: { write: 0.
|
|
210
|
+
input: { default: 0.15, over_200k: 0.15 }, # $0.15/MTok (half of peak)
|
|
211
|
+
output: { default: 0.60, over_200k: 0.60 }, # $0.60/MTok
|
|
212
|
+
cache: { write: 0.15, read: 0.003 } # $0.003/MTok cache hit
|
|
201
213
|
}
|
|
202
214
|
},
|
|
203
215
|
|
|
@@ -205,30 +217,20 @@ module Clacky
|
|
|
205
217
|
# as tokens per DeepSeek's image tokenization rules).
|
|
206
218
|
"deepseek-v4-flash-vision-exp" => {
|
|
207
219
|
deepseek: true,
|
|
208
|
-
legacy: {
|
|
209
|
-
input: { default: 0.14, over_200k: 0.14 },
|
|
210
|
-
output: { default: 0.28, over_200k: 0.28 },
|
|
211
|
-
cache: { write: 0.14, read: 0.0028 }
|
|
212
|
-
},
|
|
213
220
|
peak: {
|
|
214
|
-
input: { default: 0.
|
|
215
|
-
output: { default: 1.
|
|
216
|
-
cache: { write: 0.
|
|
221
|
+
input: { default: 0.30, over_200k: 0.30 },
|
|
222
|
+
output: { default: 1.20, over_200k: 1.20 },
|
|
223
|
+
cache: { write: 0.30, read: 0.006 }
|
|
217
224
|
},
|
|
218
225
|
off_peak: {
|
|
219
|
-
input: { default: 0.
|
|
220
|
-
output: { default: 0.
|
|
221
|
-
cache: { write: 0.
|
|
226
|
+
input: { default: 0.15, over_200k: 0.15 },
|
|
227
|
+
output: { default: 0.60, over_200k: 0.60 },
|
|
228
|
+
cache: { write: 0.15, read: 0.003 }
|
|
222
229
|
}
|
|
223
230
|
},
|
|
224
231
|
|
|
225
232
|
"deepseek-v4-pro" => {
|
|
226
233
|
deepseek: true,
|
|
227
|
-
legacy: {
|
|
228
|
-
input: { default: 0.435, over_200k: 0.435 }, # $0.435/MTok (pre-cutover flat)
|
|
229
|
-
output: { default: 0.87, over_200k: 0.87 }, # $0.87/MTok
|
|
230
|
-
cache: { write: 0.435, read: 0.003625 } # $0.003625/MTok cache hit
|
|
231
|
-
},
|
|
232
234
|
peak: {
|
|
233
235
|
input: { default: 1.32, over_200k: 1.32 }, # $1.32/MTok cache miss (peak)
|
|
234
236
|
output: { default: 3.96, over_200k: 3.96 }, # $3.96/MTok
|
|
@@ -238,6 +240,18 @@ module Clacky
|
|
|
238
240
|
input: { default: 0.66, over_200k: 0.66 }, # $0.66/MTok (half of peak)
|
|
239
241
|
output: { default: 1.98, over_200k: 1.98 }, # $1.98/MTok
|
|
240
242
|
cache: { write: 0.66, read: 0.022 } # $0.022/MTok cache hit
|
|
243
|
+
},
|
|
244
|
+
# Routed to V4.1 Flash from 2026-09-14 12:00 Beijing time and billed
|
|
245
|
+
# at the flash rates.
|
|
246
|
+
flash_peak: {
|
|
247
|
+
input: { default: 0.30, over_200k: 0.30 },
|
|
248
|
+
output: { default: 1.20, over_200k: 1.20 },
|
|
249
|
+
cache: { write: 0.30, read: 0.006 }
|
|
250
|
+
},
|
|
251
|
+
flash_off_peak: {
|
|
252
|
+
input: { default: 0.15, over_200k: 0.15 },
|
|
253
|
+
output: { default: 0.60, over_200k: 0.60 },
|
|
254
|
+
cache: { write: 0.15, read: 0.003 }
|
|
241
255
|
}
|
|
242
256
|
},
|
|
243
257
|
|
|
@@ -484,53 +498,80 @@ module Clacky
|
|
|
484
498
|
}
|
|
485
499
|
},
|
|
486
500
|
|
|
487
|
-
# GPT-5.6
|
|
488
|
-
#
|
|
489
|
-
#
|
|
490
|
-
#
|
|
491
|
-
# not published separately; it follows the GPT-5.5 convention (= input).
|
|
501
|
+
# GPT-5.6 via Bedrock's OpenAI-compatible endpoint (Global CRIS).
|
|
502
|
+
# Tiered at 272K input tokens (OpenAI's breakpoint) — same caveat as
|
|
503
|
+
# GPT-6 Astra below. USD per 1M tokens, source: llm_proxy bedrock_openai
|
|
504
|
+
# pricing. *-pro variants are priced identically to the base tier.
|
|
492
505
|
"gpt-5.6-sol" => {
|
|
493
506
|
input: {
|
|
494
|
-
default:
|
|
495
|
-
over_200k:
|
|
507
|
+
default: 4.00,
|
|
508
|
+
over_200k: 8.00
|
|
496
509
|
},
|
|
497
510
|
output: {
|
|
498
|
-
default:
|
|
499
|
-
over_200k:
|
|
511
|
+
default: 20.00,
|
|
512
|
+
over_200k: 30.00
|
|
500
513
|
},
|
|
501
514
|
cache: {
|
|
502
|
-
|
|
503
|
-
|
|
515
|
+
write_default: 5.00,
|
|
516
|
+
write_over_200k: 10.00,
|
|
517
|
+
read_default: 0.40,
|
|
518
|
+
read_over_200k: 0.80
|
|
504
519
|
}
|
|
505
520
|
},
|
|
506
521
|
|
|
507
522
|
"gpt-5.6-terra" => {
|
|
508
523
|
input: {
|
|
509
524
|
default: 2.00,
|
|
510
|
-
over_200k:
|
|
525
|
+
over_200k: 4.00
|
|
511
526
|
},
|
|
512
527
|
output: {
|
|
513
528
|
default: 12.00,
|
|
514
|
-
over_200k:
|
|
529
|
+
over_200k: 18.00
|
|
515
530
|
},
|
|
516
531
|
cache: {
|
|
517
|
-
|
|
518
|
-
|
|
532
|
+
write_default: 2.50,
|
|
533
|
+
write_over_200k: 5.00,
|
|
534
|
+
read_default: 0.20,
|
|
535
|
+
read_over_200k: 0.40
|
|
519
536
|
}
|
|
520
537
|
},
|
|
521
538
|
|
|
522
539
|
"gpt-5.6-luna" => {
|
|
523
540
|
input: {
|
|
524
541
|
default: 0.20,
|
|
525
|
-
over_200k: 0.
|
|
542
|
+
over_200k: 0.40
|
|
526
543
|
},
|
|
527
544
|
output: {
|
|
528
545
|
default: 1.20,
|
|
529
|
-
over_200k: 1.
|
|
546
|
+
over_200k: 1.80
|
|
530
547
|
},
|
|
531
548
|
cache: {
|
|
532
|
-
|
|
533
|
-
|
|
549
|
+
write_default: 0.25,
|
|
550
|
+
write_over_200k: 0.50,
|
|
551
|
+
read_default: 0.02,
|
|
552
|
+
read_over_200k: 0.04
|
|
553
|
+
}
|
|
554
|
+
},
|
|
555
|
+
|
|
556
|
+
# GPT-6 Astra via Bedrock's OpenAI-compatible endpoint (Global CRIS).
|
|
557
|
+
# Tiered at 272K input tokens (OpenAI's breakpoint, not the global 200K)
|
|
558
|
+
# — the 200K–272K band is slightly over-estimated, same caveat as
|
|
559
|
+
# GPT-5.5/5.4 below. USD per 1M tokens, source: llm_proxy bedrock_openai
|
|
560
|
+
# pricing.
|
|
561
|
+
"gpt-6-astra" => {
|
|
562
|
+
input: {
|
|
563
|
+
default: 10.00,
|
|
564
|
+
over_200k: 20.00
|
|
565
|
+
},
|
|
566
|
+
output: {
|
|
567
|
+
default: 50.00,
|
|
568
|
+
over_200k: 75.00
|
|
569
|
+
},
|
|
570
|
+
cache: {
|
|
571
|
+
write_default: 12.50,
|
|
572
|
+
write_over_200k: 25.00,
|
|
573
|
+
read_default: 1.00,
|
|
574
|
+
read_over_200k: 2.00
|
|
534
575
|
}
|
|
535
576
|
},
|
|
536
577
|
|
|
@@ -854,9 +895,9 @@ module Clacky
|
|
|
854
895
|
# Costs for prompts between 200K–272K will be slightly over-estimated.
|
|
855
896
|
TIERED_PRICING_THRESHOLD = 200_000
|
|
856
897
|
|
|
857
|
-
#
|
|
858
|
-
# 2026-
|
|
859
|
-
|
|
898
|
+
# v4-pro is routed to V4.1 Flash and billed at the flash rates from
|
|
899
|
+
# 2026-09-14 12:00 Beijing time (= 04:00 UTC).
|
|
900
|
+
DEEPSEEK_V4_PRO_FLASH_START = Time.utc(2026, 9, 14, 4, 0, 0)
|
|
860
901
|
|
|
861
902
|
class << self
|
|
862
903
|
# Calculate cost for the given model and usage
|
|
@@ -1000,6 +1041,9 @@ module Clacky
|
|
|
1000
1041
|
"deepseek-v4-flash-vision-exp"
|
|
1001
1042
|
when /deepseek-v4-flash/i, /deepseek.*v4.*flash/i
|
|
1002
1043
|
"deepseek-v4-flash"
|
|
1044
|
+
# V4.1 Flash — new canonical id; native multimodal.
|
|
1045
|
+
when /deepseek-flash/i
|
|
1046
|
+
"deepseek-flash"
|
|
1003
1047
|
# Legacy aliases: deepseek-chat and deepseek-reasoner are being
|
|
1004
1048
|
# deprecated on 2026-07-24 and map to deepseek-v4-flash's
|
|
1005
1049
|
# non-thinking / thinking modes respectively. Bill at flash rates.
|
|
@@ -1102,11 +1146,13 @@ module Clacky
|
|
|
1102
1146
|
# anchored rules above would otherwise miss it) and the "-pro"
|
|
1103
1147
|
# suffix (pro is priced identically to the base tier). Batch ids
|
|
1104
1148
|
# (":batch") stay unmatched - they bill at half price.
|
|
1105
|
-
when
|
|
1149
|
+
when /^(?:abs-|us\.openai\.|global\.openai\.|openai\/)?gpt-?6[.-]?astra$/i
|
|
1150
|
+
"gpt-6-astra"
|
|
1151
|
+
when %r{^(?:abs-|global\.openai\.|openai/)?gpt-?5[\.-]?6[\.-]?sol(-pro)?$}i
|
|
1106
1152
|
"gpt-5.6-sol"
|
|
1107
|
-
when %r{^(openai/)?gpt-?5[\.-]?6[\.-]?terra(-pro)?$}i
|
|
1153
|
+
when %r{^(?:abs-|global\.openai\.|openai/)?gpt-?5[\.-]?6[\.-]?terra(-pro)?$}i
|
|
1108
1154
|
"gpt-5.6-terra"
|
|
1109
|
-
when %r{^(openai/)?gpt-?5[\.-]?6[\.-]?luna(-pro)?$}i
|
|
1155
|
+
when %r{^(?:abs-|global\.openai\.|openai/)?gpt-?5[\.-]?6[\.-]?luna(-pro)?$}i
|
|
1110
1156
|
"gpt-5.6-luna"
|
|
1111
1157
|
when /^gpt-?5\.?5$/i, /^gpt-?5[\.-]?5$/i
|
|
1112
1158
|
"gpt-5.5"
|
|
@@ -1163,17 +1209,19 @@ module Clacky
|
|
|
1163
1209
|
cache_cost
|
|
1164
1210
|
end
|
|
1165
1211
|
|
|
1166
|
-
# Resolve a DeepSeek pricing entry (which holds
|
|
1167
|
-
#
|
|
1212
|
+
# Resolve a DeepSeek pricing entry (which holds peak/off_peak tiers) to
|
|
1213
|
+
# the single tier that applies at the given time.
|
|
1168
1214
|
def resolve_deepseek_tier(pricing, now)
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1215
|
+
# v4-pro migrates to V4.1 Flash on 2026-09-14; its entry carries
|
|
1216
|
+
# flash_peak/flash_off_peak.
|
|
1217
|
+
if pricing.key?(:flash_peak) && now >= DEEPSEEK_V4_PRO_FLASH_START
|
|
1218
|
+
return deepseek_weekend?(now) || !deepseek_peak_hour?(now) ? pricing[:flash_off_peak] : pricing[:flash_peak]
|
|
1219
|
+
end
|
|
1220
|
+
|
|
1221
|
+
if deepseek_weekend?(now) || !deepseek_peak_hour?(now)
|
|
1172
1222
|
pricing[:off_peak]
|
|
1173
|
-
elsif deepseek_peak_hour?(now)
|
|
1174
|
-
pricing[:peak]
|
|
1175
1223
|
else
|
|
1176
|
-
pricing[:
|
|
1224
|
+
pricing[:peak]
|
|
1177
1225
|
end
|
|
1178
1226
|
end
|
|
1179
1227
|
|