openclacky 0.9.36 → 0.9.37

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a7f74cba226bc2332676ed9f0a72c27f3fb6b45bafacd60198f7ec9b59a84c7
4
- data.tar.gz: 7396d2258a38e203a5ffe217f0409fb101487bdb74dad8f224649a09b8adb775
3
+ metadata.gz: 951665db04cf6c2a4f8ef9b5c0555f4df91b84af08f63d21097d97cbd64d44b2
4
+ data.tar.gz: 82457a522007f54ecd5fcc36fee8e79cf16a65996dd9b95d7a00fd0dcfbc2cac
5
5
  SHA512:
6
- metadata.gz: 7f10c234d4a22d825e3623bff7e0a0faf55af4cea34dc15a939fbde3e6e3792bf1a11cd97d68e0c6890dd37c011814019cfed7dea70c724fcb2fc11b49cff15a
7
- data.tar.gz: 12dcd062214b42191f33e4b2e09ec62f24cb9f91206451ff23861672a2cc8b680d89e5498cf0da6c53b74d4da2933bd53863666cee608539edb398f016226df5
6
+ metadata.gz: 031b1031a702aca3a7cee36ea8ba23bc4982e95f8381e59d07ecb652432f6bc8a40e9a386e4a254d640f18f0088d9e18fa1f6434d92c038844f4821cef40a703
7
+ data.tar.gz: 5c5c36517efebb37a7a44d0531e0baadedb8600319682c39696a971771852019f2649386e15a3505d9ae32e86cf8c5818c64cf36943247220a7db6e0df24e994
data/CHANGELOG.md CHANGED
@@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.9.37] - 2026-04-24
11
+
12
+ ### Fixed
13
+ - **Critical: pinned sessions could silently disappear from the sidebar** ("the pinned one isn't showing, and refreshing sometimes fixes it"). Root cause: the backend `list` endpoint only sorted by `created_at` and applied `limit` blindly, so a pinned session older than the first page's rows was cut off entirely — the frontend's `byPinnedAndTime` sort never saw it. "Refreshing sometimes worked" only if the pinned session happened to be recent enough to land in the first 20 rows. Fix: `SessionRegistry#list` now partitions results and **always returns ALL matching pinned sessions on the first page regardless of `limit`**, followed by up to `limit` non-pinned sessions. The `before` cursor applies only to the non-pinned section, so "load more" pages never re-send or duplicate pinned rows. `/api/sessions`'s `has_more` is now computed from non-pinned overflow only. Frontend `loadMore` cursor also excludes pinned rows so pagination jumps correctly. Regression specs cover: (a) an old pinned session still appears when `limit=3`, (b) multiple pinned sessions all fit on page one with `limit=1`, (c) pinned sessions never duplicate into `before`-cursor pages.
14
+ - **Critical: saving one model in Web UI Settings silently wiped other models' API keys.** The 0.9.36 index→id refactor (commit `b61e22e`) rebuilt each model hash from scratch on save (`"api_key" => api_key.to_s`), dropping the old `existing["api_key"] = api_key if api_key` guard. Combined with `/api/config` returning only `api_key_masked` (never `api_key`), every non-edited row in the POST body arrived with `api_key: undefined` — the backend then rewrote those rows' keys to `""`. Now `api_save_config` has three explicit cases for resolving `api_key`: (1) masked placeholder → keep stored key, (2) **missing/blank on an existing row → keep stored key (this fix)**, (3) otherwise use incoming value. Brand-new models (no `id`) still create with an empty key as before.
15
+ - **Critical: in-app upgrade no longer falsely reports failure.** The 0.9.36 upgrade flow shared a PTY helper (`run_shell`) with the new unified Terminal tool, which — by design — returns early with a `session_id` when command output stays quiet for 3 seconds. Long-running `gem install` operations routinely hit this during dependency resolution, causing the Web UI to show `✗ Upgrade failed.` even when the gem installed successfully. `run_shell` now delegates to a new `Terminal.run_sync` Ruby API that polls until the command truly completes, and `finish_upgrade` additionally re-checks the installed gem version as a defensive fallback.
16
+ - **Critical: "历史记录获取失败 (500: source sequence is illegal/malformed utf-8)" when opening a session.** When `file_reader` / `edit` / `grep` / `glob` encountered a file with non-UTF-8 bytes (e.g. GBK-encoded text or a Chinese Windows-exported CSV), the dirty bytes flowed through tool results into the agent history and session chunks on disk. Later, when `GET /api/sessions/:id/messages` replayed that history, `JSON.generate` would blow up on the invalid byte sequence and return 500. Now every IO source point scrubs invalid bytes to U+FFFD (`�`) at read time: `file_reader` (both content and directory entry names), `edit`, `grep` (`File.foreach` + context `readlines`), `glob` (`Dir.glob` path strings), `session_serializer` (chunk md replay), and `tool_executor` (diff preview). A defense-in-depth layer in `MessageHistory#append` / `#replace_all` recursively sanitizes every string that enters the message tree — so even a future tool that forgets to scrub cannot poison the session.
17
+
18
+ ### Added
19
+ - **New `Terminal.run_sync` internal API** for Ruby callers that need synchronous command capture (drop-in replacement for `Open3.capture2e`, but using the same PTY + login-shell + Security pipeline as the AI-facing tool).
20
+ - **DeepSeek V4 provider preset.** New `deepseekv4` entry in `Clacky::Providers` (positioned right after `openrouter`) with default model `deepseek-v4-pro` and models list `deepseek-v4-flash`, `deepseek-v4-pro`, plus the deprecated-aliases `deepseek-chat` / `deepseek-reasoner` (to be removed on 2026-07-24). Uses the OpenAI-compatible endpoint `https://api.deepseek.com`; for Anthropic-format usage, point `base_url` at `https://api.deepseek.com/anthropic` and switch `api` to `anthropic-messages`.
21
+ - **DeepSeek V4 pricing.** Added `deepseek-v4-flash` ($0.14 in / $0.28 out / $0.028 cache-hit per MTok) and `deepseek-v4-pro` ($1.74 in / $3.48 out / $0.145 cache-hit per MTok) to `Clacky::ModelPricing::PRICING_TABLE`. Legacy aliases `deepseek-chat` and `deepseek-reasoner` normalize to `deepseek-v4-flash`. DeepSeek has no separate cache-write charge, so cache writes are billed at the cache-miss (input) rate. Prices sourced from the official pricing page (USD per 1M tokens).
22
+
10
23
  ## [0.9.36] - 2026-04-24
11
24
 
12
25
  ### Fixed
@@ -243,7 +243,12 @@ module Clacky
243
243
  end
244
244
  visited = visited.dup.add(canonical)
245
245
 
246
- raw = File.read(resolved)
246
+ # Scrub invalid UTF-8 bytes defensively — chunk files written before
247
+ # the 0.9.37 fix may contain poisoned bytes from file_reader results.
248
+ raw = File.read(resolved).then do |s|
249
+ s.encoding == Encoding::UTF_8 && s.valid_encoding? ? s :
250
+ s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
251
+ end
247
252
 
248
253
  # Parse YAML front matter to get archived_at for synthetic timestamps
249
254
  archived_at = nil
@@ -358,6 +358,7 @@ module Clacky
358
358
  @ui&.show_diff("", new_content, max_lines: 50)
359
359
  else
360
360
  old_content = File.read(expanded_path)
361
+ old_content = old_content.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}") unless old_content.encoding == Encoding::UTF_8 && old_content.valid_encoding?
361
362
  @ui&.show_diff(old_content, new_content, max_lines: 50)
362
363
  end
363
364
  nil
@@ -34,7 +34,7 @@ module Clacky
34
34
  if message[:role] == "user"
35
35
  drop_dangling_tool_calls!
36
36
  end
37
- @messages << message
37
+ @messages << deep_sanitize_utf8(message)
38
38
  self
39
39
  end
40
40
 
@@ -53,7 +53,7 @@ module Clacky
53
53
 
54
54
  # Replace the entire message list (used by compression rebuild).
55
55
  def replace_all(new_messages)
56
- @messages = new_messages.dup
56
+ @messages = new_messages.map { |m| deep_sanitize_utf8(m) }
57
57
  self
58
58
  end
59
59
 
@@ -229,5 +229,46 @@ module Clacky
229
229
  private def strip_internal_fields(message)
230
230
  message.reject { |k, _| INTERNAL_FIELDS.include?(k) }
231
231
  end
232
+
233
+ # Defense-in-depth: recursively scrub invalid UTF-8 bytes from every String
234
+ # stored in the message tree. Even if a tool forgets to scrub its output,
235
+ # nothing poisoned will ever reach session persistence or JSON.generate.
236
+ #
237
+ # Fast path: if the tree contains only valid UTF-8 strings, the original
238
+ # object is returned unchanged — preserving object identity for callers
239
+ # that rely on `equal?` (e.g. rollback_before).
240
+ # Slow path: any invalid byte triggers a rebuild with scrubbed strings
241
+ # (invalid bytes → U+FFFD).
242
+ private def deep_sanitize_utf8(obj)
243
+ case obj
244
+ when String
245
+ return obj if obj.encoding == Encoding::UTF_8 && obj.valid_encoding?
246
+ obj.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
247
+ when Hash
248
+ return obj unless contains_dirty_utf8?(obj)
249
+ obj.transform_values { |v| deep_sanitize_utf8(v) }
250
+ when Array
251
+ return obj unless contains_dirty_utf8?(obj)
252
+ obj.map { |v| deep_sanitize_utf8(v) }
253
+ else
254
+ obj
255
+ end
256
+ end
257
+
258
+ # Cheap recursive check: does this subtree contain any invalid-UTF-8 string?
259
+ # Short-circuits on first offender. Keeps the common case (all valid UTF-8)
260
+ # allocation-free.
261
+ private def contains_dirty_utf8?(obj)
262
+ case obj
263
+ when String
264
+ !(obj.encoding == Encoding::UTF_8 && obj.valid_encoding?)
265
+ when Hash
266
+ obj.any? { |_, v| contains_dirty_utf8?(v) }
267
+ when Array
268
+ obj.any? { |v| contains_dirty_utf8?(v) }
269
+ else
270
+ false
271
+ end
272
+ end
232
273
  end
233
274
  end
@@ -41,6 +41,27 @@ module Clacky
41
41
  "website_url" => "https://openrouter.ai/keys"
42
42
  }.freeze,
43
43
 
44
+ "deepseekv4" => {
45
+ "name" => "DeepSeek V4",
46
+ # DeepSeek API is compatible with both OpenAI and Anthropic formats.
47
+ # We use the OpenAI-compatible endpoint here (matches kimi/minimax/glm style).
48
+ # For Anthropic-format usage, point base_url at https://api.deepseek.com/anthropic
49
+ # and change "api" to "anthropic-messages".
50
+ "base_url" => "https://api.deepseek.com",
51
+ "api" => "openai-completions",
52
+ "default_model" => "deepseek-v4-pro",
53
+ # Note: deepseek-chat and deepseek-reasoner are legacy aliases being
54
+ # deprecated on 2026-07-24; they map to deepseek-v4-flash's non-thinking
55
+ # and thinking modes respectively. Prefer deepseek-v4-flash / deepseek-v4-pro.
56
+ "models" => [
57
+ "deepseek-v4-flash",
58
+ "deepseek-v4-pro",
59
+ "deepseek-chat",
60
+ "deepseek-reasoner"
61
+ ],
62
+ "website_url" => "https://platform.deepseek.com/api_keys"
63
+ }.freeze,
64
+
44
65
  "minimax" => {
45
66
  "name" => "Minimax",
46
67
  "base_url" => "https://api.minimaxi.com/v1",
@@ -468,10 +468,19 @@ module Clacky
468
468
  # Backward-compat: ?source=<x> and ?profile=coding → type
469
469
  type ||= query["profile"].to_s.strip.then { |v| v.empty? ? nil : v }
470
470
  type ||= query["source"].to_s.strip.then { |v| v.empty? ? nil : v }
471
- # Fetch one extra to detect has_more without a separate count query
471
+
472
+ # Fetch one extra NON-PINNED row to detect has_more without a separate count query.
473
+ # `registry.list` always returns ALL matching pinned rows first (on the
474
+ # first page; `before` == nil), followed by non-pinned rows up to `limit+1`.
475
+ # So has_more is determined by whether the non-pinned section overflowed.
472
476
  sessions = @registry.list(limit: limit + 1, before: before, q: q, date: date, type: type)
473
- has_more = sessions.size > limit
474
- sessions = sessions.first(limit)
477
+
478
+ # Split pinned vs non-pinned to apply has_more only to the non-pinned tail.
479
+ pinned_part, non_pinned_part = sessions.partition { |s| s[:pinned] }
480
+ has_more = non_pinned_part.size > limit
481
+ non_pinned_part = non_pinned_part.first(limit)
482
+ sessions = pinned_part + non_pinned_part
483
+
475
484
  json_response(res, 200, { sessions: sessions, has_more: has_more })
476
485
  end
477
486
 
@@ -930,8 +939,12 @@ module Clacky
930
939
  key.empty? ? nil : key
931
940
  end
932
941
 
933
- # Extract bearer token / query param / cookie from a WEBrick request.
934
- # Priority: Authorization: Bearer > ?access_key= > Cookie clacky_access_key
942
+ # Extract bearer token or query param from a WEBrick request.
943
+ # Priority: Authorization: Bearer > ?access_key=
944
+ # The query string form is only used by WebSocket connections, which
945
+ # cannot set custom headers from the browser. All HTTP clients —
946
+ # including the web UI (via a fetch interceptor in auth.js) — use the
947
+ # Authorization header.
935
948
  private def extract_key(req)
936
949
  auth = req["Authorization"].to_s.strip
937
950
  if auth.start_with?("Bearer ")
@@ -943,10 +956,6 @@ module Clacky
943
956
  token = query["access_key"].to_s.strip
944
957
  return token unless token.empty?
945
958
 
946
- req.cookies.each do |c|
947
- return c.value if c.name == "clacky_access_key" && !c.value.to_s.empty?
948
- end
949
-
950
959
  nil
951
960
  end
952
961
 
@@ -1119,7 +1128,18 @@ module Clacky
1119
1128
  end
1120
1129
 
1121
1130
  # Broadcast final upgrade result with appropriate log message.
1131
+ #
1132
+ # Defensive post-check: if `run_shell` reported failure but the gem
1133
+ # is in fact now installed at the latest version, reverse the verdict.
1134
+ # This guards against false negatives from the Terminal idle-poll
1135
+ # mechanism (see: 0.9.36 upgrade failure bug).
1122
1136
  private def finish_upgrade(success, fallback_hint: "gem update openclacky")
1137
+ if !success && gem_actually_upgraded?
1138
+ Clacky::Logger.warn("[Upgrade] run_shell reported failure, but installed version matches latest — treating as success.")
1139
+ broadcast_all(type: "upgrade_log", line: "\n(Verified: the new version is installed — reclassifying as success.)\n")
1140
+ success = true
1141
+ end
1142
+
1123
1143
  if success
1124
1144
  Clacky::Logger.info("[Upgrade] Success!")
1125
1145
  broadcast_all(type: "upgrade_log", line: "\n✓ Upgrade successful! Please restart the server to apply the new version.\n")
@@ -1131,6 +1151,22 @@ module Clacky
1131
1151
  end
1132
1152
  end
1133
1153
 
1154
+ # Check whether the latest published version of openclacky is already
1155
+ # installed locally. Used as a post-upgrade sanity check so a flaky
1156
+ # run_shell result doesn't mask a successful install.
1157
+ # Returns false on any error (conservative — don't fabricate success).
1158
+ private def gem_actually_upgraded?
1159
+ latest = fetch_latest_version_from_rubygems_api
1160
+ return false unless latest
1161
+
1162
+ out, exit_code = run_shell("gem list openclacky -i -v #{latest}", timeout: 30)
1163
+ return false unless exit_code&.zero?
1164
+ out.to_s.strip.downcase == "true"
1165
+ rescue StandardError => e
1166
+ Clacky::Logger.warn("[Upgrade] gem_actually_upgraded? error: #{e.message}")
1167
+ false
1168
+ end
1169
+
1134
1170
  # POST /api/restart
1135
1171
  # Re-execs the current process so the newly installed gem version is loaded.
1136
1172
  # Uses the absolute script path captured at startup to avoid relative-path issues.
@@ -1242,18 +1278,11 @@ module Clacky
1242
1278
  # Run a shell command via the unified Terminal tool and return
1243
1279
  # [output, exit_code] — drop-in replacement for Open3.capture2e.
1244
1280
  #
1245
- # Uses Terminal#execute so the command inherits the user's real
1246
- # login shell (rbenv/mise shims, configured gem mirrors, etc.).
1247
- # On timeout / still-running, returns [output_so_far, nil].
1248
- #
1249
- # The command is routed through the Security layer like any other
1250
- # Terminal call; server-side commands (`gem ...`, `curl -fsSL ... -o ...`)
1251
- # pass through unchanged.
1281
+ # Delegates to Terminal.run_sync which handles the idle-poll loop
1282
+ # internally (see its docs for why that's needed — this wrapper used
1283
+ # to re-implement it wrong and caused the 0.9.36 upgrade bug).
1252
1284
  private def run_shell(command, timeout: 120)
1253
- result = Clacky::Tools::Terminal.new.execute(command: command, timeout: timeout)
1254
- output = result[:output].to_s
1255
- exit_code = result[:exit_code] # nil when the session is still running
1256
- [output, exit_code]
1285
+ Clacky::Tools::Terminal.run_sync(command, timeout: timeout)
1257
1286
  end
1258
1287
 
1259
1288
  # ── Channel API ───────────────────────────────────────────────────────────
@@ -1915,18 +1944,32 @@ module Clacky
1915
1944
  # Lookup by id only. No id means a brand-new model — we mint one.
1916
1945
  existing = m["id"] && existing_by_id[m["id"]]
1917
1946
 
1918
- # Resolve api_key: if masked placeholder, keep the stored key
1919
- api_key = if m["api_key"].to_s.include?("****")
1920
- existing&.dig("api_key")
1947
+ # Resolve api_key with THREE cases (ordered, fail-safe):
1948
+ # 1. Incoming key is the masked placeholder ("sk-ab12****...5678")
1949
+ # → user didn't retype; keep the stored key.
1950
+ # 2. Incoming key is missing/blank AND we have an existing model
1951
+ # → the browser omitted api_key for non-edited rows; keep the
1952
+ # stored key. This is the critical 0.9.37 fix — without it,
1953
+ # saving one model silently wiped api_keys of all others,
1954
+ # because the frontend only ever hydrates api_key for the
1955
+ # row currently being edited (/api/config returns only
1956
+ # api_key_masked, never api_key).
1957
+ # 3. Otherwise: user typed a new key (or this is a brand-new
1958
+ # model); use the incoming value.
1959
+ incoming_key = m["api_key"].to_s
1960
+ api_key = if incoming_key.include?("****")
1961
+ existing&.dig("api_key").to_s
1962
+ elsif incoming_key.empty? && existing
1963
+ existing["api_key"].to_s
1921
1964
  else
1922
- m["api_key"]
1965
+ incoming_key
1923
1966
  end
1924
1967
 
1925
1968
  {
1926
1969
  "id" => (existing && existing["id"]) || SecureRandom.uuid,
1927
1970
  "model" => m["model"].to_s,
1928
1971
  "base_url" => m["base_url"].to_s,
1929
- "api_key" => api_key.to_s,
1972
+ "api_key" => api_key,
1930
1973
  "anthropic_format" => m["anthropic_format"] || false,
1931
1974
  "type" => m["type"]
1932
1975
  }.tap { |h| h.delete("type") if h["type"].nil? || h["type"].to_s.empty? }
@@ -143,11 +143,22 @@ module Clacky
143
143
  # nil = no source filter (all sessions)
144
144
  # profile: "general"|"coding"|nil
145
145
  # nil = no agent_profile filter
146
- # limit: max sessions to return
146
+ # limit: max sessions to return (applies to NON-PINNED only; see below)
147
147
  # before: ISO8601 cursor — only sessions with created_at < before
148
+ # (also applies to NON-PINNED only; pinned items are a separate
149
+ # logical section, they should never be paginated away)
150
+ # include_pinned: when true (default), all matching pinned sessions are
151
+ # always returned on the FIRST page (before == nil) regardless
152
+ # of limit. Subsequent pages (before set) contain only
153
+ # non-pinned sessions. This guarantees that users who pinned
154
+ # an old session always see it at the top of the sidebar,
155
+ # even if many newer sessions exist.
156
+ #
157
+ # Ordering of the returned array:
158
+ # [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
148
159
  #
149
160
  # source and profile are orthogonal — either can be nil independently.
150
- def list(limit: nil, before: nil, q: nil, date: nil, type: nil)
161
+ def list(limit: nil, before: nil, q: nil, date: nil, type: nil, include_pinned: true)
151
162
  return [] unless @session_manager
152
163
 
153
164
  live = @mutex.synchronize do
@@ -185,10 +196,26 @@ module Clacky
185
196
  }
186
197
  end
187
198
 
188
- all = all.select { |s| (s[:created_at] || "") < before } if before
189
- all = all.first(limit) if limit
199
+ # ── Split pinned vs non-pinned BEFORE applying `before`/`limit`.
200
+ # Pinned sessions bypass pagination entirely so an old pinned session
201
+ # never falls off the first page just because newer sessions exist.
202
+ # (Regression fix for 0.9.37: previously `all_sessions` was only
203
+ # sorted by created_at and `limit` cut off old pinned rows, making
204
+ # them invisible until the user clicked "load more".)
205
+ pinned, non_pinned = all.partition { |s| s[:pinned] }
206
+
207
+ # `before` cursor ONLY applies to non-pinned (paginated) sessions.
208
+ non_pinned = non_pinned.select { |s| (s[:created_at] || "") < before } if before
209
+ non_pinned = non_pinned.first(limit) if limit
210
+
211
+ # Pinned section: only included on the first page (before == nil) so
212
+ # "load more" responses don't re-send them. On first page, return ALL
213
+ # matching pinned sessions regardless of limit.
214
+ pinned_section = (include_pinned && before.nil?) ? pinned : []
215
+
216
+ ordered = pinned_section + non_pinned
190
217
 
191
- all.map do |s|
218
+ ordered.map do |s|
192
219
  id = s[:session_id]
193
220
  ls = live[id]
194
221
  {
@@ -44,7 +44,10 @@ module Clacky
44
44
  end
45
45
 
46
46
  begin
47
- content = File.read(path)
47
+ # Scrub invalid UTF-8 bytes at read time — otherwise editing a file
48
+ # that contains non-UTF-8 bytes would poison history / error messages
49
+ # and cause JSON.generate to fail during replay.
50
+ content = safe_utf8(File.read(path))
48
51
 
49
52
  # Find matching string using layered strategy (shared with preview)
50
53
  match_result = Utils::StringMatcher.find_match(content, old_string)
@@ -127,6 +130,13 @@ module Clacky
127
130
  replacements = result[:replacements] || result["replacements"] || 1
128
131
  "Modified #{replacements} occurrence#{replacements > 1 ? "s" : ""}"
129
132
  end
133
+
134
+ # Scrub invalid UTF-8 byte sequences (see file_reader.rb for rationale).
135
+ private def safe_utf8(str)
136
+ return str if str.nil?
137
+ return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
138
+ str.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
139
+ end
130
140
  end
131
141
  end
132
142
  end
@@ -86,8 +86,10 @@ module Clacky
86
86
  }
87
87
  end
88
88
 
89
- # Read text file with optional line range
90
- all_lines = File.readlines(expanded_path)
89
+ # Read text file with optional line range.
90
+ # Scrub invalid UTF-8 bytes (e.g. GBK-encoded files) so downstream
91
+ # JSON.generate / history persistence won't blow up later.
92
+ all_lines = File.readlines(expanded_path).map! { |line| safe_utf8(line) }
91
93
  total_lines = all_lines.size
92
94
 
93
95
  # Calculate start index (convert 1-indexed to 0-indexed)
@@ -313,7 +315,11 @@ module Clacky
313
315
  # List first-level directory contents (files and directories)
314
316
  private def list_directory_contents(path)
315
317
  begin
316
- entries = Dir.entries(path).reject { |entry| entry == "." || entry == ".." }
318
+ # Scrub entry names filenames on disk may contain non-UTF-8 bytes
319
+ # (e.g. GBK/Shift-JIS names on macOS/Linux) which would poison history.
320
+ entries = Dir.entries(path)
321
+ .map { |entry| safe_utf8(entry) }
322
+ .reject { |entry| entry == "." || entry == ".." }
317
323
 
318
324
  # Separate files and directories
319
325
  files = []
@@ -353,6 +359,16 @@ module Clacky
353
359
  }
354
360
  end
355
361
  end
362
+
363
+ # Scrub invalid UTF-8 byte sequences so the result survives
364
+ # JSON.generate (session replay, API responses).
365
+ # Invalid bytes are replaced with U+FFFD (�). Valid UTF-8 is
366
+ # returned untouched via the fast path.
367
+ private def safe_utf8(str)
368
+ return str if str.nil?
369
+ return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
370
+ str.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
371
+ end
356
372
  end
357
373
  end
358
374
  end
@@ -84,6 +84,7 @@ module Clacky
84
84
  always_ignored_dirs = Clacky::Utils::FileIgnoreHelper::ALWAYS_IGNORED_DIRS
85
85
 
86
86
  all_matches = Dir.glob(full_pattern, File::FNM_DOTMATCH)
87
+ .map { |p| p.encoding == Encoding::UTF_8 && p.valid_encoding? ? p : p.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}") }
87
88
  .reject { |path| File.directory?(path) }
88
89
  .reject { |path| path.end_with?(".", "..") }
89
90
  .reject do |path|
@@ -271,8 +271,10 @@ module Clacky
271
271
  def search_file(file, regex, context_lines, max_matches)
272
272
  matches = []
273
273
 
274
- # Use File.foreach for memory-efficient line-by-line reading
275
- File.foreach(file, chomp: true).with_index do |line, index|
274
+ # Use File.foreach for memory-efficient line-by-line reading.
275
+ # Scrub invalid UTF-8 bytes so results survive JSON encoding.
276
+ File.foreach(file, chomp: true).with_index do |raw_line, index|
277
+ line = safe_utf8(raw_line)
276
278
  # Stop if we have enough matches for this file
277
279
  break if matches.length >= max_matches
278
280
 
@@ -302,7 +304,7 @@ module Clacky
302
304
 
303
305
  # Get context lines around a match
304
306
  def get_line_context(file, match_index, context_lines)
305
- lines = File.readlines(file, chomp: true)
307
+ lines = File.readlines(file, chomp: true).map! { |l| safe_utf8(l) }
306
308
  start_line = [0, match_index - context_lines].max
307
309
  end_line = [lines.length - 1, match_index + context_lines].min
308
310
 
@@ -325,6 +327,13 @@ module Clacky
325
327
  rescue StandardError
326
328
  nil
327
329
  end
330
+
331
+ # Scrub invalid UTF-8 byte sequences (see file_reader.rb for rationale).
332
+ private def safe_utf8(str)
333
+ return str if str.nil?
334
+ return str if str.encoding == Encoding::UTF_8 && str.valid_encoding?
335
+ str.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
336
+ end
328
337
  end
329
338
  end
330
339
  end
@@ -147,6 +147,71 @@ module Clacky
147
147
  Clacky::Tools::Security.command_safe_for_auto_execution?(command)
148
148
  end
149
149
 
150
+ # ---------------------------------------------------------------------
151
+ # Internal Ruby API — synchronous capture
152
+ # ---------------------------------------------------------------------
153
+ #
154
+ # Run a shell command and BLOCK until it terminates, returning
155
+ # [output, exit_code]. Drop-in replacement for Open3.capture2e that
156
+ # goes through the same PTY + login-shell + Security pipeline used by
157
+ # the AI-facing tool (so rbenv/mise shims and gem mirrors work).
158
+ #
159
+ # Why this exists separately from #execute:
160
+ #
161
+ # `execute` may return early with a :session_id the moment output
162
+ # goes idle for DEFAULT_IDLE_MS (3s) — this is intentional for AI
163
+ # agents (they can inspect progress, inject input, decide to kill).
164
+ # Ruby callers like the HTTP server's upgrade flow only care about
165
+ # "did it finish, with what output, what exit code" — they need
166
+ # synchronous semantics. Previously each caller re-implemented the
167
+ # poll loop (and 0.9.36's run_shell forgot to, causing the upgrade
168
+ # failure bug).
169
+ #
170
+ # NOT exposed in tool_parameters — AI agents cannot invoke this.
171
+ #
172
+ # @param command [String] the shell command to run
173
+ # @param timeout [Integer] per-poll timeout AND the basis for the
174
+ # overall deadline (deadline = timeout + 60s)
175
+ # @param cwd [String] optional working directory
176
+ # @param env [Hash] optional env overrides
177
+ # @return [Array(String, Integer|nil)] [output, exit_code].
178
+ # exit_code is nil only if the overall deadline was hit and
179
+ # the session had to be force-killed.
180
+ def self.run_sync(command, timeout: 120, cwd: nil, env: nil)
181
+ terminal = new
182
+ result = terminal.execute(
183
+ command: command,
184
+ timeout: timeout,
185
+ cwd: cwd,
186
+ env: env,
187
+ )
188
+ output = result[:output].to_s
189
+
190
+ # Hard deadline in wall-clock terms — a genuinely stuck command
191
+ # must terminate. Each individual poll still carries `timeout`.
192
+ deadline = Time.now + timeout.to_i + 60
193
+
194
+ while result[:exit_code].nil? && result[:session_id] && Time.now < deadline
195
+ result = terminal.execute(
196
+ session_id: result[:session_id],
197
+ input: "",
198
+ timeout: timeout,
199
+ )
200
+ output += result[:output].to_s
201
+ end
202
+
203
+ # Deadline exceeded — best-effort cleanup so the session doesn't leak.
204
+ if result[:exit_code].nil? && result[:session_id]
205
+ begin
206
+ terminal.execute(session_id: result[:session_id], kill: true)
207
+ rescue StandardError
208
+ # swallow — cleanup is best-effort
209
+ end
210
+ end
211
+
212
+ [output, result[:exit_code]]
213
+ end
214
+
150
215
  # ---------------------------------------------------------------------
151
216
  # 1) Start a new command
152
217
  # ---------------------------------------------------------------------
@@ -105,6 +105,42 @@ module Clacky
105
105
  }
106
106
  },
107
107
 
108
+ # DeepSeek V4 models
109
+ # Source: https://api-docs.deepseek.com/quick_start/pricing (USD / 1M tokens)
110
+ # DeepSeek billing model:
111
+ # - "cache miss input" = regular prompt_tokens rate
112
+ # - "cache hit input" = cache_read rate (DeepSeek has no separate cache-write charge)
113
+ # - No tiered pricing (single rate regardless of context length)
114
+ "deepseek-v4-flash" => {
115
+ input: {
116
+ default: 0.14, # $0.14/MTok cache miss
117
+ over_200k: 0.14 # no tiered pricing
118
+ },
119
+ output: {
120
+ default: 0.28, # $0.28/MTok
121
+ over_200k: 0.28
122
+ },
123
+ cache: {
124
+ write: 0.14, # DeepSeek doesn't charge extra for writes; bill at miss rate
125
+ read: 0.028 # $0.028/MTok cache hit
126
+ }
127
+ },
128
+
129
+ "deepseek-v4-pro" => {
130
+ input: {
131
+ default: 1.74, # $1.74/MTok cache miss
132
+ over_200k: 1.74
133
+ },
134
+ output: {
135
+ default: 3.48, # $3.48/MTok
136
+ over_200k: 3.48
137
+ },
138
+ cache: {
139
+ write: 1.74, # no separate write charge; bill at miss rate
140
+ read: 0.145 # $0.145/MTok cache hit
141
+ }
142
+ },
143
+
108
144
  # Default fallback pricing (conservative estimates)
109
145
  "default" => {
110
146
  input: {
@@ -238,6 +274,15 @@ module Clacky
238
274
  "claude-3-5-sonnet-20240620"
239
275
  when /claude-3-5-haiku-20241022/i
240
276
  "claude-3-5-haiku-20241022"
277
+ when /deepseek-v4-pro/i, /deepseek.*v4.*pro/i
278
+ "deepseek-v4-pro"
279
+ when /deepseek-v4-flash/i, /deepseek.*v4.*flash/i
280
+ "deepseek-v4-flash"
281
+ # Legacy aliases: deepseek-chat and deepseek-reasoner are being
282
+ # deprecated on 2026-07-24 and map to deepseek-v4-flash's
283
+ # non-thinking / thinking modes respectively. Bill at flash rates.
284
+ when /^deepseek-chat$/i, /^deepseek-reasoner$/i
285
+ "deepseek-v4-flash"
241
286
  else
242
287
  "default"
243
288
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Clacky
4
- VERSION = "0.9.36"
4
+ VERSION = "0.9.37"
5
5
  end
@@ -1027,8 +1027,14 @@ const Sessions = (() => {
1027
1027
  Sessions.renderList();
1028
1028
 
1029
1029
  try {
1030
- // Cursor: oldest created_at in the current list
1030
+ // Cursor: oldest created_at in the current list, EXCLUDING pinned
1031
+ // sessions. The backend always returns ALL pinned sessions on the
1032
+ // first page (they bypass pagination), so their created_at is
1033
+ // irrelevant for cursor calculation. Including them here would
1034
+ // cause the cursor to jump too far back and skip sessions between
1035
+ // the oldest pinned one and the real last-loaded non-pinned row.
1031
1036
  const oldest = _sessions.reduce((min, s) => {
1037
+ if (s.pinned) return min; // ignore pinned
1032
1038
  if (!s.created_at) return min;
1033
1039
  return (!min || s.created_at < min) ? s.created_at : min;
1034
1040
  }, null);
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: openclacky
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.36
4
+ version: 0.9.37
5
5
  platform: ruby
6
6
  authors:
7
7
  - windy