openclacky 1.3.1 → 1.3.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 +44 -0
- data/Dockerfile +3 -0
- data/README.md +1 -1
- data/README_JA.md +237 -0
- data/lib/clacky/agent/session_serializer.rb +65 -11
- data/lib/clacky/agent/time_machine.rb +247 -26
- data/lib/clacky/agent.rb +12 -1
- data/lib/clacky/agent_config.rb +14 -2
- data/lib/clacky/brand_config.rb +1 -1
- data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
- data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
- data/lib/clacky/default_agents/coding/profile.yml +3 -0
- data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
- data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
- data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
- data/lib/clacky/default_skills/media-gen/SKILL.md +30 -6
- data/lib/clacky/media/openai_compat.rb +64 -1
- data/lib/clacky/media/output_dir.rb +43 -0
- data/lib/clacky/message_history.rb +9 -0
- data/lib/clacky/server/channel/channel_manager.rb +26 -0
- data/lib/clacky/server/git_panel.rb +115 -0
- data/lib/clacky/server/http_server.rb +521 -13
- data/lib/clacky/server/server_master.rb +6 -4
- data/lib/clacky/utils/environment_detector.rb +16 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +512 -60
- data/lib/clacky/web/app.js +30 -7
- data/lib/clacky/web/components/code-editor.js +197 -0
- data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
- data/lib/clacky/web/core/aside.js +112 -0
- data/lib/clacky/web/core/ext.js +387 -0
- data/lib/clacky/web/features/backup/store.js +92 -0
- data/lib/clacky/web/features/backup/view.js +94 -0
- data/lib/clacky/web/features/billing/store.js +163 -0
- data/lib/clacky/web/{billing.js → features/billing/view.js} +134 -242
- data/lib/clacky/web/features/brand/store.js +110 -0
- data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
- data/lib/clacky/web/features/channels/store.js +103 -0
- data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
- data/lib/clacky/web/features/creator/store.js +81 -0
- data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
- data/lib/clacky/web/features/mcp/store.js +158 -0
- data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
- data/lib/clacky/web/features/model-tester/store.js +77 -0
- data/lib/clacky/web/features/model-tester/view.js +7 -0
- data/lib/clacky/web/features/profile/store.js +170 -0
- data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
- data/lib/clacky/web/features/share/store.js +145 -0
- data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
- data/lib/clacky/web/features/skills/store.js +303 -0
- data/lib/clacky/web/features/skills/view.js +550 -0
- data/lib/clacky/web/features/tasks/store.js +135 -0
- data/lib/clacky/web/features/tasks/view.js +241 -0
- data/lib/clacky/web/features/trash/store.js +242 -0
- data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
- data/lib/clacky/web/features/version/store.js +165 -0
- data/lib/clacky/web/features/version/view.js +323 -0
- data/lib/clacky/web/features/workspace/store.js +99 -0
- data/lib/clacky/web/features/workspace/view.js +305 -0
- data/lib/clacky/web/i18n.js +60 -6
- data/lib/clacky/web/index.html +117 -57
- data/lib/clacky/web/sessions.js +221 -25
- data/lib/clacky/web/settings.js +121 -25
- data/lib/clacky/web/skills.js +3 -821
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
- data/lib/clacky.rb +1 -0
- metadata +45 -20
- data/lib/clacky/web/backup.js +0 -119
- data/lib/clacky/web/model-tester.js +0 -66
- data/lib/clacky/web/tasks.js +0 -365
- data/lib/clacky/web/version.js +0 -449
- data/lib/clacky/web/workspace.js +0 -212
- /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
- /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
- /data/lib/clacky/web/{onboard.js → components/onboard.js} +0 -0
- /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
- /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
|
@@ -41,22 +41,39 @@ module Clacky
|
|
|
41
41
|
@task_parents ||= {} # { task_id => parent_id }
|
|
42
42
|
@current_task_id ||= 0 # Latest created task ID
|
|
43
43
|
@active_task_id ||= 0 # Current active task ID (for undo/redo)
|
|
44
|
+
@task_meta ||= {} # { task_id => { title:, started_at:, ended_at: } }
|
|
45
|
+
@latest_after_dirty = false if @latest_after_dirty.nil?
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
# Start a new task and establish parent relationship
|
|
49
|
+
# @param title [String, nil] Short label for this turn (typically the
|
|
50
|
+
# user's first message, truncated). Used by the UI to label snapshots
|
|
51
|
+
# even after the original conversation has been compressed out of
|
|
52
|
+
# @history. nil → leave unset; the UI falls back to "Task N".
|
|
47
53
|
# Made public for testing
|
|
48
|
-
def start_new_task
|
|
54
|
+
def start_new_task(title: nil)
|
|
49
55
|
# Before the currently-active task stops being the latest, freeze its
|
|
50
56
|
# end-of-task disk state into an AFTER snapshot. Without this, a task
|
|
51
57
|
# that later gets superseded by a sibling branch would have no record
|
|
52
58
|
# of its result, making a forward switch back to it impossible.
|
|
53
59
|
checkpoint_latest_task_after
|
|
54
60
|
|
|
61
|
+
# Close out the task we're leaving.
|
|
62
|
+
if @active_task_id.to_i > 0 && @task_meta[@active_task_id]
|
|
63
|
+
@task_meta[@active_task_id][:ended_at] ||= Time.now.to_f
|
|
64
|
+
end
|
|
65
|
+
|
|
55
66
|
parent_id = @active_task_id
|
|
56
67
|
@current_task_id += 1
|
|
57
68
|
@active_task_id = @current_task_id
|
|
58
69
|
@task_parents[@current_task_id] = parent_id
|
|
59
70
|
|
|
71
|
+
@task_meta[@current_task_id] = {
|
|
72
|
+
title: title ? truncate_task_title(title) : nil,
|
|
73
|
+
started_at: Time.now.to_f,
|
|
74
|
+
ended_at: nil,
|
|
75
|
+
}
|
|
76
|
+
|
|
60
77
|
# Claim ownership of this task for the current thread.
|
|
61
78
|
# If a stale thread (e.g. a slow subagent) wakes up later it will see
|
|
62
79
|
# @task_thread != Thread.current via check_stale! and self-terminate
|
|
@@ -68,6 +85,21 @@ module Clacky
|
|
|
68
85
|
@current_task_id
|
|
69
86
|
end
|
|
70
87
|
|
|
88
|
+
# Update the title of the currently-active task. Used by callers that
|
|
89
|
+
# only learn the user-facing label after start_new_task has run.
|
|
90
|
+
def set_current_task_title(title)
|
|
91
|
+
return if @active_task_id.to_i <= 0
|
|
92
|
+
@task_meta[@active_task_id] ||= { started_at: Time.now.to_f, ended_at: nil }
|
|
93
|
+
@task_meta[@active_task_id][:title] = truncate_task_title(title)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
private def truncate_task_title(text)
|
|
97
|
+
s = text.to_s
|
|
98
|
+
# Collapse whitespace so multi-line inputs render as a single label.
|
|
99
|
+
s = s.gsub(/\s+/, " ").strip
|
|
100
|
+
s.length > 60 ? "#{s[0...57]}..." : s
|
|
101
|
+
end
|
|
102
|
+
|
|
71
103
|
# Record a file's BEFORE state for the current task, the first time the
|
|
72
104
|
# task touches it. Call this immediately before a tool mutates the file.
|
|
73
105
|
# Subsequent calls within the same task are no-ops so the earliest state
|
|
@@ -157,10 +189,27 @@ module Clacky
|
|
|
157
189
|
# Freeze the task we're leaving so a later forward switch can return.
|
|
158
190
|
checkpoint_latest_task_after
|
|
159
191
|
|
|
192
|
+
plan = build_restore_plan(task_id)
|
|
193
|
+
plan.each do |rel, decision|
|
|
194
|
+
target = File.join(@working_dir, rel)
|
|
195
|
+
if decision[:action] == :delete
|
|
196
|
+
FileUtils.rm_f(target)
|
|
197
|
+
else
|
|
198
|
+
FileUtils.mkdir_p(File.dirname(target))
|
|
199
|
+
FileUtils.cp(decision[:source], target)
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
rescue StandardError
|
|
203
|
+
raise
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
# Decide, for every file the session has ever touched, whether restoring
|
|
207
|
+
# to `task_id` should overwrite it with a snapshot or delete it. Pure
|
|
208
|
+
# function over the snapshot tree — does not touch the working dir.
|
|
209
|
+
# @return [Hash{String => Hash}] rel_path => { action: :delete | :restore, source: String|nil }
|
|
210
|
+
private def build_restore_plan(task_id)
|
|
160
211
|
session_root = TimeMachine.session_dir(@session_id)
|
|
161
212
|
|
|
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
213
|
ancestors = []
|
|
165
214
|
tid = task_id
|
|
166
215
|
until tid.nil? || tid <= 0 || ancestors.include?(tid)
|
|
@@ -168,22 +217,20 @@ module Clacky
|
|
|
168
217
|
tid = @task_parents[tid]
|
|
169
218
|
end
|
|
170
219
|
|
|
171
|
-
# Every file ever touched by any task in this session.
|
|
172
220
|
all_rels = Set.new
|
|
173
221
|
Dir.glob(File.join(session_root, "task-*", "before", "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
174
222
|
next if File.directory?(path)
|
|
175
|
-
|
|
176
223
|
rel = path.sub(%r{\A.*/before/}, "")
|
|
177
224
|
rel = rel.sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
|
|
178
225
|
all_rels << rel
|
|
179
226
|
end
|
|
180
227
|
|
|
228
|
+
plan = {}
|
|
181
229
|
all_rels.each do |rel|
|
|
182
230
|
action = :delete
|
|
183
231
|
source = nil
|
|
184
232
|
matched = false
|
|
185
233
|
|
|
186
|
-
# Closest ancestor (starting at the target) that produced this file.
|
|
187
234
|
ancestors.each do |aid|
|
|
188
235
|
after_dir = File.join(session_root, "task-#{aid}", "after")
|
|
189
236
|
content_path = File.join(after_dir, rel)
|
|
@@ -201,11 +248,6 @@ module Clacky
|
|
|
201
248
|
end
|
|
202
249
|
end
|
|
203
250
|
|
|
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
251
|
unless matched
|
|
210
252
|
initial = earliest_before_snapshot(session_root, rel)
|
|
211
253
|
if initial
|
|
@@ -216,16 +258,49 @@ module Clacky
|
|
|
216
258
|
end
|
|
217
259
|
end
|
|
218
260
|
|
|
261
|
+
plan[rel] = { action: action, source: source }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
plan
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# Preview the file-level effect of restore_to_task_state(task_id) without
|
|
268
|
+
# touching disk. Compares the resolved restore plan against the current
|
|
269
|
+
# working-dir state and returns only files that would actually change.
|
|
270
|
+
# @return [Array<Hash>] [{ path:, action: "create"|"modify"|"delete" }]
|
|
271
|
+
def preview_restore_to_task(task_id)
|
|
272
|
+
return [] unless task_id.is_a?(Integer) && task_id >= 0
|
|
273
|
+
|
|
274
|
+
checkpoint_latest_task_after
|
|
275
|
+
plan = build_restore_plan(task_id)
|
|
276
|
+
changes = []
|
|
277
|
+
|
|
278
|
+
plan.each do |rel, decision|
|
|
219
279
|
target = File.join(@working_dir, rel)
|
|
220
|
-
|
|
221
|
-
|
|
280
|
+
target_exists = File.exist?(target)
|
|
281
|
+
|
|
282
|
+
if decision[:action] == :delete
|
|
283
|
+
changes << { path: rel, action: "delete" } if target_exists
|
|
222
284
|
else
|
|
223
|
-
|
|
224
|
-
|
|
285
|
+
src = decision[:source]
|
|
286
|
+
next unless src && File.exist?(src)
|
|
287
|
+
|
|
288
|
+
if !target_exists
|
|
289
|
+
changes << { path: rel, action: "create" }
|
|
290
|
+
elsif !files_equal?(src, target)
|
|
291
|
+
changes << { path: rel, action: "modify" }
|
|
292
|
+
end
|
|
225
293
|
end
|
|
226
294
|
end
|
|
295
|
+
|
|
296
|
+
changes.sort_by { |c| c[:path] }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
private def files_equal?(a, b)
|
|
300
|
+
return false unless File.size(a) == File.size(b)
|
|
301
|
+
File.binread(a) == File.binread(b)
|
|
227
302
|
rescue StandardError
|
|
228
|
-
|
|
303
|
+
false
|
|
229
304
|
end
|
|
230
305
|
|
|
231
306
|
# The initial (pre-session) content path for a file, taken from the
|
|
@@ -331,6 +406,143 @@ module Clacky
|
|
|
331
406
|
@task_parents.select { |_, parent| parent == task_id }.keys
|
|
332
407
|
end
|
|
333
408
|
|
|
409
|
+
# Cheap version of task_diff_files: just count how many distinct files
|
|
410
|
+
# this task touched, so the timeline can grey out no-op tasks without
|
|
411
|
+
# paying for a full diff walk per row.
|
|
412
|
+
def task_change_count(task_id)
|
|
413
|
+
return 0 unless task_id.is_a?(Integer) && task_id > 0
|
|
414
|
+
|
|
415
|
+
session_root = TimeMachine.session_dir(@session_id)
|
|
416
|
+
before_dir = File.join(session_root, "task-#{task_id}", "before")
|
|
417
|
+
after_dir = File.join(session_root, "task-#{task_id}", "after")
|
|
418
|
+
return 0 unless Dir.exist?(before_dir)
|
|
419
|
+
return 0 if task_id == @current_task_id && @latest_after_dirty == true && !Dir.exist?(after_dir)
|
|
420
|
+
|
|
421
|
+
rels = Set.new
|
|
422
|
+
[before_dir, after_dir].each do |root|
|
|
423
|
+
next unless Dir.exist?(root)
|
|
424
|
+
Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
425
|
+
next if File.directory?(path)
|
|
426
|
+
rel = path.sub(root + "/", "").sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
|
|
427
|
+
rels << rel
|
|
428
|
+
end
|
|
429
|
+
end
|
|
430
|
+
rels.size
|
|
431
|
+
end
|
|
432
|
+
|
|
433
|
+
# File-level summary of changes a task introduced. Diff is task-N/before
|
|
434
|
+
# vs task-N/after (after is captured by checkpoint_latest_task_after when
|
|
435
|
+
# the task stops being the latest, so this method has no useful answer
|
|
436
|
+
# for the currently-active task — callers get an empty list back).
|
|
437
|
+
# @return [Array<Hash>] Each entry: { path:, status: "added"|"modified"|"deleted", binary: Bool }
|
|
438
|
+
def task_diff_files(task_id)
|
|
439
|
+
return [] unless task_id.is_a?(Integer) && task_id > 0
|
|
440
|
+
|
|
441
|
+
session_root = TimeMachine.session_dir(@session_id)
|
|
442
|
+
before_dir = File.join(session_root, "task-#{task_id}", "before")
|
|
443
|
+
after_dir = File.join(session_root, "task-#{task_id}", "after")
|
|
444
|
+
return [] unless Dir.exist?(before_dir)
|
|
445
|
+
return [] if task_id == @current_task_id && @latest_after_dirty == true && !Dir.exist?(after_dir)
|
|
446
|
+
|
|
447
|
+
rels = Set.new
|
|
448
|
+
[before_dir, after_dir].each do |root|
|
|
449
|
+
next unless Dir.exist?(root)
|
|
450
|
+
Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).each do |path|
|
|
451
|
+
next if File.directory?(path)
|
|
452
|
+
rel = path.sub(root + "/", "").sub(/\.#{Regexp.escape(ABSENT_MARKER)}\z/, "")
|
|
453
|
+
rels << rel
|
|
454
|
+
end
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
rels.sort.map do |rel|
|
|
458
|
+
before_file, before_absent = snapshot_paths(before_dir, rel)
|
|
459
|
+
after_file, after_absent = snapshot_paths(after_dir, rel)
|
|
460
|
+
|
|
461
|
+
status = if before_absent && after_file
|
|
462
|
+
"added"
|
|
463
|
+
elsif before_file && after_absent
|
|
464
|
+
"deleted"
|
|
465
|
+
elsif before_file && after_file
|
|
466
|
+
"modified"
|
|
467
|
+
elsif before_file && !File.exist?(after_dir)
|
|
468
|
+
# No AFTER captured (e.g. the very latest task) — still surface
|
|
469
|
+
# what was touched as "modified" so the UI can list the file.
|
|
470
|
+
"modified"
|
|
471
|
+
else
|
|
472
|
+
"modified"
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
binary = looks_binary?(before_file) || looks_binary?(after_file)
|
|
476
|
+
{ path: rel, status: status, binary: binary }
|
|
477
|
+
end
|
|
478
|
+
end
|
|
479
|
+
|
|
480
|
+
# Unified diff of a single file for a task. Returns nil if either side
|
|
481
|
+
# is missing or binary. text format = "@@ ... @@" patch (3-context),
|
|
482
|
+
# ready for the UI to render with a diff renderer.
|
|
483
|
+
# @return [Hash, nil] { path:, before:, after:, patch:, binary: }
|
|
484
|
+
def task_file_diff(task_id, rel_path)
|
|
485
|
+
return nil unless task_id.is_a?(Integer) && task_id > 0
|
|
486
|
+
return nil if rel_path.to_s.include?("..")
|
|
487
|
+
|
|
488
|
+
session_root = TimeMachine.session_dir(@session_id)
|
|
489
|
+
before_dir = File.join(session_root, "task-#{task_id}", "before")
|
|
490
|
+
after_dir = File.join(session_root, "task-#{task_id}", "after")
|
|
491
|
+
|
|
492
|
+
before_file, before_absent = snapshot_paths(before_dir, rel_path)
|
|
493
|
+
after_file, after_absent = snapshot_paths(after_dir, rel_path)
|
|
494
|
+
|
|
495
|
+
before_text = before_absent ? "" : (before_file ? read_text_safe(before_file) : nil)
|
|
496
|
+
after_text = after_absent ? "" : (after_file ? read_text_safe(after_file) : nil)
|
|
497
|
+
|
|
498
|
+
if before_text.nil? && after_text.nil?
|
|
499
|
+
return nil
|
|
500
|
+
end
|
|
501
|
+
|
|
502
|
+
# Detect binary on either side: bail out, the UI will render a stub.
|
|
503
|
+
if (before_file && looks_binary?(before_file)) || (after_file && looks_binary?(after_file))
|
|
504
|
+
return { path: rel_path, before: nil, after: nil, patch: nil, binary: true }
|
|
505
|
+
end
|
|
506
|
+
|
|
507
|
+
require "diffy" unless defined?(Diffy)
|
|
508
|
+
raw = Diffy::Diff.new(before_text || "", after_text || "",
|
|
509
|
+
context: 3, include_diff_info: true).to_s(:text)
|
|
510
|
+
# Strip Diffy's "--- /tmp/diffy.../before" header pair: it leaks
|
|
511
|
+
# tempfile paths and adds noise the UI doesn't need.
|
|
512
|
+
patch = raw.sub(/\A(?:---[^\n]*\n[^\n]*\n)/, "")
|
|
513
|
+
|
|
514
|
+
{ path: rel_path, before: before_text, after: after_text, patch: patch, binary: false }
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
private def snapshot_paths(dir, rel)
|
|
518
|
+
content_path = File.join(dir, rel)
|
|
519
|
+
absent_path = "#{content_path}.#{ABSENT_MARKER}"
|
|
520
|
+
if File.exist?(content_path)
|
|
521
|
+
[content_path, false]
|
|
522
|
+
elsif File.exist?(absent_path)
|
|
523
|
+
[nil, true]
|
|
524
|
+
else
|
|
525
|
+
[nil, false]
|
|
526
|
+
end
|
|
527
|
+
end
|
|
528
|
+
|
|
529
|
+
private def looks_binary?(path)
|
|
530
|
+
return false if path.nil? || !File.exist?(path)
|
|
531
|
+
sample = File.binread(path, 8000)
|
|
532
|
+
sample.include?("\x00") || !sample.dup.force_encoding("UTF-8").valid_encoding?
|
|
533
|
+
rescue StandardError
|
|
534
|
+
true
|
|
535
|
+
end
|
|
536
|
+
|
|
537
|
+
private def read_text_safe(path)
|
|
538
|
+
File.read(path, mode: "rb").then do |s|
|
|
539
|
+
s.encoding == Encoding::UTF_8 && s.valid_encoding? ? s :
|
|
540
|
+
s.encode("UTF-8", invalid: :replace, undef: :replace, replace: "\u{FFFD}")
|
|
541
|
+
end
|
|
542
|
+
rescue StandardError
|
|
543
|
+
""
|
|
544
|
+
end
|
|
545
|
+
|
|
334
546
|
# Get task history with summaries for UI display
|
|
335
547
|
# @param limit [Integer] Maximum number of recent tasks to return
|
|
336
548
|
# @return [Array<Hash>] Task history with metadata
|
|
@@ -341,17 +553,23 @@ module Clacky
|
|
|
341
553
|
|
|
342
554
|
tasks = []
|
|
343
555
|
(1..@current_task_id).to_a.reverse.take(limit).reverse.each do |task_id|
|
|
344
|
-
|
|
345
|
-
first_user_msg = @history.to_a.find do |msg|
|
|
346
|
-
msg[:task_id] == task_id && msg[:role] == "user"
|
|
347
|
-
end
|
|
556
|
+
meta = (@task_meta || {})[task_id] || {}
|
|
348
557
|
|
|
349
|
-
summary = if
|
|
350
|
-
|
|
351
|
-
# Truncate to 60 characters (including "...")
|
|
352
|
-
content.length > 60 ? "#{content[0...57]}..." : content
|
|
558
|
+
summary = if meta[:title] && !meta[:title].to_s.empty?
|
|
559
|
+
meta[:title]
|
|
353
560
|
else
|
|
354
|
-
|
|
561
|
+
# Best-effort fallback: scan @history for the task's first real
|
|
562
|
+
# user message. Returns nothing for tasks that have already been
|
|
563
|
+
# compressed out — the UI then shows "Task N".
|
|
564
|
+
first = @history.to_a.find do |msg|
|
|
565
|
+
msg[:role] == "user" && msg[:task_id] == task_id && !msg[:system_injected]
|
|
566
|
+
end
|
|
567
|
+
if first
|
|
568
|
+
text = extract_message_text(first[:content]).to_s.gsub(/\s+/, " ").strip
|
|
569
|
+
text.length > 60 ? "#{text[0...57]}..." : text
|
|
570
|
+
else
|
|
571
|
+
"Task #{task_id}"
|
|
572
|
+
end
|
|
355
573
|
end
|
|
356
574
|
|
|
357
575
|
# Status relative to the ACTIVE task chain (not a linear id compare),
|
|
@@ -372,8 +590,11 @@ module Clacky
|
|
|
372
590
|
tasks << {
|
|
373
591
|
task_id: task_id,
|
|
374
592
|
summary: summary,
|
|
593
|
+
started_at: meta[:started_at],
|
|
594
|
+
ended_at: meta[:ended_at],
|
|
375
595
|
status: status,
|
|
376
|
-
has_branches: has_branches
|
|
596
|
+
has_branches: has_branches,
|
|
597
|
+
change_count: task_change_count(task_id),
|
|
377
598
|
}
|
|
378
599
|
end
|
|
379
600
|
|
data/lib/clacky/agent.rb
CHANGED
|
@@ -262,7 +262,7 @@ module Clacky
|
|
|
262
262
|
@ui&.show_progress
|
|
263
263
|
|
|
264
264
|
# Start new task for Time Machine
|
|
265
|
-
task_id = start_new_task
|
|
265
|
+
task_id = start_new_task(title: display_text.to_s.empty? ? user_input.to_s : display_text.to_s)
|
|
266
266
|
|
|
267
267
|
# Continuation of a previously-interrupted task (e.g. user sent a
|
|
268
268
|
# supplementary message without stopping the running task) keeps the
|
|
@@ -1371,6 +1371,17 @@ module Clacky
|
|
|
1371
1371
|
cloned_messages = deep_clone(@history.to_a)
|
|
1372
1372
|
subagent.instance_variable_set(:@history, MessageHistory.new(cloned_messages))
|
|
1373
1373
|
|
|
1374
|
+
# The cloned history carries per-message task_id tags. Without the parent's
|
|
1375
|
+
# Time Machine task state the subagent's @active_task_id stays 0, so
|
|
1376
|
+
# active_task_chain collapses to {0} and active_messages filters out every
|
|
1377
|
+
# message tagged task_id > 0 — silently shrinking the context and busting
|
|
1378
|
+
# prompt caching. Carry the task state alongside @history so the subagent
|
|
1379
|
+
# sees the same chain (and cache prefix) as the parent.
|
|
1380
|
+
subagent.instance_variable_set(:@task_parents, deep_clone(@task_parents))
|
|
1381
|
+
subagent.instance_variable_set(:@current_task_id, @current_task_id)
|
|
1382
|
+
subagent.instance_variable_set(:@active_task_id, @active_task_id)
|
|
1383
|
+
subagent.instance_variable_set(:@task_meta, deep_clone(@task_meta))
|
|
1384
|
+
|
|
1374
1385
|
# Append system prompt suffix as user message (for cache reuse)
|
|
1375
1386
|
if system_prompt_suffix
|
|
1376
1387
|
subagent_history = subagent.history
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -165,7 +165,8 @@ module Clacky
|
|
|
165
165
|
:memory_update_enabled, :skill_evolution,
|
|
166
166
|
:max_running_agents, :max_idle_agents,
|
|
167
167
|
:default_working_dir,
|
|
168
|
-
:proxy_url
|
|
168
|
+
:proxy_url,
|
|
169
|
+
:media_output_dir
|
|
169
170
|
|
|
170
171
|
def initialize(options = {})
|
|
171
172
|
@permission_mode = validate_permission_mode(options[:permission_mode])
|
|
@@ -223,6 +224,15 @@ module Clacky
|
|
|
223
224
|
# a proxy. Leave nil to go direct.
|
|
224
225
|
@proxy_url = options[:proxy_url]
|
|
225
226
|
|
|
227
|
+
# User-configured directory where generated images / videos / audio
|
|
228
|
+
# land when a /api/media/* call doesn't pass an explicit output_dir.
|
|
229
|
+
# Final on-disk path is `<media_output_dir>/assets/generated/<file>`
|
|
230
|
+
# (the `assets/generated/` suffix is fixed by Media::Base for stable
|
|
231
|
+
# markdown/relative-path semantics across docs).
|
|
232
|
+
# Leave nil → fall back to Dir.pwd (legacy behavior, preserved for
|
|
233
|
+
# older configs that have no key set).
|
|
234
|
+
@media_output_dir = options[:media_output_dir]
|
|
235
|
+
|
|
226
236
|
# Per-session virtual model overlay.
|
|
227
237
|
# When set, #current_model returns a *merged* hash (the resolved @models
|
|
228
238
|
# entry merged with this overlay) without mutating the shared @models
|
|
@@ -415,6 +425,7 @@ module Clacky
|
|
|
415
425
|
skill_evolution max_running_agents max_idle_agents
|
|
416
426
|
default_working_dir
|
|
417
427
|
proxy_url
|
|
428
|
+
media_output_dir
|
|
418
429
|
].freeze
|
|
419
430
|
|
|
420
431
|
# Serialize the current agent configuration to YAML.
|
|
@@ -434,7 +445,8 @@ module Clacky
|
|
|
434
445
|
"max_running_agents" => @max_running_agents,
|
|
435
446
|
"max_idle_agents" => @max_idle_agents,
|
|
436
447
|
"default_working_dir" => @default_working_dir,
|
|
437
|
-
"proxy_url" => @proxy_url
|
|
448
|
+
"proxy_url" => @proxy_url,
|
|
449
|
+
"media_output_dir" => @media_output_dir
|
|
438
450
|
}
|
|
439
451
|
YAML.dump("settings" => settings, "models" => persistable_models)
|
|
440
452
|
end
|
data/lib/clacky/brand_config.rb
CHANGED
|
@@ -961,7 +961,7 @@ module Clacky
|
|
|
961
961
|
#
|
|
962
962
|
# @param skill_name [String] The slug/name of the skill to remove.
|
|
963
963
|
# @return [void]
|
|
964
|
-
|
|
964
|
+
def delete_brand_skill!(skill_name)
|
|
965
965
|
# Remove files from disk.
|
|
966
966
|
skill_dir = File.join(brand_skills_dir, skill_name)
|
|
967
967
|
FileUtils.rm_rf(skill_dir) if Dir.exist?(skill_dir)
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
// ── Official panel: changes (git, made friendly) ──────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// "改动 / Changes": a non-technical view of what the AI changed, backed by the
|
|
4
|
+
// built-in git API (GET/POST /api/sessions/:id/git/*). Mounted as a tab in the
|
|
5
|
+
// "session.aside" slot, scoped to agents declaring `panels: [git]`.
|
|
6
|
+
//
|
|
7
|
+
// Deliberately hides git jargon: no porcelain status codes (M/??), no
|
|
8
|
+
// branch/ahead/behind unless the branch is NOT the main line (main/master) —
|
|
9
|
+
// then it's surfaced as a gentle notice. The only write is a zero-input
|
|
10
|
+
// "save version" that auto-generates the commit message.
|
|
11
|
+
//
|
|
12
|
+
// Native DOM + textContent on all git output (paths, branch) so nothing can
|
|
13
|
+
// inject. tab.badge tracks the number of changed files.
|
|
14
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
(() => {
|
|
17
|
+
if (!window.Clacky || !Clacky.ext) return;
|
|
18
|
+
|
|
19
|
+
const MAIN_BRANCHES = { main: true, master: true };
|
|
20
|
+
const t = (k, fallback) => {
|
|
21
|
+
const v = (typeof I18n !== "undefined") ? I18n.t(k) : null;
|
|
22
|
+
return (v && v !== k) ? v : fallback;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
if (!document.getElementById("changes-panel-style")) {
|
|
26
|
+
const style = document.createElement("style");
|
|
27
|
+
style.id = "changes-panel-style";
|
|
28
|
+
style.textContent = `
|
|
29
|
+
.changes-panel { display: flex; flex-direction: column; flex: 1; min-height: 0; }
|
|
30
|
+
.changes-summary { flex: none; padding: 14px 16px 10px; border-bottom: 1px solid var(--color-border-secondary); }
|
|
31
|
+
.changes-summary .h { font-size: 13px; color: var(--color-text-secondary); }
|
|
32
|
+
.changes-summary .h b { color: var(--color-text-primary); }
|
|
33
|
+
.changes-summary .sub { font-size: 12px; color: var(--color-text-tertiary); margin-top: 3px; }
|
|
34
|
+
.changes-branch { display: flex; align-items: center; gap: 6px; margin-top: 8px; padding: 5px 8px; border-radius: var(--radius-sm); background: var(--color-warning-bg); color: var(--color-warning); font-size: 11.5px; }
|
|
35
|
+
.changes-branch code { font-family: ui-monospace, monospace; font-weight: 600; }
|
|
36
|
+
.changes-list { flex: 1; min-height: 0; overflow: auto; padding: 6px 8px; }
|
|
37
|
+
.change-row { display: flex; align-items: center; gap: 8px; padding: 7px 8px; border-radius: var(--radius-sm); }
|
|
38
|
+
.change-row:hover { background: var(--color-bg-hover); }
|
|
39
|
+
.change-tag { flex: none; font-size: 11px; padding: 1px 7px; border-radius: var(--radius-pill); font-weight: 500; }
|
|
40
|
+
.change-tag.add { background: var(--color-success-bg); color: var(--color-success); }
|
|
41
|
+
.change-tag.mod { background: #eff6ff; color: #2563eb; }
|
|
42
|
+
.change-tag.del { background: var(--color-error-bg); color: var(--color-error); }
|
|
43
|
+
.change-path { font-size: 13px; color: var(--color-text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
44
|
+
.change-dir { color: var(--color-text-tertiary); }
|
|
45
|
+
.changes-foot { flex: none; padding: 12px 16px; border-top: 1px solid var(--color-border-primary); }
|
|
46
|
+
.changes-save-btn { width: 100%; padding: 9px; border: none; border-radius: var(--radius-md); background: var(--color-accent-primary); color: var(--color-text-inverse); font-size: 13px; font-weight: 500; cursor: pointer; }
|
|
47
|
+
.changes-save-btn:hover:not(:disabled) { background: var(--color-accent-hover); }
|
|
48
|
+
.changes-save-btn:disabled { opacity: 0.5; cursor: default; }
|
|
49
|
+
.changes-hint { text-align: center; font-size: 11px; color: var(--color-text-tertiary); margin-top: 7px; min-height: 1em; }
|
|
50
|
+
.changes-empty, .changes-loading, .changes-error { color: var(--color-text-tertiary); padding: 16px; font-size: 12px; text-align: center; }
|
|
51
|
+
.changes-error { color: var(--color-error); }
|
|
52
|
+
`;
|
|
53
|
+
document.head.appendChild(style);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function el(tag, attrs, ...kids) {
|
|
57
|
+
const node = document.createElement(tag);
|
|
58
|
+
if (attrs) {
|
|
59
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
60
|
+
if (k === "class") node.className = v;
|
|
61
|
+
else if (k === "text") node.textContent = v;
|
|
62
|
+
else if (k.startsWith("on") && typeof v === "function") node.addEventListener(k.slice(2), v);
|
|
63
|
+
else node.setAttribute(k, v);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
kids.forEach((c) => node.appendChild(typeof c === "string" ? document.createTextNode(c) : c));
|
|
67
|
+
return node;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function api(sessionId, action, opts) {
|
|
71
|
+
const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}/git/${action}`, opts);
|
|
72
|
+
return res.json();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Map a porcelain status entry to a friendly kind without exposing codes.
|
|
76
|
+
function classify(f) {
|
|
77
|
+
if (f.untracked) return "add";
|
|
78
|
+
const code = `${f.x || ""}${f.y || ""}`;
|
|
79
|
+
if (code.includes("D")) return "del";
|
|
80
|
+
if (code.includes("A")) return "add";
|
|
81
|
+
return "mod";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const TAG_LABEL = {
|
|
85
|
+
add: () => t("changes.tag.add", "新增"),
|
|
86
|
+
mod: () => t("changes.tag.mod", "修改"),
|
|
87
|
+
del: () => t("changes.tag.del", "删除"),
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
function splitPath(path) {
|
|
91
|
+
const i = path.lastIndexOf("/");
|
|
92
|
+
return i < 0 ? { dir: "", name: path } : { dir: path.slice(0, i + 1), name: path.slice(i + 1) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function autoMessage() {
|
|
96
|
+
const d = new Date();
|
|
97
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
98
|
+
const stamp = `${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
99
|
+
return `${t("changes.save.prefix", "手动存档")} · ${stamp}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function renderFiles(files) {
|
|
103
|
+
const list = el("div", { class: "changes-list" });
|
|
104
|
+
files.forEach((f) => {
|
|
105
|
+
const kind = classify(f);
|
|
106
|
+
const { dir, name } = splitPath(f.path);
|
|
107
|
+
const path = el("span", { class: "change-path" });
|
|
108
|
+
if (dir) path.appendChild(el("span", { class: "change-dir", text: dir }));
|
|
109
|
+
path.appendChild(document.createTextNode(name));
|
|
110
|
+
list.appendChild(el("div", { class: "change-row" },
|
|
111
|
+
el("span", { class: `change-tag ${kind}`, text: TAG_LABEL[kind]() }),
|
|
112
|
+
path,
|
|
113
|
+
));
|
|
114
|
+
});
|
|
115
|
+
return list;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function refresh(sessionId, root, body, ctx) {
|
|
119
|
+
body.replaceChildren(el("div", { class: "changes-loading", text: t("changes.loading", "正在读取改动…") }));
|
|
120
|
+
|
|
121
|
+
let status;
|
|
122
|
+
try {
|
|
123
|
+
status = await api(sessionId, "status");
|
|
124
|
+
} catch (_e) {
|
|
125
|
+
body.replaceChildren(el("div", { class: "changes-error", text: t("changes.error", "读取改动失败") }));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (!status.repo) {
|
|
129
|
+
if (ctx && ctx.setBadge) ctx.setBadge(null);
|
|
130
|
+
body.replaceChildren(el("div", { class: "changes-empty", text: t("changes.noRepo", "这个项目还没有启用版本管理。") }));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const files = status.files || [];
|
|
135
|
+
if (ctx && ctx.setBadge) ctx.setBadge(files.length || null);
|
|
136
|
+
|
|
137
|
+
const count = files.length;
|
|
138
|
+
const summary = el("div", { class: "changes-summary" });
|
|
139
|
+
const h = el("div", { class: "h" });
|
|
140
|
+
if (count === 0) {
|
|
141
|
+
h.textContent = t("changes.cleanTitle", "暂无改动");
|
|
142
|
+
} else {
|
|
143
|
+
h.appendChild(document.createTextNode(t("changes.changedPre", "AI 改了 ")));
|
|
144
|
+
h.appendChild(el("b", { text: `${count} ${t("changes.filesUnit", "个文件")}` }));
|
|
145
|
+
}
|
|
146
|
+
summary.appendChild(h);
|
|
147
|
+
summary.appendChild(el("div", { class: "sub", text: t("changes.sub", "由 Git 管理 · 自上次存档以来") }));
|
|
148
|
+
|
|
149
|
+
const branch = (status.branch || "").trim();
|
|
150
|
+
if (branch && !MAIN_BRANCHES[branch.toLowerCase()]) {
|
|
151
|
+
const note = el("div", { class: "changes-branch" });
|
|
152
|
+
note.appendChild(document.createTextNode(t("changes.branchPre", "当前分支:")));
|
|
153
|
+
note.appendChild(el("code", { text: branch }));
|
|
154
|
+
summary.appendChild(note);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const hint = el("div", { class: "changes-hint" });
|
|
158
|
+
const saveBtn = el("button", { class: "changes-save-btn", type: "button", text: t("changes.save.btn", "存档当前版本") });
|
|
159
|
+
saveBtn.disabled = count === 0;
|
|
160
|
+
saveBtn.addEventListener("click", async () => {
|
|
161
|
+
saveBtn.disabled = true;
|
|
162
|
+
hint.textContent = t("changes.save.saving", "正在存档…");
|
|
163
|
+
try {
|
|
164
|
+
const paths = files.map((f) => f.path);
|
|
165
|
+
const res = await api(sessionId, "commit", {
|
|
166
|
+
method: "POST",
|
|
167
|
+
headers: { "Content-Type": "application/json" },
|
|
168
|
+
body: JSON.stringify({ message: autoMessage(), files: paths }),
|
|
169
|
+
});
|
|
170
|
+
if (res.ok) {
|
|
171
|
+
hint.textContent = t("changes.save.done", "已存档,可在「时光机」里回到这个版本");
|
|
172
|
+
refresh(sessionId, root, body, ctx);
|
|
173
|
+
} else {
|
|
174
|
+
hint.textContent = res.error || t("changes.save.failed", "存档失败");
|
|
175
|
+
saveBtn.disabled = false;
|
|
176
|
+
}
|
|
177
|
+
} catch (_e) {
|
|
178
|
+
hint.textContent = t("changes.save.failed", "存档失败");
|
|
179
|
+
saveBtn.disabled = false;
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
body.replaceChildren(
|
|
184
|
+
summary,
|
|
185
|
+
count === 0 ? el("div", { class: "changes-empty", text: t("changes.clean", "工作区是干净的,没有未存档的改动。") }) : renderFiles(files),
|
|
186
|
+
el("div", { class: "changes-foot" }, saveBtn, hint),
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
Clacky.ext.ui.mount("session.aside", (ctx) => {
|
|
191
|
+
if (!ctx || !ctx.sessionId) return null;
|
|
192
|
+
const body = el("div", { class: "changes-panel" });
|
|
193
|
+
const root = el("div", { class: "changes-root", "data-panel": "changes" }, body);
|
|
194
|
+
refresh(ctx.sessionId, root, body, ctx);
|
|
195
|
+
return root;
|
|
196
|
+
}, {
|
|
197
|
+
panel: "git",
|
|
198
|
+
order: 10,
|
|
199
|
+
tab: { id: "changes", label: (typeof I18n !== "undefined" ? (I18n.t("changes.tab") !== "changes.tab" ? I18n.t("changes.tab") : "Git 管理") : "Git 管理") },
|
|
200
|
+
});
|
|
201
|
+
})();
|