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.
Files changed (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +54 -0
  3. data/Dockerfile +3 -0
  4. data/README.md +1 -1
  5. data/README_JA.md +237 -0
  6. data/docs/rich_ui_guide.md +277 -0
  7. data/docs/rich_ui_refactor_plan.md +396 -0
  8. data/lib/clacky/agent/llm_caller.rb +10 -4
  9. data/lib/clacky/agent/session_serializer.rb +52 -7
  10. data/lib/clacky/agent/time_machine.rb +247 -26
  11. data/lib/clacky/agent.rb +15 -3
  12. data/lib/clacky/api_extension.rb +262 -0
  13. data/lib/clacky/api_extension_loader.rb +156 -0
  14. data/lib/clacky/cli.rb +93 -3
  15. data/lib/clacky/client.rb +38 -13
  16. data/lib/clacky/default_agents/_panels/git/panel.js +201 -0
  17. data/lib/clacky/default_agents/_panels/time_machine/panel.js +640 -0
  18. data/lib/clacky/default_agents/coding/profile.yml +3 -0
  19. data/lib/clacky/default_agents/coding/webui/.gitkeep +0 -0
  20. data/lib/clacky/default_skills/cron-task-creator/SKILL.md +1 -1
  21. data/lib/clacky/default_skills/extend-openclacky/SKILL.md +6 -4
  22. data/lib/clacky/default_skills/media-gen/SKILL.md +37 -10
  23. data/lib/clacky/idle_compression_timer.rb +3 -1
  24. data/lib/clacky/locales/en.rb +26 -0
  25. data/lib/clacky/locales/i18n.rb +26 -0
  26. data/lib/clacky/locales/zh.rb +26 -0
  27. data/lib/clacky/media/openai_compat.rb +64 -1
  28. data/lib/clacky/message_history.rb +9 -0
  29. data/lib/clacky/rich_ui/components/base_component.rb +50 -0
  30. data/lib/clacky/rich_ui/components/dialogs/approval_dialog.rb +142 -0
  31. data/lib/clacky/rich_ui/components/dialogs/config_menu_dialog.rb +106 -0
  32. data/lib/clacky/rich_ui/components/dialogs/form_dialog.rb +128 -0
  33. data/lib/clacky/rich_ui/components/sidebar.rb +119 -0
  34. data/lib/clacky/rich_ui/components/sidebar_panels.rb +134 -0
  35. data/lib/clacky/rich_ui/components/status_view.rb +58 -0
  36. data/lib/clacky/rich_ui/components/thinking_live_view.rb +79 -0
  37. data/lib/clacky/rich_ui/entry_tracker.rb +56 -0
  38. data/lib/clacky/rich_ui/layout_adapter.rb +16 -0
  39. data/lib/clacky/rich_ui/progress_handle_adapter.rb +24 -0
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +868 -0
  41. data/lib/clacky/rich_ui/shell/rich_agent_shell.rb +184 -0
  42. data/lib/clacky/rich_ui/view_renderer.rb +291 -0
  43. data/lib/clacky/rich_ui.rb +57 -0
  44. data/lib/clacky/rich_ui_controller.rb +3 -1549
  45. data/lib/clacky/server/api_extension_dispatcher.rb +120 -0
  46. data/lib/clacky/server/channel/channel_manager.rb +26 -0
  47. data/lib/clacky/server/git_panel.rb +115 -0
  48. data/lib/clacky/server/http_server.rb +547 -15
  49. data/lib/clacky/server/server_master.rb +6 -4
  50. data/lib/clacky/server/session_registry.rb +1 -1
  51. data/lib/clacky/shell_hook_loader.rb +1 -1
  52. data/lib/clacky/tools/edit.rb +14 -2
  53. data/lib/clacky/ui2/ui_controller.rb +7 -0
  54. data/lib/clacky/version.rb +1 -1
  55. data/lib/clacky/web/app.css +511 -101
  56. data/lib/clacky/web/app.js +95 -14
  57. data/lib/clacky/web/components/code-editor.js +197 -0
  58. data/lib/clacky/web/{notify.js → components/notify.js} +1 -1
  59. data/lib/clacky/web/{onboard.js → components/onboard.js} +18 -2
  60. data/lib/clacky/web/core/aside.js +117 -0
  61. data/lib/clacky/web/core/ext.js +387 -0
  62. data/lib/clacky/web/features/backup/store.js +92 -0
  63. data/lib/clacky/web/features/backup/view.js +94 -0
  64. data/lib/clacky/web/features/billing/store.js +163 -0
  65. data/lib/clacky/web/{billing.js → features/billing/view.js} +132 -240
  66. data/lib/clacky/web/features/brand/store.js +110 -0
  67. data/lib/clacky/web/{brand.js → features/brand/view.js} +49 -199
  68. data/lib/clacky/web/features/channels/store.js +103 -0
  69. data/lib/clacky/web/{channels.js → features/channels/view.js} +50 -127
  70. data/lib/clacky/web/features/creator/store.js +81 -0
  71. data/lib/clacky/web/{creator.js → features/creator/view.js} +53 -102
  72. data/lib/clacky/web/features/mcp/store.js +158 -0
  73. data/lib/clacky/web/{mcp.js → features/mcp/view.js} +57 -134
  74. data/lib/clacky/web/features/model-tester/store.js +77 -0
  75. data/lib/clacky/web/features/model-tester/view.js +7 -0
  76. data/lib/clacky/web/features/profile/store.js +170 -0
  77. data/lib/clacky/web/{profile.js → features/profile/view.js} +94 -144
  78. data/lib/clacky/web/features/share/store.js +145 -0
  79. data/lib/clacky/web/{share.js → features/share/view.js} +66 -202
  80. data/lib/clacky/web/features/skills/store.js +331 -0
  81. data/lib/clacky/web/features/skills/view.js +581 -0
  82. data/lib/clacky/web/features/tasks/store.js +135 -0
  83. data/lib/clacky/web/features/tasks/view.js +241 -0
  84. data/lib/clacky/web/features/trash/store.js +242 -0
  85. data/lib/clacky/web/{trash.js → features/trash/view.js} +102 -293
  86. data/lib/clacky/web/features/version/store.js +165 -0
  87. data/lib/clacky/web/features/version/view.js +323 -0
  88. data/lib/clacky/web/features/workspace/store.js +99 -0
  89. data/lib/clacky/web/features/workspace/view.js +305 -0
  90. data/lib/clacky/web/i18n.js +68 -6
  91. data/lib/clacky/web/index.html +113 -62
  92. data/lib/clacky/web/sessions.js +493 -39
  93. data/lib/clacky/web/settings.js +143 -49
  94. data/lib/clacky/web/skills.js +3 -863
  95. data/lib/clacky/web/vendor/codemirror/codemirror.min.js +29 -0
  96. data/lib/clacky/web/ws-dispatcher.js +7 -3
  97. data/lib/clacky.rb +17 -1
  98. metadata +81 -20
  99. data/lib/clacky/web/backup.js +0 -119
  100. data/lib/clacky/web/model-tester.js +0 -66
  101. data/lib/clacky/web/tasks.js +0 -373
  102. data/lib/clacky/web/version.js +0 -449
  103. data/lib/clacky/web/workspace.js +0 -316
  104. /data/lib/clacky/web/{notify.mp3 → assets/notify.mp3} +0 -0
  105. /data/lib/clacky/web/{datepicker.js → components/datepicker.js} +0 -0
  106. /data/lib/clacky/web/{sidebar.js → components/sidebar.js} +0 -0
  107. /data/lib/clacky/web/{marked.min.js → vendor/marked/marked.min.js} +0 -0
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "timeout"
5
+ require "uri"
6
+
7
+ module Clacky
8
+ module Server
9
+ # Routes /api/ext/<name>/<sub-path> requests to the matching ApiExtension
10
+ # subclass. Wraps each handler invocation with a timeout and a unified
11
+ # JSON error envelope so a misbehaving extension cannot break neighboring
12
+ # extensions or the host process.
13
+ module ApiExtensionDispatcher
14
+ MOUNT_PREFIX = "/api/ext/"
15
+
16
+ class << self
17
+ # Public entry called from HttpServer#_dispatch_rest.
18
+ # Returns true to indicate the request was handled (200/4xx/5xx).
19
+ def handle(req, res, http_server:)
20
+ ext_id, sub_path = parse_path(req.path)
21
+ return not_found(res, "extension id missing") unless ext_id
22
+
23
+ klass = Clacky::ApiExtension.registry[ext_id]
24
+ return not_found(res, "extension '#{ext_id}' not found") unless klass
25
+
26
+ method = req.request_method.to_s.downcase.to_sym
27
+ route, params = find_route(klass, method, sub_path)
28
+ return not_found(res, "no route for #{req.request_method} #{req.path}") unless route
29
+
30
+ # Public-endpoint check is done at HttpServer level (it owns access-key
31
+ # logic); by the time we get here, auth has already been resolved.
32
+
33
+ timeout_sec = route.options[:timeout] || klass.class_timeout || Clacky::ApiExtension::DEFAULT_TIMEOUT
34
+ invoke_route(klass, route, params, req, res, http_server, timeout_sec)
35
+ true
36
+ end
37
+
38
+ # Tells HttpServer whether a given /api/ext/... path can skip access-key
39
+ # auth, so the host can keep its single-source-of-truth auth logic.
40
+ def public_path?(path, method)
41
+ ext_id, sub_path = parse_path(path)
42
+ return false unless ext_id
43
+
44
+ klass = Clacky::ApiExtension.registry[ext_id]
45
+ return false unless klass
46
+ return false if klass.public_paths.empty?
47
+
48
+ route, _params = find_route(klass, method.to_s.downcase.to_sym, sub_path)
49
+ return false unless route
50
+
51
+ klass.public_paths.include?(route.pattern)
52
+ end
53
+
54
+ private def parse_path(path)
55
+ return [nil, nil] unless path.to_s.start_with?(MOUNT_PREFIX)
56
+
57
+ tail = path[MOUNT_PREFIX.length..]
58
+ slash = tail.index("/")
59
+ if slash
60
+ [tail[0...slash], tail[slash..]]
61
+ else
62
+ [tail, "/"]
63
+ end
64
+ end
65
+
66
+ private def find_route(klass, method, sub_path)
67
+ klass.routes.each do |route|
68
+ next unless route.method == method
69
+ next unless (m = route.regex.match(sub_path))
70
+
71
+ params = {}
72
+ route.param_names.each_with_index do |name, i|
73
+ params[name] = URI.decode_www_form_component(m[i + 1].to_s)
74
+ end
75
+ return [route, params]
76
+ end
77
+ [nil, nil]
78
+ end
79
+
80
+ private def invoke_route(klass, route, params, req, res, http_server, timeout_sec)
81
+ instance = klass.new(req: req, res: res, route: route, params: params, http_server: http_server)
82
+ Timeout.timeout(timeout_sec) { instance.invoke }
83
+
84
+ # Handler exited without writing — empty 204
85
+ empty_response(res)
86
+ rescue Clacky::ApiExtension::Halt => halt
87
+ write_response(res, halt.status, halt.payload, halt.content_type)
88
+ rescue Timeout::Error
89
+ Clacky::Logger.warn("[api_ext:#{klass.ext_id}] Timed out after #{timeout_sec}s on #{route.method.upcase} #{route.pattern}")
90
+ write_json(res, 503, error: "request timed out")
91
+ rescue StandardError => e
92
+ Clacky::Logger.warn("[api_ext:#{klass.ext_id}] #{e.class}: #{e.message}\n#{e.backtrace.first(5).join("\n")}")
93
+ write_json(res, 500, error: e.message)
94
+ end
95
+
96
+ private def write_response(res, status, body, content_type)
97
+ res.status = status
98
+ res.content_type = content_type
99
+ res["Access-Control-Allow-Origin"] = "*"
100
+ res.body = body
101
+ end
102
+
103
+ private def write_json(res, status, payload)
104
+ write_response(res, status, JSON.generate(payload), "application/json; charset=utf-8")
105
+ end
106
+
107
+ private def empty_response(res)
108
+ res.status = 204
109
+ res["Access-Control-Allow-Origin"] = "*"
110
+ res.body = ""
111
+ end
112
+
113
+ private def not_found(res, message)
114
+ write_json(res, 404, error: message)
115
+ true
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -43,6 +43,8 @@ module Clacky
43
43
  @running = false
44
44
  @mutex = Mutex.new
45
45
  @session_counters = Hash.new(0)
46
+ @dedup_mutex = Mutex.new
47
+ @last_message = {} # channel_key => [digest, monotonic_time]
46
48
  end
47
49
 
48
50
  # Start all enabled adapters in background threads. Non-blocking.
@@ -242,6 +244,16 @@ module Clacky
242
244
  files = event[:files] || []
243
245
  return if (text.nil? || text.empty?) && files.empty?
244
246
 
247
+ # Safety net against adapter-side message storms: drop an identical message
248
+ # repeated on the same channel within a short window. A real user never
249
+ # sends the exact same text+files twice within DEDUP_WINDOW seconds, but a
250
+ # misbehaving adapter (or noisy IM system messages) can, which would
251
+ # otherwise spin the interrupt→restart loop below indefinitely.
252
+ if duplicate_message?(event, text, files)
253
+ Clacky::Logger.info("[ChannelManager] Dropping duplicate message on #{channel_key(event)} within #{DEDUP_WINDOW}s")
254
+ return
255
+ end
256
+
245
257
  # Handle built-in commands
246
258
  if text&.match?(KNOWN_COMMAND) || text&.match?(/\A([\?h]|help)\z/i)
247
259
  handle_command(adapter, event, text)
@@ -749,6 +761,20 @@ module Clacky
749
761
  adapter.send_text(chat_id, "Recent sessions:\n#{lines.join("\n")}\n\nUse `/bind <n>` to switch.")
750
762
  end
751
763
 
764
+ DEDUP_WINDOW = 2.0 # seconds; identical messages on the same channel within this window are dropped
765
+
766
+ private def duplicate_message?(event, text, files)
767
+ key = channel_key(event)
768
+ digest = [text, files.map { |f| f.is_a?(Hash) ? f[:name] : f.to_s }].hash
769
+ now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
770
+
771
+ @dedup_mutex.synchronize do
772
+ prev_digest, prev_time = @last_message[key]
773
+ @last_message[key] = [digest, now]
774
+ !prev_digest.nil? && prev_digest == digest && (now - prev_time) < DEDUP_WINDOW
775
+ end
776
+ end
777
+
752
778
  def channel_key(event)
753
779
  platform = event[:platform].to_s
754
780
  case @binding_mode
@@ -0,0 +1,115 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+
5
+ module Clacky
6
+ module Server
7
+ # Read-mostly git operations scoped to a session's working directory, backing
8
+ # the official "git" WebUI panel. Commands run with explicit argv (no shell),
9
+ # so user-supplied values (paths, messages) cannot inject. Write operations
10
+ # are limited to a guarded `commit`; history-rewriting / remote-mutating
11
+ # commands are never exposed here.
12
+ module GitPanel
13
+ module_function
14
+
15
+ # Run a git subcommand in `dir` with argv-style args (no shell). Returns
16
+ # [stdout, stderr, success_bool]. Never raises on git failure.
17
+ def git(dir, *args)
18
+ out, err, status = Open3.capture3("git", "-C", dir.to_s, *args)
19
+ [out, err, status.success?]
20
+ rescue StandardError => e
21
+ ["", e.message, false]
22
+ end
23
+
24
+ # Whether `dir` is inside a git work tree.
25
+ def repo?(dir)
26
+ out, _err, ok = git(dir, "rev-parse", "--is-inside-work-tree")
27
+ ok && out.strip == "true"
28
+ end
29
+
30
+ # { branch:, ahead:, behind:, files: [{ path:, x:, y:, staged:, untracked: }] }
31
+ # Parsed from `git status --porcelain=v2 --branch`.
32
+ def status(dir)
33
+ out, _err, ok = git(dir, "status", "--porcelain=v2", "--branch")
34
+ return { branch: nil, files: [] } unless ok
35
+
36
+ branch = nil
37
+ ahead = behind = 0
38
+ files = []
39
+ out.each_line do |line|
40
+ line = line.chomp
41
+ if line.start_with?("# branch.head ")
42
+ branch = line.sub("# branch.head ", "")
43
+ elsif line.start_with?("# branch.ab ")
44
+ m = line.match(/\+(\d+) -(\d+)/)
45
+ ahead, behind = m[1].to_i, m[2].to_i if m
46
+ elsif line.start_with?("1 ", "2 ")
47
+ xy = line.split(" ")[1]
48
+ path = line.split(" ", 9).last
49
+ files << { path: path, x: xy[0], y: xy[1],
50
+ staged: xy[0] != ".", untracked: false }
51
+ elsif line.start_with?("? ")
52
+ files << { path: line.sub("? ", ""), x: "?", y: "?",
53
+ staged: false, untracked: true }
54
+ end
55
+ end
56
+ { branch: branch, ahead: ahead, behind: behind, files: files }
57
+ end
58
+
59
+ # Unified diff. `file` (optional, relative) limits to one path; omitted =
60
+ # whole working tree (tracked changes). `--` guards path from being read
61
+ # as an option.
62
+ def diff(dir, file: nil)
63
+ args = ["diff"]
64
+ args += ["--", file] if file && !file.empty?
65
+ out, _err, _ok = git(dir, *args)
66
+ out
67
+ end
68
+
69
+ # Recent commits: [{ hash:, short:, author:, date:, subject: }].
70
+ def log(dir, limit: 50)
71
+ limit = limit.to_i.clamp(1, 200)
72
+ fmt = "%H%x1f%h%x1f%an%x1f%ad%x1f%s"
73
+ out, _err, ok = git(dir, "log", "-n", limit.to_s, "--date=short", "--pretty=format:#{fmt}")
74
+ return [] unless ok
75
+
76
+ out.each_line.filter_map do |line|
77
+ h, short, author, date, subject = line.chomp.split("\x1f")
78
+ next unless h
79
+ { hash: h, short: short, author: author, date: date, subject: subject }
80
+ end
81
+ end
82
+
83
+ # [{ name:, current: bool }] from `git branch`.
84
+ def branches(dir)
85
+ out, _err, ok = git(dir, "branch", "--format=%(refname:short)%00%(HEAD)")
86
+ return [] unless ok
87
+
88
+ out.each_line.filter_map do |line|
89
+ name, head = line.chomp.split("\x00")
90
+ next unless name && !name.empty?
91
+ { name: name, current: head == "*" }
92
+ end
93
+ end
94
+
95
+ # Stage `files` (relative paths) and commit with `message`. Returns
96
+ # { ok:, error?:, hash? }. Refuses empty message / empty file set. Uses
97
+ # argv so paths/message cannot inject; no --no-verify, no amend.
98
+ def commit(dir, message:, files:)
99
+ msg = message.to_s.strip
100
+ paths = Array(files).map(&:to_s).reject(&:empty?)
101
+ return { ok: false, error: "commit message is required" } if msg.empty?
102
+ return { ok: false, error: "no files selected" } if paths.empty?
103
+
104
+ _out, add_err, add_ok = git(dir, "add", "--", *paths)
105
+ return { ok: false, error: "git add failed: #{add_err.strip}" } unless add_ok
106
+
107
+ _out, c_err, c_ok = git(dir, "commit", "-m", msg, "--", *paths)
108
+ return { ok: false, error: "git commit failed: #{c_err.strip}" } unless c_ok
109
+
110
+ head, _err, _ok = git(dir, "rev-parse", "--short", "HEAD")
111
+ { ok: true, hash: head.strip }
112
+ end
113
+ end
114
+ end
115
+ end