openclacky 1.5.11 → 1.5.13

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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +82 -0
  3. data/lib/clacky/access_key.rb +59 -0
  4. data/lib/clacky/agent/cost_tracker.rb +2 -0
  5. data/lib/clacky/agent/message_compressor_helper.rb +53 -2
  6. data/lib/clacky/agent/session_serializer.rb +72 -19
  7. data/lib/clacky/agent/time_machine.rb +10 -1
  8. data/lib/clacky/agent.rb +107 -26
  9. data/lib/clacky/agent_config.rb +40 -0
  10. data/lib/clacky/agent_profile.rb +5 -5
  11. data/lib/clacky/billing/billing_record.rb +3 -0
  12. data/lib/clacky/billing/billing_store.rb +31 -5
  13. data/lib/clacky/billing/platform_billing.rb +231 -0
  14. data/lib/clacky/brand_config.rb +4 -3
  15. data/lib/clacky/cancel_flag.rb +23 -0
  16. data/lib/clacky/cli.rb +4 -2
  17. data/lib/clacky/client.rb +107 -0
  18. data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
  19. data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
  20. data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
  21. data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
  22. data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
  23. data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
  24. data/lib/clacky/default_extensions/git/ext.yml +1 -1
  25. data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
  26. data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
  27. data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
  28. data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
  29. data/lib/clacky/extension/packager.rb +4 -2
  30. data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
  31. data/lib/clacky/extension/verifier.rb +14 -1
  32. data/lib/clacky/fanout.rb +49 -2
  33. data/lib/clacky/json_ui_controller.rb +3 -2
  34. data/lib/clacky/message_format/open_ai.rb +5 -1
  35. data/lib/clacky/message_format/open_ai_responses.rb +409 -0
  36. data/lib/clacky/message_history.rb +43 -1
  37. data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
  38. data/lib/clacky/plain_ui_controller.rb +15 -5
  39. data/lib/clacky/providers.rb +24 -7
  40. data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
  41. data/lib/clacky/server/channel/channel_manager.rb +93 -18
  42. data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
  43. data/lib/clacky/server/dir_picker.rb +154 -0
  44. data/lib/clacky/server/git_panel.rb +61 -9
  45. data/lib/clacky/server/http_server.rb +366 -42
  46. data/lib/clacky/server/web_ui_controller.rb +7 -5
  47. data/lib/clacky/session_manager.rb +74 -17
  48. data/lib/clacky/tools/ask_user.rb +3 -2
  49. data/lib/clacky/tools/terminal.rb +30 -3
  50. data/lib/clacky/ui2/markdown_renderer.rb +15 -12
  51. data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
  52. data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
  53. data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
  54. data/lib/clacky/ui2/ui_controller.rb +2 -2
  55. data/lib/clacky/ui_interface.rb +2 -2
  56. data/lib/clacky/utils/model_pricing.rb +92 -4
  57. data/lib/clacky/version.rb +1 -1
  58. data/lib/clacky/web/app.css +915 -142
  59. data/lib/clacky/web/components/code-editor.js +33 -1
  60. data/lib/clacky/web/components/composer.js +315 -0
  61. data/lib/clacky/web/components/custom-select.js +136 -0
  62. data/lib/clacky/web/components/mentions.js +655 -0
  63. data/lib/clacky/web/components/model-picker.js +527 -0
  64. data/lib/clacky/web/components/onboard.js +29 -55
  65. data/lib/clacky/web/core/aside.js +3 -3
  66. data/lib/clacky/web/core/ext.js +38 -0
  67. data/lib/clacky/web/features/billing/store.js +2 -6
  68. data/lib/clacky/web/features/billing/view.js +32 -16
  69. data/lib/clacky/web/features/extensions/view.js +22 -10
  70. data/lib/clacky/web/features/new-session/store.js +15 -1
  71. data/lib/clacky/web/features/new-session/view.js +80 -34
  72. data/lib/clacky/web/features/trash/view.js +31 -19
  73. data/lib/clacky/web/features/workspace/store.js +1 -0
  74. data/lib/clacky/web/features/workspace/view.js +766 -80
  75. data/lib/clacky/web/i18n.js +107 -17
  76. data/lib/clacky/web/index.html +125 -45
  77. data/lib/clacky/web/sessions.js +704 -635
  78. data/lib/clacky/web/settings.js +164 -190
  79. data/lib/clacky/web/skills.js +11 -17
  80. data/lib/clacky/web/ws-dispatcher.js +11 -33
  81. data/lib/clacky.rb +4 -0
  82. metadata +12 -1
@@ -67,7 +67,7 @@ module Clacky
67
67
  send_text("[USER] #{content}")
68
68
  end
69
69
 
70
- def show_assistant_message(content, files:, interim: false)
70
+ def show_assistant_message(content, files:, interim: false, created_at: nil)
71
71
  if interim
72
72
  # Intermediate narration before a tool call. Suppressed unless
73
73
  # tool-process messages are enabled; flush pending previews first
@@ -95,7 +95,20 @@ module Clacky
95
95
  end
96
96
 
97
97
  def show_tool_call(name, args)
98
- # Suppress — too noisy for IM
98
+ # ask_user is the one tool that must reach the user: the agent stops and
99
+ # waits for an answer, so swallowing it leaves the chat silently stuck.
100
+ # Sent unconditionally — it is a question, not process noise, so the
101
+ # process-messages toggle must not gate it.
102
+ if Clacky::Tools::AskUser.feedback_tool?(name)
103
+ args_data = args.is_a?(String) ? (JSON.parse(args) rescue args) : args
104
+ questions = Clacky::Tools::AskUser.normalize_questions(args_data)
105
+ return if questions.empty?
106
+
107
+ context = args_data.is_a?(Hash) ? (args_data[:context] || args_data["context"]).to_s : ""
108
+ flush_buffer
109
+ send_text(Clacky::Tools::AskUser.render_text(questions, context))
110
+ return
111
+ end
99
112
  end
100
113
 
101
114
  def show_tool_result(result)
@@ -136,7 +149,7 @@ module Clacky
136
149
  # Suppress
137
150
  end
138
151
 
139
- def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil)
152
+ def show_complete(iterations:, cost:, duration: nil, cache_stats: nil, awaiting_user_feedback: false, cost_source: nil, task_id: nil)
140
153
  flush_buffer
141
154
  return unless status_messages?
142
155
 
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Clacky
4
+ module Server
5
+ # Directory-picker quick-access places + WSL/Windows drive detection.
6
+ # Extracted from HttpServer to keep the path/place helpers cohesive.
7
+ module DirPicker
8
+ # Windows "special" folders under C:\Users that aren't real user profiles.
9
+ WINDOWS_PROFILE_DIRS = ["Public", "Default", "Default User", "All Users"].freeze
10
+
11
+ # Sidebar quick-access favorites for the directory picker (Finder-style).
12
+ # Translated client-side via `id`. On WSL every favorite targets the
13
+ # Windows profile (/mnt/<drive>/Users/<name>/...) so they match where a
14
+ # Windows browser actually saves files; elsewhere they stay under Dir.home.
15
+ private def dir_picker_places
16
+ home = Dir.home
17
+ win_home = wsl_windows_home
18
+ places = []
19
+
20
+ [["home", win_home || home],
21
+ ["desktop", File.join(win_home || home, "Desktop")],
22
+ ["downloads", File.join(win_home || home, "Downloads")],
23
+ ["documents", File.join(win_home || home, "Documents")]].each do |id, path|
24
+ places << { id: id, path: path, kind: "favorite" } if Dir.exist?(path)
25
+ end
26
+
27
+ places.concat(dir_picker_drives)
28
+ places
29
+ end
30
+
31
+ # Drive letters exposed to the picker so Windows users can reach D:/E:/…
32
+ # directly. Under WSL these are the /mnt/<drive> mounts; on native
33
+ # Windows the actual drive letters. Empty on macOS/Linux.
34
+ private def dir_picker_drives
35
+ if wsl?
36
+ mounts = "/mnt"
37
+ return [] unless Dir.exist?(mounts)
38
+
39
+ drives = []
40
+ Dir.children(mounts).sort.each do |drive|
41
+ next unless drive.match?(/\A[a-zA-Z]\z/)
42
+ path = File.join(mounts, drive)
43
+ next unless Dir.exist?(path)
44
+ drives << { id: "drive_#{drive.downcase}", path: path, letter: drive.upcase, kind: "drive" }
45
+ end
46
+ drives
47
+ elsif Gem.win_platform?
48
+ drives = []
49
+ ("A".."Z").each do |letter|
50
+ path = "#{letter}:/"
51
+ next unless Dir.exist?(path)
52
+ drives << { id: "drive_#{letter.downcase}", path: path, letter: letter, kind: "drive" }
53
+ end
54
+ drives
55
+ else
56
+ []
57
+ end
58
+ end
59
+
60
+ # True when running inside Windows Subsystem for Linux.
61
+ private def wsl?
62
+ return true if ENV["WSL_DISTRO_NAME"]
63
+ return true if File.exist?("/proc/sys/fs/binfmt_misc/WSLInterop")
64
+ version = begin
65
+ File.read("/proc/version")
66
+ rescue StandardError
67
+ ""
68
+ end
69
+ version.downcase.include?("microsoft")
70
+ end
71
+
72
+ # Best-effort path to the Windows user profile under WSL, or nil when it
73
+ # can't be determined (then callers fall back to Dir.home). Scans every
74
+ # mounted drive's Users dir instead of assuming the system drive is C:.
75
+ # Username sources are tried most-trusted first:
76
+ # 1. WINDOWS_USERNAME / USERNAME env (explicitly set or WSLENV-forwarded)
77
+ # 2. the live Windows %USERNAME%, read via cmd.exe interop
78
+ # 3. USER (the WSL Linux username, which usually matches the Windows one)
79
+ # 4. the first non-system profile dir found (last-resort guess)
80
+ private def wsl_windows_home
81
+ return nil unless wsl?
82
+
83
+ roots = wsl_windows_users_roots
84
+ wsl_profile_for(roots, ENV["WINDOWS_USERNAME"]) ||
85
+ wsl_profile_for(roots, ENV["USERNAME"]) ||
86
+ wsl_profile_for(roots, wsl_windows_username) ||
87
+ wsl_profile_for(roots, ENV["USER"]) ||
88
+ wsl_first_user_profile(roots)
89
+ end
90
+
91
+ # The first mounted profile dir named exactly `username`, or nil. WSL
92
+ # never inherits Windows env vars by default, which is why `username`
93
+ # may also come from cmd.exe interop rather than ENV.
94
+ private def wsl_profile_for(roots, username)
95
+ return nil if username.nil? || username.empty?
96
+
97
+ roots.each do |root|
98
+ candidate = File.join(root, username)
99
+ return candidate if Dir.exist?(candidate)
100
+ end
101
+ nil
102
+ end
103
+
104
+ # Last-resort guess: the first non-system profile dir under any mounted
105
+ # Users root. Filesystem enumeration order is arbitrary, so on
106
+ # multi-profile machines this may pick an inactive profile — every
107
+ # username source in wsl_windows_home exists to keep this unreached.
108
+ private def wsl_first_user_profile(roots)
109
+ roots.each do |root|
110
+ profiles = Dir.children(root).select do |name|
111
+ full = File.join(root, name)
112
+ File.directory?(full) &&
113
+ !WINDOWS_PROFILE_DIRS.include?(name) &&
114
+ !name.start_with?(".")
115
+ end
116
+ return File.join(root, profiles.first) if profiles.any?
117
+ end
118
+ nil
119
+ end
120
+
121
+ # The live Windows-side %USERNAME%, read via cmd.exe interop. Returns
122
+ # nil when interop is disabled or cmd.exe is unreachable; callers must
123
+ # still validate against the mounted Users directory. Cached —
124
+ # including failures — because interop availability doesn't change
125
+ # within a process and the spawn otherwise costs 100ms+ on every
126
+ # directory listing.
127
+ private def wsl_windows_username
128
+ return @wsl_windows_username if defined?(@wsl_windows_username)
129
+
130
+ @wsl_windows_username = begin
131
+ require "open3"
132
+ out, _err, status = Open3.capture3("cmd.exe", "/c", "echo %USERNAME%", binmode: true)
133
+ status.success? ? out.lines.first&.strip : nil
134
+ rescue StandardError
135
+ nil
136
+ end
137
+ end
138
+
139
+ # Every mounted drive that exposes a Windows Users directory, e.g.
140
+ # /mnt/c/Users and /mnt/d/Users.
141
+ private def wsl_windows_users_roots
142
+ mounts = "/mnt"
143
+ return [] unless Dir.exist?(mounts)
144
+
145
+ roots = []
146
+ Dir.children(mounts).each do |drive|
147
+ root = File.join(mounts, drive, "Users")
148
+ roots << root if Dir.exist?(root)
149
+ end
150
+ roots
151
+ end
152
+ end
153
+ end
154
+ end
@@ -7,8 +7,8 @@ module Clacky
7
7
  # Read-mostly git operations scoped to a session's working directory, backing
8
8
  # the official "git" WebUI panel. Commands run with explicit argv (no shell),
9
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.
10
+ # are limited to a guarded `commit` and a single-file `restore`;
11
+ # history-rewriting / remote-mutating commands are never exposed here.
12
12
  module GitPanel
13
13
  module_function
14
14
 
@@ -56,14 +56,24 @@ module Clacky
56
56
  { branch: branch, ahead: ahead, behind: behind, files: files }
57
57
  end
58
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.
59
+ # Unified diff for one file, or the whole tree when `file` is nil.
60
+ # Baseline is HEAD, not the index, so changes the agent staged with
61
+ # `git add` still show up. Untracked files have no baseline at all, so
62
+ # they diff against /dev/null via --no-index (rendered as pure
63
+ # additions); the trailing `--` keeps a leading-dash filename from
64
+ # being read as an option in that mode too.
62
65
  def diff(dir, file: nil)
63
- args = ["diff"]
64
- args += ["--", file] if file && !file.empty?
65
- out, _err, _ok = git(dir, *args)
66
- out
66
+ if file && !file.empty?
67
+ others, _e, _ok = git(dir, "ls-files", "--others", "--exclude-standard", "--", file)
68
+ if others.strip.empty?
69
+ out, _e, _ok = git(dir, "diff", "HEAD", "--", file)
70
+ else
71
+ out, _e, _ok = git(dir, "diff", "--no-index", "--", "/dev/null", file)
72
+ end
73
+ out
74
+ else
75
+ git(dir, "diff", "HEAD")[0]
76
+ end
67
77
  end
68
78
 
69
79
  # Recent commits: [{ hash:, short:, author:, date:, subject: }].
@@ -92,6 +102,48 @@ module Clacky
92
102
  end
93
103
  end
94
104
 
105
+ # Discard uncommitted changes to one file and return it to its HEAD
106
+ # state (same baseline the diff API uses, so "what you saw is what you
107
+ # lose"). Files that never existed in HEAD (staged-new or untracked)
108
+ # have no earlier version, so they are removed instead. argv-only and
109
+ # guarded against path traversal; history rewriting and remote-mutating
110
+ # commands remain out of bounds.
111
+ def restore(dir, file:)
112
+ path = file.to_s.strip
113
+ return { ok: false, error: "file is required" } if path.empty?
114
+ return { ok: false, error: "invalid path" } if invalid_path?(path)
115
+
116
+ head_tree, _e, _ok = git(dir, "ls-tree", "HEAD", "--", path)
117
+ if head_tree.include?("\t#{path}")
118
+ _o, err, ok = git(dir, "checkout", "HEAD", "--", path)
119
+ return ok ? { ok: true } : { ok: false, error: "git checkout failed: #{err.strip}" }
120
+ end
121
+
122
+ in_index, _e, idx_ok = git(dir, "ls-files", "--", path)
123
+ if idx_ok && !in_index.strip.empty?
124
+ _o, err, ok = git(dir, "rm", "-f", "--", path)
125
+ return ok ? { ok: true } : { ok: false, error: "git rm failed: #{err.strip}" }
126
+ end
127
+
128
+ target = File.expand_path(path, dir.to_s)
129
+ root = File.expand_path(dir.to_s)
130
+ return { ok: false, error: "invalid path" } unless target.start_with?(root + File::SEPARATOR)
131
+
132
+ begin
133
+ File.delete(target)
134
+ { ok: true }
135
+ rescue Errno::ENOENT
136
+ { ok: true }
137
+ rescue StandardError => e
138
+ { ok: false, error: e.message }
139
+ end
140
+ end
141
+
142
+ # Relative repo paths only: no absolute paths, no traversal segments.
143
+ def invalid_path?(path)
144
+ path.start_with?("/") || path.split("/").include?("..")
145
+ end
146
+
95
147
  # Stage `files` (relative paths) and commit with `message`. Returns
96
148
  # { ok:, error?:, hash? }. Refuses empty message / empty file set. Uses
97
149
  # argv so paths/message cannot inject; no --no-verify, no amend.