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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +71 -0
  3. data/README.md +3 -3
  4. data/README_CN.md +2 -2
  5. data/README_JA.md +2 -2
  6. data/lib/clacky/agent.rb +2 -3
  7. data/lib/clacky/agent_config.rb +40 -18
  8. data/lib/clacky/brand_config.rb +12 -7
  9. data/lib/clacky/cli.rb +0 -1
  10. data/lib/clacky/default_extensions/ext-studio/ext.yml +1 -1
  11. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +27 -14
  12. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +22 -5
  13. data/lib/clacky/default_extensions/general/ext.yml +1 -1
  14. data/lib/clacky/extension/api_loader.rb +4 -11
  15. data/lib/clacky/extension/packager.rb +1 -1
  16. data/lib/clacky/extension/scaffold/templates/hello/test/handler_test.rb.erb +22 -0
  17. data/lib/clacky/extension/verifier.rb +1 -1
  18. data/lib/clacky/media/generator.rb +3 -3
  19. data/lib/clacky/message_format/anthropic.rb +19 -2
  20. data/lib/clacky/message_format/bedrock.rb +8 -3
  21. data/lib/clacky/message_history.rb +1 -1
  22. data/lib/clacky/providers.rb +159 -105
  23. data/lib/clacky/server/channel/adapters/feishu/ws_client.rb +6 -0
  24. data/lib/clacky/server/http_server.rb +67 -79
  25. data/lib/clacky/session_manager.rb +7 -0
  26. data/lib/clacky/skill_loader.rb +4 -0
  27. data/lib/clacky/tools/base.rb +11 -1
  28. data/lib/clacky/tools/edit.rb +28 -0
  29. data/lib/clacky/tools/terminal.rb +5 -0
  30. data/lib/clacky/tools/trash_manager.rb +1 -1
  31. data/lib/clacky/ui2/components/inline_input.rb +1 -0
  32. data/lib/clacky/ui2/components/input_area.rb +1 -0
  33. data/lib/clacky/ui2/components/modal_component.rb +0 -1
  34. data/lib/clacky/ui2/components/todo_area.rb +1 -0
  35. data/lib/clacky/ui2/line_editor.rb +1 -0
  36. data/lib/clacky/ui2/markdown_renderer.rb +5 -1
  37. data/lib/clacky/ui2/ui_controller.rb +1 -0
  38. data/lib/clacky/utils/model_pricing.rb +62 -27
  39. data/lib/clacky/utils/string_matcher.rb +29 -2
  40. data/lib/clacky/version.rb +1 -1
  41. data/lib/clacky/web/app.css +278 -221
  42. data/lib/clacky/web/components/chat-navigator.js +1 -1
  43. data/lib/clacky/web/features/billing/store.js +3 -3
  44. data/lib/clacky/web/features/billing/view.js +5 -61
  45. data/lib/clacky/web/features/extensions/store.js +65 -41
  46. data/lib/clacky/web/features/extensions/view.js +74 -10
  47. data/lib/clacky/web/features/new-session/view.js +24 -0
  48. data/lib/clacky/web/features/version/view.js +2 -2
  49. data/lib/clacky/web/i18n.js +36 -22
  50. data/lib/clacky/web/index.html +17 -17
  51. data/lib/clacky/web/projects.js +83 -285
  52. data/lib/clacky/web/sessions.js +151 -17
  53. data/lib/clacky/web/settings.js +6 -1
  54. 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|
@@ -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"))
@@ -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
@@ -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 = File.expand_path(file_path, project_root)
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,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "tty-screen"
3
4
  require_relative "../line_editor"
4
5
 
5
6
  module Clacky
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pastel"
4
+ require "tty-screen"
4
5
  require "tempfile"
5
6
  require_relative "../theme_manager"
6
7
  require_relative "../line_editor"
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'io/console'
4
- require 'tty-prompt'
5
4
  require_relative 'base_component'
6
5
 
7
6
  module Clacky
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pastel"
4
+ require "tty-screen"
4
5
 
5
6
  module Clacky
6
7
  module UI2
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "pastel"
4
+ require "tty-screen"
4
5
 
5
6
  module Clacky
6
7
  module UI2
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "tty-markdown"
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
 
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "pastel"
3
4
  require_relative "layout_manager"
4
5
  require_relative "view_renderer"
5
6
  require_relative "progress_handle"
@@ -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
- # Cache-hit prices are 1/10 of launch (global, permanent since 2026-04-26).
163
- # v4-pro is on a 75% off promo through 2026-05-31 23:59 CST; the same
164
- # numbers become the permanent price after that date (= original × 1/4),
165
- # so we bill at the discounted rates both before and after the cutover.
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
- input: {
168
- default: 0.14, # $0.14/MTok cache miss
169
- over_200k: 0.14 # no tiered pricing
170
- },
171
- output: {
172
- default: 0.28, # $0.28/MTok
173
- over_200k: 0.28
174
- },
175
- cache: {
176
- write: 0.14, # DeepSeek doesn't charge extra for writes; bill at miss rate
177
- read: 0.0028 # $0.0028/MTok cache hit
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
- input: {
183
- default: 0.435, # $0.435/MTok cache miss (75% off; permanent after 5/31)
184
- over_200k: 0.435
185
- },
186
- output: {
187
- default: 0.87, # $0.87/MTok (75% off; permanent after 5/31)
188
- over_200k: 0.87
189
- },
190
- cache: {
191
- write: 0.435, # no separate write charge; bill at miss rate
192
- read: 0.003625 # $0.003625/MTok cache hit (1/10 × 75% off)
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 differences)
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 || norm1 == unescape_over_escaped(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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "1.5.7"
4
+ VERSION = "1.5.9"
5
5
  end