openclacky 1.5.7 → 1.5.9
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 +71 -0
- data/README.md +3 -3
- data/README_CN.md +2 -2
- data/README_JA.md +2 -2
- data/lib/clacky/agent.rb +2 -3
- data/lib/clacky/agent_config.rb +40 -18
- data/lib/clacky/brand_config.rb +12 -7
- data/lib/clacky/cli.rb +0 -1
- data/lib/clacky/default_extensions/ext-studio/ext.yml +1 -1
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +27 -14
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +22 -5
- data/lib/clacky/default_extensions/general/ext.yml +1 -1
- data/lib/clacky/extension/api_loader.rb +4 -11
- data/lib/clacky/extension/packager.rb +1 -1
- data/lib/clacky/extension/scaffold/templates/hello/test/handler_test.rb.erb +22 -0
- data/lib/clacky/extension/verifier.rb +1 -1
- data/lib/clacky/media/generator.rb +3 -3
- data/lib/clacky/message_format/anthropic.rb +19 -2
- data/lib/clacky/message_format/bedrock.rb +8 -3
- data/lib/clacky/message_history.rb +1 -1
- data/lib/clacky/providers.rb +159 -105
- data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +6 -0
- data/lib/clacky/server/http_server.rb +67 -79
- data/lib/clacky/session_manager.rb +7 -0
- data/lib/clacky/skill_loader.rb +4 -0
- data/lib/clacky/tools/base.rb +11 -1
- data/lib/clacky/tools/edit.rb +28 -0
- data/lib/clacky/tools/terminal.rb +5 -0
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/ui2/components/inline_input.rb +1 -0
- data/lib/clacky/ui2/components/input_area.rb +1 -0
- data/lib/clacky/ui2/components/modal_component.rb +0 -1
- data/lib/clacky/ui2/components/todo_area.rb +1 -0
- data/lib/clacky/ui2/line_editor.rb +1 -0
- data/lib/clacky/ui2/markdown_renderer.rb +5 -1
- data/lib/clacky/ui2/ui_controller.rb +1 -0
- data/lib/clacky/utils/model_pricing.rb +62 -27
- data/lib/clacky/utils/string_matcher.rb +29 -2
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +278 -221
- data/lib/clacky/web/components/chat-navigator.js +1 -1
- data/lib/clacky/web/features/billing/store.js +3 -3
- data/lib/clacky/web/features/billing/view.js +5 -61
- data/lib/clacky/web/features/extensions/store.js +65 -41
- data/lib/clacky/web/features/extensions/view.js +74 -10
- data/lib/clacky/web/features/new-session/view.js +24 -0
- data/lib/clacky/web/features/version/view.js +2 -2
- data/lib/clacky/web/i18n.js +36 -22
- data/lib/clacky/web/index.html +17 -17
- data/lib/clacky/web/projects.js +83 -285
- data/lib/clacky/web/sessions.js +151 -17
- data/lib/clacky/web/settings.js +6 -1
- metadata +2 -1
|
@@ -335,6 +335,13 @@ module Clacky
|
|
|
335
335
|
# sessions are never deleted and do not count toward the cap.
|
|
336
336
|
# Returns count of soft-deleted sessions.
|
|
337
337
|
def cleanup_by_count(keep:, grouped_keep: 200)
|
|
338
|
+
# Fast path: if total session files can't exceed any single group's cap,
|
|
339
|
+
# skip the expensive all_sessions() full-scan (read + JSON.parse every file).
|
|
340
|
+
# min() is correct because groups are mutually exclusive — any single
|
|
341
|
+
# group's count ≤ non_pinned ≤ file_count ≤ min(keep, grouped_keep).
|
|
342
|
+
file_count = Dir.glob(File.join(@sessions_dir, "*.json")).size
|
|
343
|
+
return 0 if file_count <= [keep, grouped_keep].min
|
|
344
|
+
|
|
338
345
|
non_pinned = all_sessions.reject { |s| s[:pinned] }
|
|
339
346
|
|
|
340
347
|
groups = non_pinned.group_by do |s|
|
data/lib/clacky/skill_loader.rb
CHANGED
|
@@ -485,6 +485,10 @@ module Clacky
|
|
|
485
485
|
|
|
486
486
|
result.skills.each do |unit|
|
|
487
487
|
next if unit.spec["protected"] || unit.spec["encrypted"]
|
|
488
|
+
# Builtin extension skills are already loaded as :default by
|
|
489
|
+
# load_default_skills; skipping them here keeps their source stable
|
|
490
|
+
# instead of being re-registered (and reclassified) as :extension.
|
|
491
|
+
next if unit.layer == :builtin
|
|
488
492
|
|
|
489
493
|
skill_dir_abs = unit.spec["skill_dir_abs"]
|
|
490
494
|
next unless File.directory?(skill_dir_abs) && File.file?(File.join(skill_dir_abs, "SKILL.md"))
|
data/lib/clacky/tools/base.rb
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "../utils/environment_detector"
|
|
4
|
+
|
|
3
5
|
module Clacky
|
|
4
6
|
module Tools
|
|
5
7
|
class Base
|
|
@@ -29,12 +31,20 @@ module Clacky
|
|
|
29
31
|
end
|
|
30
32
|
|
|
31
33
|
# Expand ~ to home directory only if path starts with ~
|
|
32
|
-
# Relative paths are resolved against working_dir if provided
|
|
34
|
+
# Relative paths are resolved against working_dir if provided.
|
|
35
|
+
# On WSL, Windows-style paths (C:\..., C:/..., /C:/...) are translated to
|
|
36
|
+
# their /mnt/<drive>/... equivalents so that paths copied from Windows
|
|
37
|
+
# (clipboard, error messages, file dialogs) work unchanged.
|
|
33
38
|
# @param path [String, nil] The path to expand
|
|
34
39
|
# @param working_dir [String, nil] The working directory to resolve relative paths against
|
|
35
40
|
# @return [String, nil] The expanded path, or original if no ~ present
|
|
36
41
|
private def expand_path(path, working_dir: nil)
|
|
37
42
|
return path if path.nil? || path.strip.empty?
|
|
43
|
+
|
|
44
|
+
# WSL: normalize Windows drive paths BEFORE anything else, otherwise
|
|
45
|
+
# the leading "C:" makes File.expand_path treat them as relative.
|
|
46
|
+
path = Clacky::Utils::EnvironmentDetector.win_to_linux_path(path)
|
|
47
|
+
|
|
38
48
|
return File.expand_path(path) if path.start_with?("~")
|
|
39
49
|
return File.expand_path(path, working_dir) if working_dir && !path.start_with?("/")
|
|
40
50
|
# Always resolve relative paths to absolute (even without working_dir), so callers
|
data/lib/clacky/tools/edit.rb
CHANGED
|
@@ -49,6 +49,14 @@ module Clacky
|
|
|
49
49
|
# and cause JSON.generate to fail during replay.
|
|
50
50
|
content = safe_utf8(File.read(path))
|
|
51
51
|
|
|
52
|
+
# Detect the file's dominant line-ending style BEFORE any
|
|
53
|
+
# modification. Windows-created files (and /mnt/c on WSL) use
|
|
54
|
+
# CRLF; AI-supplied old_string/new_string always use LF. Without
|
|
55
|
+
# preservation, the replaced fragment introduces LF into a CRLF
|
|
56
|
+
# file, creating mixed line endings that confuse Git diff and
|
|
57
|
+
# Windows IDEs.
|
|
58
|
+
preserve_crlf = content.include?("\r\n")
|
|
59
|
+
|
|
52
60
|
# Find matching string using layered strategy (shared with preview)
|
|
53
61
|
match_result = Utils::StringMatcher.find_match(content, old_string)
|
|
54
62
|
|
|
@@ -82,6 +90,11 @@ module Clacky
|
|
|
82
90
|
content.sub(actual_old_string) { new_string }
|
|
83
91
|
end
|
|
84
92
|
|
|
93
|
+
# Preserve the original line-ending style: if the file was CRLF,
|
|
94
|
+
# re-normalize the entire content to CRLF so the newly inserted
|
|
95
|
+
# LF fragments (from new_string) match the rest of the file.
|
|
96
|
+
content = normalize_line_endings(content, :crlf) if preserve_crlf
|
|
97
|
+
|
|
85
98
|
File.write(path, content)
|
|
86
99
|
|
|
87
100
|
{
|
|
@@ -143,6 +156,21 @@ module Clacky
|
|
|
143
156
|
"Modified #{replacements} occurrence#{replacements > 1 ? "s" : ""}"
|
|
144
157
|
end
|
|
145
158
|
|
|
159
|
+
# Normalize all line endings in `content` to the given style.
|
|
160
|
+
# Works by first collapsing everything to LF, then expanding to the
|
|
161
|
+
# target — this avoids double-converting existing CRLF into CRCRLF.
|
|
162
|
+
# @param content [String]
|
|
163
|
+
# @param style [Symbol] :crlf or :lf
|
|
164
|
+
# @return [String]
|
|
165
|
+
private def normalize_line_endings(content, style)
|
|
166
|
+
case style
|
|
167
|
+
when :crlf
|
|
168
|
+
content.gsub(/\r\n/, "\n").gsub(/\r/, "\n").gsub(/\n/, "\r\n")
|
|
169
|
+
else
|
|
170
|
+
content.gsub(/\r\n/, "\n").gsub(/\r/, "\n")
|
|
171
|
+
end
|
|
172
|
+
end
|
|
173
|
+
|
|
146
174
|
# Scrub invalid UTF-8 byte sequences (see file_reader.rb for rationale).
|
|
147
175
|
private def safe_utf8(str)
|
|
148
176
|
return str if str.nil?
|
|
@@ -223,6 +223,11 @@ module Clacky
|
|
|
223
223
|
timeout = (timeout || DEFAULT_TIMEOUT).to_f
|
|
224
224
|
idle_ms = (idle_ms || DEFAULT_IDLE_MS).to_i
|
|
225
225
|
cwd ||= working_dir
|
|
226
|
+
# Normalize cwd the same way file tools do: on WSL translate Windows
|
|
227
|
+
# drive paths (C:\..., C:/...) to /mnt, expand ~ and relative paths.
|
|
228
|
+
# Without this, a Windows-style cwd fails the Dir.exist? check below
|
|
229
|
+
# and the agent gets a misleading "cwd does not exist".
|
|
230
|
+
cwd = expand_path(cwd) if cwd
|
|
226
231
|
|
|
227
232
|
# Kill
|
|
228
233
|
if kill
|
|
@@ -93,7 +93,7 @@ module Clacky
|
|
|
93
93
|
|
|
94
94
|
def restore_file(trash_dir, file_path, project_root)
|
|
95
95
|
deleted_files = get_deleted_files(trash_dir, project_root)
|
|
96
|
-
expanded_path =
|
|
96
|
+
expanded_path = expand_path(file_path, working_dir: project_root)
|
|
97
97
|
|
|
98
98
|
target_file = deleted_files.find { |f| f[:original_path] == expanded_path }
|
|
99
99
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "tty-
|
|
3
|
+
require "tty-screen"
|
|
4
4
|
require_relative "theme_manager"
|
|
5
5
|
|
|
6
6
|
module Clacky
|
|
@@ -14,6 +14,10 @@ module Clacky
|
|
|
14
14
|
def render(content)
|
|
15
15
|
return content if content.nil? || content.empty?
|
|
16
16
|
|
|
17
|
+
# tty-markdown pulls in rouge + kramdown (~0.4s). Server mode renders
|
|
18
|
+
# markdown in the browser, so only pay that cost on a real terminal render.
|
|
19
|
+
require "tty-markdown" unless defined?(TTY::Markdown)
|
|
20
|
+
|
|
17
21
|
# Get current theme colors
|
|
18
22
|
theme = ThemeManager.current_theme
|
|
19
23
|
|
|
@@ -159,37 +159,45 @@ module Clacky
|
|
|
159
159
|
# - "cache miss input" = regular prompt_tokens rate
|
|
160
160
|
# - "cache hit input" = cache_read rate (DeepSeek has no separate cache-write charge)
|
|
161
161
|
# - No tiered pricing (single rate regardless of context length)
|
|
162
|
-
#
|
|
163
|
-
#
|
|
164
|
-
#
|
|
165
|
-
#
|
|
162
|
+
# Effective 2026-08-16 16:00 UTC DeepSeek switched to peak/off-peak billing
|
|
163
|
+
# (off-peak = half of peak; peak = 01:00-04:00 & 06:00-10:00 UTC).
|
|
164
|
+
# Each entry carries legacy/peak/off_peak tiers; calculate_cost resolves
|
|
165
|
+
# the active tier from the request time.
|
|
166
166
|
"deepseek-v4-flash" => {
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
over_200k: 0.14
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
write: 0.
|
|
177
|
-
|
|
167
|
+
deepseek: true,
|
|
168
|
+
legacy: {
|
|
169
|
+
input: { default: 0.14, over_200k: 0.14 }, # $0.14/MTok (pre-cutover flat)
|
|
170
|
+
output: { default: 0.28, over_200k: 0.28 }, # $0.28/MTok
|
|
171
|
+
cache: { write: 0.14, read: 0.0028 } # $0.0028/MTok cache hit
|
|
172
|
+
},
|
|
173
|
+
peak: {
|
|
174
|
+
input: { default: 0.44, over_200k: 0.44 }, # $0.44/MTok cache miss (peak)
|
|
175
|
+
output: { default: 1.32, over_200k: 1.32 }, # $1.32/MTok
|
|
176
|
+
cache: { write: 0.44, read: 0.014 } # $0.014/MTok cache hit
|
|
177
|
+
},
|
|
178
|
+
off_peak: {
|
|
179
|
+
input: { default: 0.22, over_200k: 0.22 }, # $0.22/MTok (half of peak)
|
|
180
|
+
output: { default: 0.66, over_200k: 0.66 }, # $0.66/MTok
|
|
181
|
+
cache: { write: 0.22, read: 0.007 } # $0.007/MTok cache hit
|
|
178
182
|
}
|
|
179
183
|
},
|
|
180
184
|
|
|
181
185
|
"deepseek-v4-pro" => {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
over_200k: 0.435
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
write:
|
|
192
|
-
|
|
186
|
+
deepseek: true,
|
|
187
|
+
legacy: {
|
|
188
|
+
input: { default: 0.435, over_200k: 0.435 }, # $0.435/MTok (pre-cutover flat)
|
|
189
|
+
output: { default: 0.87, over_200k: 0.87 }, # $0.87/MTok
|
|
190
|
+
cache: { write: 0.435, read: 0.003625 } # $0.003625/MTok cache hit
|
|
191
|
+
},
|
|
192
|
+
peak: {
|
|
193
|
+
input: { default: 1.32, over_200k: 1.32 }, # $1.32/MTok cache miss (peak)
|
|
194
|
+
output: { default: 3.96, over_200k: 3.96 }, # $3.96/MTok
|
|
195
|
+
cache: { write: 1.32, read: 0.044 } # $0.044/MTok cache hit
|
|
196
|
+
},
|
|
197
|
+
off_peak: {
|
|
198
|
+
input: { default: 0.66, over_200k: 0.66 }, # $0.66/MTok (half of peak)
|
|
199
|
+
output: { default: 1.98, over_200k: 1.98 }, # $1.98/MTok
|
|
200
|
+
cache: { write: 0.66, read: 0.022 } # $0.022/MTok cache hit
|
|
193
201
|
}
|
|
194
202
|
},
|
|
195
203
|
|
|
@@ -687,6 +695,10 @@ module Clacky
|
|
|
687
695
|
# Costs for prompts between 200K–272K will be slightly over-estimated.
|
|
688
696
|
TIERED_PRICING_THRESHOLD = 200_000
|
|
689
697
|
|
|
698
|
+
# DeepSeek switched from flat legacy rates to peak/off-peak billing at
|
|
699
|
+
# 2026-08-17 00:00 Beijing time (= 2026-08-16 16:00 UTC).
|
|
700
|
+
DEEPSEEK_PEAK_PRICING_START = Time.utc(2026, 8, 16, 16, 0, 0).freeze
|
|
701
|
+
|
|
690
702
|
class << self
|
|
691
703
|
# Calculate cost for the given model and usage
|
|
692
704
|
#
|
|
@@ -696,10 +708,12 @@ module Clacky
|
|
|
696
708
|
# - completion_tokens: number of output tokens
|
|
697
709
|
# - cache_creation_input_tokens: tokens written to cache (optional)
|
|
698
710
|
# - cache_read_input_tokens: tokens read from cache (optional)
|
|
711
|
+
# @param now [Time] Request time used to resolve peak/off-peak tiers
|
|
712
|
+
# (defaults to the current time)
|
|
699
713
|
# @return [Hash] Hash containing:
|
|
700
714
|
# - cost: Cost in USD (Float) or nil if model pricing is unknown
|
|
701
715
|
# - source: Cost source (:price) or nil if unknown (Symbol or nil)
|
|
702
|
-
def calculate_cost(model:, usage:)
|
|
716
|
+
def calculate_cost(model:, usage:, now: Time.now)
|
|
703
717
|
pricing_result = get_pricing_with_source(model)
|
|
704
718
|
pricing = pricing_result[:pricing]
|
|
705
719
|
source = pricing_result[:source]
|
|
@@ -708,6 +722,9 @@ module Clacky
|
|
|
708
722
|
# Unknown models should display as N/A, never fall back to guesses.
|
|
709
723
|
return { cost: nil, source: nil } unless pricing
|
|
710
724
|
|
|
725
|
+
# DeepSeek peak/off-peak pricing: resolve to the tier active at `now`.
|
|
726
|
+
pricing = resolve_deepseek_tier(pricing, now) if pricing[:deepseek]
|
|
727
|
+
|
|
711
728
|
prompt_tokens = usage[:prompt_tokens] || 0
|
|
712
729
|
completion_tokens = usage[:completion_tokens] || 0
|
|
713
730
|
cache_write_tokens = usage[:cache_creation_input_tokens] || 0
|
|
@@ -960,6 +977,24 @@ module Clacky
|
|
|
960
977
|
|
|
961
978
|
cache_cost
|
|
962
979
|
end
|
|
980
|
+
|
|
981
|
+
# Resolve a DeepSeek pricing entry (which holds legacy/peak/off_peak
|
|
982
|
+
# tiers) to the single tier that applies at the given time.
|
|
983
|
+
def resolve_deepseek_tier(pricing, now)
|
|
984
|
+
if now < DEEPSEEK_PEAK_PRICING_START
|
|
985
|
+
pricing[:legacy]
|
|
986
|
+
elsif deepseek_peak_hour?(now)
|
|
987
|
+
pricing[:peak]
|
|
988
|
+
else
|
|
989
|
+
pricing[:off_peak]
|
|
990
|
+
end
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
# Peak hours: 01:00-04:00 and 06:00-10:00 UTC (all other hours off-peak).
|
|
994
|
+
def deepseek_peak_hour?(time)
|
|
995
|
+
hour = time.utc.hour
|
|
996
|
+
(hour >= 1 && hour < 4) || (hour >= 6 && hour < 10)
|
|
997
|
+
end
|
|
963
998
|
end
|
|
964
999
|
end
|
|
965
1000
|
end
|
|
@@ -6,6 +6,22 @@ module Clacky
|
|
|
6
6
|
# Used by the Edit tool and edit preview to apply a consistent
|
|
7
7
|
# layered matching strategy: exact → trim → unescape → smart line match.
|
|
8
8
|
module StringMatcher
|
|
9
|
+
# Visually-identical Unicode punctuation LLMs swap for ASCII when
|
|
10
|
+
# re-quoting file content (and vice versa). Mapping is 1:1 so character
|
|
11
|
+
# indices are preserved, letting normalized matches map back to the
|
|
12
|
+
# original string.
|
|
13
|
+
HOMOGLYPHS = {
|
|
14
|
+
"\u2013" => "-", # – EN DASH
|
|
15
|
+
"\u2014" => "-", # — EM DASH
|
|
16
|
+
"\u2212" => "-", # − MINUS SIGN
|
|
17
|
+
"\u2018" => "'", # ‘ LEFT SINGLE QUOTATION MARK
|
|
18
|
+
"\u2019" => "'", # ’ RIGHT SINGLE QUOTATION MARK
|
|
19
|
+
"\u201C" => '"', # “ LEFT DOUBLE QUOTATION MARK
|
|
20
|
+
"\u201D" => '"' # ” RIGHT DOUBLE QUOTATION MARK
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
HOMOGLYPH_PATTERN = /[\u2013\u2014\u2212\u2018\u2019\u201C\u201D]/.freeze
|
|
24
|
+
|
|
9
25
|
# Find a matching string in content using a layered strategy.
|
|
10
26
|
#
|
|
11
27
|
# Strategy (applied in order):
|
|
@@ -13,7 +29,7 @@ module Clacky
|
|
|
13
29
|
# 2. Trimmed match (leading/trailing whitespace stripped)
|
|
14
30
|
# 3. Unescaped match (over-escaped sequences normalised)
|
|
15
31
|
# 4. Combined trim + unescape
|
|
16
|
-
# 5. Smart line-by-line match (tolerates indent
|
|
32
|
+
# 5. Smart line-by-line match (tolerates indent & Unicode homoglyphs)
|
|
17
33
|
#
|
|
18
34
|
# @param content [String] File content to search in
|
|
19
35
|
# @param old_string [String] String to locate
|
|
@@ -77,6 +93,14 @@ module Clacky
|
|
|
77
93
|
].uniq
|
|
78
94
|
end
|
|
79
95
|
|
|
96
|
+
# Map visually-identical Unicode punctuation to ASCII equivalents.
|
|
97
|
+
def self.normalize_homoglyphs(str)
|
|
98
|
+
return str if str.nil? || str.empty?
|
|
99
|
+
return str unless HOMOGLYPH_PATTERN.match?(str)
|
|
100
|
+
|
|
101
|
+
str.each_char.map { |ch| HOMOGLYPHS.fetch(ch, ch) }.join
|
|
102
|
+
end
|
|
103
|
+
|
|
80
104
|
# Convert over-escaped sequences back to their real characters.
|
|
81
105
|
# This handles the common case where LLMs double-escape backslashes.
|
|
82
106
|
#
|
|
@@ -150,7 +174,10 @@ module Clacky
|
|
|
150
174
|
norm1 = line1.sub(/^\s+/, " ").chomp
|
|
151
175
|
norm2 = line2.sub(/^\s+/, " ").chomp
|
|
152
176
|
|
|
153
|
-
norm1 == norm2 ||
|
|
177
|
+
norm1 == norm2 ||
|
|
178
|
+
norm1 == unescape_over_escaped(norm2) ||
|
|
179
|
+
normalize_homoglyphs(norm1) == normalize_homoglyphs(norm2) ||
|
|
180
|
+
normalize_homoglyphs(norm1) == normalize_homoglyphs(unescape_over_escaped(norm2))
|
|
154
181
|
end
|
|
155
182
|
end
|
|
156
183
|
end
|
data/lib/clacky/version.rb
CHANGED