openclacky 1.5.20 → 1.5.21
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 +49 -0
- data/README.md +18 -0
- data/lib/clacky/agent/chunk_index.rb +8 -1
- data/lib/clacky/agent/feedback.rb +30 -0
- data/lib/clacky/agent/history_navigation.rb +17 -3
- data/lib/clacky/agent/hook_manager.rb +6 -1
- data/lib/clacky/agent/llm_caller.rb +1 -0
- data/lib/clacky/agent/message_compressor_helper.rb +28 -2
- data/lib/clacky/agent/session_serializer.rb +20 -2
- data/lib/clacky/agent.rb +56 -43
- data/lib/clacky/agent_config.rb +82 -4
- data/lib/clacky/billing/platform_billing.rb +5 -1
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/cli.rb +3 -2
- data/lib/clacky/client.rb +5 -5
- data/lib/clacky/deepseek_web_tools.rb +156 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +8 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +21 -1
- data/lib/clacky/extension/loader.rb +5 -3
- data/lib/clacky/locales/en.rb +5 -5
- data/lib/clacky/locales/zh.rb +5 -5
- data/lib/clacky/message_format/anthropic.rb +20 -0
- data/lib/clacky/message_format/open_ai.rb +31 -14
- data/lib/clacky/message_history.rb +21 -3
- data/lib/clacky/platform_http_client.rb +6 -2
- data/lib/clacky/providers.rb +23 -14
- data/lib/clacky/search_config.rb +47 -13
- data/lib/clacky/server/browser_manager.rb +35 -5
- data/lib/clacky/server/channel/channel_manager.rb +6 -6
- data/lib/clacky/server/discover.rb +5 -4
- data/lib/clacky/server/http_server.rb +194 -47
- data/lib/clacky/server/model_prices.rb +5 -3
- data/lib/clacky/server/pid_files.rb +25 -0
- data/lib/clacky/server/server_master.rb +9 -3
- data/lib/clacky/server/session_registry.rb +19 -7
- data/lib/clacky/server/web_ui_controller.rb +61 -38
- data/lib/clacky/session_manager.rb +7 -1
- data/lib/clacky/skill.rb +1 -1
- data/lib/clacky/tools/ask_user.rb +4 -1
- data/lib/clacky/tools/base.rb +3 -2
- data/lib/clacky/tools/browser.rb +18 -2
- data/lib/clacky/tools/file_reader.rb +1 -1
- data/lib/clacky/tools/terminal/session_manager.rb +19 -1
- data/lib/clacky/tools/terminal.rb +65 -23
- data/lib/clacky/tools/trash_manager.rb +5 -3
- data/lib/clacky/tools/web_fetch.rb +81 -22
- data/lib/clacky/tools/web_search.rb +45 -4
- data/lib/clacky/ui2/ui_controller.rb +1 -1
- data/lib/clacky/ui_interface.rb +4 -3
- data/lib/clacky/utils/file_processor.rb +51 -0
- data/lib/clacky/utils/model_pricing.rb +132 -13
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +366 -105
- data/lib/clacky/web/components/chat-navigator.js +8 -1
- data/lib/clacky/web/components/mentions.js +2 -13
- data/lib/clacky/web/components/model-picker.js +3 -1
- data/lib/clacky/web/components/onboard.js +13 -10
- data/lib/clacky/web/features/archive/store.js +30 -16
- data/lib/clacky/web/features/archive/view.js +20 -10
- data/lib/clacky/web/features/backup/view.js +5 -5
- data/lib/clacky/web/features/new-session/store.js +3 -3
- data/lib/clacky/web/features/trash/store.js +10 -7
- data/lib/clacky/web/features/trash/view.js +9 -4
- data/lib/clacky/web/i18n.js +106 -30
- data/lib/clacky/web/index.html +90 -60
- data/lib/clacky/web/projects.js +56 -1
- data/lib/clacky/web/sessions.js +244 -131
- data/lib/clacky/web/settings.js +143 -8
- data/lib/clacky/web/ws-dispatcher.js +9 -13
- data/lib/clacky.rb +1 -0
- metadata +4 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a120f8d1189892876508f1c49309b7e4a21652c9293499211444b6e42770f649
|
|
4
|
+
data.tar.gz: 2c02a199d26ae835426fa5ce8fb65665ac3c342aa7b717337c3e72470fd868f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: aed2dcb3fb47de8a9fc9b20cccbae1f45e70338b37a44e5edbea6ec54c8c3065a27976a2ea5cb2b34ac209cbc3d53c8b6866598deb1a78ff5194ecbef6c0d66e
|
|
7
|
+
data.tar.gz: 948e67f2dc8f003e55daea5f8e4d790e5368ef58eec63489d317e8be4336e2e04a9db498bdb1e362005a66b502f930a1dd5c88e9da6607863d9dfc4b06986feb
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,55 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
|
|
7
|
+
## [1.5.21] - 2026-10-22
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- Switch the current session between Manual and Auto approval from the composer, even while a task is running
|
|
11
|
+
- Wait 20 seconds on `ask_user` in auto mode before the agent decides, with the delay configurable through `ask_user_timeout_seconds`
|
|
12
|
+
- Add DeepSeek hosted web search and page fetch
|
|
13
|
+
- Archive every session in a project at once
|
|
14
|
+
- Add MiniMax M3.1 Flash
|
|
15
|
+
- Support multiline terminal commands (#641 - @YeEmrick)
|
|
16
|
+
- Fall back to a dependency-free PNG256 encoder when no image tool is available (#668 - @YeEmrick)
|
|
17
|
+
- Configure where the server writes its PID file (#663)
|
|
18
|
+
- Explain an unreachable license server where the license is entered
|
|
19
|
+
- Point extension publishers at the contributor page
|
|
20
|
+
- Prepare and persist shared feedback requests (#649)
|
|
21
|
+
|
|
22
|
+
### Improved
|
|
23
|
+
- Downscale oversized browser screenshots so they cost fewer tokens
|
|
24
|
+
- Raise the file reader per-line limit to 8000 characters (#645 - @YeEmrick)
|
|
25
|
+
- Spell out the billing basis in price tooltips
|
|
26
|
+
- Tidy the session search palette and its content-match snippets
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- Refresh data management lists after archiving or deleting from the sidebar (#673)
|
|
30
|
+
- Make bulk delete in the recycle bin and archive respect the active filters (#672)
|
|
31
|
+
- Keep routing channel messages to a session after it is archived (#671)
|
|
32
|
+
- Stop long terminal commands from echoing a duplicated wrapped tail (#670)
|
|
33
|
+
- Page session lists by a server-issued cursor so long lists no longer skip sessions (#669)
|
|
34
|
+
- Refresh the header title when the active session is renamed (#666)
|
|
35
|
+
- Match chat navigator rounds by task id so compression no longer misaligns them (#664)
|
|
36
|
+
- Stop dropping replayed chunks that continue an earlier turn (#662)
|
|
37
|
+
- Apply model card edits to a running session immediately (#661)
|
|
38
|
+
- Keep the attachment strip inside the new-session composer card (#659)
|
|
39
|
+
- Keep the /#new draft name out of sessions created by a quick action (#658)
|
|
40
|
+
- Keep the Default reasoning choice intact after reopening settings
|
|
41
|
+
- Suggest switching models when retries are exhausted
|
|
42
|
+
- Correct MiniMax-M3 rates with its 512K tier
|
|
43
|
+
- Complete MiniMax Flash effort handling and retire the legacy Ollama choices (#655 - @octo-patch)
|
|
44
|
+
- Drop tool results with a blank call id before sending
|
|
45
|
+
- Preserve response headers when handling Anthropic stream errors (#642)
|
|
46
|
+
- Stop compressing oversized recent messages over and over (#643)
|
|
47
|
+
- Localize device-login errors and stop showing the user code
|
|
48
|
+
- Localize the QR lightbox caption and stop clipping its close button
|
|
49
|
+
- Keep web search on the free engine chain unless DeepSeek is chosen
|
|
50
|
+
- Stop extension reloads from serving a stale cached bundle when the manifest path changes
|
|
51
|
+
|
|
52
|
+
### More
|
|
53
|
+
- Retire the slow GPT-6 and Grok 4.7 aliases from the model picker; their lanes stay routable for existing sessions
|
|
54
|
+
- Bill the Bedrock GPT lane at a 20% markup
|
|
55
|
+
|
|
7
56
|
## [1.5.20] - 2026-10-15
|
|
8
57
|
|
|
9
58
|
### Added
|
data/README.md
CHANGED
|
@@ -207,6 +207,24 @@ Set your **API Key**, **Model**, and **Base URL** (any OpenAI-compatible provide
|
|
|
207
207
|
|
|
208
208
|
Supported out of the box: **Claude (Anthropic) · GPT (OpenAI) · DeepSeek · Kimi (Moonshot) · MiniMax · OpenRouter** — or any custom endpoint.
|
|
209
209
|
|
|
210
|
+
In Web, use the permission selector above the message box to switch the current
|
|
211
|
+
session between **Manual** and **Auto**, including while running. The choice
|
|
212
|
+
is saved for that session. Running tools finish normally; subsequent actions use
|
|
213
|
+
the new mode. Switching to Manual stops an active question countdown. Auto
|
|
214
|
+
approve runs tools without approval.
|
|
215
|
+
|
|
216
|
+
In auto-approve mode, CLI and Web wait 20 seconds after `ask_user` before the
|
|
217
|
+
agent decides how to continue. Typing or selecting an answer cancels the timer
|
|
218
|
+
and waits for submission. Configure the duration in `~/.clacky/config.yml`:
|
|
219
|
+
|
|
220
|
+
```yaml
|
|
221
|
+
settings:
|
|
222
|
+
ask_user_timeout_seconds: 20
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Use a non-negative integer (seconds); `0` continues immediately. Missing or
|
|
226
|
+
invalid values use 20 seconds. Other permission modes wait for an answer.
|
|
227
|
+
|
|
210
228
|
## Coding use case
|
|
211
229
|
|
|
212
230
|
OpenClacky works as a general AI coding assistant — scaffold full-stack apps, add features, or explore unfamiliar codebases:
|
|
@@ -40,6 +40,7 @@ module Clacky
|
|
|
40
40
|
rounds = []
|
|
41
41
|
nested = []
|
|
42
42
|
section_count = 0
|
|
43
|
+
continuation_start = nil
|
|
43
44
|
user_start = nil
|
|
44
45
|
user_task_id = nil
|
|
45
46
|
archived_at = nil
|
|
@@ -60,6 +61,7 @@ module Clacky
|
|
|
60
61
|
header = chunk_section_header(text, directory)
|
|
61
62
|
if header
|
|
62
63
|
section_count += 1
|
|
64
|
+
continuation_start ||= start
|
|
63
65
|
nested << header[:nested_chunk] if header[:nested_chunk]
|
|
64
66
|
user_start = header[:role] == "user" ? start : nil
|
|
65
67
|
user_task_id = header[:role] == "user" ? header[:task_id] : nil
|
|
@@ -78,7 +80,12 @@ module Clacky
|
|
|
78
80
|
end
|
|
79
81
|
rounds.last[:length] = position - rounds.last[:start] if rounds.any?
|
|
80
82
|
end
|
|
81
|
-
|
|
83
|
+
# Compression can cut mid-turn: sections before the first user turn continue the previous turn.
|
|
84
|
+
continuation_end = rounds.any? ? rounds.first[:start] : position
|
|
85
|
+
if continuation_start && continuation_end > continuation_start
|
|
86
|
+
continuation_range = { start: continuation_start, length: continuation_end - continuation_start }
|
|
87
|
+
end
|
|
88
|
+
{ rounds: rounds, nested: nested, section_count: section_count, continuation_range: continuation_range,
|
|
82
89
|
base_time: (archived_at || File.mtime(path)).to_f }
|
|
83
90
|
end
|
|
84
91
|
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
class Agent
|
|
5
|
+
module Feedback
|
|
6
|
+
# Extensions prepare a single immutable snapshot before the UI or its
|
|
7
|
+
# countdown sees it. The same snapshot becomes the model's tool result.
|
|
8
|
+
private def prepare_feedback_request(call, result, assistant_message:)
|
|
9
|
+
args = call[:arguments].is_a?(String) ? JSON.parse(call[:arguments]) : call[:arguments]
|
|
10
|
+
request = {
|
|
11
|
+
request_id: call[:id].to_s,
|
|
12
|
+
questions: Tools::AskUser.normalize_questions(args),
|
|
13
|
+
context: Tools::AskUser.fetch_key(args, :context).to_s
|
|
14
|
+
}
|
|
15
|
+
@hooks.trigger(:prepare_feedback, request)
|
|
16
|
+
check_stale!
|
|
17
|
+
request[:request_id] = call[:id].to_s
|
|
18
|
+
request[:questions] = Tools::AskUser.normalize_questions(request)
|
|
19
|
+
raise ArgumentError, "Feedback preparation removed all questions" if request[:questions].empty?
|
|
20
|
+
|
|
21
|
+
request[:context] = request[:context].to_s
|
|
22
|
+
request[:answer_context] = request[:answer_context].to_s
|
|
23
|
+
@history.attach_feedback_request(message: assistant_message, tool_call_id: call[:id], request: request)
|
|
24
|
+
result[:message] = [Tools::AskUser.render_text(request[:questions], request[:context]),
|
|
25
|
+
request[:answer_context]].reject(&:empty?).join("\n\n")
|
|
26
|
+
request.reject { |key, _| key == :answer_context }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -11,7 +11,7 @@ module Clacky
|
|
|
11
11
|
sources = navigation_sources
|
|
12
12
|
manifest = sources.map do |source|
|
|
13
13
|
entry = { key: source[:key], version: source[:version], count: navigation_source_count(source),
|
|
14
|
-
volatile: !!source[:rounds] || source[:continuation].to_a.any? }
|
|
14
|
+
volatile: !!source[:rounds] || source[:continuation].to_a.any? || source[:continuation_ranges].to_a.any? }
|
|
15
15
|
if source[:rounds]
|
|
16
16
|
entry[:identities] = source[:rounds].map { |round| round[:user_msg].values_at(:created_at, :task_id) }
|
|
17
17
|
else
|
|
@@ -144,6 +144,13 @@ module Clacky
|
|
|
144
144
|
end
|
|
145
145
|
chunks.uniq.each { |path| append_source.call(path) }
|
|
146
146
|
@chunk_index_mutex&.synchronize { @chunk_indexes&.delete_if { |path, _| !visited.include?(path) } }
|
|
147
|
+
owner = nil
|
|
148
|
+
sources.each do |source|
|
|
149
|
+
if owner && source[:index][:continuation_range]
|
|
150
|
+
(owner[:continuation_ranges] ||= []) << { path: source[:path], index: source[:index] }
|
|
151
|
+
end
|
|
152
|
+
owner = source if navigation_source_count(source).positive?
|
|
153
|
+
end
|
|
147
154
|
last_archive = sources.reverse_each.find { |source| navigation_source_count(source).positive? }
|
|
148
155
|
last_archive[:continuation] = leading if last_archive
|
|
149
156
|
version = rounds.map { |round| round[:user_msg].values_at(:created_at, :task_id) }.hash.to_s
|
|
@@ -201,8 +208,15 @@ module Clacky
|
|
|
201
208
|
end
|
|
202
209
|
result
|
|
203
210
|
end
|
|
204
|
-
if rounds.any? && offset + length == count
|
|
205
|
-
|
|
211
|
+
if rounds.any? && offset + length == count
|
|
212
|
+
Array(source[:continuation_ranges]).each do |entry|
|
|
213
|
+
events = parse_chunk_md_events(entry[:path], excluded_paths: siblings,
|
|
214
|
+
byte_range: entry[:index][:continuation_range], index: entry[:index])
|
|
215
|
+
raise ArgumentError, "History changed while reading" unless chunk_file_version(entry[:path]) == entry[:index][:version]
|
|
216
|
+
|
|
217
|
+
rounds.last[:events].concat(events)
|
|
218
|
+
end
|
|
219
|
+
rounds.last[:events].concat(source[:continuation]) if source[:continuation]
|
|
206
220
|
end
|
|
207
221
|
rounds.each { |round| round[:events].reject! { |event| event[:compressed_summary] } }
|
|
208
222
|
rounds.each_with_index do |round, position|
|
|
@@ -5,6 +5,7 @@ module Clacky
|
|
|
5
5
|
HOOK_EVENTS = [
|
|
6
6
|
:before_tool_use,
|
|
7
7
|
:after_tool_use,
|
|
8
|
+
:prepare_feedback,
|
|
8
9
|
:on_tool_error,
|
|
9
10
|
:on_start,
|
|
10
11
|
:on_complete,
|
|
@@ -34,6 +35,10 @@ module Clacky
|
|
|
34
35
|
# call `agent.emit_event(...)`. Blocks are procs — those declaring fewer
|
|
35
36
|
# params (`|call|`, `|call, result|`) silently ignore extra arguments.
|
|
36
37
|
# Start/complete append context after the agent. Completion is observation-only.
|
|
38
|
+
# prepare_feedback receives (request, agent) after tool tracking, before UI
|
|
39
|
+
# display/countdown. It may extend questions, display context and model-only
|
|
40
|
+
# answer_context in place; answer_context is never sent to UI subscribers.
|
|
41
|
+
# return values are ignored. Keep work bounded; no separate UI events.
|
|
37
42
|
def trigger(event, *args)
|
|
38
43
|
validate_event!(event)
|
|
39
44
|
# Keep payload hashes positional: Ruby 2.6 otherwise promotes their keys
|
|
@@ -46,7 +51,7 @@ module Clacky
|
|
|
46
51
|
callback_args = [*args, @agent]
|
|
47
52
|
callback_args << context.dup if context
|
|
48
53
|
hook_result = hook.call(*callback_args)
|
|
49
|
-
next if
|
|
54
|
+
next if [:on_complete, :prepare_feedback].include?(event)
|
|
50
55
|
next unless hook_result.is_a?(Hash)
|
|
51
56
|
# First deny wins and stops the chain: a weaker later verdict must
|
|
52
57
|
# never clobber a stronger earlier one, and the first deny's reason
|
|
@@ -48,6 +48,7 @@ module Clacky
|
|
|
48
48
|
private def call_llm(agent_upstream_fails: nil, agent_upgrade_fails: nil)
|
|
49
49
|
# Transition :fallback_active → :probing if cooling-off has expired.
|
|
50
50
|
@config.maybe_start_probing
|
|
51
|
+
refresh_client_if_card_changed!
|
|
51
52
|
|
|
52
53
|
tools_to_send = @tool_registry.all_definitions
|
|
53
54
|
|
|
@@ -246,6 +246,25 @@ module Clacky
|
|
|
246
246
|
# Note: we need to remove the compression instruction message we just added
|
|
247
247
|
original_messages = @history.to_a[0..-2] # All except the last (compression instruction)
|
|
248
248
|
|
|
249
|
+
# Keep the usual recent messages unless they would immediately trigger
|
|
250
|
+
# compression again. The summary covers all original_messages, so any
|
|
251
|
+
# dropped recent messages can safely join the archive below.
|
|
252
|
+
[nil, 10, 0].each do |count|
|
|
253
|
+
if count
|
|
254
|
+
compression_context[:recent_messages] = get_recent_messages_with_tool_pairs(compression_context[:recent_messages], count)
|
|
255
|
+
end
|
|
256
|
+
candidate = @message_compressor.rebuild_with_compression(
|
|
257
|
+
compressed_content,
|
|
258
|
+
original_messages: original_messages,
|
|
259
|
+
recent_messages: compression_context[:recent_messages],
|
|
260
|
+
pulled_back_messages: compression_context[:pulled_back_messages] || []
|
|
261
|
+
)
|
|
262
|
+
break if MessageHistory.new(candidate).estimate_tokens < @config.compression_threshold &&
|
|
263
|
+
candidate.size < @config.message_count_threshold
|
|
264
|
+
|
|
265
|
+
raise "History still exceeds compression thresholds after summarization" if count == 0
|
|
266
|
+
end
|
|
267
|
+
|
|
249
268
|
# Archive compressed messages to a chunk MD file before discarding them.
|
|
250
269
|
#
|
|
251
270
|
# IMPORTANT: chunk_index and previous_chunks MUST come from disk, not from
|
|
@@ -309,7 +328,7 @@ module Clacky
|
|
|
309
328
|
{ basename: c[:basename], path: c[:path], topics: c[:topics] }
|
|
310
329
|
end
|
|
311
330
|
|
|
312
|
-
@
|
|
331
|
+
rebuilt_messages = @message_compressor.rebuild_with_compression(
|
|
313
332
|
compressed_content,
|
|
314
333
|
original_messages: original_messages,
|
|
315
334
|
recent_messages: compression_context[:recent_messages],
|
|
@@ -317,7 +336,14 @@ module Clacky
|
|
|
317
336
|
topics: topics,
|
|
318
337
|
previous_chunks: previous_chunks,
|
|
319
338
|
pulled_back_messages: compression_context[:pulled_back_messages] || []
|
|
320
|
-
)
|
|
339
|
+
)
|
|
340
|
+
# Archive references also consume context; never re-enter compression
|
|
341
|
+
# indefinitely if even the final rebuilt history cannot fit.
|
|
342
|
+
if MessageHistory.new(rebuilt_messages).estimate_tokens >= @config.compression_threshold ||
|
|
343
|
+
rebuilt_messages.size >= @config.message_count_threshold
|
|
344
|
+
raise "History still exceeds compression thresholds after summarization"
|
|
345
|
+
end
|
|
346
|
+
@history.replace_all(rebuilt_messages)
|
|
321
347
|
|
|
322
348
|
# Reset to the estimated size of the rebuilt (small) history.
|
|
323
349
|
# The compression call_llm reported the OLD large token count, so
|
|
@@ -43,6 +43,9 @@ module Clacky
|
|
|
43
43
|
# Restore project association (nil = no project)
|
|
44
44
|
@project_id = session_data[:project_id]
|
|
45
45
|
|
|
46
|
+
mode_override = session_data.dig(:config, :permission_mode_override)
|
|
47
|
+
self.permission_mode = mode_override if %w[confirm_all auto_approve].include?(mode_override)
|
|
48
|
+
|
|
46
49
|
# Restore standing goal state (nil = no goal). The judge client/model
|
|
47
50
|
# are rebound lazily via #goal_manager when first accessed after
|
|
48
51
|
# restore — but the persisted state must be reattached here so a
|
|
@@ -283,6 +286,7 @@ module Clacky
|
|
|
283
286
|
# model_name is saved so the session can restore its original model on restart
|
|
284
287
|
# (falling back to the current default if the model no longer exists).
|
|
285
288
|
permission_mode: @config.permission_mode.to_s,
|
|
289
|
+
permission_mode_override: @permission_mode_override,
|
|
286
290
|
enable_compression: @config.enable_compression,
|
|
287
291
|
enable_idle_compression: @config.enable_idle_compression,
|
|
288
292
|
enable_prompt_caching: @config.enable_prompt_caching,
|
|
@@ -430,7 +434,7 @@ module Clacky
|
|
|
430
434
|
# @param chunk_path [String] Path to the chunk md file
|
|
431
435
|
# @return [Array<Hash>] rounds array (may be empty if file missing/unreadable)
|
|
432
436
|
private def parse_chunk_md_to_rounds(chunk_path, visited: Set.new, excluded_paths: Set.new,
|
|
433
|
-
byte_range: nil, index: nil, round_offset: 0)
|
|
437
|
+
byte_range: nil, index: nil, round_offset: 0, continue_round: nil)
|
|
434
438
|
return [] unless chunk_path
|
|
435
439
|
|
|
436
440
|
# 1. Try the stored absolute path first (same machine, normal case).
|
|
@@ -506,7 +510,7 @@ module Clacky
|
|
|
506
510
|
|
|
507
511
|
# Group into rounds: each user section starts a new round
|
|
508
512
|
rounds = []
|
|
509
|
-
current_round =
|
|
513
|
+
current_round = continue_round
|
|
510
514
|
round_index = 0
|
|
511
515
|
|
|
512
516
|
sections.each do |sec|
|
|
@@ -606,6 +610,14 @@ module Clacky
|
|
|
606
610
|
[]
|
|
607
611
|
end
|
|
608
612
|
|
|
613
|
+
# Parse a chunk byte range holding only the tail of a turn that began in an earlier chunk.
|
|
614
|
+
private def parse_chunk_md_events(chunk_path, byte_range:, index:, excluded_paths: Set.new)
|
|
615
|
+
round = { events: [] }
|
|
616
|
+
parse_chunk_md_to_rounds(chunk_path, excluded_paths: excluded_paths, byte_range: byte_range,
|
|
617
|
+
index: index, continue_round: round)
|
|
618
|
+
round[:events]
|
|
619
|
+
end
|
|
620
|
+
|
|
609
621
|
|
|
610
622
|
# Pull lightweight attachment badge metadata out of a chunk section.
|
|
611
623
|
# The marker is internal archive data and must not appear in replayed text.
|
|
@@ -779,6 +791,12 @@ module Clacky
|
|
|
779
791
|
# Special handling: the ask_user question is shown as an assistant
|
|
780
792
|
# message (matching real-time behavior), not as a tool call.
|
|
781
793
|
if Clacky::Tools::AskUser.feedback_tool?(name)
|
|
794
|
+
prepared = msg[:feedback_requests] || {}
|
|
795
|
+
request = prepared[tc[:id].to_s] || prepared[tc[:id].to_s.to_sym]
|
|
796
|
+
if request
|
|
797
|
+
ui.show_tool_call(name, request.reject { |key, _| key.to_s == "answer_context" })
|
|
798
|
+
next
|
|
799
|
+
end
|
|
782
800
|
questions = Clacky::Tools::AskUser.normalize_questions(args)
|
|
783
801
|
context = Clacky::Tools::AskUser.fetch_key(args, :context).to_s
|
|
784
802
|
first = questions.first
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -24,6 +24,7 @@ require_relative "agent/skill_evolution"
|
|
|
24
24
|
require_relative "agent/skill_reflector"
|
|
25
25
|
require_relative "agent/skill_auto_creator"
|
|
26
26
|
require_relative "agent/fake_tool_call_detector"
|
|
27
|
+
require_relative "agent/feedback"
|
|
27
28
|
require_relative "agent/goal_state"
|
|
28
29
|
require_relative "agent/goal_manager"
|
|
29
30
|
|
|
@@ -31,6 +32,7 @@ module Clacky
|
|
|
31
32
|
class Agent
|
|
32
33
|
# Include all functionality modules
|
|
33
34
|
include MessageCompressorHelper
|
|
35
|
+
include Feedback
|
|
34
36
|
include ToolExecutor
|
|
35
37
|
include CostTracker
|
|
36
38
|
include SessionSerializer
|
|
@@ -57,6 +59,13 @@ module Clacky
|
|
|
57
59
|
attr_accessor :archived_at
|
|
58
60
|
|
|
59
61
|
REASONING_EFFORTS = %w[low medium high xhigh max].freeze
|
|
62
|
+
# Sessionbar/config values meaning "no explicit level". They stay a string
|
|
63
|
+
# instead of collapsing to nil so format adapters can tell an explicit
|
|
64
|
+
# "default" apart from "user never chose": for models whose provider
|
|
65
|
+
# default is the deepest level (MiniMax M3.1-Flash, GLM-5.3) the adapters
|
|
66
|
+
# map this value to their lightest effort.
|
|
67
|
+
REASONING_DEFAULT = "off"
|
|
68
|
+
REASONING_DEFAULT_ALIASES = %w[off none disabled nothink].freeze
|
|
60
69
|
MAX_VIDEO_UNDERSTANDING_BYTES = Utils::FileProcessor::MAX_FILE_BYTES
|
|
61
70
|
MAX_VIDEO_BASE64_BYTES = 45 * 1024 * 1024
|
|
62
71
|
MAX_VIDEO_DESCRIPTION_CHARS = 500
|
|
@@ -71,6 +80,16 @@ module Clacky
|
|
|
71
80
|
@config&.permission_mode&.to_s || ""
|
|
72
81
|
end
|
|
73
82
|
|
|
83
|
+
# Persist an explicit session choice separately from temporary goal mode.
|
|
84
|
+
def permission_mode=(value)
|
|
85
|
+
mode = value.to_s
|
|
86
|
+
raise ArgumentError, "Invalid permission mode" unless %w[confirm_all auto_approve].include?(mode)
|
|
87
|
+
@permission_mode_override = mode
|
|
88
|
+
@config.permission_mode = mode.to_sym
|
|
89
|
+
@pre_goal_permission_mode = nil
|
|
90
|
+
notify_permission_mode_change(mode.to_sym)
|
|
91
|
+
end
|
|
92
|
+
|
|
74
93
|
def reasoning_effort=(value)
|
|
75
94
|
@reasoning_effort = normalize_reasoning_effort(value)
|
|
76
95
|
end
|
|
@@ -78,7 +97,8 @@ module Clacky
|
|
|
78
97
|
private def normalize_reasoning_effort(value)
|
|
79
98
|
return nil if value.nil?
|
|
80
99
|
str = value.to_s.strip.downcase
|
|
81
|
-
return nil if str.empty?
|
|
100
|
+
return nil if str.empty?
|
|
101
|
+
return REASONING_DEFAULT if REASONING_DEFAULT_ALIASES.include?(str)
|
|
82
102
|
return str if REASONING_EFFORTS.include?(str)
|
|
83
103
|
nil
|
|
84
104
|
end
|
|
@@ -88,6 +108,7 @@ module Clacky
|
|
|
88
108
|
def initialize(client, config, working_dir:, ui:, profile:, session_id:, source:)
|
|
89
109
|
@client = client # Client for current model
|
|
90
110
|
@config = config.is_a?(AgentConfig) ? config : AgentConfig.new(config)
|
|
111
|
+
@client_options = @config.client_options
|
|
91
112
|
@agent_profile = AgentProfile.load(profile)
|
|
92
113
|
@source = source.to_sym # :manual | :cron | :channel
|
|
93
114
|
@channel_info = nil # { platform:, user_id:, user_name:, chat_id: } set by ChannelManager
|
|
@@ -117,7 +138,7 @@ module Clacky
|
|
|
117
138
|
@task_cost_source = :estimated # Track cost source for current task
|
|
118
139
|
@previous_total_tokens = 0 # Track tokens from previous iteration for delta calculation
|
|
119
140
|
@latest_latency = nil # Most recent LLM call's latency metrics (see Client#send_messages_with_tools)
|
|
120
|
-
@reasoning_effort =
|
|
141
|
+
@reasoning_effort = REASONING_DEFAULT # Sessionbar shows "Default" for a fresh session; adapters resolve it per model
|
|
121
142
|
@ui = ui # UIController for direct UI interaction
|
|
122
143
|
@debug_logs = [] # Debug logs for troubleshooting
|
|
123
144
|
@input_mutex = Mutex.new
|
|
@@ -239,23 +260,25 @@ module Clacky
|
|
|
239
260
|
# Rebuild the underlying Client (and dependent components) to pick up
|
|
240
261
|
# credentials/model name from the currently-selected model in @config.
|
|
241
262
|
private def rebuild_client_for_current_model!
|
|
242
|
-
|
|
243
|
-
@client = Clacky::Client.new(
|
|
244
|
-
@config.api_key,
|
|
245
|
-
base_url: @config.base_url,
|
|
246
|
-
model: @config.model_name,
|
|
247
|
-
anthropic_format: @config.anthropic_format?,
|
|
248
|
-
api_format: @config.api_format,
|
|
249
|
-
provider_id: @config.provider_id_for(entry),
|
|
250
|
-
capabilities: entry && entry["capabilities"]
|
|
251
|
-
)
|
|
252
|
-
# Update message compressor with new client and model
|
|
253
|
-
@message_compressor = MessageCompressor.new(@client, model: current_model)
|
|
263
|
+
build_client!
|
|
254
264
|
|
|
255
265
|
# Inject a new session context to notify the AI of the model switch
|
|
256
266
|
inject_session_context
|
|
257
267
|
end
|
|
258
268
|
|
|
269
|
+
private def build_client!
|
|
270
|
+
@client_options = @config.client_options
|
|
271
|
+
opts = @client_options.dup
|
|
272
|
+
@client = Clacky::Client.new(opts.delete(:api_key), **opts)
|
|
273
|
+
# Update message compressor with new client and model
|
|
274
|
+
@message_compressor = MessageCompressor.new(@client, model: current_model)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# The shared model card can be edited (Settings, config reload) under a live session.
|
|
278
|
+
private def refresh_client_if_card_changed!
|
|
279
|
+
build_client! unless @config.client_options == @client_options
|
|
280
|
+
end
|
|
281
|
+
|
|
259
282
|
# Change the working directory for this session
|
|
260
283
|
# Injects a new session context to notify the AI of the directory change
|
|
261
284
|
def change_working_dir(new_dir)
|
|
@@ -497,7 +520,7 @@ module Clacky
|
|
|
497
520
|
@name = new_name.to_s.strip
|
|
498
521
|
end
|
|
499
522
|
|
|
500
|
-
def run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: nil
|
|
523
|
+
def run(user_input, files: nil, reference_contexts: nil, display_text: nil, created_at: nil, references_display: nil)
|
|
501
524
|
# Initialized here (not mid-body) because run's rescue/ensure are
|
|
502
525
|
# method-level and must be able to reference them on any exit path.
|
|
503
526
|
result = nil
|
|
@@ -534,7 +557,6 @@ module Clacky
|
|
|
534
557
|
|
|
535
558
|
# Start new task for Time Machine
|
|
536
559
|
task_id = start_new_task(title: display_text.to_s.empty? ? user_input.to_s : display_text.to_s)
|
|
537
|
-
@feedback = feedback
|
|
538
560
|
run_context = { session_id: @session_id, run_id: task_id.to_s,
|
|
539
561
|
source: @source.to_s, input_created_at: created_at,
|
|
540
562
|
observed_at: Time.now.utc.iso8601(6), status: "running" }
|
|
@@ -770,7 +792,7 @@ module Clacky
|
|
|
770
792
|
@ui&.show_token_usage(response[:token_usage]) if response[:token_usage]
|
|
771
793
|
|
|
772
794
|
# Act: Execute tool calls
|
|
773
|
-
action_result = act(response[:tool_calls])
|
|
795
|
+
action_result = act(response[:tool_calls], assistant_message: response.delete(:history_message))
|
|
774
796
|
|
|
775
797
|
# Check if ask_user was called
|
|
776
798
|
if action_result[:awaiting_feedback]
|
|
@@ -1330,7 +1352,6 @@ module Clacky
|
|
|
1330
1352
|
end
|
|
1331
1353
|
entries.each do |entry|
|
|
1332
1354
|
options = entry[:options].dup
|
|
1333
|
-
options.delete(:feedback)
|
|
1334
1355
|
source = options.delete(:source) || :web
|
|
1335
1356
|
history_size = @history.size
|
|
1336
1357
|
begin
|
|
@@ -1537,7 +1558,7 @@ module Clacky
|
|
|
1537
1558
|
# "reasoning_content must be passed back" contract.
|
|
1538
1559
|
msg[:reasoning_content] = response[:reasoning_content] if response[:reasoning_content]
|
|
1539
1560
|
check_stale!
|
|
1540
|
-
@history.append(msg)
|
|
1561
|
+
@history.append(msg) { |stored_message| response[:history_message] = stored_message }
|
|
1541
1562
|
|
|
1542
1563
|
# Close the thinking spinner before returning. The caller (run loop)
|
|
1543
1564
|
# is about to render the assistant message and/or tool invocations,
|
|
@@ -1561,7 +1582,7 @@ module Clacky
|
|
|
1561
1582
|
raise Clacky::AgentInterrupted, "Task superseded by a newer task on another thread"
|
|
1562
1583
|
end
|
|
1563
1584
|
|
|
1564
|
-
private def act(tool_calls)
|
|
1585
|
+
private def act(tool_calls, assistant_message: nil)
|
|
1565
1586
|
return { denied: false, feedback: nil, tool_results: [], awaiting_feedback: false } unless tool_calls
|
|
1566
1587
|
|
|
1567
1588
|
denied = false
|
|
@@ -1742,31 +1763,21 @@ module Clacky
|
|
|
1742
1763
|
# result path so the model sees the error and can retry.
|
|
1743
1764
|
if Tools::AskUser.feedback_tool?(call[:name]) &&
|
|
1744
1765
|
result.is_a?(Hash) && result[:awaiting_feedback]
|
|
1745
|
-
#
|
|
1746
|
-
#
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
question = questions.first
|
|
1752
|
-
label = if questions.one? && !question[:multi] && question[:recommended]
|
|
1753
|
-
question[:options][question[:recommended]]
|
|
1754
|
-
end
|
|
1755
|
-
answer, feedback_options = @ui.wait_for_feedback(timeout_seconds: @feedback[:timeout_seconds], question: question[:question], recommended_label: label) do
|
|
1756
|
-
@ui.show_tool_call(call[:name], call[:arguments])
|
|
1757
|
-
end
|
|
1758
|
-
check_stale!
|
|
1759
|
-
result[:awaiting_feedback] = awaiting_feedback = !answer
|
|
1760
|
-
enqueue_input("Answer: #{answer}", delivery: :steer, created_at: Time.now.to_f, **(feedback_options || {})) if answer
|
|
1761
|
-
elsif @config.permission_mode == :auto_approve
|
|
1762
|
-
@ui&.show_tool_call(call[:name], call[:arguments])
|
|
1766
|
+
# Prepare and persist the complete request before displaying it or
|
|
1767
|
+
# starting the wait. Extensions and native questions share one result.
|
|
1768
|
+
request = prepare_feedback_request(call, result, assistant_message: assistant_message)
|
|
1769
|
+
@ui&.show_tool_call(call[:name], request)
|
|
1770
|
+
|
|
1771
|
+
if @config.permission_mode == :auto_approve
|
|
1763
1772
|
# auto_approve means the agent runs unattended by default, but a
|
|
1764
1773
|
# human MAY be watching the terminal. Show a short interactive
|
|
1765
1774
|
# countdown: if the user steps in, hand control over and wait for
|
|
1766
1775
|
# their answer; otherwise auto-decide and keep going.
|
|
1767
|
-
countdown = @ui&.request_feedback_with_countdown(seconds:
|
|
1776
|
+
countdown = @ui&.request_feedback_with_countdown(seconds: @config.ask_user_timeout_seconds)
|
|
1768
1777
|
|
|
1769
|
-
if
|
|
1778
|
+
if countdown == :timeout && @config.permission_mode != :auto_approve
|
|
1779
|
+
awaiting_feedback = true
|
|
1780
|
+
elsif @ui.nil? || countdown == :timeout
|
|
1770
1781
|
result = result.merge(
|
|
1771
1782
|
auto_reply: "No user is available. Please make a reasonable decision based on the context and continue."
|
|
1772
1783
|
)
|
|
@@ -1782,7 +1793,6 @@ module Clacky
|
|
|
1782
1793
|
end
|
|
1783
1794
|
else
|
|
1784
1795
|
# confirm_all / confirm_safes — a human is present, truly wait for user input.
|
|
1785
|
-
@ui&.show_tool_call(call[:name], call[:arguments])
|
|
1786
1796
|
awaiting_feedback = true
|
|
1787
1797
|
end
|
|
1788
1798
|
else
|
|
@@ -2105,8 +2115,11 @@ module Clacky
|
|
|
2105
2115
|
@tool_registry.register(Tools::Edit.new)
|
|
2106
2116
|
@tool_registry.register(Tools::Glob.new)
|
|
2107
2117
|
@tool_registry.register(Tools::Grep.new)
|
|
2108
|
-
|
|
2109
|
-
|
|
2118
|
+
[Tools::WebSearch, Tools::WebFetch].each do |klass|
|
|
2119
|
+
tool = klass.new
|
|
2120
|
+
tool.agent = self
|
|
2121
|
+
@tool_registry.register(tool)
|
|
2122
|
+
end
|
|
2110
2123
|
@tool_registry.register(Tools::TodoManager.new)
|
|
2111
2124
|
@tool_registry.register(Tools::AskUser.new)
|
|
2112
2125
|
@tool_registry.register(Tools::InvokeSkill.new)
|