openclacky 1.5.12 → 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 +48 -0
- data/lib/clacky/access_key.rb +59 -0
- data/lib/clacky/agent/message_compressor_helper.rb +52 -1
- data/lib/clacky/agent/session_serializer.rb +35 -2
- data/lib/clacky/agent/time_machine.rb +9 -1
- data/lib/clacky/agent.rb +11 -7
- data/lib/clacky/agent_config.rb +40 -0
- data/lib/clacky/agent_profile.rb +5 -5
- data/lib/clacky/billing/platform_billing.rb +84 -1
- data/lib/clacky/brand_config.rb +4 -3
- data/lib/clacky/cli.rb +4 -2
- data/lib/clacky/client.rb +107 -0
- data/lib/clacky/default_extensions/ext-studio/api/handler.rb +3 -0
- data/lib/clacky/default_extensions/ext-studio/panels/studio/view.js +40 -21
- data/lib/clacky/default_extensions/git/ext.yml +1 -1
- data/lib/clacky/default_extensions/git/panels/git/view.js +483 -77
- 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/verifier.rb +14 -1
- 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 +6 -2
- data/lib/clacky/openai_responses_stream_aggregator.rb +294 -0
- data/lib/clacky/providers.rb +18 -6
- 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 +147 -103
- data/lib/clacky/tools/terminal.rb +27 -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/utils/model_pricing.rb +70 -4
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +412 -108
- data/lib/clacky/web/components/composer.js +45 -7
- data/lib/clacky/web/components/custom-select.js +136 -0
- data/lib/clacky/web/components/mentions.js +58 -2
- data/lib/clacky/web/components/model-picker.js +527 -0
- data/lib/clacky/web/components/onboard.js +29 -55
- data/lib/clacky/web/core/ext.js +38 -0
- data/lib/clacky/web/features/billing/view.js +20 -6
- 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 +31 -22
- 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 +54 -3
- data/lib/clacky/web/i18n.js +73 -11
- data/lib/clacky/web/index.html +92 -37
- data/lib/clacky/web/sessions.js +550 -592
- data/lib/clacky/web/settings.js +163 -190
- data/lib/clacky.rb +3 -0
- metadata +8 -1
|
@@ -19,6 +19,7 @@ require "open3"
|
|
|
19
19
|
require_relative "session_registry"
|
|
20
20
|
require_relative "project_manager"
|
|
21
21
|
require_relative "git_panel"
|
|
22
|
+
require_relative "dir_picker"
|
|
22
23
|
require_relative "web_ui_controller"
|
|
23
24
|
require_relative "model_prices"
|
|
24
25
|
require_relative "scheduler"
|
|
@@ -166,6 +167,8 @@ module Clacky
|
|
|
166
167
|
# * /api/* → JSON REST API (sessions, tasks, schedules)
|
|
167
168
|
# GET /** → static files served from lib/clacky/web/ directory
|
|
168
169
|
class HttpServer
|
|
170
|
+
include DirPicker
|
|
171
|
+
|
|
169
172
|
WEB_ROOT = File.expand_path("../web", __dir__)
|
|
170
173
|
# How long shutdown waits for each agent thread to unwind before falling
|
|
171
174
|
# back to a manual session save.
|
|
@@ -658,6 +661,8 @@ module Clacky
|
|
|
658
661
|
when ["PATCH", "/api/config/settings"] then api_update_settings(req, res)
|
|
659
662
|
when ["POST", "/api/config/models"] then api_add_model(req, res)
|
|
660
663
|
when ["POST", "/api/config/test"] then api_test_config(req, res)
|
|
664
|
+
when ["POST", "/api/config/reload"] then api_config_reload(req, res)
|
|
665
|
+
when ["POST", "/api/access_key/reload"] then api_access_key_reload(req, res)
|
|
661
666
|
when ["POST", "/api/config/media/test"] then api_test_media_config(req, res)
|
|
662
667
|
when ["GET", "/api/config/media"] then api_get_media_config(res)
|
|
663
668
|
when ["GET", "/api/config/ocr"] then api_get_ocr_config(res)
|
|
@@ -796,6 +801,9 @@ module Clacky
|
|
|
796
801
|
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/commit$})
|
|
797
802
|
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
798
803
|
api_session_git_commit(session_id, req, res)
|
|
804
|
+
elsif method == "POST" && path.match?(%r{^/api/sessions/[^/]+/git/restore$})
|
|
805
|
+
session_id = path[%r{^/api/sessions/([^/]+)/git/}, 1]
|
|
806
|
+
api_session_git_restore(session_id, req, res)
|
|
799
807
|
elsif method == "GET" && path.match?(%r{^/api/sessions/[^/]+/time_machine$})
|
|
800
808
|
session_id = path.sub("/api/sessions/", "").sub("/time_machine", "")
|
|
801
809
|
api_session_time_machine(session_id, res)
|
|
@@ -3409,21 +3417,21 @@ module Clacky
|
|
|
3409
3417
|
private def platform_usage_summary(period:, model:)
|
|
3410
3418
|
require_relative "../billing/platform_billing"
|
|
3411
3419
|
|
|
3412
|
-
|
|
3413
|
-
return nil if
|
|
3420
|
+
api_keys = platform_api_keys
|
|
3421
|
+
return nil if api_keys.empty?
|
|
3414
3422
|
|
|
3415
3423
|
real = Clacky::Billing::PlatformBilling.real_model(model) || model
|
|
3416
|
-
Clacky::Billing::PlatformBilling.
|
|
3424
|
+
Clacky::Billing::PlatformBilling.fetch_summary_merged(api_keys, period: period, model: real)
|
|
3417
3425
|
end
|
|
3418
3426
|
|
|
3419
3427
|
private def platform_usage_daily(days:, model:)
|
|
3420
3428
|
require_relative "../billing/platform_billing"
|
|
3421
3429
|
|
|
3422
|
-
|
|
3423
|
-
return nil if
|
|
3430
|
+
api_keys = platform_api_keys
|
|
3431
|
+
return nil if api_keys.empty?
|
|
3424
3432
|
|
|
3425
3433
|
real = Clacky::Billing::PlatformBilling.real_model(model) || model
|
|
3426
|
-
Clacky::Billing::PlatformBilling.
|
|
3434
|
+
Clacky::Billing::PlatformBilling.fetch_daily_merged(api_keys, days: days, model: real)
|
|
3427
3435
|
end
|
|
3428
3436
|
|
|
3429
3437
|
private def merge_billing_models(platform_models, local_models)
|
|
@@ -3456,14 +3464,19 @@ module Clacky
|
|
|
3456
3464
|
merged.transform_values { |v| v.round(6) }
|
|
3457
3465
|
end
|
|
3458
3466
|
|
|
3459
|
-
|
|
3467
|
+
# All openclacky api keys in the user's model config, deduped and
|
|
3468
|
+
# empty-stripped. The billing page queries the platform once per key
|
|
3469
|
+
# and merges the results, so every configured account is included.
|
|
3470
|
+
private def platform_api_keys
|
|
3471
|
+
keys = []
|
|
3460
3472
|
@agent_config.models.each do |m|
|
|
3461
3473
|
next unless m.is_a?(Hash)
|
|
3462
3474
|
next unless @agent_config.provider_id_for(m) == "openclacky"
|
|
3475
|
+
|
|
3463
3476
|
key = m["api_key"].to_s.strip
|
|
3464
|
-
|
|
3477
|
+
keys << key unless key.empty?
|
|
3465
3478
|
end
|
|
3466
|
-
|
|
3479
|
+
keys.uniq
|
|
3467
3480
|
end
|
|
3468
3481
|
|
|
3469
3482
|
# POST /api/ui/open_aside
|
|
@@ -3551,10 +3564,10 @@ module Clacky
|
|
|
3551
3564
|
s.start_with?("127.") || s == "::ffff:127.0.0.1"
|
|
3552
3565
|
end
|
|
3553
3566
|
|
|
3554
|
-
# Resolve access key from
|
|
3567
|
+
# Resolve access key from ~/.clacky/access_key, falling back to the
|
|
3568
|
+
# CLACKY_ACCESS_KEY env var.
|
|
3555
3569
|
private def resolve_access_key
|
|
3556
|
-
|
|
3557
|
-
key.empty? ? nil : key
|
|
3570
|
+
Clacky::AccessKey.resolve
|
|
3558
3571
|
end
|
|
3559
3572
|
|
|
3560
3573
|
# Extract bearer token or query param from a WEBrick request.
|
|
@@ -3676,7 +3689,7 @@ module Clacky
|
|
|
3676
3689
|
Clacky::Logger.info("[Upgrade] Official source — running: #{cmd}")
|
|
3677
3690
|
broadcast_all(type: "upgrade_log", line: "Starting upgrade: #{cmd}\n")
|
|
3678
3691
|
|
|
3679
|
-
output, exit_code = run_shell(cmd, timeout: 600)
|
|
3692
|
+
output, exit_code = run_shell(cmd, timeout: 600, env: gem_install_env)
|
|
3680
3693
|
|
|
3681
3694
|
Clacky::Logger.info("[Upgrade] exit_code=#{exit_code}")
|
|
3682
3695
|
Clacky::Logger.info("[Upgrade] output=#{output.slice(0, 1000)}")
|
|
@@ -3734,7 +3747,7 @@ module Clacky
|
|
|
3734
3747
|
broadcast_all(type: "upgrade_log", line: "Installing...\n")
|
|
3735
3748
|
Clacky::Logger.info("[Upgrade] Running: #{cmd}")
|
|
3736
3749
|
|
|
3737
|
-
output, exit_code = run_shell(cmd, timeout: 600)
|
|
3750
|
+
output, exit_code = run_shell(cmd, timeout: 600, env: gem_install_env)
|
|
3738
3751
|
success = exit_code&.zero? || false
|
|
3739
3752
|
|
|
3740
3753
|
broadcast_all(type: "upgrade_log", line: output)
|
|
@@ -3810,6 +3823,63 @@ module Clacky
|
|
|
3810
3823
|
schedule_restart
|
|
3811
3824
|
end
|
|
3812
3825
|
|
|
3826
|
+
# POST /api/config/reload
|
|
3827
|
+
# Re-reads ~/.clacky/config.yml into the live @agent_config. Every session
|
|
3828
|
+
# shares the same @models array, so a reload reaches all of them at once.
|
|
3829
|
+
def api_config_reload(_req, res)
|
|
3830
|
+
was_configured = @agent_config.models_configured?
|
|
3831
|
+
|
|
3832
|
+
unless @agent_config.reload!
|
|
3833
|
+
json_response(res, 500, { ok: false, error: "Failed to reload config.yml" })
|
|
3834
|
+
return
|
|
3835
|
+
end
|
|
3836
|
+
|
|
3837
|
+
# A server that booted with an empty config never built its default
|
|
3838
|
+
# session (create_default_session bails on models_configured?). Now
|
|
3839
|
+
# that models exist, complete that deferred startup step.
|
|
3840
|
+
if !was_configured && @agent_config.models_configured?
|
|
3841
|
+
create_default_session
|
|
3842
|
+
end
|
|
3843
|
+
|
|
3844
|
+
json_response(res, 200, {
|
|
3845
|
+
ok: true,
|
|
3846
|
+
models: @agent_config.models.size,
|
|
3847
|
+
models_configured: @agent_config.models_configured?
|
|
3848
|
+
})
|
|
3849
|
+
end
|
|
3850
|
+
|
|
3851
|
+
# POST /api/access_key/reload
|
|
3852
|
+
# Re-reads the access key from ~/.clacky/access_key (or CLACKY_ACCESS_KEY).
|
|
3853
|
+
# Write the new key to that file first, then call this to swap it in
|
|
3854
|
+
# without restarting. Pass {"key": "..."} to have the server persist the
|
|
3855
|
+
# key for you, or {"generate": true} to get a freshly generated one back.
|
|
3856
|
+
def api_access_key_reload(req, res)
|
|
3857
|
+
body = parse_json_body(req)
|
|
3858
|
+
|
|
3859
|
+
if body["generate"]
|
|
3860
|
+
key = Clacky::AccessKey.write(Clacky::AccessKey.generate)
|
|
3861
|
+
elsif !body["key"].to_s.strip.empty?
|
|
3862
|
+
key = Clacky::AccessKey.write(body["key"])
|
|
3863
|
+
else
|
|
3864
|
+
key = Clacky::AccessKey.resolve
|
|
3865
|
+
end
|
|
3866
|
+
|
|
3867
|
+
@access_key = @localhost_only ? nil : key
|
|
3868
|
+
# Old lockout counters were scoped to the previous key; a client that
|
|
3869
|
+
# tripped them should not stay banned after a rotation.
|
|
3870
|
+
@auth_failures_mutex.synchronize { @auth_failures.clear }
|
|
3871
|
+
|
|
3872
|
+
Clacky::Logger.info("[Auth] Access key reloaded (configured=#{!key.nil?})")
|
|
3873
|
+
|
|
3874
|
+
payload = { ok: true, configured: !key.nil? }
|
|
3875
|
+
# Only echo the key when the server generated it — otherwise the caller
|
|
3876
|
+
# already knows it, and logging/proxies shouldn't see it again.
|
|
3877
|
+
payload[:access_key] = key if body["generate"]
|
|
3878
|
+
json_response(res, 200, payload)
|
|
3879
|
+
rescue ArgumentError => e
|
|
3880
|
+
json_response(res, 400, { ok: false, error: e.message })
|
|
3881
|
+
end
|
|
3882
|
+
|
|
3813
3883
|
private def schedule_restart
|
|
3814
3884
|
Clacky::ThreadRegistry.spawn(name: "schedule-restart") do
|
|
3815
3885
|
sleep 0.5 # Let WEBrick flush the HTTP response
|
|
@@ -3922,8 +3992,23 @@ module Clacky
|
|
|
3922
3992
|
# Delegates to Terminal.run_sync which handles the idle-poll loop
|
|
3923
3993
|
# internally (see its docs for why that's needed — this wrapper used
|
|
3924
3994
|
# to re-implement it wrong and caused the 0.9.36 upgrade bug).
|
|
3925
|
-
private def run_shell(command, timeout: 120)
|
|
3926
|
-
Clacky::Tools::Terminal.run_sync(command, timeout: timeout)
|
|
3995
|
+
private def run_shell(command, timeout: 120, env: nil)
|
|
3996
|
+
Clacky::Tools::Terminal.run_sync(command, timeout: timeout, env: env)
|
|
3997
|
+
end
|
|
3998
|
+
|
|
3999
|
+
# Install next to the gem the server is currently running from, bypassing
|
|
4000
|
+
# any GEM_HOME hardcoded in the user's shell rc (system-Ruby users).
|
|
4001
|
+
private def gem_install_env
|
|
4002
|
+
spec = Gem.loaded_specs["openclacky"]
|
|
4003
|
+
gem_home = if spec
|
|
4004
|
+
spec.base_dir
|
|
4005
|
+
else
|
|
4006
|
+
Gem.dir
|
|
4007
|
+
end
|
|
4008
|
+
{
|
|
4009
|
+
"GEM_HOME" => gem_home,
|
|
4010
|
+
"GEM_PATH" => Gem.path.join(File::PATH_SEPARATOR),
|
|
4011
|
+
}
|
|
3927
4012
|
end
|
|
3928
4013
|
|
|
3929
4014
|
# ── Channel API ───────────────────────────────────────────────────────────
|
|
@@ -4975,7 +5060,26 @@ module Clacky
|
|
|
4975
5060
|
end
|
|
4976
5061
|
end
|
|
4977
5062
|
|
|
4978
|
-
#
|
|
5063
|
+
# POST /api/sessions/:id/git/restore - body: { file: }.
|
|
5064
|
+
# Discards uncommitted changes to one file, back to its HEAD state.
|
|
5065
|
+
def api_session_git_restore(session_id, req, res)
|
|
5066
|
+
dir = git_session_dir(session_id, res)
|
|
5067
|
+
return unless dir
|
|
5068
|
+
|
|
5069
|
+
unless Clacky::Server::GitPanel.repo?(dir)
|
|
5070
|
+
return json_response(res, 400, { error: "Not a git repository" })
|
|
5071
|
+
end
|
|
5072
|
+
|
|
5073
|
+
body = parse_json_body(req)
|
|
5074
|
+
result = Clacky::Server::GitPanel.restore(dir, file: body["file"])
|
|
5075
|
+
if result[:ok]
|
|
5076
|
+
json_response(res, 200, result)
|
|
5077
|
+
else
|
|
5078
|
+
json_response(res, 422, { error: result[:error] })
|
|
5079
|
+
end
|
|
5080
|
+
end
|
|
5081
|
+
|
|
5082
|
+
# GET /api/sessions/:id/time_machine - task history for the Time Machine
|
|
4979
5083
|
# panel. Mirrors the CLI menu: each entry carries id, summary, status
|
|
4980
5084
|
# (current/past/undone) and whether it branches.
|
|
4981
5085
|
def api_session_time_machine(session_id, res)
|
|
@@ -5076,82 +5180,6 @@ module Clacky
|
|
|
5076
5180
|
# Path traversal outside working_dir is rejected. Noisy dirs are hidden.
|
|
5077
5181
|
IGNORED_FILE_ENTRIES = %w[.git .svn .hg node_modules .DS_Store .bundle vendor/bundle tmp .sass-cache].freeze
|
|
5078
5182
|
|
|
5079
|
-
# Windows "special" folders under C:\Users that aren't real user profiles.
|
|
5080
|
-
WINDOWS_PROFILE_DIRS = ["Public", "Default", "Default User", "All Users"].freeze
|
|
5081
|
-
|
|
5082
|
-
# Sidebar quick-access favorites for the directory picker (Finder-style).
|
|
5083
|
-
# Translated client-side via `id`. On WSL every favorite targets the
|
|
5084
|
-
# Windows profile (/mnt/<drive>/Users/<name>/...) so they match where a
|
|
5085
|
-
# Windows browser actually saves files; elsewhere they stay under Dir.home.
|
|
5086
|
-
private def dir_picker_places
|
|
5087
|
-
home = Dir.home
|
|
5088
|
-
win_home = wsl_windows_home
|
|
5089
|
-
places = []
|
|
5090
|
-
|
|
5091
|
-
[["home", win_home || home],
|
|
5092
|
-
["desktop", File.join(win_home || home, "Desktop")],
|
|
5093
|
-
["downloads", File.join(win_home || home, "Downloads")],
|
|
5094
|
-
["documents", File.join(win_home || home, "Documents")]].each do |id, path|
|
|
5095
|
-
places << { id: id, path: path, kind: "favorite" } if Dir.exist?(path)
|
|
5096
|
-
end
|
|
5097
|
-
|
|
5098
|
-
places
|
|
5099
|
-
end
|
|
5100
|
-
|
|
5101
|
-
# True when running inside Windows Subsystem for Linux.
|
|
5102
|
-
private def wsl?
|
|
5103
|
-
return true if ENV["WSL_DISTRO_NAME"]
|
|
5104
|
-
return true if File.exist?("/proc/sys/fs/binfmt_misc/WSLInterop")
|
|
5105
|
-
version = begin
|
|
5106
|
-
File.read("/proc/version")
|
|
5107
|
-
rescue StandardError
|
|
5108
|
-
""
|
|
5109
|
-
end
|
|
5110
|
-
version.downcase.include?("microsoft")
|
|
5111
|
-
end
|
|
5112
|
-
|
|
5113
|
-
# Best-effort path to the Windows user profile under WSL, or nil when it
|
|
5114
|
-
# can't be determined (then callers fall back to Dir.home). Scans every
|
|
5115
|
-
# mounted drive's Users dir instead of assuming the system drive is C:.
|
|
5116
|
-
private def wsl_windows_home
|
|
5117
|
-
return nil unless wsl?
|
|
5118
|
-
|
|
5119
|
-
roots = wsl_windows_users_roots
|
|
5120
|
-
username = ENV["WINDOWS_USERNAME"] || ENV["USERNAME"]
|
|
5121
|
-
if username && !username.empty?
|
|
5122
|
-
roots.each do |root|
|
|
5123
|
-
candidate = File.join(root, username)
|
|
5124
|
-
return candidate if Dir.exist?(candidate)
|
|
5125
|
-
end
|
|
5126
|
-
end
|
|
5127
|
-
|
|
5128
|
-
roots.each do |root|
|
|
5129
|
-
profiles = Dir.children(root).select do |name|
|
|
5130
|
-
full = File.join(root, name)
|
|
5131
|
-
File.directory?(full) &&
|
|
5132
|
-
!WINDOWS_PROFILE_DIRS.include?(name) &&
|
|
5133
|
-
!name.start_with?(".")
|
|
5134
|
-
end
|
|
5135
|
-
return File.join(root, profiles.first) if profiles.one?
|
|
5136
|
-
end
|
|
5137
|
-
|
|
5138
|
-
nil
|
|
5139
|
-
end
|
|
5140
|
-
|
|
5141
|
-
# Every mounted drive that exposes a Windows Users directory, e.g.
|
|
5142
|
-
# /mnt/c/Users and /mnt/d/Users.
|
|
5143
|
-
private def wsl_windows_users_roots
|
|
5144
|
-
mounts = "/mnt"
|
|
5145
|
-
return [] unless Dir.exist?(mounts)
|
|
5146
|
-
|
|
5147
|
-
roots = []
|
|
5148
|
-
Dir.children(mounts).each do |drive|
|
|
5149
|
-
root = File.join(mounts, drive, "Users")
|
|
5150
|
-
roots << root if Dir.exist?(root)
|
|
5151
|
-
end
|
|
5152
|
-
roots
|
|
5153
|
-
end
|
|
5154
|
-
|
|
5155
5183
|
def api_session_files(session_id, req, res)
|
|
5156
5184
|
unless @registry.ensure(session_id)
|
|
5157
5185
|
return json_response(res, 404, { error: "Session not found" })
|
|
@@ -5170,6 +5198,7 @@ module Clacky
|
|
|
5170
5198
|
|
|
5171
5199
|
# Absolute mode: allow browsing outside working directory (e.g., root "/")
|
|
5172
5200
|
if absolute_mode
|
|
5201
|
+
rel = Utils::EnvironmentDetector.win_to_linux_path(rel)
|
|
5173
5202
|
target = File.expand_path(rel.empty? ? "/" : rel)
|
|
5174
5203
|
display_root = target
|
|
5175
5204
|
# Normalize rel for API response
|
|
@@ -5224,7 +5253,9 @@ module Clacky
|
|
|
5224
5253
|
show_hidden = query["show_hidden"] == "true"
|
|
5225
5254
|
include_files = query["files"] == "true"
|
|
5226
5255
|
rel = Dir.home if rel.empty?
|
|
5256
|
+
rel = Utils::EnvironmentDetector.win_to_linux_path(rel)
|
|
5227
5257
|
target = File.expand_path(rel.start_with?("~") ? rel.sub(/\A~/, Dir.home) : rel)
|
|
5258
|
+
requested_target = target
|
|
5228
5259
|
|
|
5229
5260
|
# The requested directory may not exist yet (e.g. the default
|
|
5230
5261
|
# ~/clacky_workspace before any session created it). Instead of 404,
|
|
@@ -5251,7 +5282,7 @@ module Clacky
|
|
|
5251
5282
|
end
|
|
5252
5283
|
items.sort_by! { |it| [it[:type] == "dir" ? 0 : 1, it[:name].downcase] }
|
|
5253
5284
|
|
|
5254
|
-
json_response(res, 200, { root: target, path: target, parent: File.dirname(target), home: Dir.home, default: default_working_dir, entries: items, places: dir_picker_places })
|
|
5285
|
+
json_response(res, 200, { root: target, path: target, parent: File.dirname(target), exact: target == requested_target, home: Dir.home, default: default_working_dir, entries: items, places: dir_picker_places })
|
|
5255
5286
|
rescue StandardError => e
|
|
5256
5287
|
json_response(res, 500, { error: e.message })
|
|
5257
5288
|
end
|
|
@@ -5272,7 +5303,7 @@ module Clacky
|
|
|
5272
5303
|
# Body: { parent: "/abs/parent", name: "New Folder" }
|
|
5273
5304
|
def api_dirs_mkdir(req, res)
|
|
5274
5305
|
body = parse_json_body(req)
|
|
5275
|
-
parent = body["parent"].to_s
|
|
5306
|
+
parent = Utils::EnvironmentDetector.win_to_linux_path(body["parent"].to_s)
|
|
5276
5307
|
name = body["name"].to_s.strip
|
|
5277
5308
|
|
|
5278
5309
|
return json_response(res, 422, { error: "parent must be an absolute path" }) unless parent.start_with?("/")
|
|
@@ -5754,10 +5785,12 @@ module Clacky
|
|
|
5754
5785
|
# GET /api/profile
|
|
5755
5786
|
# Returns { ok:, user: { path, content, is_default }, soul: { ... } }
|
|
5756
5787
|
private def api_profile_get(res)
|
|
5788
|
+
soul = _profile_read_file("SOUL.md")
|
|
5789
|
+
soul[:name] = _soul_name(soul[:content])
|
|
5757
5790
|
json_response(res, 200, {
|
|
5758
5791
|
ok: true,
|
|
5759
5792
|
user: _profile_read_file("USER.md"),
|
|
5760
|
-
soul:
|
|
5793
|
+
soul: soul
|
|
5761
5794
|
})
|
|
5762
5795
|
end
|
|
5763
5796
|
|
|
@@ -5828,6 +5861,16 @@ module Clacky
|
|
|
5828
5861
|
{ path: "", content: "", is_default: true, error: e.message }
|
|
5829
5862
|
end
|
|
5830
5863
|
|
|
5864
|
+
# Extracts the AI name from a SOUL.md heading ("# 老六 — Soul"). Returns
|
|
5865
|
+
# nil when the heading is absent (e.g. the built-in default soul).
|
|
5866
|
+
private def _soul_name(content)
|
|
5867
|
+
return nil if content.nil? || content.empty?
|
|
5868
|
+
m = content.match(/^#\s*(.+?)\s*[—–-]\s*Soul\s*$/i)
|
|
5869
|
+
return nil unless m
|
|
5870
|
+
name = m[1].to_s.strip
|
|
5871
|
+
name.empty? ? nil : name
|
|
5872
|
+
end
|
|
5873
|
+
|
|
5831
5874
|
# ── Memories API (~/.clacky/memories/*.md) ───────────────────────
|
|
5832
5875
|
#
|
|
5833
5876
|
# Long-term memories are plain Markdown files with YAML frontmatter
|
|
@@ -6377,13 +6420,13 @@ module Clacky
|
|
|
6377
6420
|
# never send "the whole list" anymore.
|
|
6378
6421
|
|
|
6379
6422
|
# Normalize an incoming api_format value into one of the supported
|
|
6380
|
-
# explicit formats ("anthropic-messages" / "openai-completions"
|
|
6381
|
-
# for auto. Returns :invalid for unsupported
|
|
6382
|
-
# reject the request outright.
|
|
6423
|
+
# explicit formats ("anthropic-messages" / "openai-completions" /
|
|
6424
|
+
# "openai-responses"), or nil for auto. Returns :invalid for unsupported
|
|
6425
|
+
# values so callers can reject the request outright.
|
|
6383
6426
|
private def normalize_api_format(value)
|
|
6384
6427
|
return nil if value.nil? || value.to_s.strip.empty?
|
|
6385
6428
|
v = value.to_s.strip
|
|
6386
|
-
return v if %w[anthropic-messages openai-completions].include?(v)
|
|
6429
|
+
return v if %w[anthropic-messages openai-completions openai-responses].include?(v)
|
|
6387
6430
|
:invalid
|
|
6388
6431
|
end
|
|
6389
6432
|
|
|
@@ -7384,10 +7427,11 @@ module Clacky
|
|
|
7384
7427
|
# ── Session actions ───────────────────────────────────────────────────────
|
|
7385
7428
|
|
|
7386
7429
|
def handle_edit_message(session_id, content, created_at)
|
|
7387
|
-
|
|
7430
|
+
session = @registry.get(session_id)
|
|
7431
|
+
return unless session
|
|
7432
|
+
return if session[:status] == :running
|
|
7388
7433
|
|
|
7389
|
-
agent =
|
|
7390
|
-
@registry.with_session(session_id) { |s| agent = s[:agent] }
|
|
7434
|
+
agent = session[:agent]
|
|
7391
7435
|
return unless agent
|
|
7392
7436
|
|
|
7393
7437
|
if agent.history.respond_to?(:truncate_from_created_at) && !created_at.to_s.empty?
|
|
@@ -876,7 +876,8 @@ module Clacky
|
|
|
876
876
|
end
|
|
877
877
|
|
|
878
878
|
marker_token = SecureRandom.hex(8)
|
|
879
|
-
|
|
879
|
+
session_box = []
|
|
880
|
+
reader_thread = start_reader_thread(reader, log_io, session_box)
|
|
880
881
|
|
|
881
882
|
session = SessionManager.register(
|
|
882
883
|
pid: pid, command: command, cwd: cwd || Dir.pwd,
|
|
@@ -886,6 +887,7 @@ module Clacky
|
|
|
886
887
|
mode: "shell", marker_token: marker_token,
|
|
887
888
|
shell_name: shell_name
|
|
888
889
|
)
|
|
890
|
+
session_box << session
|
|
889
891
|
|
|
890
892
|
# Give the shell a moment to print its startup banner (zsh -l -i
|
|
891
893
|
# loads a lot of stuff), then drain whatever noise it wrote so the
|
|
@@ -905,8 +907,21 @@ module Clacky
|
|
|
905
907
|
session
|
|
906
908
|
end
|
|
907
909
|
|
|
908
|
-
# Background thread: drain PTY → log file.
|
|
909
|
-
|
|
910
|
+
# Background thread: drain PTY → log file. It doubles as the session's
|
|
911
|
+
# death watcher: when the child exits, read_nonblock raises EIO and the
|
|
912
|
+
# thread ends up in the ensure block below. Releasing the PTY fds and
|
|
913
|
+
# forgetting the session there means a dead session's master fd is
|
|
914
|
+
# reclaimed the moment its child dies — even when the calling agent
|
|
915
|
+
# never comes back to poll or kill it, which used to leak the fd until
|
|
916
|
+
# the process hit EMFILE ("Too many open files").
|
|
917
|
+
#
|
|
918
|
+
# `session_box` is a one-element array filled by spawn_shell right
|
|
919
|
+
# after SessionManager.register (the session cannot exist before the
|
|
920
|
+
# thread starts); an empty box means registration never happened and
|
|
921
|
+
# spawn_shell itself owns the cleanup. Every close is rescue-guarded
|
|
922
|
+
# and SessionManager.forget is a plain Hash#delete, so this whole
|
|
923
|
+
# block is a no-op when the main thread's cleanup_session ran first.
|
|
924
|
+
private def start_reader_thread(reader, log_io, session_box)
|
|
910
925
|
Clacky::ThreadRegistry.spawn(name: "terminal-pty-reader") do
|
|
911
926
|
loop do
|
|
912
927
|
break if reader.closed? || log_io.closed?
|
|
@@ -926,6 +941,15 @@ module Clacky
|
|
|
926
941
|
end
|
|
927
942
|
ensure
|
|
928
943
|
log_io.close rescue nil
|
|
944
|
+
if (session = session_box[0])
|
|
945
|
+
# Flip the status first so a main thread parked in
|
|
946
|
+
# read_until_marker's poll loop notices the death immediately
|
|
947
|
+
# (its refresh() no longer finds the session in the registry).
|
|
948
|
+
session.status = "exited" unless %w[exited killed].include?(session.status)
|
|
949
|
+
session.writer.close rescue nil
|
|
950
|
+
session.reader.close rescue nil
|
|
951
|
+
SessionManager.forget(session.id)
|
|
952
|
+
end
|
|
929
953
|
end
|
|
930
954
|
end
|
|
931
955
|
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "tty-screen"
|
|
4
4
|
require_relative "theme_manager"
|
|
5
|
+
require_relative "strings_cjk_patch"
|
|
5
6
|
|
|
6
7
|
module Clacky
|
|
7
8
|
module UI2
|
|
@@ -21,14 +22,24 @@ module Clacky
|
|
|
21
22
|
# Get current theme colors
|
|
22
23
|
theme = ThemeManager.current_theme
|
|
23
24
|
|
|
25
|
+
width = TTY::Screen.width - 4 # Leave some margin
|
|
26
|
+
|
|
24
27
|
# Configure tty-markdown with custom theme and symbols
|
|
25
28
|
parsed = TTY::Markdown.parse(content,
|
|
26
29
|
theme: theme_colors,
|
|
27
30
|
symbols: custom_symbols,
|
|
28
|
-
width:
|
|
31
|
+
width: width
|
|
29
32
|
)
|
|
30
33
|
|
|
31
34
|
parsed
|
|
35
|
+
rescue IndexError => e
|
|
36
|
+
# Safety net behind strings_cjk_patch; retry uncolored if wrap still fails.
|
|
37
|
+
warn "[markdown] color render failed (#{e.class}), retrying without color" if ENV["CLACKY_DEBUG"]
|
|
38
|
+
begin
|
|
39
|
+
TTY::Markdown.parse(content, color: :never, symbols: custom_symbols, width: width)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
content
|
|
42
|
+
end
|
|
32
43
|
rescue StandardError => e
|
|
33
44
|
warn "[markdown] render failed: #{e.class}: #{e.message}" if ENV["CLACKY_DEBUG"]
|
|
34
45
|
content
|
|
@@ -59,20 +70,12 @@ module Clacky
|
|
|
59
70
|
def theme_colors
|
|
60
71
|
theme = ThemeManager.current_theme
|
|
61
72
|
|
|
62
|
-
#
|
|
63
|
-
#
|
|
73
|
+
# tty-markdown accepts a single :header key for all header levels
|
|
74
|
+
# (h1 additionally underlines). Code block colors come from rouge.
|
|
64
75
|
{
|
|
65
|
-
|
|
66
|
-
h1: Array(theme.colors[:info]) + [:bold],
|
|
67
|
-
h2: Array(theme.colors[:info]) + [:bold],
|
|
68
|
-
h3: Array(theme.colors[:info]),
|
|
69
|
-
h4: Array(theme.colors[:info]),
|
|
70
|
-
h5: Array(theme.colors[:info]),
|
|
71
|
-
h6: Array(theme.colors[:info]),
|
|
76
|
+
header: [:bright_cyan, :bold],
|
|
72
77
|
# Horizontal rule - make it subtle (dim gray)
|
|
73
78
|
hr: [:bright_black],
|
|
74
|
-
# Code blocks use dim color
|
|
75
|
-
code: Array(theme.colors[:thinking]),
|
|
76
79
|
# Links use success color (green)
|
|
77
80
|
link: Array(theme.colors[:success]),
|
|
78
81
|
# Lists use default text color
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "strings"
|
|
4
|
+
|
|
5
|
+
# strings 0.2.1 (latest release, 2021) tracks ANSI reinsertion offsets in
|
|
6
|
+
# display width (CJK chars count as 2) but applies them as String character
|
|
7
|
+
# indexes via String#insert, so any colored CJK content that needs wrapping
|
|
8
|
+
# raises IndexError. These methods mirror upstream with offsets tracked in
|
|
9
|
+
# character count instead of display width.
|
|
10
|
+
module Strings
|
|
11
|
+
module Wrap
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def format_line(text_line, wrap_at, ansi_stack)
|
|
15
|
+
lines = []
|
|
16
|
+
line = []
|
|
17
|
+
word = []
|
|
18
|
+
ansi = []
|
|
19
|
+
ansi_matched = false
|
|
20
|
+
word_length = 0
|
|
21
|
+
line_length = 0
|
|
22
|
+
word_chars = 0
|
|
23
|
+
line_chars = 0
|
|
24
|
+
char_length = 0
|
|
25
|
+
text_length = display_width(text_line)
|
|
26
|
+
total_length = 0
|
|
27
|
+
|
|
28
|
+
UnicodeUtils.each_grapheme(text_line) do |char|
|
|
29
|
+
if char == Strings::ANSI::CSI || ansi.length > 0
|
|
30
|
+
ansi << char
|
|
31
|
+
if Strings::ANSI.only_ansi?(ansi.join)
|
|
32
|
+
ansi_matched = true
|
|
33
|
+
elsif ansi_matched
|
|
34
|
+
ansi_stack << [ansi[0...-1].join, line_chars + word_chars]
|
|
35
|
+
ansi_matched = false
|
|
36
|
+
|
|
37
|
+
if ansi.last == Strings::ANSI::CSI
|
|
38
|
+
ansi = [ansi.last]
|
|
39
|
+
else
|
|
40
|
+
ansi = []
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
next if ansi.length > 0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
char_length = display_width(char)
|
|
47
|
+
total_length += char_length
|
|
48
|
+
if line_length + word_length + char_length <= wrap_at
|
|
49
|
+
if char == SPACE || total_length == text_length
|
|
50
|
+
line << word.join + char
|
|
51
|
+
line_length += word_length + char_length
|
|
52
|
+
line_chars += word_chars + char.size
|
|
53
|
+
word = []
|
|
54
|
+
word_length = 0
|
|
55
|
+
word_chars = 0
|
|
56
|
+
else
|
|
57
|
+
word << char
|
|
58
|
+
word_length += char_length
|
|
59
|
+
word_chars += char.size
|
|
60
|
+
end
|
|
61
|
+
next
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
if char == SPACE
|
|
65
|
+
lines << insert_ansi(line.join, ansi_stack)
|
|
66
|
+
line = []
|
|
67
|
+
line_length = 0
|
|
68
|
+
line_chars = 0
|
|
69
|
+
word << char
|
|
70
|
+
word_length += char_length
|
|
71
|
+
word_chars += char.size
|
|
72
|
+
elsif word_length + char_length <= wrap_at
|
|
73
|
+
lines << insert_ansi(line.join, ansi_stack)
|
|
74
|
+
line = [word.join + char]
|
|
75
|
+
line_length = word_length + char_length
|
|
76
|
+
line_chars = word_chars + char.size
|
|
77
|
+
word = []
|
|
78
|
+
word_length = 0
|
|
79
|
+
word_chars = 0
|
|
80
|
+
else # hyphenate word - too long to fit a line
|
|
81
|
+
lines << insert_ansi(word.join, ansi_stack)
|
|
82
|
+
line_length = 0
|
|
83
|
+
line_chars = 0
|
|
84
|
+
word = [char]
|
|
85
|
+
word_length = char_length
|
|
86
|
+
word_chars = char.size
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
lines << insert_ansi(line.join, ansi_stack) unless line.empty?
|
|
90
|
+
lines << insert_ansi(word.join, ansi_stack) unless word.empty?
|
|
91
|
+
lines
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def insert_ansi(string, ansi_stack = [])
|
|
95
|
+
return string if ansi_stack.empty?
|
|
96
|
+
return string if string.empty?
|
|
97
|
+
|
|
98
|
+
new_stack = []
|
|
99
|
+
output = string.dup
|
|
100
|
+
length = string.size
|
|
101
|
+
matched_reset = false
|
|
102
|
+
ansi_reset = Strings::ANSI::RESET
|
|
103
|
+
|
|
104
|
+
ansi_stack.reverse_each do |ansi|
|
|
105
|
+
pos = ansi[1]
|
|
106
|
+
# Offsets carried over from a previous (longer) line can exceed this
|
|
107
|
+
# line; such codes belong before the wrapped content.
|
|
108
|
+
pos = 0 if pos > length
|
|
109
|
+
if ansi[0] =~ /#{Regexp.quote(ansi_reset)}/
|
|
110
|
+
matched_reset = true
|
|
111
|
+
output.insert(pos, ansi_reset)
|
|
112
|
+
next
|
|
113
|
+
elsif !matched_reset # ansi without reset
|
|
114
|
+
matched_reset = false
|
|
115
|
+
new_stack << ansi # keep the ansi
|
|
116
|
+
next if pos == length
|
|
117
|
+
if output.end_with?(NEWLINE)
|
|
118
|
+
output.insert(-2, ansi_reset)
|
|
119
|
+
else
|
|
120
|
+
output.insert(-1, ansi_reset)
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
output.insert(pos, ansi[0])
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
ansi_stack.replace(new_stack)
|
|
128
|
+
|
|
129
|
+
output
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -31,7 +31,7 @@ module Clacky
|
|
|
31
31
|
COLORS = {
|
|
32
32
|
# Format: [symbol_color, dark_bg_text, light_bg_text]
|
|
33
33
|
user: [:bright_black, :white, :black], # User prompt and input
|
|
34
|
-
assistant: [:bright_green, :
|
|
34
|
+
assistant: [:bright_green, :bright_white, :black], # AI response
|
|
35
35
|
tool_call: [:bright_cyan, :cyan, :cyan], # Tool execution
|
|
36
36
|
tool_result: [:bright_cyan, :bright_black, :bright_black], # Tool output
|
|
37
37
|
tool_denied: [:bright_yellow, :yellow, :yellow], # Denied actions
|
|
@@ -28,7 +28,7 @@ module Clacky
|
|
|
28
28
|
COLORS = {
|
|
29
29
|
# Format: [symbol_color, dark_bg_text, light_bg_text]
|
|
30
30
|
user: [:bright_black, :bright_black, :black], # User prompt and input
|
|
31
|
-
assistant: [:green, :
|
|
31
|
+
assistant: [:green, :bright_white, :black], # AI response
|
|
32
32
|
tool_call: [:cyan, :cyan, :cyan], # Tool execution
|
|
33
33
|
tool_result: [:cyan, :bright_black, :bright_black], # Tool output
|
|
34
34
|
tool_denied: [:yellow, :yellow, :yellow], # Denied actions
|