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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +82 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/cost_tracker.rb +2 -0
- data/lib/clacky/agent/message_compressor_helper.rb +53 -2
- data/lib/clacky/agent/session_serializer.rb +72 -19
- data/lib/clacky/agent/time_machine.rb +10 -1
- data/lib/clacky/agent.rb +107 -26
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/billing_record.rb +3 -0
- data/lib/clacky/billing/billing_store.rb +31 -5
- data/lib/clacky/billing/platform_billing.rb +231 -0
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cancel_flag.rb +23 -0
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/advisor/advisors/general.md +23 -17
- data/lib/clacky/default_extensions/advisor/hooks/advisor.rb +104 -5
- data/lib/clacky/default_extensions/advisor/panels/advisor/view.js +92 -75
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +31 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +74 -24
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +16 -37
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -67
- data/lib/clacky/default_extensions/meeting/ext.yml +2 -1
- data/lib/clacky/default_extensions/time_machine/ext.yml +1 -1
- data/lib/clacky/default_extensions/time_machine/panels/time_machine/view.js +186 -208
- data/lib/clacky/extension/packager.rb +4 -2
- data/lib/clacky/extension/scaffold/templates/full/panels/dashboard/view.js.erb +2 -3
- data/lib/clacky/extension/verifier.rb +14 -1
- data/lib/clacky/fanout.rb +49 -2
- data/lib/clacky/json_ui_controller.rb +3 -2
- data/lib/clacky/message_format/open_ai.rb +5 -1
- data/lib/clacky/message_format/open_ai_responses.rb +409 -0
- data/lib/clacky/message_history.rb +43 -1
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/plain_ui_controller.rb +15 -5
- data/lib/clacky/providers.rb +24 -7
- data/lib/clacky/rich_ui/rich_ui_controller.rb +2 -2
- data/lib/clacky/server/channel/channel_manager.rb +93 -18
- data/lib/clacky/server/channel/channel_ui_controller.rb +16 -3
- data/lib/clacky/server/dir_picker.rb +154 -0
- data/lib/clacky/server/git_panel.rb +61 -9
- data/lib/clacky/server/http_server.rb +366 -42
- data/lib/clacky/server/web_ui_controller.rb +7 -5
- data/lib/clacky/session_manager.rb +74 -17
- data/lib/clacky/tools/ask_user.rb +3 -2
- data/lib/clacky/tools/terminal.rb +30 -3
- data/lib/clacky/ui2/markdown_renderer.rb +15 -12
- data/lib/clacky/ui2/strings_cjk_patch.rb +132 -0
- data/lib/clacky/ui2/themes/hacker_theme.rb +1 -1
- data/lib/clacky/ui2/themes/minimal_theme.rb +1 -1
- data/lib/clacky/ui2/ui_controller.rb +2 -2
- data/lib/clacky/ui_interface.rb +2 -2
- data/lib/clacky/utils/model_pricing.rb +92 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +915 -142
- data/lib/clacky/web/components/code-editor.js +33 -1
- data/lib/clacky/web/components/composer.js +315 -0
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +655 -0
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/aside.js +3 -3
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/store.js +2 -6
- data/lib/clacky/web/features/billing/view.js +32 -16
- data/lib/clacky/web/features/extensions/view.js +22 -10
- data/lib/clacky/web/features/new-session/store.js +15 -1
- data/lib/clacky/web/features/new-session/view.js +80 -34
- data/lib/clacky/web/features/trash/view.js +31 -19
- data/lib/clacky/web/features/workspace/store.js +1 -0
- data/lib/clacky/web/features/workspace/view.js +766 -80
- data/lib/clacky/web/i18n.js +107 -17
- data/lib/clacky/web/index.html +125 -45
- data/lib/clacky/web/sessions.js +704 -635
- data/lib/clacky/web/settings.js +164 -190
- data/lib/clacky/web/skills.js +11 -17
- data/lib/clacky/web/ws-dispatcher.js +11 -33
- data/lib/clacky.rb +4 -0
- 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
|
-
#
|
|
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
|
|
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
|
|
60
|
-
#
|
|
61
|
-
#
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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.
|