openclacky 1.3.2 → 1.3.4
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 +54 -0
- data/Dockerfile +3 -0
- data/README.md +1 -1
- data/README_JA.md +237 -0
- data/docs/rich_ui_guide.md +277 -0
- data/docs/rich_ui_refactor_plan.md +396 -0
- data/lib/clacky/agent/llm_caller.rb +10 -4
- data/lib/clacky/agent/session_serializer.rb +52 -7
- data/lib/clacky/agent/time_machine.rb +247 -26
- data/lib/clacky/agent.rb +15 -3
- data/lib/clacky/api_extension.rb +262 -0
- data/lib/clacky/api_extension_loader.rb +156 -0
- data/lib/clacky/cli.rb +93 -3
- data/lib/clacky/client.rb +38 -13
- 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 +37 -10
- data/lib/clacky/idle_compression_timer.rb +3 -1
- data/lib/clacky/locales/en.rb +26 -0
- data/lib/clacky/locales/i18n.rb +26 -0
- data/lib/clacky/locales/zh.rb +26 -0
- data/lib/clacky/media/openai_compat.rb +64 -1
- data/lib/clacky/message_history.rb +9 -0
- data/lib/clacky/rich_ui/components/base_component.rb +50 -0
- data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
- data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
- data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
- data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
- data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
- data/lib/clacky/rich_ui/components/status_view.rb +58 -0
- data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
- data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
- data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
- data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
- data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
- data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
- data/lib/clacky/rich_ui/view_renderer.rb +291 -0
- data/lib/clacky/rich_ui.rb +57 -0
- data/lib/clacky/rich_ui_controller.rb +3 -1549
- data/lib/clacky/server/api_extension_dispatcher.rb +120 -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 +547 -15
- data/lib/clacky/server/server_master.rb +6 -4
- data/lib/clacky/server/session_registry.rb +1 -1
- data/lib/clacky/shell_hook_loader.rb +1 -1
- data/lib/clacky/tools/edit.rb +14 -2
- data/lib/clacky/ui2/ui_controller.rb +7 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +511 -101
- data/lib/clacky/web/app.js +95 -14
- 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/{onboard.js → components/onboard.js} +18 -2
- data/lib/clacky/web/core/aside.js +117 -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} +132 -240
- 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 +331 -0
- data/lib/clacky/web/features/skills/view.js +581 -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 +68 -6
- data/lib/clacky/web/index.html +113 -62
- data/lib/clacky/web/sessions.js +493 -39
- data/lib/clacky/web/settings.js +143 -49
- data/lib/clacky/web/skills.js +3 -863
- data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
- data/lib/clacky/web/ws-dispatcher.js +7 -3
- data/lib/clacky.rb +17 -1
- metadata +81 -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 -373
- data/lib/clacky/web/version.js +0 -449
- data/lib/clacky/web/workspace.js +0 -316
- /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/{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
|
@@ -252,7 +252,7 @@ module Clacky
|
|
|
252
252
|
@name = new_name.to_s.strip
|
|
253
253
|
end
|
|
254
254
|
|
|
255
|
-
def run(user_input, files: [], display_text: nil)
|
|
255
|
+
def run(user_input, files: [], display_text: nil, created_at: nil)
|
|
256
256
|
# Show the "thinking" indicator as early as possible so the user gets
|
|
257
257
|
# immediate feedback after sending a message. Without this the UI stays
|
|
258
258
|
# silent during synchronous setup work (system prompt assembly, file
|
|
@@ -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
|
|
@@ -361,7 +361,8 @@ module Clacky
|
|
|
361
361
|
preview_path: f[:preview_path] || f["preview_path"] }
|
|
362
362
|
end
|
|
363
363
|
|
|
364
|
-
|
|
364
|
+
created_at ||= Time.now.to_f
|
|
365
|
+
@history.append({ role: "user", content: user_content, task_id: task_id, created_at: created_at,
|
|
365
366
|
display_text: display_text,
|
|
366
367
|
display_files: display_files.empty? ? nil : display_files })
|
|
367
368
|
@total_tasks += 1
|
|
@@ -1371,6 +1372,17 @@ module Clacky
|
|
|
1371
1372
|
cloned_messages = deep_clone(@history.to_a)
|
|
1372
1373
|
subagent.instance_variable_set(:@history, MessageHistory.new(cloned_messages))
|
|
1373
1374
|
|
|
1375
|
+
# The cloned history carries per-message task_id tags. Without the parent's
|
|
1376
|
+
# Time Machine task state the subagent's @active_task_id stays 0, so
|
|
1377
|
+
# active_task_chain collapses to {0} and active_messages filters out every
|
|
1378
|
+
# message tagged task_id > 0 — silently shrinking the context and busting
|
|
1379
|
+
# prompt caching. Carry the task state alongside @history so the subagent
|
|
1380
|
+
# sees the same chain (and cache prefix) as the parent.
|
|
1381
|
+
subagent.instance_variable_set(:@task_parents, deep_clone(@task_parents))
|
|
1382
|
+
subagent.instance_variable_set(:@current_task_id, @current_task_id)
|
|
1383
|
+
subagent.instance_variable_set(:@active_task_id, @active_task_id)
|
|
1384
|
+
subagent.instance_variable_set(:@task_meta, deep_clone(@task_meta))
|
|
1385
|
+
|
|
1374
1386
|
# Append system prompt suffix as user message (for cache reuse)
|
|
1375
1387
|
if system_prompt_suffix
|
|
1376
1388
|
subagent_history = subagent.history
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
# Base class for user-defined HTTP API extensions loaded from
|
|
8
|
+
# ~/.clacky/api_ext/<name>/handler.rb. Subclasses use a tiny route DSL
|
|
9
|
+
# (get/post/put/patch/delete) to expose endpoints under
|
|
10
|
+
# /api/ext/<name>/<sub-path>
|
|
11
|
+
#
|
|
12
|
+
# The framework wires up access-key auth, timeouts, JSON error envelopes,
|
|
13
|
+
# path-parameter parsing, and a curated handler context — extension authors
|
|
14
|
+
# only fill in business logic.
|
|
15
|
+
#
|
|
16
|
+
# Minimal example (~/.clacky/api_ext/my-dashboard/handler.rb):
|
|
17
|
+
#
|
|
18
|
+
# class MyDashboardExt < Clacky::ApiExtension
|
|
19
|
+
# get "/summary" do
|
|
20
|
+
# json(sessions: session_manager.list.size)
|
|
21
|
+
# end
|
|
22
|
+
# end
|
|
23
|
+
#
|
|
24
|
+
# Mounted automatically at: GET /api/ext/my-dashboard/summary
|
|
25
|
+
class ApiExtension
|
|
26
|
+
HTTP_METHODS = %i[get post put patch delete].freeze
|
|
27
|
+
MAX_TIMEOUT = 600
|
|
28
|
+
DEFAULT_TIMEOUT = 10
|
|
29
|
+
|
|
30
|
+
Route = Struct.new(:method, :pattern, :regex, :param_names, :block, :options, keyword_init: true)
|
|
31
|
+
|
|
32
|
+
class Halt < StandardError
|
|
33
|
+
attr_reader :status, :payload, :content_type
|
|
34
|
+
|
|
35
|
+
def initialize(status, payload, content_type)
|
|
36
|
+
super("api_ext halt #{status}")
|
|
37
|
+
@status = status
|
|
38
|
+
@payload = payload
|
|
39
|
+
@content_type = content_type
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
class << self
|
|
44
|
+
# Registry of all loaded ApiExtension subclasses, keyed by extension id
|
|
45
|
+
# (== directory name == mount prefix segment).
|
|
46
|
+
def registry
|
|
47
|
+
@registry ||= {}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def register(ext_id, klass)
|
|
51
|
+
registry[ext_id] = klass
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def reset_registry!
|
|
55
|
+
@registry = {}
|
|
56
|
+
@pending_subclasses = []
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Captures every subclass at the moment its `class` body finishes being
|
|
60
|
+
# required — the loader pops the most recent one off this list to bind
|
|
61
|
+
# an ext_id/dir without relying on ObjectSpace scans.
|
|
62
|
+
def pending_subclasses
|
|
63
|
+
@pending_subclasses ||= []
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def inherited(subclass)
|
|
67
|
+
super
|
|
68
|
+
Clacky::ApiExtension.pending_subclasses << subclass
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Per-subclass state — inherited classes carry their own routes/options.
|
|
72
|
+
def routes
|
|
73
|
+
@routes ||= []
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def class_timeout
|
|
77
|
+
@class_timeout
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def public_paths
|
|
81
|
+
@public_paths ||= []
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def ext_id
|
|
85
|
+
@ext_id
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def ext_id=(value)
|
|
89
|
+
@ext_id = value
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def ext_dir
|
|
93
|
+
@ext_dir
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def ext_dir=(value)
|
|
97
|
+
@ext_dir = value
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def meta
|
|
101
|
+
@meta ||= {}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def meta=(value)
|
|
105
|
+
@meta = value || {}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Set a default timeout (seconds) for every handler in this class.
|
|
109
|
+
# Per-route override available via `get "/x", timeout: 30 do ... end`.
|
|
110
|
+
def timeout(seconds)
|
|
111
|
+
raise ArgumentError, "timeout must be > 0" unless seconds.is_a?(Numeric) && seconds > 0
|
|
112
|
+
raise ArgumentError, "timeout exceeds MAX_TIMEOUT (#{MAX_TIMEOUT}s)" if seconds > MAX_TIMEOUT
|
|
113
|
+
|
|
114
|
+
@class_timeout = seconds.to_f
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Mark a route as not requiring access-key auth. Caller must also
|
|
118
|
+
# declare `public: true` in meta.yml for the framework to honor this.
|
|
119
|
+
def public_endpoint(pattern)
|
|
120
|
+
public_paths << normalize_pattern(pattern)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
HTTP_METHODS.each do |verb|
|
|
124
|
+
define_method(verb) do |pattern, **opts, &block|
|
|
125
|
+
raise ArgumentError, "missing handler block for #{verb.upcase} #{pattern}" unless block
|
|
126
|
+
|
|
127
|
+
per_route_timeout = opts[:timeout]
|
|
128
|
+
if per_route_timeout
|
|
129
|
+
raise ArgumentError, "timeout must be > 0" unless per_route_timeout.is_a?(Numeric) && per_route_timeout > 0
|
|
130
|
+
raise ArgumentError, "timeout exceeds MAX_TIMEOUT (#{MAX_TIMEOUT}s)" if per_route_timeout > MAX_TIMEOUT
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
normalized = normalize_pattern(pattern)
|
|
134
|
+
regex, param_names = compile_pattern(normalized)
|
|
135
|
+
routes << Route.new(
|
|
136
|
+
method: verb,
|
|
137
|
+
pattern: normalized,
|
|
138
|
+
regex: regex,
|
|
139
|
+
param_names: param_names,
|
|
140
|
+
block: block,
|
|
141
|
+
options: opts.dup
|
|
142
|
+
)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def normalize_pattern(pattern)
|
|
147
|
+
pattern = pattern.to_s
|
|
148
|
+
pattern = "/#{pattern}" unless pattern.start_with?("/")
|
|
149
|
+
pattern = pattern.chomp("/")
|
|
150
|
+
pattern.empty? ? "/" : pattern
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def compile_pattern(pattern)
|
|
154
|
+
param_names = []
|
|
155
|
+
regex_str = pattern.gsub(%r{:([a-zA-Z_][a-zA-Z0-9_]*)}) do |_match|
|
|
156
|
+
param_names << Regexp.last_match(1).to_sym
|
|
157
|
+
"([^/]+)"
|
|
158
|
+
end
|
|
159
|
+
[Regexp.new("\\A#{regex_str}\\z"), param_names]
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
attr_reader :req, :res, :route, :params
|
|
164
|
+
|
|
165
|
+
def initialize(req:, res:, route:, params:, http_server:)
|
|
166
|
+
@req = req
|
|
167
|
+
@res = res
|
|
168
|
+
@route = route
|
|
169
|
+
@params = params
|
|
170
|
+
@http_server = http_server
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def invoke
|
|
174
|
+
instance_exec(&route.block)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# ---- handler context (white-listed access to host process) ----
|
|
178
|
+
|
|
179
|
+
def json(*args, **kwargs)
|
|
180
|
+
if args.empty?
|
|
181
|
+
# Treat kwargs as the body: json(foo: 1, bar: 2)
|
|
182
|
+
# For non-200 status, pass an explicit hash: json({foo: 1}, status: 422)
|
|
183
|
+
raise Halt.new(200, JSON.generate(kwargs), "application/json; charset=utf-8")
|
|
184
|
+
elsif args.size == 1
|
|
185
|
+
status = kwargs[:status] || 200
|
|
186
|
+
raise Halt.new(status, JSON.generate(args[0]), "application/json; charset=utf-8")
|
|
187
|
+
else
|
|
188
|
+
raise ArgumentError, "json: expected (hash) or (key: value, ...)"
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def text(str, status: 200)
|
|
193
|
+
raise Halt.new(status, str.to_s, "text/plain; charset=utf-8")
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def error!(message, status: 400, **extra)
|
|
197
|
+
payload = { error: message.to_s }
|
|
198
|
+
payload.merge!(extra) unless extra.empty?
|
|
199
|
+
raise Halt.new(status, JSON.generate(payload), "application/json; charset=utf-8")
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
def json_body
|
|
203
|
+
@json_body ||= begin
|
|
204
|
+
return {} if req.body.nil? || req.body.empty?
|
|
205
|
+
JSON.parse(req.body)
|
|
206
|
+
rescue JSON::ParserError
|
|
207
|
+
{}
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def query
|
|
212
|
+
@query ||= req.query || {}
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
def data_path(*parts)
|
|
216
|
+
base = File.join(self.class.ext_dir, "data")
|
|
217
|
+
FileUtils.mkdir_p(base)
|
|
218
|
+
File.join(base, *parts.map(&:to_s))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def ext_dir
|
|
222
|
+
self.class.ext_dir
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def ext_id
|
|
226
|
+
self.class.ext_id
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
def config
|
|
230
|
+
self.class.meta["config"] || {}
|
|
231
|
+
end
|
|
232
|
+
|
|
233
|
+
def session_manager
|
|
234
|
+
@http_server&.instance_variable_get(:@session_manager)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def agent_config
|
|
238
|
+
@http_server&.instance_variable_get(:@agent_config)
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
def server_start_time
|
|
242
|
+
@http_server&.instance_variable_get(:@start_time)
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def logger
|
|
246
|
+
@logger ||= ScopedLogger.new(self.class.ext_id)
|
|
247
|
+
end
|
|
248
|
+
|
|
249
|
+
# Lightweight wrapper that prefixes log lines with the extension id.
|
|
250
|
+
class ScopedLogger
|
|
251
|
+
def initialize(ext_id)
|
|
252
|
+
@prefix = "[api_ext:#{ext_id}]"
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
%i[debug info warn error].each do |level|
|
|
256
|
+
define_method(level) do |msg|
|
|
257
|
+
Clacky::Logger.public_send(level, "#{@prefix} #{msg}")
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|