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 +4 -4
- data/CHANGELOG.md +16 -1
- data/lib/clacky/agent/llm_caller.rb +218 -0
- data/lib/clacky/agent/message_compressor.rb +15 -4
- data/lib/clacky/agent/message_compressor_helper.rb +41 -2
- data/lib/clacky/agent/tool_registry.rb +109 -0
- data/lib/clacky/agent.rb +16 -0
- data/lib/clacky/agent_config.rb +17 -0
- data/lib/clacky/cli.rb +65 -0
- data/lib/clacky/default_skills/channel-setup/SKILL.md +57 -3
- data/lib/clacky/server/channel/adapters/weixin/adapter.rb +7 -0
- data/lib/clacky/server/channel/channel_manager.rb +91 -0
- data/lib/clacky/server/discover.rb +77 -0
- data/lib/clacky/server/epipe_safe_io.rb +105 -0
- data/lib/clacky/server/http_server.rb +80 -40
- data/lib/clacky/server/server_master.rb +6 -0
- data/lib/clacky/skill.rb +30 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +58 -22
- data/lib/clacky/web/i18n.js +4 -2
- data/lib/clacky/web/sessions.js +29 -17
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 448b47d4336764c1646147f9b86fc04f8bad84a34565b9b67cbf558000c185bf
|
|
4
|
+
data.tar.gz: 827ace1367511360cd6586f5a89529b504d31cdce68d5ecd90fadbe92069c2b5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
# @
|
|
97
|
-
|
|
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
|
-
[
|
|
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
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -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
|