openclacky 1.2.17 → 1.3.0
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 +34 -0
- data/lib/clacky/agent/skill_manager.rb +1 -1
- data/lib/clacky/agent/time_machine.rb +256 -74
- data/lib/clacky/agent/tool_executor.rb +12 -0
- data/lib/clacky/agent.rb +21 -31
- data/lib/clacky/agent_config.rb +18 -0
- data/lib/clacky/cli.rb +55 -3
- data/lib/clacky/default_skills/media-gen/SKILL.md +173 -5
- data/lib/clacky/default_skills/skill-creator/SKILL.md +1 -0
- data/lib/clacky/media/base.rb +125 -0
- data/lib/clacky/media/dashscope.rb +243 -0
- data/lib/clacky/media/gemini.rb +10 -0
- data/lib/clacky/media/generator.rb +75 -0
- data/lib/clacky/media/openai_compat.rb +160 -0
- data/lib/clacky/message_history.rb +12 -7
- data/lib/clacky/providers.rb +28 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1
- data/lib/clacky/server/backup_manager.rb +200 -0
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -2
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +68 -15
- data/lib/clacky/server/channel/channel_manager.rb +180 -81
- data/lib/clacky/server/http_server.rb +348 -15
- data/lib/clacky/server/scheduler.rb +19 -0
- data/lib/clacky/server/session_registry.rb +8 -4
- data/lib/clacky/session_manager.rb +40 -2
- data/lib/clacky/skill.rb +3 -1
- data/lib/clacky/tools/trash_manager.rb +14 -0
- data/lib/clacky/ui2/components/command_suggestions.rb +1 -0
- data/lib/clacky/ui2/components/modal_component.rb +34 -7
- data/lib/clacky/ui2/ui_controller.rb +150 -19
- data/lib/clacky/utils/file_processor.rb +75 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +2038 -1147
- data/lib/clacky/web/app.js +22 -1
- data/lib/clacky/web/backup.js +119 -0
- data/lib/clacky/web/billing.js +94 -7
- data/lib/clacky/web/channels.js +81 -11
- data/lib/clacky/web/design-sample.css +247 -0
- data/lib/clacky/web/design-sample.html +127 -0
- data/lib/clacky/web/favicon.svg +16 -0
- data/lib/clacky/web/i18n.js +159 -31
- data/lib/clacky/web/index.html +175 -55
- data/lib/clacky/web/logo_nav_dark.png +0 -0
- data/lib/clacky/web/onboard.js +114 -28
- data/lib/clacky/web/sessions.js +436 -192
- data/lib/clacky/web/settings.js +21 -1
- data/lib/clacky/web/skills.js +6 -6
- data/lib/clacky/web/tasks.js +129 -61
- data/lib/clacky/web/utils.js +72 -0
- data/lib/clacky/web/ws-dispatcher.js +6 -0
- data/lib/clacky.rb +1 -0
- metadata +8 -3
- data/lib/clacky/server/channel/group_message_buffer.rb +0 -53
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8423d4e64f056251f1763e5cc34502e394f08e2b26de993da5da6a85a88111ef
|
|
4
|
+
data.tar.gz: 62e60aee3f8654e881870117b4716e053ab0f9328bc3898392ff1ed27517474c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d675c6b981a1fc5f24bbee79dd19dc273817a7f4e233c2ed86a42def4f2fd2876cbc845a9f693383d7f4b67d39f6a62df1eafa65def2a8ace7bb876559c6aba5
|
|
7
|
+
data.tar.gz: 48adb156c4c8ab26d2908537ac409c161a56b1f6fa5aa2146bdaa5df6b11c2b333e5a04a6aafc4404f1c94ce210fe0c23854d0f86f9cc509f1a27a781f9aa007
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,40 @@ 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.3.0] - 2026-06-17
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Complete Web UI overhaul: skeleton loading, task card layout, new session dialog, onboarding flow, inline image preview, and redesigned session bar
|
|
12
|
+
- Multimedia generation: video generation, text-to-speech (TTS), and OCR via vision models
|
|
13
|
+
- IM channel management UI — bind and unbind channels directly in Web UI
|
|
14
|
+
- `/model` command in terminal UI for quick model switching
|
|
15
|
+
- AI-key device login flow
|
|
16
|
+
- Session sharing and backup support
|
|
17
|
+
|
|
18
|
+
### Improved
|
|
19
|
+
- Terminal output auto-collapses on completion with normalized result display
|
|
20
|
+
- Billing page mobile layout, global tooltip, and logo animation polish
|
|
21
|
+
- Live chat history API replaces group buffer polling for real-time sync
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- Sidebar scrolling back to active session on content updates
|
|
25
|
+
- Channel key missing arbitration on session restore
|
|
26
|
+
- Race condition in model switching
|
|
27
|
+
- Channel keys and info out of sync on bind/unbind
|
|
28
|
+
|
|
29
|
+
## [1.2.18] - 2026-06-13
|
|
30
|
+
|
|
31
|
+
### Added
|
|
32
|
+
- Alibaba DashScope (Qwen-Image) as a new image generation backend
|
|
33
|
+
- "Always show" toggle for media-gen and skill-creators default skills, keeping them visible in all sessions
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
- Brand skill files not accessible outside their initial session context
|
|
37
|
+
- `/model` command
|
|
38
|
+
|
|
39
|
+
### More
|
|
40
|
+
- Brand skills page now auto-refreshes on enter
|
|
41
|
+
|
|
8
42
|
## [1.2.17] - 2026-06-12
|
|
9
43
|
|
|
10
44
|
### Added
|
|
@@ -346,7 +346,7 @@ module Clacky
|
|
|
346
346
|
|
|
347
347
|
# For encrypted brand skills with supporting scripts: decrypt to a tmpdir so the
|
|
348
348
|
# LLM receives the real paths it can execute. The tmpdir is registered on the agent
|
|
349
|
-
# and
|
|
349
|
+
# and lives for the agent's lifetime (the session).
|
|
350
350
|
script_dir = nil
|
|
351
351
|
if skill.encrypted? && skill.has_supporting_files?
|
|
352
352
|
script_dir = Dir.mktmpdir("clacky-skill-#{skill.identifier}-")
|
|
@@ -1,10 +1,41 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
3
6
|
module Clacky
|
|
4
7
|
class Agent
|
|
5
|
-
# Time Machine module for task history management with undo/redo support
|
|
6
|
-
#
|
|
8
|
+
# Time Machine module for task history management with undo/redo support.
|
|
9
|
+
#
|
|
10
|
+
# Snapshots capture the BEFORE state of each file the moment a task first
|
|
11
|
+
# touches it (via record_file_before_change). task-N/ therefore holds
|
|
12
|
+
# "what every file looked like just before task N changed it" — including
|
|
13
|
+
# an .absent marker for files that did not yet exist. Restoring to task T
|
|
14
|
+
# replays the earliest BEFORE recorded in any task after T, which equals
|
|
15
|
+
# the on-disk state at the end of task T.
|
|
7
16
|
module TimeMachine
|
|
17
|
+
# Marker file written alongside a snapshot path when the original file
|
|
18
|
+
# did not exist before the task changed it. Restoring such an entry
|
|
19
|
+
# deletes the file instead of copying content back.
|
|
20
|
+
ABSENT_MARKER = ".clacky-absent"
|
|
21
|
+
|
|
22
|
+
# Root directory holding per-session file snapshots.
|
|
23
|
+
def self.snapshots_root
|
|
24
|
+
File.join(Dir.home, ".clacky", "snapshots")
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Snapshot directory for a single session.
|
|
28
|
+
def self.session_dir(session_id)
|
|
29
|
+
File.join(snapshots_root, session_id.to_s)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Remove all snapshots for a session. Safe to call when none exist.
|
|
33
|
+
def self.delete_session_snapshots(session_id)
|
|
34
|
+
return if session_id.to_s.empty?
|
|
35
|
+
|
|
36
|
+
FileUtils.rm_rf(session_dir(session_id))
|
|
37
|
+
end
|
|
38
|
+
|
|
8
39
|
# Initialize Time Machine state
|
|
9
40
|
private def init_time_machine
|
|
10
41
|
@task_parents ||= {} # { task_id => parent_id }
|
|
@@ -15,6 +46,12 @@ module Clacky
|
|
|
15
46
|
# Start a new task and establish parent relationship
|
|
16
47
|
# Made public for testing
|
|
17
48
|
def start_new_task
|
|
49
|
+
# Before the currently-active task stops being the latest, freeze its
|
|
50
|
+
# end-of-task disk state into an AFTER snapshot. Without this, a task
|
|
51
|
+
# that later gets superseded by a sibling branch would have no record
|
|
52
|
+
# of its result, making a forward switch back to it impossible.
|
|
53
|
+
checkpoint_latest_task_after
|
|
54
|
+
|
|
18
55
|
parent_id = @active_task_id
|
|
19
56
|
@current_task_id += 1
|
|
20
57
|
@active_task_id = @current_task_id
|
|
@@ -26,101 +63,242 @@ module Clacky
|
|
|
26
63
|
# before it can write to history.
|
|
27
64
|
@task_thread = Thread.current
|
|
28
65
|
|
|
66
|
+
@latest_after_dirty = true
|
|
67
|
+
|
|
29
68
|
@current_task_id
|
|
30
69
|
end
|
|
31
70
|
|
|
32
|
-
#
|
|
33
|
-
#
|
|
71
|
+
# Record a file's BEFORE state for the current task, the first time the
|
|
72
|
+
# task touches it. Call this immediately before a tool mutates the file.
|
|
73
|
+
# Subsequent calls within the same task are no-ops so the earliest state
|
|
74
|
+
# (the true "before this task" snapshot) is preserved.
|
|
34
75
|
# Made public for testing
|
|
35
|
-
def
|
|
36
|
-
return if
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
FileUtils.
|
|
56
|
-
FileUtils.cp(file_path, snapshot_file)
|
|
76
|
+
def record_file_before_change(file_path)
|
|
77
|
+
return if @current_task_id.to_i <= 0
|
|
78
|
+
|
|
79
|
+
full_path = File.expand_path(file_path.to_s, @working_dir)
|
|
80
|
+
rel = snapshot_relative_path(full_path)
|
|
81
|
+
before_dir = File.join(TimeMachine.session_dir(@session_id), "task-#{@current_task_id}", "before")
|
|
82
|
+
snapshot_file = File.join(before_dir, rel)
|
|
83
|
+
marker_file = "#{snapshot_file}.#{ABSENT_MARKER}"
|
|
84
|
+
|
|
85
|
+
# Already recorded for this task — keep the earliest capture.
|
|
86
|
+
return if File.exist?(snapshot_file) || File.exist?(marker_file)
|
|
87
|
+
|
|
88
|
+
# A fresh change to the latest task invalidates its stale AFTER checkpoint.
|
|
89
|
+
@latest_after_dirty = true
|
|
90
|
+
|
|
91
|
+
FileUtils.mkdir_p(File.dirname(snapshot_file))
|
|
92
|
+
if File.exist?(full_path)
|
|
93
|
+
FileUtils.cp(full_path, snapshot_file)
|
|
94
|
+
else
|
|
95
|
+
# File did not exist before this task — mark it so a restore deletes it.
|
|
96
|
+
FileUtils.touch(marker_file)
|
|
57
97
|
end
|
|
58
|
-
rescue StandardError
|
|
59
|
-
#
|
|
98
|
+
rescue StandardError
|
|
99
|
+
# Snapshotting must never break the actual file operation.
|
|
60
100
|
end
|
|
61
101
|
|
|
62
|
-
#
|
|
102
|
+
# Snapshot a task's current on-disk state into its AFTER tree, so a
|
|
103
|
+
# forward switch (redo / branch switch) back to it can be reconstructed.
|
|
104
|
+
# Only the files the task touched (its BEFORE entries) are captured.
|
|
105
|
+
# Defaults to the active task, which holds the live disk state right
|
|
106
|
+
# before we leave it (start_new_task / switch).
|
|
107
|
+
private def checkpoint_latest_task_after(task_id = @active_task_id)
|
|
108
|
+
return if task_id.to_i <= 0
|
|
109
|
+
# Re-snapshotting the latest task is skipped when nothing changed.
|
|
110
|
+
return if task_id == @current_task_id && @latest_after_dirty == false
|
|
111
|
+
|
|
112
|
+
session_root = TimeMachine.session_dir(@session_id)
|
|
113
|
+
before_dir = File.join(session_root, "task-#{task_id}", "before")
|
|
114
|
+
return unless Dir.exist?(before_dir)
|
|
115
|
+
|
|
116
|
+
after_dir = File.join(session_root, "task-#{task_id}", "after")
|
|
117
|
+
FileUtils.rm_rf(after_dir)
|
|
118
|
+
|
|
119
|
+
Dir.glob(File.join(before_dir, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
120
|
+
next if File.directory?(path)
|
|
121
|
+
|
|
122
|
+
rel = path.sub(before_dir + "/", "")
|
|
123
|
+
rel = rel.sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
|
|
124
|
+
target = File.join(@working_dir, rel)
|
|
125
|
+
dest = File.join(after_dir, rel)
|
|
126
|
+
if File.exist?(target)
|
|
127
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
128
|
+
FileUtils.cp(target, dest)
|
|
129
|
+
else
|
|
130
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
131
|
+
FileUtils.touch("#{dest}.#{ABSENT_MARKER}")
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
@latest_after_dirty = false if task_id == @current_task_id
|
|
135
|
+
rescue StandardError
|
|
136
|
+
# Checkpointing must never break a restore.
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Restore files to the on-disk state at the END of the given task.
|
|
140
|
+
#
|
|
141
|
+
# History is a TREE (undo + a new message forks a sibling branch), so a
|
|
142
|
+
# linear "replay every task after T" model is wrong: a sibling branch's
|
|
143
|
+
# files would leak in or get wrongly deleted. Instead we reconstruct T's
|
|
144
|
+
# end state from the task tree:
|
|
145
|
+
#
|
|
146
|
+
# * Each task owns an AFTER snapshot = the content of the files it
|
|
147
|
+
# touched, as they looked when that task finished.
|
|
148
|
+
# * To rebuild "end of task T", walk T's ancestor chain (T -> root).
|
|
149
|
+
# For every file ever touched in the whole session, the winning
|
|
150
|
+
# content is the closest ancestor (starting at T) whose AFTER holds
|
|
151
|
+
# that file. If no ancestor on the chain ever touched it, the file
|
|
152
|
+
# did not exist at T and is removed.
|
|
153
|
+
#
|
|
63
154
|
# @param task_id [Integer] Target task ID
|
|
64
155
|
# Made public for testing
|
|
65
156
|
def restore_to_task_state(task_id)
|
|
66
|
-
#
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
(
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
next unless Dir.exist?(snapshot_dir)
|
|
79
|
-
|
|
80
|
-
Dir.glob(File.join(snapshot_dir, "**", "*")).each do |snapshot_file|
|
|
81
|
-
next if File.directory?(snapshot_file)
|
|
82
|
-
|
|
83
|
-
relative_path = snapshot_file.sub(snapshot_dir + "/", "")
|
|
84
|
-
files_to_restore[relative_path] = snapshot_file
|
|
85
|
-
end
|
|
157
|
+
# Freeze the task we're leaving so a later forward switch can return.
|
|
158
|
+
checkpoint_latest_task_after
|
|
159
|
+
|
|
160
|
+
session_root = TimeMachine.session_dir(@session_id)
|
|
161
|
+
|
|
162
|
+
# Ancestor chain from the target task up to (and excluding) root 0,
|
|
163
|
+
# ordered nearest-first so the closest writer of each file wins.
|
|
164
|
+
ancestors = []
|
|
165
|
+
tid = task_id
|
|
166
|
+
until tid.nil? || tid <= 0 || ancestors.include?(tid)
|
|
167
|
+
ancestors << tid
|
|
168
|
+
tid = @task_parents[tid]
|
|
86
169
|
end
|
|
87
|
-
|
|
88
|
-
#
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
170
|
+
|
|
171
|
+
# Every file ever touched by any task in this session.
|
|
172
|
+
all_rels = Set.new
|
|
173
|
+
Dir.glob(File.join(session_root, "task-*", "before", "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
174
|
+
next if File.directory?(path)
|
|
175
|
+
|
|
176
|
+
rel = path.sub(%r{\A.*/before/}, "")
|
|
177
|
+
rel = rel.sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
|
|
178
|
+
all_rels << rel
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
all_rels.each do |rel|
|
|
182
|
+
action = :delete
|
|
183
|
+
source = nil
|
|
184
|
+
matched = false
|
|
185
|
+
|
|
186
|
+
# Closest ancestor (starting at the target) that produced this file.
|
|
187
|
+
ancestors.each do |aid|
|
|
188
|
+
after_dir = File.join(session_root, "task-#{aid}", "after")
|
|
189
|
+
content_path = File.join(after_dir, rel)
|
|
190
|
+
absent_path = "#{content_path}.#{ABSENT_MARKER}"
|
|
191
|
+
|
|
192
|
+
if File.exist?(content_path)
|
|
193
|
+
action = :restore
|
|
194
|
+
source = content_path
|
|
195
|
+
matched = true
|
|
196
|
+
break
|
|
197
|
+
elsif File.exist?(absent_path)
|
|
198
|
+
action = :delete
|
|
199
|
+
matched = true
|
|
200
|
+
break
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
# No task on the chain produced this file. Restore the session's
|
|
205
|
+
# INITIAL content for it — captured as the earliest BEFORE recorded
|
|
206
|
+
# for this file by any task (BEFORE = state just before that task
|
|
207
|
+
# ran; the smallest task id therefore holds the pre-session state).
|
|
208
|
+
# No BEFORE at all => the file never existed initially, so delete.
|
|
209
|
+
unless matched
|
|
210
|
+
initial = earliest_before_snapshot(session_root, rel)
|
|
211
|
+
if initial
|
|
212
|
+
action = :restore
|
|
213
|
+
source = initial
|
|
214
|
+
else
|
|
215
|
+
action = :delete
|
|
216
|
+
end
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
target = File.join(@working_dir, rel)
|
|
220
|
+
if action == :delete
|
|
221
|
+
FileUtils.rm_f(target)
|
|
222
|
+
else
|
|
223
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
224
|
+
FileUtils.cp(source, target)
|
|
225
|
+
end
|
|
93
226
|
end
|
|
94
|
-
rescue StandardError
|
|
95
|
-
# Silently handle errors in tests
|
|
227
|
+
rescue StandardError
|
|
96
228
|
raise
|
|
97
229
|
end
|
|
98
230
|
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
231
|
+
# The initial (pre-session) content path for a file, taken from the
|
|
232
|
+
# earliest BEFORE snapshot any task recorded for it. Returns the snapshot
|
|
233
|
+
# path to copy back, or nil if the earliest record is an absent marker
|
|
234
|
+
# (file did not exist at the session start).
|
|
235
|
+
private def earliest_before_snapshot(session_root, rel)
|
|
236
|
+
task_ids = Dir.glob(File.join(session_root, "task-*")).filter_map do |dir|
|
|
237
|
+
m = File.basename(dir).match(/\Atask-(\d+)\z/)
|
|
238
|
+
m && m[1].to_i
|
|
239
|
+
end.sort
|
|
240
|
+
|
|
241
|
+
task_ids.each do |tid|
|
|
242
|
+
before_dir = File.join(session_root, "task-#{tid}", "before")
|
|
243
|
+
content_path = File.join(before_dir, rel)
|
|
244
|
+
absent_path = "#{content_path}.#{ABSENT_MARKER}"
|
|
245
|
+
return content_path if File.exist?(content_path)
|
|
246
|
+
return nil if File.exist?(absent_path)
|
|
247
|
+
end
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
# Relative path used to key a snapshot. Files inside the working dir keep
|
|
252
|
+
# their relative path; anything else falls back to its basename.
|
|
253
|
+
private def snapshot_relative_path(full_path)
|
|
254
|
+
if full_path.start_with?(@working_dir + "/")
|
|
255
|
+
full_path.sub(@working_dir + "/", "")
|
|
256
|
+
else
|
|
257
|
+
File.basename(full_path)
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Filter messages to only the active task's ancestor chain.
|
|
262
|
+
# After an undo (and especially after sending a NEW message post-undo,
|
|
263
|
+
# which forks a fresh task off the undone point) the history still holds
|
|
264
|
+
# the abandoned/sibling-branch turns. We must send the LLM only the turns
|
|
265
|
+
# on the path from the root to the active task — never undone siblings.
|
|
266
|
+
# Returns API-ready array (strips internal fields + repairs orphaned
|
|
267
|
+
# tool_calls), so this stays consistent with the normal to_api path.
|
|
102
268
|
# @param force_reasoning_content_pad [Boolean] forwarded to MessageHistory,
|
|
103
269
|
# enables one-shot pad-and-retry for thinking-mode providers that
|
|
104
270
|
# require reasoning_content on every assistant message.
|
|
105
271
|
# Made public for testing
|
|
106
272
|
def active_messages(force_reasoning_content_pad: false)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
273
|
+
@history.to_api(
|
|
274
|
+
force_reasoning_content_pad: force_reasoning_content_pad,
|
|
275
|
+
task_chain: active_task_chain
|
|
276
|
+
)
|
|
277
|
+
end
|
|
110
278
|
|
|
111
|
-
|
|
112
|
-
|
|
279
|
+
# The set of task IDs on the path from the root to @active_task_id,
|
|
280
|
+
# walked via @task_parents. Used to filter history so undone or
|
|
281
|
+
# sibling-branch turns are excluded from what the LLM sees. Task 0 is the
|
|
282
|
+
# root and is always included when reached (early turns are tagged 0).
|
|
283
|
+
private def active_task_chain
|
|
284
|
+
chain = Set.new
|
|
285
|
+
tid = @active_task_id
|
|
286
|
+
# Guard against a malformed parent map producing a cycle.
|
|
287
|
+
until tid.nil? || chain.include?(tid)
|
|
288
|
+
chain << tid
|
|
289
|
+
break if tid <= 0
|
|
290
|
+
tid = @task_parents[tid]
|
|
113
291
|
end
|
|
114
|
-
|
|
115
|
-
# Time Machine replays satisfy thinking-mode providers after a
|
|
116
|
-
# 400 retry.
|
|
117
|
-
MessageHistory.pad_reasoning_content_if_needed(stripped, force: force_reasoning_content_pad)
|
|
292
|
+
chain
|
|
118
293
|
end
|
|
119
294
|
|
|
120
|
-
# Undo to parent task
|
|
295
|
+
# Undo to parent task. Task 0 represents the original pre-task state,
|
|
296
|
+
# which is reachable from task 1 thanks to its BEFORE snapshots.
|
|
121
297
|
def undo_last_task
|
|
298
|
+
return { success: false, message: "Already at root task" } if @active_task_id == 0
|
|
299
|
+
|
|
122
300
|
parent_id = @task_parents[@active_task_id]
|
|
123
|
-
return { success: false, message: "Already at root task" } if parent_id.nil?
|
|
301
|
+
return { success: false, message: "Already at root task" } if parent_id.nil?
|
|
124
302
|
|
|
125
303
|
restore_to_task_state(parent_id)
|
|
126
304
|
@active_task_id = parent_id
|
|
@@ -159,6 +337,8 @@ module Clacky
|
|
|
159
337
|
def get_task_history(limit: 10)
|
|
160
338
|
return [] if @current_task_id == 0
|
|
161
339
|
|
|
340
|
+
chain = active_task_chain
|
|
341
|
+
|
|
162
342
|
tasks = []
|
|
163
343
|
(1..@current_task_id).to_a.reverse.take(limit).reverse.each do |task_id|
|
|
164
344
|
# Find first user message for this task
|
|
@@ -174,13 +354,15 @@ module Clacky
|
|
|
174
354
|
"Task #{task_id}"
|
|
175
355
|
end
|
|
176
356
|
|
|
177
|
-
#
|
|
357
|
+
# Status relative to the ACTIVE task chain (not a linear id compare),
|
|
358
|
+
# so undone/abandoned branches are flagged distinctly from the path
|
|
359
|
+
# the user is currently on.
|
|
178
360
|
status = if task_id == @active_task_id
|
|
179
361
|
:current
|
|
180
|
-
elsif task_id
|
|
362
|
+
elsif chain.include?(task_id)
|
|
181
363
|
:past
|
|
182
364
|
else
|
|
183
|
-
:
|
|
365
|
+
:undone
|
|
184
366
|
end
|
|
185
367
|
|
|
186
368
|
# Check if task has branches (multiple children)
|
|
@@ -254,6 +254,18 @@ module Clacky
|
|
|
254
254
|
}
|
|
255
255
|
end
|
|
256
256
|
|
|
257
|
+
# Show countdown before auto-executing in auto_approve mode.
|
|
258
|
+
# Gives the user time to see what's happening and Ctrl+C to cancel.
|
|
259
|
+
# @param seconds [Integer] Countdown duration
|
|
260
|
+
private def auto_approve_countdown(seconds: 10)
|
|
261
|
+
return unless @ui
|
|
262
|
+
|
|
263
|
+
seconds.downto(1) do |remaining|
|
|
264
|
+
@ui.show_info(" Auto-executing in #{remaining}s... (Ctrl+C to cancel)", prefix_newline: false)
|
|
265
|
+
sleep 1
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
|
|
257
269
|
# Check if a tool is potentially slow and should show progress
|
|
258
270
|
# @param tool_name [String] Name of the tool
|
|
259
271
|
# @param args [Hash] Tool arguments
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -42,7 +42,7 @@ module Clacky
|
|
|
42
42
|
|
|
43
43
|
attr_reader :session_id, :name, :history, :iterations, :total_cost, :working_dir, :created_at, :total_tasks, :todos,
|
|
44
44
|
:cache_stats, :cost_source, :ui, :skill_loader, :agent_profile,
|
|
45
|
-
:status, :error, :updated_at, :source,
|
|
45
|
+
:status, :error, :updated_at, :source, :config,
|
|
46
46
|
:latest_latency, # Hash of latency metrics from the most recent LLM call (see Client#send_messages_with_tools)
|
|
47
47
|
:reasoning_effort
|
|
48
48
|
attr_accessor :pinned
|
|
@@ -102,7 +102,7 @@ module Clacky
|
|
|
102
102
|
@ui = ui # UIController for direct UI interaction
|
|
103
103
|
@debug_logs = [] # Debug logs for troubleshooting
|
|
104
104
|
@pending_injections = [] # Pending inline skill injections to flush after observe()
|
|
105
|
-
@pending_script_tmpdirs = [] # Decrypted-script tmpdirs
|
|
105
|
+
@pending_script_tmpdirs = [] # Decrypted-script tmpdirs that live for the agent's lifetime
|
|
106
106
|
@pending_error_rollback = false # Deferred rollback flag set by restore_session on error
|
|
107
107
|
@last_run_interrupted = false # Set when run() exits via AgentInterrupted; tells the next run() to keep the task-start snapshot (continuation of the same task across a relay, not a brand-new task)
|
|
108
108
|
|
|
@@ -605,12 +605,6 @@ module Clacky
|
|
|
605
605
|
|
|
606
606
|
result = build_result
|
|
607
607
|
|
|
608
|
-
# Save snapshots of modified files for Time Machine
|
|
609
|
-
if @modified_files_in_task && !@modified_files_in_task.empty?
|
|
610
|
-
save_modified_files_snapshot(@modified_files_in_task)
|
|
611
|
-
@modified_files_in_task = [] # Reset for next task
|
|
612
|
-
end
|
|
613
|
-
|
|
614
608
|
# Run skill evolution hooks after main loop completes
|
|
615
609
|
# Skip if task was interrupted by user (denied tool) or awaiting user feedback
|
|
616
610
|
# Only for main agent (not subagents) to avoid recursive evolution
|
|
@@ -677,11 +671,6 @@ module Clacky
|
|
|
677
671
|
Clacky::Logger.warn("[ph_debug] agent_run_ensure")
|
|
678
672
|
@ui&.show_progress(phase: "done")
|
|
679
673
|
|
|
680
|
-
# Shred any decrypted-script tmpdirs created during this run for encrypted brand skills.
|
|
681
|
-
# This covers the inline-injection path; the subagent path shreds immediately after
|
|
682
|
-
# subagent.run returns (see execute_skill_with_subagent).
|
|
683
|
-
shred_script_tmpdirs
|
|
684
|
-
|
|
685
674
|
# Fire-and-forget telemetry after every agent run.
|
|
686
675
|
# Tracks daily active users (distinct devices per day) and task volume.
|
|
687
676
|
Clacky::Telemetry.task!(result: result)
|
|
@@ -958,8 +947,11 @@ module Clacky
|
|
|
958
947
|
end
|
|
959
948
|
end
|
|
960
949
|
|
|
961
|
-
# Special handling for request_user_feedback
|
|
962
|
-
|
|
950
|
+
# Special handling for request_user_feedback
|
|
951
|
+
if call[:name] == "request_user_feedback"
|
|
952
|
+
# In auto_approve mode, give user time to see and cancel before auto-answering
|
|
953
|
+
auto_approve_countdown(seconds: 10) if @config.permission_mode == :auto_approve
|
|
954
|
+
else
|
|
963
955
|
@ui&.show_tool_call(call[:name], redact_tool_args(call[:arguments]))
|
|
964
956
|
end
|
|
965
957
|
|
|
@@ -1009,6 +1001,10 @@ module Clacky
|
|
|
1009
1001
|
# instant tools like edit/write/read/glob/grep. Truly slow
|
|
1010
1002
|
# tools (terminal running a build, web_fetch) exceed the
|
|
1011
1003
|
# threshold and their final frame is preserved as usual.
|
|
1004
|
+
# Record BEFORE-change snapshots for Time Machine right before the
|
|
1005
|
+
# tool runs, so undo can restore (or delete) any file it touches.
|
|
1006
|
+
record_tool_target_before(call[:name], args)
|
|
1007
|
+
|
|
1012
1008
|
result = nil
|
|
1013
1009
|
if @ui
|
|
1014
1010
|
progress_message = build_tool_progress_message(call[:name], args)
|
|
@@ -1023,9 +1019,6 @@ module Clacky
|
|
|
1023
1019
|
result = tool.execute(**args)
|
|
1024
1020
|
end
|
|
1025
1021
|
|
|
1026
|
-
# Track modified files for Time Machine snapshots
|
|
1027
|
-
track_modified_files(call[:name], args)
|
|
1028
|
-
|
|
1029
1022
|
# Hook: after_tool_use
|
|
1030
1023
|
@hooks.trigger(:after_tool_use, call, result)
|
|
1031
1024
|
|
|
@@ -1055,7 +1048,7 @@ module Clacky
|
|
|
1055
1048
|
else
|
|
1056
1049
|
# Use tool's format_result method to get display-friendly string
|
|
1057
1050
|
formatted_result = tool.respond_to?(:format_result) ? tool.format_result(result) : result.to_s
|
|
1058
|
-
@ui&.show_tool_result(formatted_result)
|
|
1051
|
+
@ui&.show_tool_result(redact_tool_args(formatted_result))
|
|
1059
1052
|
end
|
|
1060
1053
|
|
|
1061
1054
|
results << build_success_result(call, result)
|
|
@@ -1073,7 +1066,7 @@ module Clacky
|
|
|
1073
1066
|
Clacky::Logger.error("tool_execution_error", tool: call[:name], error: e)
|
|
1074
1067
|
|
|
1075
1068
|
@hooks.trigger(:on_tool_error, call, e)
|
|
1076
|
-
@ui&.show_tool_error(e)
|
|
1069
|
+
@ui&.show_tool_error(redact_tool_args(e.message))
|
|
1077
1070
|
# Use build_denied_result with system_injected=true so LLM knows it can retry
|
|
1078
1071
|
results << build_denied_result(call, e.message, true)
|
|
1079
1072
|
end
|
|
@@ -1176,8 +1169,8 @@ module Clacky
|
|
|
1176
1169
|
end
|
|
1177
1170
|
|
|
1178
1171
|
# Register a tmpdir that contains decrypted brand skill scripts.
|
|
1179
|
-
# SkillManager calls this after decrypt_all_scripts
|
|
1180
|
-
#
|
|
1172
|
+
# SkillManager calls this after decrypt_all_scripts. The tmpdir lives for
|
|
1173
|
+
# the agent's lifetime (a session), not just a single agent.run.
|
|
1181
1174
|
# @param dir [String] Absolute path to the tmpdir
|
|
1182
1175
|
def register_script_tmpdir(dir)
|
|
1183
1176
|
@pending_script_tmpdirs << dir
|
|
@@ -1585,7 +1578,7 @@ module Clacky
|
|
|
1585
1578
|
|
|
1586
1579
|
image = data_url ? { data_url: data_url } : { path: path }
|
|
1587
1580
|
|
|
1588
|
-
@ui&.show_progress("
|
|
1581
|
+
@ui&.show_progress("Reading image…", progress_type: "vision", phase: "active")
|
|
1589
1582
|
begin
|
|
1590
1583
|
Clacky::Vision::Resolver.new(ocr_entry).describe(image)
|
|
1591
1584
|
ensure
|
|
@@ -1814,17 +1807,14 @@ module Clacky
|
|
|
1814
1807
|
@ui&.show_assistant_message(full_content, files: parsed[:files])
|
|
1815
1808
|
end
|
|
1816
1809
|
|
|
1817
|
-
#
|
|
1818
|
-
#
|
|
1810
|
+
# Record BEFORE-change snapshots for any file a tool is about to mutate,
|
|
1811
|
+
# so Time Machine can later restore or delete it.
|
|
1812
|
+
# @param tool_name [String] Name of the tool about to be executed
|
|
1819
1813
|
# @param args [Hash] Arguments passed to the tool
|
|
1820
|
-
def
|
|
1821
|
-
@modified_files_in_task ||= []
|
|
1822
|
-
|
|
1814
|
+
private def record_tool_target_before(tool_name, args)
|
|
1823
1815
|
case tool_name
|
|
1824
1816
|
when "write", "edit"
|
|
1825
|
-
|
|
1826
|
-
full_path = File.expand_path(file_path, @working_dir)
|
|
1827
|
-
@modified_files_in_task << full_path unless @modified_files_in_task.include?(full_path)
|
|
1817
|
+
record_file_before_change(args[:path]) if args[:path]
|
|
1828
1818
|
end
|
|
1829
1819
|
end
|
|
1830
1820
|
end
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -825,6 +825,24 @@ module Clacky
|
|
|
825
825
|
available = default_provider ? Clacky::Providers.ocr_models(default_provider) : []
|
|
826
826
|
|
|
827
827
|
if raw_entry && raw_entry["disabled"]
|
|
828
|
+
# A disabled OCR sidecar only means "no separate vision model"; it must
|
|
829
|
+
# not override the fact that the chat model may handle images itself.
|
|
830
|
+
anchor = current_model || default
|
|
831
|
+
anchor_provider = anchor && Clacky::Providers.resolve_provider(
|
|
832
|
+
base_url: anchor["base_url"], api_key: anchor["api_key"]
|
|
833
|
+
)
|
|
834
|
+
if anchor && anchor_provider &&
|
|
835
|
+
Clacky::Providers.supports?(anchor_provider, :vision, model_name: anchor["model"])
|
|
836
|
+
return {
|
|
837
|
+
"configured" => true,
|
|
838
|
+
"source" => "primary",
|
|
839
|
+
"model" => anchor["model"],
|
|
840
|
+
"base_url" => anchor["base_url"],
|
|
841
|
+
"provider" => anchor_provider,
|
|
842
|
+
"primary" => true,
|
|
843
|
+
"available" => available
|
|
844
|
+
}
|
|
845
|
+
end
|
|
828
846
|
return {
|
|
829
847
|
"configured" => false,
|
|
830
848
|
"source" => "off",
|