openclacky 1.0.2 → 1.0.3

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: d36230a47c25a8b5fb04dfc14f9359155489a2539d0a699843e140deed1434ba
4
- data.tar.gz: c237725ed637d2d7a852d3624611cca101290e2348e0c6befb2650342550ec03
3
+ metadata.gz: 448b47d4336764c1646147f9b86fc04f8bad84a34565b9b67cbf558000c185bf
4
+ data.tar.gz: 827ace1367511360cd6586f5a89529b504d31cdce68d5ecd90fadbe92069c2b5
5
5
  SHA512:
6
- metadata.gz: 89c65d848c67dff3ed63ae70cd6a0539a7a8068682d72009b34741ea09c44749f5fa05c5839bc9c02c5c499709c8e5bce321165561bdbf8a43500539d1e4b21c
7
- data.tar.gz: 74ebac898a16e090481c8ba423ac7c2d9cafe918f09cdc87066b54c911034b941c713650d24aaa8d71c627c48d3c8c56a780c2ffa6e717448e4712cdd5ca9512
6
+ metadata.gz: 667591fbe92e0e4d01de03cd1e9924ff595a1a11fa5196a7b338675366e37445d7cfe02844fc6bd1eb768ab54134d56195a9573fb95dc20c57d448429bcfb8d2
7
+ data.tar.gz: b324a9f5161eb7574f846736c200341fb2f4db39786f3bd5c2210c178a8c2ed115a520251e36da4ed82572ea6ebf0e88d1072a51536a817169d0838ae86d7dea
data/CHANGELOG.md CHANGED
@@ -5,7 +5,22 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [1.0.2] - 2026-05-07
8
+ ## [1.0.3] - 2026-05-09
9
+
10
+ ### Added
11
+ - **Channel send command — push messages from CLI/agent to IM channels.** New `clacky channel send` CLI command and full outbound channel pipeline. The agent can now actively reach out to users on Feishu/WeCom/WeChat (e.g. for cron tasks or background completions) instead of only replying. Includes a new `ChannelManager` for routing, multi-master server discovery, and proper `chat_id` extraction for outbound messages. (#73)
12
+ - **`--model` flag to override the model per invocation.** Run any one-off command with a different model without changing config: `clacky --model gpt-4o-mini "..."`. Useful for quick comparisons or routing specific tasks to cheaper/faster models. (#76)
13
+ - **Fuzzy tool-name resolution for cross-model compatibility.** When a model emits a slightly off tool name (e.g. `read_file` vs `file_reader`, case mismatches, or hyphen/underscore differences), the agent now resolves it to the closest registered tool instead of erroring out. Significantly improves reliability when switching between Claude, GPT, and other providers. (#78)
14
+ - **Context overflow auto-recovery.** When an upstream LLM call hits a context-length error, the agent now detects it via `LlmCaller`'s error classification and automatically compresses message history to retry — instead of bubbling a hard error to the user. Backed by 175 new error-detection and 169 new recovery specs.
15
+ - **Refined session list UI with SVG icons.** Reworked sidebar session list with crisp SVG icons and tightened styling for a more polished look. (#83)
16
+
17
+ ### Fixed
18
+ - **EPIPE crashes when stdout/stderr is closed.** Wrapped server I/O in `EpipeSafeIO` so the master/web server no longer crashes when its output stream goes away (e.g. terminal closed, pipe broken). Covered by 193 new specs.
19
+ - **Duplicate `$` in CLI completion line.** Removed the stray dollar sign that appeared at the end of completed commands. (C-5583, #86)
20
+ - **Session list scroll jump on "load more".** The list no longer snaps back to the top when older sessions are paginated in. (C-5568, #85)
21
+ - Reverted an earlier message line-wrap change (#74) that caused regressions; will be revisited. (#84)
22
+
23
+
9
24
 
10
25
  ### Added
11
26
  - **Multi-region provider endpoints.** Providers can now expose multiple endpoint variants (e.g. global vs. CN-optimized Anthropic), and you can switch between them from both the onboarding flow and the Settings page. Bundled with updated model pricing data so cost estimates stay accurate across regions. (#67)
@@ -79,6 +79,14 @@ module Clacky
79
79
  # the error is something else and we let it propagate.
80
80
  force_reasoning_content_pad = false
81
81
  thinking_retry_attempted = false
82
+ # One-shot flag for context-overflow recovery. When the server complains
83
+ # the input exceeds the model's context window, we run a forced
84
+ # compression with pull_back_from_tail: 1 (preserves the model's
85
+ # two-checkpoint prompt cache) and retry the original request once.
86
+ # We retry at most once — if still overflowing afterward, the issue is
87
+ # something else (e.g. tool schemas alone exceed the window) and we let
88
+ # the error propagate.
89
+ context_overflow_retry_attempted = false
82
90
 
83
91
  begin
84
92
  begin
@@ -220,6 +228,55 @@ module Clacky
220
228
  end
221
229
 
222
230
  rescue Clacky::BadRequestError => e
231
+ # One-shot recovery for "context too long" errors. The model's
232
+ # context window is exceeded by the current history+tools+system
233
+ # prompt. We run a forced compression with pull_back_from_tail: 1
234
+ # (preserves the two-checkpoint prompt cache so the compression
235
+ # call itself still hits cache#A on the second-to-last position),
236
+ # then retry the original request once.
237
+ if !context_overflow_retry_attempted &&
238
+ !@compressing_for_overflow &&
239
+ context_too_long_error?(e) &&
240
+ respond_to?(:compress_messages_if_needed, true)
241
+ context_overflow_retry_attempted = true
242
+ Clacky::Logger.info(
243
+ "[context-overflow] caught BadRequestError, attempting forced compression with pull-back",
244
+ error_message: e.message[0, 200],
245
+ history_size: @history.size,
246
+ previous_total_tokens: @previous_total_tokens
247
+ )
248
+ # Layer 1: standard cache-preserving compression (pull_back: 1).
249
+ # Handles 99% of real overflow cases (newest message tipped the
250
+ # request just past the window).
251
+ if perform_context_overflow_compression(mode: :standard)
252
+ retry
253
+ end
254
+
255
+ # Layer 2: aggressive fallback. The Layer 1 compression call
256
+ # itself overflowed — happens when a single newly-appended
257
+ # message is enormous (huge tool_result, pasted file, etc.) so
258
+ # popping just K=1 didn't bring the request below the window.
259
+ # Pop ~half the history this time; sacrifices prompt cache to
260
+ # guarantee the compression call fits.
261
+ Clacky::Logger.warn(
262
+ "[context-overflow] standard compression failed, escalating to aggressive mode"
263
+ )
264
+ if perform_context_overflow_compression(mode: :aggressive)
265
+ retry
266
+ end
267
+
268
+ # Both layers exhausted. Let the original error propagate so the
269
+ # user sees the underlying provider message. This should be
270
+ # extremely rare — would require both halves of the history to
271
+ # individually exceed the window, which is essentially impossible
272
+ # under the "previous turn succeeded" invariant.
273
+ Clacky::Logger.error(
274
+ "[context-overflow] both standard and aggressive compression failed; " \
275
+ "propagating original error"
276
+ )
277
+ raise
278
+ end
279
+
223
280
  # One-shot recovery for thinking-mode providers (DeepSeek V4, Kimi K2)
224
281
  # that require every assistant message in the history to carry a
225
282
  # reasoning_content field. The history-evidence heuristic in
@@ -342,6 +399,101 @@ module Clacky
342
399
  )
343
400
  end
344
401
 
402
+ # Run a forced compression to recover from a context-overflow error.
403
+ # Called by the BadRequestError rescue when context_too_long_error?
404
+ # returns true.
405
+ #
406
+ # Two-layer defence:
407
+ # ────────────────────────────────────────────────────────────────────
408
+ # Layer 1 (mode: :standard, default) — preserves prompt cache.
409
+ # Pop K=1 message from @history tail, then run compression. This
410
+ # frees just enough token budget for the compression LLM call
411
+ # itself to fit, while preserving the model's two-checkpoint prompt
412
+ # cache (cache#A at second-to-last position is still hit). The
413
+ # popped message is reattached to the rebuilt history's tail by
414
+ # handle_compression_response, so recent task progress is not lost.
415
+ # Handles 99% of real-world cases where overflow is caused by the
416
+ # newest message pushing total just past the window.
417
+ #
418
+ # Layer 2 (mode: :aggressive) — sacrifices prompt cache to survive.
419
+ # Pop ~half the history (capped) from the tail. This dramatically
420
+ # shrinks the compression call's input regardless of how big any
421
+ # single message is. Used as a fallback when Layer 1 itself raises
422
+ # context_too_long — i.e. a single newly-appended message is so
423
+ # large (e.g. >50K-token tool_result, pasted huge file) that even
424
+ # removing it didn't bring the request under the window, OR the
425
+ # popped message was small but earlier history grew past the limit.
426
+ # Pulled-back messages are still reattached after compression so no
427
+ # user content is silently dropped.
428
+ #
429
+ # @param mode [Symbol] :standard or :aggressive
430
+ # @return [Boolean] true if compression succeeded (caller should retry
431
+ # the original request), false if compression was unable to run
432
+ # (compression disabled, history too short, etc.) or itself failed
433
+ # — caller decides whether to escalate to the next layer or
434
+ # propagate the original error.
435
+ private def perform_context_overflow_compression(mode: :standard)
436
+ return false unless respond_to?(:compress_messages_if_needed, true)
437
+
438
+ # Compute pull-back count.
439
+ # Standard: K=1 (cache-preserving).
440
+ # Aggressive: pop ~half the history, but never less than 4 and never
441
+ # more than (history_size - 2) so we always keep system + at least
442
+ # one recent message. Capped at 64 to bound the worst case (an
443
+ # enormous history that should never realistically occur).
444
+ pull_back =
445
+ if mode == :aggressive
446
+ half = @history.size / 2
447
+ [[half, 4].max, [@history.size - 2, 64].min].min
448
+ else
449
+ 1
450
+ end
451
+
452
+ @compressing_for_overflow = true
453
+ compression_context = nil
454
+
455
+ begin
456
+ compression_context = compress_messages_if_needed(
457
+ force: true,
458
+ pull_back_from_tail: pull_back
459
+ )
460
+ return false if compression_context.nil?
461
+
462
+ compression_message = compression_context[:compression_message]
463
+ @history.append(compression_message)
464
+
465
+ response = call_llm # recursive — guarded by @compressing_for_overflow
466
+ handle_compression_response(response, compression_context)
467
+ Clacky::Logger.info(
468
+ "[context-overflow] compression succeeded",
469
+ mode: mode,
470
+ pull_back: pull_back
471
+ )
472
+ true
473
+ rescue => e
474
+ # Compression failed mid-flight. Restore @history to a sensible state:
475
+ # roll back the compression instruction we appended, and re-append the
476
+ # pulled-back messages so the user's recent work isn't silently lost.
477
+ if compression_context
478
+ cm = compression_context[:compression_message]
479
+ @history.rollback_before(cm) if cm
480
+ (compression_context[:pulled_back_messages] || []).each do |m|
481
+ @history.append(m)
482
+ end
483
+ end
484
+ Clacky::Logger.warn(
485
+ "[context-overflow] compression failed during overflow recovery",
486
+ mode: mode,
487
+ pull_back: pull_back,
488
+ error_class: e.class.name,
489
+ error_message: e.message[0, 200]
490
+ )
491
+ false
492
+ ensure
493
+ @compressing_for_overflow = false
494
+ end
495
+ end
496
+
345
497
  # True when a 400 BadRequestError is specifically about a missing
346
498
  # reasoning_content field in thinking mode (DeepSeek V4, Kimi K2 thinking).
347
499
  # We require TWO distinct substrings to avoid false positives — a generic
@@ -358,6 +510,72 @@ module Clacky
358
510
  msg.include?("must be provided"))
359
511
  end
360
512
 
513
+ # True when a 400 BadRequestError indicates the request exceeded the
514
+ # model's context window (i.e. the conversation history is too long).
515
+ #
516
+ # We deliberately favour broad detection over narrow precision:
517
+ # - False positive cost: one extra (no-op) compression cycle.
518
+ # - False negative cost: user is stuck — every retry hits the same wall.
519
+ # So the matcher is intentionally permissive.
520
+ #
521
+ # Coverage (verified against real production error strings):
522
+ #
523
+ # OpenAI:
524
+ # "This model's maximum context length is 128000 tokens. However
525
+ # you requested ... Please reduce the length of the messages."
526
+ # error.code == "context_length_exceeded"
527
+ #
528
+ # Anthropic:
529
+ # "prompt is too long: 218849 tokens > 200000 maximum"
530
+ #
531
+ # Qwen / Alibaba (DashScope):
532
+ # "You passed 117345 input tokens and requested 8192 output tokens.
533
+ # However the model's context length is only 125536 tokens, resulting
534
+ # in a maximum input length of 117344 tokens. Please reduce the length
535
+ # of the input prompt. (parameter=input_tokens, value=117345)"
536
+ #
537
+ # Qwen / Alibaba (DashScope) — newer/terser format (qwen3.6 series):
538
+ # "InternalError.Algo.InvalidParameter: Range of input length should be [1, 229376]"
539
+ #
540
+ # DeepSeek / Kimi / MiniMax / most OpenAI-compatible relays:
541
+ # Variants of OpenAI-style "context length" / "tokens exceeds" wording.
542
+ #
543
+ # Generic gateways (Portkey, OpenRouter):
544
+ # "The total number of tokens exceeds the model's maximum context length"
545
+ private def context_too_long_error?(err)
546
+ return false unless err.is_a?(Clacky::BadRequestError)
547
+
548
+ msg = err.message.to_s.downcase
549
+
550
+ # Strong phrases — any one of these is conclusive on its own.
551
+ # Each phrase is two-or-more semantic words to avoid single-word noise.
552
+ strong_phrases = [
553
+ "context length", # OpenAI / Qwen / many compat APIs
554
+ "context_length_exceeded", # OpenAI error.code
555
+ "maximum context", # OpenAI variant
556
+ "maximum input length", # Qwen
557
+ "prompt is too long", # Anthropic
558
+ "input is too long", # Anthropic-compat relays
559
+ "exceeds the maximum context", # Portkey & generic gateways
560
+ "exceeds the model's context", # Generic
561
+ "exceeds the model's maximum", # Generic
562
+ "reduce the length of the input", # Qwen action hint
563
+ "reduce the length of the messages", # OpenAI action hint
564
+ "reduce the length of your", # Generic action hint
565
+ "reduce the length of the prompt", # Generic action hint
566
+ "range of input length" # Qwen DashScope qwen3.6+ terse format
567
+ ]
568
+ return true if strong_phrases.any? { |p| msg.include?(p) }
569
+
570
+ # Pattern 1: Anthropic-style "<N> tokens > <N> maximum"
571
+ return true if msg =~ /\d+\s*tokens?\s*>\s*\d+/
572
+
573
+ # Pattern 2: Qwen-style structured field "parameter=input_tokens"
574
+ return true if msg.include?("parameter=input_tokens")
575
+
576
+ false
577
+ end
578
+
361
579
  # Detect upstream tool-call truncation and raise UpstreamTruncatedError
362
580
  # so the standard RetryableError rescue (with fallback model support)
363
581
  # handles retry identically to 5xx/429.
@@ -93,8 +93,13 @@ module Clacky
93
93
  # @param original_messages [Array<Hash>] Original messages before compression
94
94
  # @param recent_messages [Array<Hash>] Recent messages to preserve
95
95
  # @param chunk_path [String, nil] Path to the archived chunk MD file (if saved)
96
- # @return [Array<Hash>] Rebuilt message list: system + compressed + recent
97
- def rebuild_with_compression(compressed_content, original_messages:, recent_messages:, chunk_path: nil, topics: nil, previous_chunks: [])
96
+ # @param pulled_back_messages [Array<Hash>] Messages temporarily popped from the
97
+ # tail of @history before the compression LLM call (to free up token budget so
98
+ # the compression call itself doesn't overflow context). These are NOT discarded —
99
+ # they are reattached to the tail of the rebuilt history so recent task progress
100
+ # is preserved. Default: [] (normal compression path doesn't need this).
101
+ # @return [Array<Hash>] Rebuilt message list: system + compressed + recent + pulled_back
102
+ def rebuild_with_compression(compressed_content, original_messages:, recent_messages:, chunk_path: nil, topics: nil, previous_chunks: [], pulled_back_messages: [])
98
103
  # Find and preserve system message
99
104
  system_msg = original_messages.find { |m| m[:role] == "system" }
100
105
 
@@ -112,13 +117,19 @@ module Clacky
112
117
  raise "LLM compression failed: unable to parse compressed messages"
113
118
  end
114
119
 
115
- # Return system message + compressed messages + recent messages.
120
+ # Return system message + compressed messages + recent messages + pulled_back messages.
116
121
  # Strip any system messages from recent_messages as a safety net —
117
122
  # get_recent_messages_with_tool_pairs already excludes them, but this
118
123
  # guard ensures we never end up with duplicate system prompts even if
119
124
  # the caller passes an unfiltered list.
125
+ #
126
+ # pulled_back_messages: messages that were temporarily popped from the tail
127
+ # of @history before the compression LLM call (to free up token budget so
128
+ # the compression call itself doesn't overflow context). They are reattached
129
+ # here to preserve recent task progress.
120
130
  safe_recent = recent_messages.reject { |m| m[:role] == "system" }
121
- [system_msg, *parsed_messages, *safe_recent].compact
131
+ safe_pulled_back = pulled_back_messages.reject { |m| m[:role] == "system" }
132
+ [system_msg, *parsed_messages, *safe_recent, *safe_pulled_back].compact
122
133
  end
123
134
 
124
135
 
@@ -103,8 +103,24 @@ module Clacky
103
103
 
104
104
  # Check if compression is needed and return compression context
105
105
  # @param force [Boolean] Force compression even if thresholds not met
106
+ # @param pull_back_from_tail [Integer] Number of messages to temporarily pop
107
+ # from the tail of history before building the compression instruction.
108
+ # Used by the context-overflow recovery path: when the current history
109
+ # is already at/over the model's context window, we cannot append even
110
+ # a small compression instruction without overflowing. Popping K messages
111
+ # from the tail frees up token budget for the compression call itself.
112
+ #
113
+ # Cache-preservation note: thanks to the model's two-checkpoint prompt
114
+ # cache (cache#A at second-to-last, cache#B at last), pulling back K=1
115
+ # message keeps cache#A intact — the compression LLM call still hits the
116
+ # cached prefix [system, m1..m(N-1)]. K>=2 sacrifices cache hits but is
117
+ # only used as fallback when one message isn't enough headroom.
118
+ #
119
+ # The popped messages are NOT discarded — they ride along in the
120
+ # returned context and are reattached to the rebuilt history's tail by
121
+ # handle_compression_response, so recent task progress is preserved.
106
122
  # @return [Hash, nil] Compression context or nil if not needed
107
- def compress_messages_if_needed(force: false)
123
+ def compress_messages_if_needed(force: false, pull_back_from_tail: 0)
108
124
  # Check if compression is enabled
109
125
  return nil unless @config.enable_compression
110
126
 
@@ -148,6 +164,27 @@ module Clacky
148
164
 
149
165
  # Get the most recent N messages, ensuring tool_calls/tool results pairs are kept together
150
166
  all_messages = @history.to_a
167
+
168
+ # Pull back K messages from the tail (context-overflow recovery path).
169
+ # We *physically* remove them from @history so the next call_llm
170
+ # (which reads @history.to_api) doesn't include them in the prompt.
171
+ # They will be reattached to the rebuilt history's tail by
172
+ # handle_compression_response after compression succeeds. If compression
173
+ # fails, the caller is responsible for restoring them via the returned
174
+ # context (rollback path).
175
+ pulled_back_messages = []
176
+ if pull_back_from_tail > 0
177
+ k = [pull_back_from_tail, all_messages.size - 1].min # never pop the system message
178
+ k.times do
179
+ popped = @history.pop_last
180
+ pulled_back_messages.unshift(popped) if popped
181
+ end
182
+ # Recompute all_messages from the now-shrunk history so downstream
183
+ # logic (recent_messages selection, build_compression_message) sees
184
+ # the post-pop view.
185
+ all_messages = @history.to_a
186
+ end
187
+
151
188
  recent_messages = get_recent_messages_with_tool_pairs(all_messages, target_recent_count)
152
189
  recent_messages = [] if recent_messages.nil?
153
190
 
@@ -160,6 +197,7 @@ module Clacky
160
197
  {
161
198
  compression_message: compression_message,
162
199
  recent_messages: recent_messages,
200
+ pulled_back_messages: pulled_back_messages,
163
201
  original_token_count: total_tokens,
164
202
  original_message_count: @history.size,
165
203
  compression_level: @compression_level
@@ -227,7 +265,8 @@ module Clacky
227
265
  recent_messages: compression_context[:recent_messages],
228
266
  chunk_path: chunk_path,
229
267
  topics: topics,
230
- previous_chunks: previous_chunks
268
+ previous_chunks: previous_chunks,
269
+ pulled_back_messages: compression_context[:pulled_back_messages] || []
231
270
  ))
232
271
 
233
272
  # Reset to the estimated size of the rebuilt (small) history.
@@ -2,18 +2,127 @@
2
2
 
3
3
  module Clacky
4
4
  class ToolRegistry
5
+ # Common aliases that LLMs frequently use instead of the registered tool names.
6
+ # Keys are downcased aliases; values are the canonical registered names.
7
+ TOOL_ALIASES = {
8
+ # file_reader aliases
9
+ "read" => "file_reader",
10
+ "read_file" => "file_reader",
11
+ "filereader" => "file_reader",
12
+ "file_read" => "file_reader",
13
+ "cat" => "file_reader",
14
+ # write aliases
15
+ "write_file" => "write",
16
+ "create_file" => "write",
17
+ "file_write" => "write",
18
+ # edit aliases
19
+ "file_edit" => "edit",
20
+ "replace" => "edit",
21
+ "replace_in_file" => "edit",
22
+ "str_replace" => "edit",
23
+ # terminal aliases
24
+ "shell" => "terminal",
25
+ "bash" => "terminal",
26
+ "exec" => "terminal",
27
+ "execute" => "terminal",
28
+ "run_command" => "terminal",
29
+ "run" => "terminal",
30
+ "command" => "terminal",
31
+ # web_search aliases
32
+ "search" => "web_search",
33
+ "websearch" => "web_search",
34
+ "internet_search" => "web_search",
35
+ "online_search" => "web_search",
36
+ # web_fetch aliases
37
+ "fetch" => "web_fetch",
38
+ "webfetch" => "web_fetch",
39
+ "browse" => "web_fetch",
40
+ "url_fetch" => "web_fetch",
41
+ "http_get" => "web_fetch",
42
+ # grep aliases
43
+ "search_files" => "grep",
44
+ "search_in_files" => "grep",
45
+ "find_in_files" => "grep",
46
+ "search_code" => "grep",
47
+ # glob aliases
48
+ "find_files" => "glob",
49
+ "list_files" => "glob",
50
+ "file_glob" => "glob",
51
+ "search_filenames" => "glob",
52
+ # invoke_skill aliases
53
+ "skill" => "invoke_skill",
54
+ "run_skill" => "invoke_skill",
55
+ # todo_manager aliases
56
+ "todo" => "todo_manager",
57
+ "task_manager" => "todo_manager",
58
+ # request_user_feedback aliases
59
+ "ask_user" => "request_user_feedback",
60
+ "user_feedback" => "request_user_feedback",
61
+ "ask" => "request_user_feedback",
62
+ # undo_task aliases
63
+ "undo" => "undo_task",
64
+ # redo_task aliases
65
+ "redo" => "redo_task",
66
+ # list_tasks aliases
67
+ "tasks" => "list_tasks",
68
+ "task_history" => "list_tasks",
69
+ # trash_manager aliases
70
+ "trash" => "trash_manager",
71
+ "delete" => "trash_manager",
72
+ "rm" => "trash_manager",
73
+ "remove" => "trash_manager",
74
+ }.freeze
75
+
5
76
  def initialize
6
77
  @tools = {}
78
+ # Downcased index for case-insensitive lookups
79
+ @downcased_index = {}
7
80
  end
8
81
 
9
82
  def register(tool)
10
83
  @tools[tool.name] = tool
84
+ @downcased_index[tool.name.downcase] = tool.name
11
85
  end
12
86
 
13
87
  def get(name)
14
88
  @tools[name] || raise(Clacky::ToolCallError, "Tool not found: #{name}")
15
89
  end
16
90
 
91
+ # Resolve a tool name (possibly misspelt or aliased) to the canonical
92
+ # registered name. Resolution order:
93
+ # 1. Exact match in the registry
94
+ # 2. Case-insensitive match (e.g. "Read" → "file_reader")
95
+ # 3. Alias lookup (e.g. "read_file" → "file_reader")
96
+ # Returns the canonical tool name, or nil if nothing matched.
97
+ def resolve(name)
98
+ return name if @tools.key?(name)
99
+
100
+ downcased = name.downcase
101
+
102
+ # Case-insensitive match
103
+ if @downcased_index.key?(downcased)
104
+ return @downcased_index[downcased]
105
+ end
106
+
107
+ # Alias lookup
108
+ if TOOL_ALIASES.key?(downcased)
109
+ return TOOL_ALIASES[downcased]
110
+ end
111
+
112
+ # Fuzzy: try underscore / hyphen normalisation (e.g. "file-reader" → "file_reader")
113
+ normalized = downcased.tr("-", "_")
114
+ if normalized != downcased
115
+ if @downcased_index.key?(normalized)
116
+ return @downcased_index[normalized]
117
+ end
118
+ if TOOL_ALIASES.key?(normalized)
119
+ return TOOL_ALIASES[normalized]
120
+ end
121
+ end
122
+
123
+ nil
124
+ end
125
+
17
126
  def all
18
127
  @tools.values
19
128
  end
data/lib/clacky/agent.rb CHANGED
@@ -768,6 +768,22 @@ module Clacky
768
768
  awaiting_feedback = false
769
769
 
770
770
  tool_calls.each_with_index do |call, index|
771
+ # Resolve tool name: handle case-insensitive and common alias mismatches
772
+ # from different LLM providers (e.g. "read" → "file_reader", "Read" → "file_reader")
773
+ original_name = call[:name]
774
+ resolved = @tool_registry.resolve(call[:name])
775
+ if resolved && resolved != call[:name]
776
+ @debug_logs << {
777
+ timestamp: Time.now.iso8601,
778
+ event: "tool_name_resolved",
779
+ original: original_name,
780
+ resolved: resolved
781
+ }
782
+ call = call.merge(name: resolved)
783
+ elsif resolved.nil?
784
+ # Tool truly not found — let the rescue below handle it with a clear message
785
+ end
786
+
771
787
  # Hook: before_tool_use
772
788
  hook_result = @hooks.trigger(:before_tool_use, call)
773
789
  if hook_result[:action] == :deny
@@ -426,6 +426,23 @@ module Clacky
426
426
  true
427
427
  end
428
428
 
429
+ # Switch to a model by its display name (fuzzy match, case-insensitive).
430
+ #
431
+ # @param name [String] the model name to search for (e.g. "gpt-5.3-codex")
432
+ # @return [Boolean] true if switched, false if name not found
433
+ def switch_model_by_name(name)
434
+ return false if name.nil? || name.to_s.strip.empty?
435
+
436
+ name_str = name.to_s.strip.downcase
437
+ index = @models.find_index { |m| m["model"].to_s.downcase == name_str }
438
+ return false if index.nil?
439
+
440
+ @current_model_id = @models[index]["id"]
441
+ @current_model_index = index
442
+
443
+ true
444
+ end
445
+
429
446
  # Set the **global** default model marker (`type: "default"`).
430
447
  #
431
448
  # This is separate from `switch_model_by_id`:
data/lib/clacky/cli.rb CHANGED
@@ -41,6 +41,7 @@ module Clacky
41
41
 
42
42
  Examples:
43
43
  $ clacky agent --mode=auto_approve --path /path/to/project
44
+ $ clacky agent --model gpt-5.3-codex -m "write a hello world script"
44
45
  LONGDESC
45
46
  option :mode, type: :string, default: "confirm_safes",
46
47
  desc: "Permission mode: auto_approve, confirm_safes, confirm_all"
@@ -56,6 +57,7 @@ module Clacky
56
57
  option :file, type: :array, aliases: "-f", desc: "File path(s) to attach (use with -m; supports images and documents)"
57
58
  option :image, type: :array, aliases: "-i", desc: "Image file path(s) to attach (alias for --file, kept for compatibility)"
58
59
  option :agent, type: :string, default: "coding", desc: "Agent profile to use: coding, general, or any custom profile name (default: coding)"
60
+ option :model, type: :string, desc: "Override the model to use (by name, e.g. gpt-5.3-codex or deepseek-v4-pro). Uses default model if not specified"
59
61
  option :help, type: :boolean, aliases: "-h", desc: "Show this help message"
60
62
  def agent
61
63
  # Handle help option
@@ -68,8 +70,25 @@ module Clacky
68
70
  # Fire-and-forget background thread; never blocks startup.
69
71
  Clacky::Telemetry.startup!
70
72
 
73
+ # ── Sibling server discovery ───────────────────────────────────────
74
+ # Bare-CLI mode does NOT boot an HTTP server, so skills that call
75
+ # back into /api/* (channels, browser, scheduler) normally can't work.
76
+ # If the user happens to have a Clacky server running on this machine
77
+ # (in another terminal or via `clacky server`), auto-wire CLACKY_SERVER_HOST
78
+ # / CLACKY_SERVER_PORT so those skills can reach it transparently.
79
+ discover_sibling_server!
80
+
71
81
  agent_config = Clacky::AgentConfig.load
72
82
 
83
+ # Override model if --model option is specified
84
+ if options[:model]
85
+ unless agent_config.switch_model_by_name(options[:model])
86
+ # During early startup @ui may not be ready; use simple error output
87
+ $stderr.puts "Error: model '#{options[:model]}' not found. Available: #{agent_config.model_names.join(', ')}"
88
+ exit 1
89
+ end
90
+ end
91
+
73
92
  # Handle session listing
74
93
  if options[:list]
75
94
  list_sessions
@@ -148,6 +167,36 @@ module Clacky
148
167
  end
149
168
 
150
169
  no_commands do
170
+ # Detect a sibling Clacky server running on this machine and expose its
171
+ # address to skills via ENV. Runs only in bare-CLI mode (where no server
172
+ # is booted by this process), and only when the user hasn't already set
173
+ # CLACKY_SERVER_HOST / CLACKY_SERVER_PORT explicitly.
174
+ #
175
+ # Why: skills like `channel-setup` and `browser-setup` call back into
176
+ # http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/*. In server
177
+ # mode those vars are injected by HTTPServer#start. In CLI mode they
178
+ # would be blank, so the skill templates expand to an unreachable URL.
179
+ #
180
+ # Discovery is best-effort and non-fatal: if nothing is found we stay
181
+ # silent and let the skill's own pre-flight check emit a friendly error.
182
+ private def discover_sibling_server!
183
+ return if ENV["CLACKY_SERVER_PORT"] && !ENV["CLACKY_SERVER_PORT"].strip.empty?
184
+
185
+ require_relative "server/discover"
186
+ info = Clacky::Server::Discover.find_local
187
+ return unless info
188
+
189
+ ENV["CLACKY_SERVER_HOST"] = info[:host]
190
+ ENV["CLACKY_SERVER_PORT"] = info[:port].to_s
191
+ Clacky::Logger.debug(
192
+ "[CLI] Discovered local server PID=#{info[:pid]} at " \
193
+ "#{info[:host]}:#{info[:port]} — CLACKY_SERVER_* exported."
194
+ )
195
+ rescue StandardError => e
196
+ # Discovery must never break `clacky agent`.
197
+ Clacky::Logger.debug("[CLI] discover_sibling_server! failed: #{e.class}: #{e.message}")
198
+ end
199
+
151
200
  # Handle the `/config` slash command.
152
201
  #
153
202
  # show_config_modal is a pure UI component — it only mutates @models
@@ -943,6 +992,22 @@ module Clacky
943
992
  # Spawned by Master. Inherit the listen socket from the file descriptor
944
993
  # passed via CLACKY_INHERIT_FD, and report back to master via CLACKY_MASTER_PID.
945
994
  require_relative "server/http_server"
995
+ require_relative "server/epipe_safe_io"
996
+
997
+ # Protect $stdout / $stderr from Errno::EPIPE.
998
+ #
999
+ # The worker inherits fd 1/2 from the Master process. If the Master's
1000
+ # stdout pipe ever breaks (e.g. it was launched by an installer or GUI
1001
+ # that has since exited), the next `puts` would raise Errno::EPIPE and
1002
+ # crash the worker — destroying all in-memory sessions, agent loops,
1003
+ # and SSE connections, and looping forever because the respawned
1004
+ # worker inherits the same broken fd.
1005
+ #
1006
+ # In healthy state these wrappers are transparent — output goes to
1007
+ # the user's terminal as usual. On first broken-pipe failure they
1008
+ # silently fall back to /dev/null and the worker stays alive.
1009
+ $stdout = Clacky::Server::EPIPESafeIO.new($stdout)
1010
+ $stderr = Clacky::Server::EPIPESafeIO.new($stderr)
946
1011
 
947
1012
  fd = ENV["CLACKY_INHERIT_FD"].to_i
948
1013
  master_pid = ENV["CLACKY_MASTER_PID"].to_i