openclacky 1.3.4 → 1.3.6

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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +39 -0
  3. data/lib/clacky/agent/fake_tool_call_detector.rb +52 -0
  4. data/lib/clacky/agent/message_compressor.rb +32 -8
  5. data/lib/clacky/agent/message_compressor_helper.rb +113 -12
  6. data/lib/clacky/agent/session_serializer.rb +3 -2
  7. data/lib/clacky/agent/tool_executor.rb +0 -12
  8. data/lib/clacky/agent.rb +74 -12
  9. data/lib/clacky/api_extension.rb +81 -0
  10. data/lib/clacky/api_extension_loader.rb +13 -1
  11. data/lib/clacky/cli.rb +0 -1
  12. data/lib/clacky/client.rb +14 -17
  13. data/lib/clacky/default_agents/_panels/time_machine/panel.js +22 -0
  14. data/lib/clacky/default_agents/base_prompt.md +1 -0
  15. data/lib/clacky/default_extensions/meeting/handler.rb +331 -0
  16. data/lib/clacky/default_extensions/meeting/meeting.js +790 -0
  17. data/lib/clacky/default_extensions/meeting/meta.yml +3 -0
  18. data/lib/clacky/default_extensions/meeting/skills/meeting-summarizer/SKILL.md +44 -0
  19. data/lib/clacky/default_skills/media-gen/SKILL.md +63 -0
  20. data/lib/clacky/default_skills/media-gen/scripts/video_seq.sh +114 -0
  21. data/lib/clacky/json_ui_controller.rb +1 -1
  22. data/lib/clacky/media/base.rb +60 -0
  23. data/lib/clacky/media/dashscope.rb +385 -21
  24. data/lib/clacky/media/gemini.rb +9 -0
  25. data/lib/clacky/media/generator.rb +52 -0
  26. data/lib/clacky/media/openai_compat.rb +166 -0
  27. data/lib/clacky/null_ui_controller.rb +13 -0
  28. data/lib/clacky/plain_ui_controller.rb +1 -1
  29. data/lib/clacky/providers.rb +50 -2
  30. data/lib/clacky/rich_ui/rich_ui_controller.rb +1 -1
  31. data/lib/clacky/server/channel/channel_ui_controller.rb +1 -1
  32. data/lib/clacky/server/http_server.rb +260 -122
  33. data/lib/clacky/server/session_registry.rb +54 -3
  34. data/lib/clacky/server/web_ui_controller.rb +3 -2
  35. data/lib/clacky/session_manager.rb +35 -4
  36. data/lib/clacky/skill_loader.rb +14 -2
  37. data/lib/clacky/tools/terminal/output_cleaner.rb +1 -3
  38. data/lib/clacky/tools/terminal.rb +0 -43
  39. data/lib/clacky/ui2/components/modal_component.rb +1 -1
  40. data/lib/clacky/ui2/layout_manager.rb +0 -5
  41. data/lib/clacky/ui2/progress_handle.rb +0 -3
  42. data/lib/clacky/ui2/ui_controller.rb +140 -40
  43. data/lib/clacky/ui_interface.rb +10 -1
  44. data/lib/clacky/utils/encoding.rb +25 -0
  45. data/lib/clacky/version.rb +1 -1
  46. data/lib/clacky/web/app.css +199 -23
  47. data/lib/clacky/web/components/onboard.js +1 -14
  48. data/lib/clacky/web/components/sidebar.js +1 -3
  49. data/lib/clacky/web/features/backup/store.js +23 -0
  50. data/lib/clacky/web/features/backup/view.js +49 -22
  51. data/lib/clacky/web/features/brand/view.js +8 -5
  52. data/lib/clacky/web/features/channels/store.js +1 -20
  53. data/lib/clacky/web/features/mcp/store.js +1 -20
  54. data/lib/clacky/web/features/profile/store.js +1 -13
  55. data/lib/clacky/web/features/profile/view.js +16 -4
  56. data/lib/clacky/web/features/skills/store.js +6 -21
  57. data/lib/clacky/web/features/tasks/view.js +77 -28
  58. data/lib/clacky/web/features/version/store.js +2 -0
  59. data/lib/clacky/web/i18n.js +46 -3
  60. data/lib/clacky/web/index.html +67 -26
  61. data/lib/clacky/web/sessions.js +177 -87
  62. data/lib/clacky/web/settings.js +34 -2
  63. data/lib/clacky/web/ws-dispatcher.js +12 -4
  64. data/lib/clacky.rb +12 -5
  65. metadata +8 -1
@@ -44,10 +44,12 @@ module Clacky
44
44
  error: nil,
45
45
  error_code: nil,
46
46
  top_up_url: nil,
47
+ raw_message: nil,
47
48
  updated_at: nil,
48
49
  agent: nil,
49
50
  ui: nil,
50
51
  thread: nil,
52
+ epoch: 0,
51
53
  idle_timer: nil,
52
54
  pending_task: nil,
53
55
  pending_working_dir: nil
@@ -137,6 +139,40 @@ module Clacky
137
139
  end
138
140
  end
139
141
 
142
+ # Atomically bump the session's task epoch and return the new value.
143
+ # Each user message that starts a task claims a fresh epoch; a stale task
144
+ # thread (interrupted but not yet dead) compares its epoch against the
145
+ # current one and discards any side effects (status writes, broadcasts)
146
+ # once it has been superseded.
147
+ def claim_epoch(session_id)
148
+ @mutex.synchronize do
149
+ session = @sessions[session_id]
150
+ return nil unless session
151
+
152
+ session[:epoch] = session[:epoch].to_i + 1
153
+ end
154
+ end
155
+
156
+ # Current task epoch for a session (0 if none / unknown).
157
+ def current_epoch(session_id)
158
+ @mutex.synchronize { @sessions[session_id]&.fetch(:epoch, 0).to_i }
159
+ end
160
+
161
+ # Update fields only if the caller still owns the current epoch. Returns
162
+ # true if the update was applied, false if the epoch was stale (a newer
163
+ # task has taken over) or the session is gone.
164
+ def update_if_epoch(session_id, epoch, **fields)
165
+ @mutex.synchronize do
166
+ session = @sessions[session_id]
167
+ return false unless session
168
+ return false unless session[:epoch].to_i == epoch.to_i
169
+
170
+ fields[:updated_at] = Time.now
171
+ session.merge!(fields)
172
+ true
173
+ end
174
+ end
175
+
140
176
  # Return a session list from disk enriched with live registry status.
141
177
  # Sorted by created_at descending (newest first).
142
178
  #
@@ -160,7 +196,7 @@ module Clacky
160
196
  # [ ...all_pinned_matching (newest-first), ...non_pinned (newest-first, limited) ]
161
197
  #
162
198
  # source and profile are orthogonal — either can be nil independently.
163
- def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, include_pinned: true)
199
+ def list(limit: nil, before: nil, q: nil, q_scope: "name", date: nil, type: nil, exclude_type: nil, include_pinned: true)
164
200
  return [] unless @session_manager
165
201
 
166
202
  live = @mutex.synchronize do
@@ -169,7 +205,7 @@ module Clacky
169
205
  live_name = s[:agent]&.name
170
206
  live_name = nil if live_name&.empty?
171
207
  live_cost_source = s[:agent]&.cost_source
172
- { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url],
208
+ { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url], raw_message: s[:raw_message],
173
209
  updated_at: s[:updated_at]&.iso8601,
174
210
  model: model_info&.dig(:model), model_id: model_info&.dig(:id), name: live_name,
175
211
  total_tasks: s[:agent]&.total_tasks, total_cost: s[:agent]&.total_cost,
@@ -195,6 +231,12 @@ module Clacky
195
231
  end
196
232
  end
197
233
 
234
+ # ── exclude_type filter ───────────────────────────────────────────────
235
+ if exclude_type
236
+ excluded = Array(exclude_type)
237
+ all = all.reject { |s| excluded.include?(s_source(s)) }
238
+ end
239
+
198
240
  # ── date filter (YYYY-MM-DD, matches created_at prefix) ──────────────
199
241
  all = all.select { |s| s[:created_at].to_s.start_with?(date) } if date
200
242
 
@@ -269,7 +311,7 @@ module Clacky
269
311
  model_info = s[:agent]&.current_model_info
270
312
  live_name = s[:agent]&.name
271
313
  live_name = nil if live_name&.empty?
272
- { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url],
314
+ { status: s[:status], error: s[:error], error_code: s[:error_code], top_up_url: s[:top_up_url], raw_message: s[:raw_message],
273
315
  updated_at: s[:updated_at]&.iso8601,
274
316
  model: model_info&.dig(:model), model_id: model_info&.dig(:id),
275
317
  name: live_name, total_tasks: s[:agent]&.total_tasks,
@@ -296,6 +338,7 @@ module Clacky
296
338
  error: ls ? ls[:error] : nil,
297
339
  error_code: ls&.dig(:error_code),
298
340
  top_up_url: ls&.dig(:top_up_url),
341
+ raw_message: ls&.dig(:raw_message),
299
342
  model: ls&.dig(:model),
300
343
  model_id: ls&.dig(:model_id),
301
344
  card_model: ls&.dig(:card_model),
@@ -347,6 +390,14 @@ module Clacky
347
390
  @session_manager.all_sessions.count { |s| s_source(s) == "cron" }
348
391
  end
349
392
 
393
+ # Count + latest updated_at for cron sessions (used by sidebar virtual entry).
394
+ def cron_stats
395
+ return { count: 0, latest_updated_at: nil } unless @session_manager
396
+ cron_all = @session_manager.all_sessions.select { |s| s_source(s) == "cron" }
397
+ latest = cron_all.map { |s| s[:updated_at] || s[:created_at] }.compact.max
398
+ { count: cron_all.size, latest_updated_at: latest }
399
+ end
400
+
350
401
  # Delete a session from registry (and interrupt its thread).
351
402
  def delete(session_id)
352
403
  @mutex.synchronize do
@@ -230,12 +230,13 @@ module Clacky
230
230
  @broadcaster.call(@session_id, event)
231
231
  end
232
232
 
233
- def show_error(message, code: nil, top_up_url: nil)
233
+ def show_error(message, code: nil, top_up_url: nil, raw_message: nil)
234
234
  payload = { message: message }
235
235
  payload[:code] = code if code
236
236
  payload[:top_up_url] = top_up_url if top_up_url
237
+ payload[:raw_message] = raw_message if raw_message
237
238
  emit("error", **payload)
238
- forward_to_subscribers { |sub| sub.show_error(message, code: code, top_up_url: top_up_url) }
239
+ forward_to_subscribers { |sub| sub.show_error(message, code: code, top_up_url: top_up_url, raw_message: raw_message) }
239
240
  end
240
241
 
241
242
  def show_success(message)
@@ -35,7 +35,7 @@ module Clacky
35
35
 
36
36
  # Keep only the most recent 200 sessions (best-effort, never block save)
37
37
  begin
38
- cleanup_by_count(keep: 200)
38
+ cleanup_by_count(keep: 200, keep_cron: 200)
39
39
  rescue Exception # rubocop:disable Lint/RescueException
40
40
  # Cleanup is non-critical; swallow all errors (including AgentInterrupted)
41
41
  end
@@ -163,6 +163,33 @@ module Clacky
163
163
  chunk_path
164
164
  end
165
165
 
166
+ # Read the raw markdown of a chunk file. Returns nil if missing.
167
+ def read_chunk(chunk_path)
168
+ return nil unless chunk_path && File.exist?(chunk_path)
169
+ File.read(chunk_path)
170
+ end
171
+
172
+ # Split raw chunk markdown into [front_matter_hash, body_string].
173
+ # front_matter_hash preserves insertion order; body is everything after
174
+ # the closing "---". Returns [nil, raw] when there is no leading block.
175
+ def split_chunk_md(raw)
176
+ return [nil, raw.to_s] unless raw.to_s.start_with?("---")
177
+
178
+ fm_end = raw.index("\n---\n", 4)
179
+ return [nil, raw] unless fm_end
180
+
181
+ fm_text = raw[4...fm_end]
182
+ body = raw[(fm_end + 5)..] || ""
183
+
184
+ fm = {}
185
+ fm_text.each_line do |line|
186
+ k, _, v = line.chomp.partition(":")
187
+ next if k.strip.empty?
188
+ fm[k.strip] = v.strip
189
+ end
190
+ [fm, body]
191
+ end
192
+
166
193
  # All sessions from disk, newest-first (sorted by last activity / updated_at,
167
194
  # falling back to created_at for legacy sessions without updated_at).
168
195
  # Optional filters:
@@ -305,11 +332,15 @@ module Clacky
305
332
  # are soft-deleted (moved to the session trash, recoverable). Pinned
306
333
  # sessions are never deleted and do not count toward the cap.
307
334
  # Returns count of soft-deleted sessions.
308
- def cleanup_by_count(keep:)
335
+ def cleanup_by_count(keep:, keep_cron: 200)
309
336
  non_pinned = all_sessions.reject { |s| s[:pinned] } # already sorted newest-first
310
- return 0 if non_pinned.size <= keep
311
337
 
312
- victims = non_pinned[keep..]
338
+ cron, regular = non_pinned.partition { |s| s[:source].to_s == "cron" }
339
+
340
+ victims = []
341
+ victims += regular[keep..] if regular.size > keep
342
+ victims += cron[keep_cron..] if cron.size > keep_cron
343
+
313
344
  victims.each { |session| soft_delete(session[:session_id]) }
314
345
  victims.size
315
346
  end
@@ -441,13 +441,11 @@ module Clacky
441
441
 
442
442
  # Load default skills from gem's default_skills directory
443
443
  private def load_default_skills
444
- # Get the gem's lib directory
445
444
  gem_lib_dir = File.expand_path("../", __dir__)
446
445
  default_skills_dir = File.join(gem_lib_dir, "clacky", "default_skills")
447
446
 
448
447
  return unless Dir.exist?(default_skills_dir)
449
448
 
450
- # Load each skill directory
451
449
  Dir.glob(File.join(default_skills_dir, "*/SKILL.md")).each do |skill_file|
452
450
  skill_dir = File.dirname(skill_file)
453
451
  skill_name = File.basename(skill_dir)
@@ -459,6 +457,20 @@ module Clacky
459
457
  @errors << "Failed to load default skill #{skill_name}: #{e.message}"
460
458
  end
461
459
  end
460
+
461
+ # Also load skills bundled inside default_extensions/*/skills/
462
+ ext_skills_dir = File.join(gem_lib_dir, "clacky", "default_extensions")
463
+ Dir.glob(File.join(ext_skills_dir, "*/skills/*/SKILL.md")).each do |skill_file|
464
+ skill_dir = File.dirname(skill_file)
465
+ skill_name = File.basename(skill_dir)
466
+
467
+ begin
468
+ skill = Skill.new(Pathname.new(skill_dir))
469
+ register_skill(skill, source: :default)
470
+ rescue StandardError => e
471
+ @errors << "Failed to load default skill #{skill_name}: #{e.message}"
472
+ end
473
+ end
462
474
  end
463
475
  end
464
476
  end
@@ -35,9 +35,7 @@ module Clacky
35
35
  def clean(raw)
36
36
  return "" if raw.nil? || raw.empty?
37
37
 
38
- s = raw.dup
39
- s.force_encoding(Encoding::UTF_8)
40
- s = s.scrub("?") unless s.valid_encoding?
38
+ s = Clacky::Utils::Encoding.pty_to_utf8(raw)
41
39
 
42
40
  s = s.gsub(CSI_REGEX, "")
43
41
  s = s.gsub(OSC_REGEX, "")
@@ -340,10 +340,6 @@ module Clacky
340
340
  project_root: cwd || Dir.pwd
341
341
  )
342
342
 
343
- # PowerShell 5 on Chinese Windows emits CP936/GBK by default; force
344
- # UTF-8 so our PTY (which decodes as UTF-8) doesn't see ??? bytes.
345
- safe_command = force_powershell_utf8(safe_command)
346
-
347
343
  # Background / dedicated path — never reuse the persistent shell,
348
344
  # because these commands stay running and would occupy the slot.
349
345
  if background
@@ -1522,45 +1518,6 @@ module Clacky
1522
1518
  return "" if lines.empty?
1523
1519
  lines.last(DISPLAY_TAIL_LINES).join("\n")
1524
1520
  end
1525
-
1526
- # PowerShell 5 on Chinese Windows defaults [Console]::OutputEncoding
1527
- # to CP936/GBK; our PTY decodes as UTF-8 so non-ASCII output becomes
1528
- # `???`. Inject UTF-8 setup into the user's PowerShell command so the
1529
- # shell emits UTF-8 bytes regardless of host locale.
1530
- POWERSHELL_PREAMBLE =
1531
- "[Console]::OutputEncoding=[Text.Encoding]::UTF8;"
1532
-
1533
- # Only rewrites simple `powershell[.exe]` / `pwsh[.exe]` invocations.
1534
- # Skips -File / -EncodedCommand / commands already handling encoding /
1535
- # pipelines (anything risky to splice).
1536
- private def force_powershell_utf8(command)
1537
- cmd = command.to_s
1538
- return command unless cmd =~ /\A\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\b/i
1539
- return command if cmd =~ /OutputEncoding/i
1540
- return command if cmd =~ /\s-(?:File|EncodedCommand|enc|f)\b/i
1541
-
1542
- # `-Command "..."` form: pipeline / chain characters inside the
1543
- # quoted body are PowerShell-internal, not shell-level, so we splice
1544
- # safely into the quoted string.
1545
- if (m = cmd.match(/\A(\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?)\s+(?:[^"'\s]+\s+)*?-(?:Command|c)\s+)(["'])(.*)\2(\s*(?:<\s*\S+\s*)?)\z/i))
1546
- head, quote, body, tail = m[1], m[2], m[3], m[4].to_s
1547
- return "#{head}#{quote}#{POWERSHELL_PREAMBLE}#{body}#{quote}#{tail}"
1548
- end
1549
-
1550
- # Outside the quoted-Command form, refuse to splice if there's any
1551
- # shell-level pipe / chain — too risky to get the boundaries right.
1552
- return command if cmd =~ /[|&;]/
1553
-
1554
- if (m = cmd.match(/\A(\s*(?:powershell(?:\.exe)?|pwsh(?:\.exe)?))(.*)\z/i))
1555
- exe, rest = m[1], m[2].to_s.strip
1556
- return command if rest.start_with?("-") && rest !~ /\A-(?:Command|c)\b/i
1557
- rest = rest.sub(/\A-(?:Command|c)\b\s*/i, "")
1558
- inner = rest.empty? ? POWERSHELL_PREAMBLE.chomp(";") : "#{POWERSHELL_PREAMBLE}#{rest}"
1559
- return %Q{#{exe} -Command "#{inner}"}
1560
- end
1561
-
1562
- command
1563
- end
1564
1521
  end
1565
1522
  end
1566
1523
  end
@@ -326,7 +326,7 @@ module Clacky
326
326
  print "\e[?25l"
327
327
  return :cancelled
328
328
  when "\u0015" # Ctrl+U - clear line
329
- buffer = ''
329
+ buffer = String.new
330
330
  cursor_pos = 0
331
331
  else
332
332
  # Regular character input
@@ -120,28 +120,23 @@ module Clacky
120
120
  @render_mutex.synchronize do
121
121
  entry = @buffer.entry_by_id(id)
122
122
  if entry.nil?
123
- Clacky::Logger.warn("[ph_debug] replace_entry_nil", id: id, content: content.to_s[0, 120])
124
123
  return
125
124
  end
126
125
  if entry.committed
127
- Clacky::Logger.warn("[ph_debug] replace_entry_committed", id: id, content: content.to_s[0, 120])
128
126
  return
129
127
  end
130
128
  if (entry.committed_line_offset || 0) > 0
131
- Clacky::Logger.warn("[ph_debug] replace_entry_partial", id: id, offset: entry.committed_line_offset, content: content.to_s[0, 120])
132
129
  return
133
130
  end
134
131
 
135
132
  old_lines = entry.lines.dup
136
133
  new_lines = wrap_content_to_lines(content)
137
134
  if old_lines == new_lines
138
- Clacky::Logger.warn("[ph_debug] replace_entry_same", id: id)
139
135
  screen.flush
140
136
  return
141
137
  end
142
138
  @buffer.replace(id, new_lines)
143
139
  is_tail = @buffer.live_entries.last&.id == id
144
- Clacky::Logger.warn("[ph_debug] replace_entry_paint", id: id, is_tail: is_tail, old_n: old_lines.length, new_n: new_lines.length, content: content.to_s[0, 120])
145
140
 
146
141
  unless @fullscreen_mode
147
142
  # repaint_entry_in_place relies on the entry being the tail of
@@ -182,7 +182,6 @@ module Clacky
182
182
  # @param final_message [String, nil] Optional override for the last
183
183
  # frame. If nil, the handle composes "<message>… (<elapsed>s)".
184
184
  def finish(final_message: nil)
185
- Clacky::Logger.warn("[ph_debug] finish_entry", oid: object_id, state: @state, unreg: @unregistered, msg: @message, eid: @entry_id)
186
185
  snapshot = @monitor.synchronize do
187
186
  return if @unregistered
188
187
  first_close = @state == :running
@@ -201,10 +200,8 @@ module Clacky
201
200
  else
202
201
  compose_final_frame(snapshot[:message], snapshot[:elapsed])
203
202
  end
204
- Clacky::Logger.warn("[ph_debug] finish_unregister", oid: object_id, eid: @entry_id, first_close: snapshot[:first_close], final_frame: final_frame.to_s[0, 200])
205
203
  @owner.unregister_progress(self, final_frame: final_frame)
206
204
  @monitor.synchronize { @unregistered = true }
207
- Clacky::Logger.warn("[ph_debug] finish_done", oid: object_id)
208
205
  end
209
206
  alias_method :cancel, :finish
210
207