openclacky 1.5.6 → 1.5.7
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/.clacky/skills/gem-release/SKILL.md +18 -1
- data/.clackyrules +25 -1
- data/CHANGELOG.md +26 -0
- data/CONTRIBUTING.md +9 -0
- data/Rakefile +15 -0
- data/docs/design-emit-and-background-skills.md +253 -0
- data/docs/mcp-architecture.md +48 -9
- data/docs/mcp.example.json +9 -0
- data/docs/superpowers/plans/2026-08-09-chatcut-extension-bridge.md +191 -0
- data/docs/superpowers/plans/2026-08-09-native-mcp-oauth.md +243 -0
- data/docs/superpowers/specs/2026-08-09-mcp-oauth-extension-fallback-design.md +115 -0
- data/lib/clacky/agent/cost_tracker.rb +17 -2
- data/lib/clacky/agent/hook_manager.rb +14 -3
- data/lib/clacky/agent/memory_updater.rb +17 -18
- data/lib/clacky/agent/message_compressor_helper.rb +23 -0
- data/lib/clacky/agent/session_serializer.rb +136 -22
- data/lib/clacky/agent/skill_auto_creator.rb +1 -2
- data/lib/clacky/agent/skill_evolution.rb +11 -7
- data/lib/clacky/agent/skill_manager.rb +20 -18
- data/lib/clacky/agent/skill_reflector.rb +3 -6
- data/lib/clacky/agent.rb +111 -20
- data/lib/clacky/anthropic_stream_aggregator.rb +6 -1
- data/lib/clacky/cli.rb +4 -0
- data/lib/clacky/client.rb +1 -1
- data/lib/clacky/default_extensions/ext-studio/skills/ext-develop/SKILL.md +13 -5
- data/lib/clacky/extension/api_extension.rb +11 -3
- data/lib/clacky/fanout.rb +100 -0
- data/lib/clacky/locales/en.rb +2 -0
- data/lib/clacky/locales/zh.rb +2 -0
- data/lib/clacky/mcp/cli_commands.rb +61 -0
- data/lib/clacky/mcp/client.rb +18 -2
- data/lib/clacky/mcp/connection_manager.rb +85 -0
- data/lib/clacky/mcp/http_transport.rb +25 -9
- data/lib/clacky/mcp/oauth/authorization_manager.rb +257 -0
- data/lib/clacky/mcp/oauth/config.rb +51 -0
- data/lib/clacky/mcp/oauth/credential_store.rb +82 -0
- data/lib/clacky/mcp/oauth/session.rb +88 -0
- data/lib/clacky/message_format/anthropic.rb +27 -13
- data/lib/clacky/message_history.rb +32 -1
- data/lib/clacky/providers.rb +11 -5
- data/lib/clacky/server/http_server.rb +42 -22
- data/lib/clacky/server/session_registry.rb +11 -15
- data/lib/clacky/server/web_ui_controller.rb +1 -1
- data/lib/clacky/session_manager.rb +13 -11
- data/lib/clacky/shell_hook_loader.rb +14 -8
- data/lib/clacky/tools/invoke_skill.rb +3 -2
- data/lib/clacky/tools/terminal.rb +11 -4
- data/lib/clacky/ui2/progress_handle.rb +27 -3
- data/lib/clacky/ui2/ui_controller.rb +206 -6
- data/lib/clacky/ui_interface.rb +13 -3
- data/lib/clacky/utils/arguments_parser.rb +73 -4
- data/lib/clacky/utils/model_pricing.rb +21 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +10 -3
- data/lib/clacky/web/features/new-session/view.js +19 -2
- data/lib/clacky/web/i18n.js +22 -8
- data/lib/clacky/web/index.html +7 -11
- data/lib/clacky/web/projects.js +26 -0
- data/lib/clacky/web/sessions.js +169 -119
- data/lib/clacky/web/settings.js +1 -0
- data/lib/clacky/web/ws-dispatcher.js +61 -34
- data/lib/clacky.rb +2 -0
- metadata +12 -1
|
@@ -226,6 +226,9 @@ module Clacky
|
|
|
226
226
|
# trigger a full output repaint (visible flicker) and the visual
|
|
227
227
|
# order would have business messages appearing below the spinner.
|
|
228
228
|
def append_output(content)
|
|
229
|
+
return nil if Thread.current[:clacky_chore_phase]
|
|
230
|
+
|
|
231
|
+
content = prefix_with_phase_label(content)
|
|
229
232
|
@progress_mutex.synchronize do
|
|
230
233
|
top = @progress_stack.last
|
|
231
234
|
if top && top.entry_id
|
|
@@ -249,6 +252,17 @@ module Clacky
|
|
|
249
252
|
@layout.append_output(content)
|
|
250
253
|
end
|
|
251
254
|
|
|
255
|
+
# With several phases running at once the CLI's line-oriented output
|
|
256
|
+
# interleaves, so each line needs its origin stamped on it. A single
|
|
257
|
+
# active phase reads fine unprefixed (the banners already delimit it).
|
|
258
|
+
private def prefix_with_phase_label(content)
|
|
259
|
+
label = Thread.current[:clacky_phase_label]
|
|
260
|
+
return content unless label
|
|
261
|
+
|
|
262
|
+
tag = "[#{label}] "
|
|
263
|
+
content.to_s.split("\n", -1).map { |line| line.empty? ? line : "#{tag}#{line}" }.join("\n")
|
|
264
|
+
end
|
|
265
|
+
|
|
252
266
|
# Log message to output area (use instead of puts)
|
|
253
267
|
# @param message [String] Message to log
|
|
254
268
|
# @param level [Symbol] Log level (:debug, :info, :warning, :error)
|
|
@@ -392,6 +406,8 @@ module Clacky
|
|
|
392
406
|
# @param path [String] File path
|
|
393
407
|
# @param is_new_file [Boolean] Whether this is a new file
|
|
394
408
|
def show_file_write_preview(path, is_new_file:)
|
|
409
|
+
return if chore_capture(:file, path)
|
|
410
|
+
|
|
395
411
|
theme = ThemeManager.current_theme
|
|
396
412
|
file_label = theme.format_symbol(:file)
|
|
397
413
|
status = is_new_file ? theme.format_text("Creating new file", :success) : theme.format_text("Modifying existing file", :warning)
|
|
@@ -402,6 +418,8 @@ module Clacky
|
|
|
402
418
|
# Show file operation preview (Edit tool)
|
|
403
419
|
# @param path [String] File path
|
|
404
420
|
def show_file_edit_preview(path)
|
|
421
|
+
return if chore_capture(:file, path)
|
|
422
|
+
|
|
405
423
|
theme = ThemeManager.current_theme
|
|
406
424
|
file_label = theme.format_symbol(:file)
|
|
407
425
|
append_output("\n#{file_label} #{path || '(unknown)'}")
|
|
@@ -410,6 +428,8 @@ module Clacky
|
|
|
410
428
|
# Show file operation error
|
|
411
429
|
# @param error_message [String] Error message
|
|
412
430
|
def show_file_error(error_message)
|
|
431
|
+
return if chore_capture(:error, error_message)
|
|
432
|
+
|
|
413
433
|
theme = ThemeManager.current_theme
|
|
414
434
|
append_output(" #{theme.format_text("Warning:", :error)} #{error_message}")
|
|
415
435
|
end
|
|
@@ -453,10 +473,69 @@ module Clacky
|
|
|
453
473
|
filtered.strip
|
|
454
474
|
end
|
|
455
475
|
|
|
476
|
+
# Record a noteworthy event that happened inside a chore phase. The raw
|
|
477
|
+
# transcript is suppressed (a line-oriented terminal has no folding),
|
|
478
|
+
# but the CLI still owes the user a digest — the collapsed-card
|
|
479
|
+
# equivalent of what the web UI lets you expand.
|
|
480
|
+
#
|
|
481
|
+
# @return [Boolean] true if a chore phase absorbed it.
|
|
482
|
+
private def chore_capture(kind, text)
|
|
483
|
+
return false unless Thread.current[:clacky_chore_phase]
|
|
484
|
+
text = text.to_s.strip
|
|
485
|
+
return true if text.empty?
|
|
486
|
+
|
|
487
|
+
@phase_mutex ||= Mutex.new
|
|
488
|
+
@phase_mutex.synchronize do
|
|
489
|
+
@chore_digest ||= { tools: Hash.new(0), files: [], errors: [] }
|
|
490
|
+
case kind
|
|
491
|
+
when :tool then @chore_digest[:tools][text] += 1
|
|
492
|
+
when :file then @chore_digest[:files] << text unless @chore_digest[:files].include?(text)
|
|
493
|
+
when :error then @chore_digest[:errors] << text
|
|
494
|
+
end
|
|
495
|
+
end
|
|
496
|
+
true
|
|
497
|
+
end
|
|
498
|
+
|
|
499
|
+
# Turn the captured digest into the handful of lines the CLI prints
|
|
500
|
+
# after a chore finishes. Errors are always listed in full; tools and
|
|
501
|
+
# files are condensed, since a memory update touching 20 files should
|
|
502
|
+
# not out-scroll the answer the user actually asked for.
|
|
503
|
+
private def chore_digest_lines
|
|
504
|
+
@phase_mutex ||= Mutex.new
|
|
505
|
+
digest = @phase_mutex.synchronize { @chore_digest }
|
|
506
|
+
return [] if digest.nil?
|
|
507
|
+
|
|
508
|
+
lines = []
|
|
509
|
+
digest[:files].first(CHORE_DIGEST_MAX_FILES).each { |f| lines << "· #{f}" }
|
|
510
|
+
if (extra = digest[:files].size - CHORE_DIGEST_MAX_FILES) > 0
|
|
511
|
+
lines << "· …#{extra} more file#{"s" if extra > 1}"
|
|
512
|
+
end
|
|
513
|
+
|
|
514
|
+
if digest[:tools].any?
|
|
515
|
+
total = digest[:tools].values.sum
|
|
516
|
+
ranked = digest[:tools].sort_by { |_n, c| -c }
|
|
517
|
+
names = ranked.first(CHORE_DIGEST_MAX_TOOLS).map do |name, count|
|
|
518
|
+
count > 1 ? "#{name}×#{count}" : name
|
|
519
|
+
end
|
|
520
|
+
if (extra = ranked.size - CHORE_DIGEST_MAX_TOOLS) > 0
|
|
521
|
+
names << "+#{extra} more"
|
|
522
|
+
end
|
|
523
|
+
lines << "· #{total} tool call#{"s" if total > 1}: #{names.join(", ")}"
|
|
524
|
+
end
|
|
525
|
+
|
|
526
|
+
digest[:errors].first(CHORE_DIGEST_MAX_ERRORS).each { |e| lines << "! #{e}" }
|
|
527
|
+
if (extra = digest[:errors].size - CHORE_DIGEST_MAX_ERRORS) > 0
|
|
528
|
+
lines << "! …#{extra} more error#{"s" if extra > 1}"
|
|
529
|
+
end
|
|
530
|
+
lines
|
|
531
|
+
end
|
|
532
|
+
|
|
456
533
|
# Show tool call
|
|
457
534
|
# @param name [String] Tool name
|
|
458
535
|
# @param args [String, Hash] Tool arguments (JSON string or Hash)
|
|
459
536
|
def show_tool_call(name, args)
|
|
537
|
+
return if chore_capture(:tool, name)
|
|
538
|
+
|
|
460
539
|
# Reset stdout buffer on each new tool call so previous command output
|
|
461
540
|
# doesn't bleed into the next one, and so the buffer is ready before
|
|
462
541
|
# on_output starts firing (which can happen before show_progress is called).
|
|
@@ -506,6 +585,8 @@ module Clacky
|
|
|
506
585
|
# @param error [String, Exception] Error message or exception
|
|
507
586
|
def show_tool_error(error)
|
|
508
587
|
error_msg = error.is_a?(Exception) ? error.message : error.to_s
|
|
588
|
+
return if chore_capture(:error, error_msg)
|
|
589
|
+
|
|
509
590
|
output = @renderer.render_tool_error(error: error_msg)
|
|
510
591
|
append_output(output)
|
|
511
592
|
end
|
|
@@ -655,6 +736,22 @@ module Clacky
|
|
|
655
736
|
|
|
656
737
|
# Called by ProgressHandle#start.
|
|
657
738
|
def register_progress(handle)
|
|
739
|
+
# Inside a chore phase only the phase's own indicator occupies a line.
|
|
740
|
+
# Nested handles (the subagent's LLM calls and tool runs) get no entry
|
|
741
|
+
# of their own — otherwise each one commits a permanent final frame on
|
|
742
|
+
# finish, since unregister_progress writes straight into the buffer and
|
|
743
|
+
# never passes through append_output's chore gate. Their activity is
|
|
744
|
+
# instead folded into the chore line by +fold_into_chore_line+.
|
|
745
|
+
if Thread.current[:clacky_chore_phase] && @chore_progress
|
|
746
|
+
@progress_mutex.synchronize do
|
|
747
|
+
@chore_nested ||= []
|
|
748
|
+
@chore_nested << handle
|
|
749
|
+
@chore_steps = @chore_steps.to_i + 1 if handle.style == :primary
|
|
750
|
+
fold_into_chore_line(handle)
|
|
751
|
+
end
|
|
752
|
+
return nil
|
|
753
|
+
end
|
|
754
|
+
|
|
658
755
|
@progress_mutex.synchronize do
|
|
659
756
|
prev_top = @progress_stack.last
|
|
660
757
|
if prev_top
|
|
@@ -675,6 +772,17 @@ module Clacky
|
|
|
675
772
|
# Called by ProgressHandle#finish.
|
|
676
773
|
def unregister_progress(handle, final_frame:)
|
|
677
774
|
@progress_mutex.synchronize do
|
|
775
|
+
if @chore_nested&.delete(handle)
|
|
776
|
+
# Folded handles own no entry; hand the chore line back to
|
|
777
|
+
# whatever is still running underneath (or the bare label).
|
|
778
|
+
fold_into_chore_line(@chore_nested.last)
|
|
779
|
+
next
|
|
780
|
+
end
|
|
781
|
+
|
|
782
|
+
# A handle that was never stacked owns nothing here: restoring
|
|
783
|
+
# "the new top" would duplicate what is legitimately on screen.
|
|
784
|
+
next unless @progress_stack.include?(handle)
|
|
785
|
+
|
|
678
786
|
if handle.entry_id
|
|
679
787
|
if final_frame && !final_frame.to_s.strip.empty?
|
|
680
788
|
update_entry(handle.entry_id, @renderer.render_progress(final_frame))
|
|
@@ -705,6 +813,19 @@ module Clacky
|
|
|
705
813
|
# +register_progress+).
|
|
706
814
|
def render_frame(handle, frame)
|
|
707
815
|
@progress_mutex.synchronize do
|
|
816
|
+
if @chore_nested&.include?(handle)
|
|
817
|
+
fold_into_chore_line(handle)
|
|
818
|
+
return
|
|
819
|
+
end
|
|
820
|
+
|
|
821
|
+
# The chore's own ticker must render the folded view too, otherwise
|
|
822
|
+
# every tick repaints the bare label and the line flickers between
|
|
823
|
+
# "<label>…" and "<label> · <activity>…".
|
|
824
|
+
if @chore_progress && @chore_progress == handle && @chore_nested&.any?
|
|
825
|
+
fold_into_chore_line(@chore_nested.last)
|
|
826
|
+
return
|
|
827
|
+
end
|
|
828
|
+
|
|
708
829
|
return unless @progress_stack.last == handle
|
|
709
830
|
return unless handle.entry_id
|
|
710
831
|
|
|
@@ -726,6 +847,27 @@ module Clacky
|
|
|
726
847
|
end
|
|
727
848
|
end
|
|
728
849
|
|
|
850
|
+
# Repaint the chore phase's single line to reflect the innermost nested
|
|
851
|
+
# activity: "<label> · <what it's doing> (Ns · step K)". Keeps the user
|
|
852
|
+
# informed without letting the chore's subagent spam one permanent line
|
|
853
|
+
# per LLM call. Must be called with @progress_mutex held.
|
|
854
|
+
private def fold_into_chore_line(inner)
|
|
855
|
+
chore = @chore_progress
|
|
856
|
+
return unless chore&.entry_id
|
|
857
|
+
|
|
858
|
+
parts = [chore.message]
|
|
859
|
+
parts << inner.message.to_s.strip if inner && !inner.message.to_s.strip.empty?
|
|
860
|
+
|
|
861
|
+
suffix = []
|
|
862
|
+
elapsed = chore.start_time ? (Time.now - chore.start_time).to_i : 0
|
|
863
|
+
suffix << "#{elapsed}s" if elapsed > 0
|
|
864
|
+
suffix << "step #{@chore_steps}" if @chore_steps.to_i > 0
|
|
865
|
+
|
|
866
|
+
line = parts.join(" · ")
|
|
867
|
+
line = "#{line}… (#{suffix.join(" · ")})" unless suffix.empty?
|
|
868
|
+
update_entry(chore.entry_id, @renderer.render_progress(line))
|
|
869
|
+
end
|
|
870
|
+
|
|
729
871
|
# Render the very first frame of +handle+ (used when registering or
|
|
730
872
|
# restoring). Mirrors +render_frame+'s formatting minus the implicit
|
|
731
873
|
# elapsed-time tick (handle hasn't ticked yet).
|
|
@@ -888,28 +1030,86 @@ module Clacky
|
|
|
888
1030
|
append_output(output)
|
|
889
1031
|
end
|
|
890
1032
|
|
|
891
|
-
|
|
1033
|
+
# Phases the user never asked for: post-task chores that run after the
|
|
1034
|
+
# answer is already on screen. The web UI folds them into a card; a
|
|
1035
|
+
# line-oriented terminal has no folding, so their transcript is
|
|
1036
|
+
# swallowed and only a one-line progress indicator remains.
|
|
1037
|
+
CHORE_PHASE_KINDS = %w[memory_update skill_evolution].freeze
|
|
1038
|
+
|
|
1039
|
+
# Caps for the post-chore digest. A chore is background work the user
|
|
1040
|
+
# never asked for, so its recap must stay far shorter than the answer.
|
|
1041
|
+
CHORE_DIGEST_MAX_FILES = 5
|
|
1042
|
+
CHORE_DIGEST_MAX_TOOLS = 4
|
|
1043
|
+
CHORE_DIGEST_MAX_ERRORS = 3
|
|
1044
|
+
|
|
1045
|
+
def phase_start(kind:, label:, concurrent: false)
|
|
892
1046
|
phase_id = SecureRandom.uuid
|
|
893
|
-
|
|
894
|
-
@
|
|
1047
|
+
chore = CHORE_PHASE_KINDS.include?(kind.to_s) && !verbose_phases?
|
|
1048
|
+
@phase_mutex ||= Mutex.new
|
|
1049
|
+
@phase_mutex.synchronize do
|
|
1050
|
+
@active_phases ||= {}
|
|
1051
|
+
@active_phases[phase_id] = { kind: kind, label: label, started_at: Time.now, chore: chore }
|
|
1052
|
+
end
|
|
895
1053
|
Thread.current[:clacky_phase_id] = phase_id
|
|
896
1054
|
|
|
1055
|
+
if chore
|
|
1056
|
+
# Start the indicator before arming the gate: this handle is the
|
|
1057
|
+
# one thing that stays visible during the chore.
|
|
1058
|
+
@chore_progress = start_progress(message: label, style: :quiet)
|
|
1059
|
+
Thread.current[:clacky_chore_phase] = phase_id
|
|
1060
|
+
return phase_id
|
|
1061
|
+
end
|
|
1062
|
+
|
|
897
1063
|
banner = "──────── ▼ #{label} ────────"
|
|
898
1064
|
append_output(@renderer.render_system_message(banner, prefix_newline: true))
|
|
1065
|
+
# Only concurrent phases interleave, so only they need per-line tagging.
|
|
1066
|
+
Thread.current[:clacky_phase_label] = concurrent ? label : nil
|
|
899
1067
|
phase_id
|
|
900
1068
|
end
|
|
901
1069
|
|
|
902
1070
|
def phase_end(phase_id, summary: nil)
|
|
903
|
-
Thread.current[:clacky_phase_id] = nil
|
|
904
|
-
|
|
1071
|
+
Thread.current[:clacky_phase_id] = nil if Thread.current[:clacky_phase_id] == phase_id
|
|
1072
|
+
Thread.current[:clacky_phase_label] = nil
|
|
1073
|
+
@phase_mutex ||= Mutex.new
|
|
1074
|
+
info = @phase_mutex.synchronize { @active_phases&.delete(phase_id) }
|
|
1075
|
+
return unless info
|
|
905
1076
|
|
|
906
|
-
info = @active_phases.delete(phase_id)
|
|
907
1077
|
label = info[:label]
|
|
1078
|
+
|
|
1079
|
+
if info[:chore]
|
|
1080
|
+
Thread.current[:clacky_chore_phase] = nil
|
|
1081
|
+
handle = @chore_progress
|
|
1082
|
+
@chore_progress = nil
|
|
1083
|
+
@chore_nested = nil
|
|
1084
|
+
@chore_steps = 0
|
|
1085
|
+
handle&.discard
|
|
1086
|
+
|
|
1087
|
+
digest = chore_digest_lines
|
|
1088
|
+
@phase_mutex.synchronize { @chore_digest = nil }
|
|
1089
|
+
|
|
1090
|
+
# No summary and nothing captured means nothing worth reporting
|
|
1091
|
+
# happened (the common "no memory updates needed" path) — leave no
|
|
1092
|
+
# trace at all.
|
|
1093
|
+
has_summary = summary && !summary.to_s.strip.empty?
|
|
1094
|
+
return if !has_summary && digest.empty?
|
|
1095
|
+
|
|
1096
|
+
elapsed = (Time.now - info[:started_at]).round
|
|
1097
|
+
head = has_summary ? "#{label} — #{summary.to_s.strip} (#{elapsed}s)" : "#{label} (#{elapsed}s)"
|
|
1098
|
+
append_output(@renderer.render_system_message(head, prefix_newline: false))
|
|
1099
|
+
theme = ThemeManager.current_theme
|
|
1100
|
+
digest.each { |line| append_output(theme.format_text(" #{line}", :thinking)) }
|
|
1101
|
+
return
|
|
1102
|
+
end
|
|
1103
|
+
|
|
908
1104
|
tail = summary && !summary.to_s.strip.empty? ? " — #{summary.to_s.strip}" : ""
|
|
909
1105
|
banner = "──────── ▲ #{label} done#{tail} ────────"
|
|
910
1106
|
append_output(@renderer.render_system_message(banner, prefix_newline: false))
|
|
911
1107
|
end
|
|
912
1108
|
|
|
1109
|
+
private def verbose_phases?
|
|
1110
|
+
@config.respond_to?(:verbose) && @config.verbose
|
|
1111
|
+
end
|
|
1112
|
+
|
|
913
1113
|
# Set workspace status to idle (called when agent stops working)
|
|
914
1114
|
def set_idle_status
|
|
915
1115
|
# Safety net: close any legacy progress slots that were opened via
|
data/lib/clacky/ui_interface.rb
CHANGED
|
@@ -159,19 +159,29 @@ module Clacky
|
|
|
159
159
|
# === Lifecycle ===
|
|
160
160
|
def stop(clear_screen: false); end
|
|
161
161
|
|
|
162
|
+
# === Custom events (extension bus) ===
|
|
163
|
+
# Push an arbitrary typed event to the UI transport. Streaming UIs (web,
|
|
164
|
+
# json) override this; UIs with no event channel (CLI, null) drop it.
|
|
165
|
+
#
|
|
166
|
+
# Extensions must namespace `type` as "ext.<extension>.<event>" so custom
|
|
167
|
+
# events never collide with the built-in protocol. The web UI forwards
|
|
168
|
+
# namespaced events straight to the `Clacky.ext` bus, where a subscriber
|
|
169
|
+
# receives them under that same name.
|
|
170
|
+
def emit(type, **data); end
|
|
171
|
+
|
|
162
172
|
# === Phase grouping (optional, web UI uses this to fold subagent runs) ===
|
|
163
173
|
# Begin a logical phase. Events emitted between phase_start and phase_end
|
|
164
174
|
# carry the phase_id so the UI can group them visually.
|
|
165
175
|
# Returns the phase_id (caller is responsible for passing it to phase_end).
|
|
166
|
-
def phase_start(kind:, label: nil)
|
|
176
|
+
def phase_start(kind:, label: nil, concurrent: false)
|
|
167
177
|
SecureRandom.uuid
|
|
168
178
|
end
|
|
169
179
|
|
|
170
180
|
def phase_end(phase_id, summary: nil); end
|
|
171
181
|
|
|
172
182
|
# Run block within a phase. Always closes via ensure.
|
|
173
|
-
def with_phase(kind:, label: nil)
|
|
174
|
-
pid = phase_start(kind: kind, label: label)
|
|
183
|
+
def with_phase(kind:, label: nil, concurrent: false)
|
|
184
|
+
pid = phase_start(kind: kind, label: label, concurrent: concurrent)
|
|
175
185
|
begin
|
|
176
186
|
yield pid
|
|
177
187
|
ensure
|
|
@@ -88,12 +88,78 @@ module Clacky
|
|
|
88
88
|
result
|
|
89
89
|
end
|
|
90
90
|
|
|
91
|
+
# Undo one layer of accidental double-serialization, guided by the tool's
|
|
92
|
+
# parameter schema.
|
|
93
|
+
#
|
|
94
|
+
# Some LLMs (e.g. glm-5.2) emit array/object parameters as a JSON *string*
|
|
95
|
+
# (e.g. {"task":"[\"a\",\"b\"]"}) instead of a native JSON value. JSON.parse
|
|
96
|
+
# only strips the outer layer, so such values arrive here as a literal
|
|
97
|
+
# String and break downstream tools that expect an Array/Hash.
|
|
98
|
+
#
|
|
99
|
+
# To stay safe we rely on the declared schema type instead of a blind
|
|
100
|
+
# "looks like JSON" heuristic:
|
|
101
|
+
# - a parameter declared `type: "string"` is NEVER parsed, so a value
|
|
102
|
+
# that merely happens to be valid JSON text (file content, a code
|
|
103
|
+
# snippet, a JSON blob to be written) is preserved verbatim;
|
|
104
|
+
# - a parameter declared `type: "array"`/`"object"` that still arrives
|
|
105
|
+
# as a String is unwrapped exactly once;
|
|
106
|
+
# - additionally, when a concrete type IS declared, the parsed result
|
|
107
|
+
# must match it (an array-typed param that receives a JSON object
|
|
108
|
+
# string is left untouched rather than silently coerced to a Hash);
|
|
109
|
+
# - a parameter with no declared `type` (e.g. flexible params such as
|
|
110
|
+
# todo_manager's task/id, which may be a scalar or a list) falls back
|
|
111
|
+
# to a conservative heuristic: unwrap only if the stripped value
|
|
112
|
+
# starts with "["/"{" and parses cleanly.
|
|
113
|
+
# Strings that fail JSON.parse are always left untouched.
|
|
114
|
+
def self.undouble_serialize_args(args, properties = {})
|
|
115
|
+
args.to_h do |key, value|
|
|
116
|
+
expected = schema_type(properties, key)
|
|
117
|
+
# Explicit string params: never unwrap (protects content / code / JSON text).
|
|
118
|
+
next [key, value] if expected == "string"
|
|
119
|
+
# Scalar-typed params (integer/number/boolean): no JSON shape to recover.
|
|
120
|
+
next [key, value] if expected && expected != "array" && expected != "object"
|
|
121
|
+
# For array/object/untyped params, only unwrap a String that looks like JSON.
|
|
122
|
+
next [key, value] unless value.is_a?(String)
|
|
123
|
+
stripped = value.strip
|
|
124
|
+
next [key, value] unless stripped.start_with?("[") || stripped.start_with?("{")
|
|
125
|
+
begin
|
|
126
|
+
# symbolize_names keeps unwrapped Hash keys consistent with the
|
|
127
|
+
# outer JSON.parse(call[:arguments], symbolize_names: true).
|
|
128
|
+
parsed = JSON.parse(value, symbolize_names: true)
|
|
129
|
+
# Type-match guard: if the schema declares a concrete type, the
|
|
130
|
+
# parsed value must match it. Prevents silently turning a Hash into
|
|
131
|
+
# an Array (or vice-versa) when an array-typed param receives a
|
|
132
|
+
# JSON object string, etc.
|
|
133
|
+
if expected == "array" && !parsed.is_a?(Array)
|
|
134
|
+
next [key, value]
|
|
135
|
+
elsif expected == "object" && !parsed.is_a?(Hash)
|
|
136
|
+
next [key, value]
|
|
137
|
+
end
|
|
138
|
+
[key, parsed]
|
|
139
|
+
rescue JSON::ParserError
|
|
140
|
+
[key, value]
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Look up a parameter's declared JSON-Schema type, tolerating string/symbol
|
|
146
|
+
# keys in both +properties+ and the per-param spec hash.
|
|
147
|
+
def self.schema_type(properties, key)
|
|
148
|
+
spec = properties[key] || properties[key.to_s] || properties[key.to_sym]
|
|
149
|
+
return nil unless spec.is_a?(Hash)
|
|
150
|
+
spec[:type] || spec["type"]
|
|
151
|
+
end
|
|
152
|
+
|
|
91
153
|
# Validate required parameters and filter unknown parameters
|
|
92
154
|
def self.validate_required_params(call, args, tool_registry)
|
|
93
155
|
tool = tool_registry.get(call[:name])
|
|
94
156
|
required = tool.parameters&.dig(:required) || []
|
|
95
157
|
properties = tool.parameters&.dig(:properties) || {}
|
|
96
158
|
|
|
159
|
+
# Undo accidental double-serialization BEFORE filtering, using the schema
|
|
160
|
+
# so that parameters declared as "string" are never touched.
|
|
161
|
+
args = undouble_serialize_args(args, properties)
|
|
162
|
+
|
|
97
163
|
missing = required.reject { |param|
|
|
98
164
|
args.key?(param.to_sym) || args.key?(param.to_s)
|
|
99
165
|
}
|
|
@@ -161,11 +227,14 @@ module Clacky
|
|
|
161
227
|
lines << " Name: #{tool.name}"
|
|
162
228
|
lines << " Description: #{tool.description}"
|
|
163
229
|
|
|
164
|
-
|
|
230
|
+
params = tool.parameters
|
|
231
|
+
properties = params && params[:properties]
|
|
232
|
+
if properties
|
|
165
233
|
lines << " Parameters:"
|
|
166
|
-
|
|
167
|
-
required_mark =
|
|
168
|
-
|
|
234
|
+
properties.each do |param, spec|
|
|
235
|
+
required_mark = params[:required]&.include?(param.to_s) ? " (required)" : ""
|
|
236
|
+
desc = spec.is_a?(Hash) ? spec[:description] : spec.to_s
|
|
237
|
+
lines << " - #{param}#{required_mark}: #{desc}"
|
|
169
238
|
end
|
|
170
239
|
end
|
|
171
240
|
|
|
@@ -377,6 +377,25 @@ module Clacky
|
|
|
377
377
|
}
|
|
378
378
|
},
|
|
379
379
|
|
|
380
|
+
# Gemini 3.6 Flash (GA 2026-07-21). Flat pricing, 1M context, 64K max output.
|
|
381
|
+
# Source: https://deepmind.google/models/gemini/flash/
|
|
382
|
+
# Cache write billed at input rate (Vertex doesn't expose a separate
|
|
383
|
+
# cache-write charge in the OpenAI shim usage response).
|
|
384
|
+
"gemini-3.6-flash" => {
|
|
385
|
+
input: {
|
|
386
|
+
default: 1.50,
|
|
387
|
+
over_200k: 1.50
|
|
388
|
+
},
|
|
389
|
+
output: {
|
|
390
|
+
default: 7.50,
|
|
391
|
+
over_200k: 7.50
|
|
392
|
+
},
|
|
393
|
+
cache: {
|
|
394
|
+
write: 1.50,
|
|
395
|
+
read: 0.15
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
|
|
380
399
|
# OpenAI GPT-5.5 / GPT-5.4 — breakpoint at 272K input tokens
|
|
381
400
|
# Source: https://openai.com/api/pricing/ (USD / 1M tokens)
|
|
382
401
|
# Note: OpenAI's actual tiered-pricing threshold is 272K, not the
|
|
@@ -882,6 +901,8 @@ module Clacky
|
|
|
882
901
|
"gemini-3.1-pro"
|
|
883
902
|
when /^or-gemini-3-5-flash$/i, /^gemini-3\.5-flash$/i, /^gemini-3-flash(-preview)?$/i
|
|
884
903
|
"gemini-3-flash"
|
|
904
|
+
when /^or-gemini-3-6-flash$/i, /^gemini-3\.6-flash$/i
|
|
905
|
+
"gemini-3.6-flash"
|
|
885
906
|
|
|
886
907
|
# OpenAI GPT-5.x models — match various dashed/dotted/compact forms
|
|
887
908
|
# (e.g. "gpt-5.5", "gpt-5-5", "gpt5.5", "gpt55")
|
data/lib/clacky/version.rb
CHANGED
data/lib/clacky/web/app.css
CHANGED
|
@@ -1021,6 +1021,12 @@ body {
|
|
|
1021
1021
|
color: #10b981;
|
|
1022
1022
|
border-color: transparent;
|
|
1023
1023
|
}
|
|
1024
|
+
/* Source: ext — violet tint (created by an installed extension). */
|
|
1025
|
+
.session-badge--ext {
|
|
1026
|
+
background: color-mix(in srgb, #8b5cf6 10%, transparent);
|
|
1027
|
+
color: #8b5cf6;
|
|
1028
|
+
border-color: transparent;
|
|
1029
|
+
}
|
|
1024
1030
|
/* Source: setup — muted neutral (rarely visible in normal use). */
|
|
1025
1031
|
.session-badge--setup {
|
|
1026
1032
|
background: var(--color-bg-tertiary);
|
|
@@ -5555,7 +5561,8 @@ body {
|
|
|
5555
5561
|
gap: 0.5rem;
|
|
5556
5562
|
align-items: center;
|
|
5557
5563
|
}
|
|
5558
|
-
.settings-network-url-row .field-input
|
|
5564
|
+
.settings-network-url-row .field-input,
|
|
5565
|
+
.settings-network-url-row .field-select { flex: 1; }
|
|
5559
5566
|
.settings-loading, .settings-empty, .settings-error {
|
|
5560
5567
|
color: var(--color-text-secondary);
|
|
5561
5568
|
font-size: 0.8125rem;
|
|
@@ -5719,7 +5726,7 @@ body {
|
|
|
5719
5726
|
width: 100%;
|
|
5720
5727
|
cursor: pointer;
|
|
5721
5728
|
appearance: none;
|
|
5722
|
-
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0
|
|
5729
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
|
|
5723
5730
|
background-repeat: no-repeat;
|
|
5724
5731
|
background-position: right 10px center;
|
|
5725
5732
|
padding-right: 1.75rem;
|
|
@@ -6889,7 +6896,7 @@ body {
|
|
|
6889
6896
|
.modal-select {
|
|
6890
6897
|
cursor: pointer;
|
|
6891
6898
|
appearance: none;
|
|
6892
|
-
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0
|
|
6899
|
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b949e' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 9l-7 7-7-7'/%3E%3C/svg%3E");
|
|
6893
6900
|
background-repeat: no-repeat;
|
|
6894
6901
|
background-position: right 12px center;
|
|
6895
6902
|
padding-right: 2rem;
|
|
@@ -222,6 +222,18 @@ const NewSessionView = (() => {
|
|
|
222
222
|
|
|
223
223
|
async function _prefillDefaultDir() {
|
|
224
224
|
if (NewSessionStore.state.advanced.workingDir) return; // already set
|
|
225
|
+
// When a project is selected (possibly restored from localStorage by
|
|
226
|
+
// loadAgents), prefer its working directory over the global default so
|
|
227
|
+
// the new session lands in the project's folder — same behavior as
|
|
228
|
+
// picking the project explicitly from the context bar popover.
|
|
229
|
+
const project = typeof Projects !== "undefined" && Projects.find
|
|
230
|
+
? Projects.find(NewSessionStore.state.advanced.projectId)
|
|
231
|
+
: null;
|
|
232
|
+
if (project && project.working_dir) {
|
|
233
|
+
NewSessionStore.updateAdvanced({ workingDir: project.working_dir });
|
|
234
|
+
_renderContextBar();
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
225
237
|
const home = await NewSessionStore.loadDefaultDirectory();
|
|
226
238
|
if (home) {
|
|
227
239
|
NewSessionStore.updateAdvanced({ workingDir: home });
|
|
@@ -857,14 +869,19 @@ const NewSessionView = (() => {
|
|
|
857
869
|
const sendBtn = $("new-session-send");
|
|
858
870
|
if (sendBtn) sendBtn.addEventListener("click", _submit);
|
|
859
871
|
|
|
860
|
-
// Context bar: load models
|
|
872
|
+
// Context bar: load models eagerly, then render chips. The working-dir
|
|
873
|
+
// prefill happens in onPanelShow() instead, so it runs after loadAgents()
|
|
874
|
+
// restores the remembered project from localStorage.
|
|
861
875
|
_populateModels().then(() => _renderContextBar());
|
|
862
|
-
_prefillDefaultDir().then(() => _renderContextBar());
|
|
863
876
|
}
|
|
864
877
|
|
|
865
878
|
async function onPanelShow() {
|
|
866
879
|
_bindOnce();
|
|
867
880
|
await NewSessionStore.loadAgents();
|
|
881
|
+
// Must run after loadAgents(): that call restores the remembered
|
|
882
|
+
// projectId from localStorage, which decides whether the working dir is
|
|
883
|
+
// prefilled from the project or from the global default.
|
|
884
|
+
await _prefillDefaultDir();
|
|
868
885
|
_renderContextBar();
|
|
869
886
|
_updateSendButton();
|
|
870
887
|
const input = $("new-session-input");
|
data/lib/clacky/web/i18n.js
CHANGED
|
@@ -13,6 +13,9 @@ const I18n = (() => {
|
|
|
13
13
|
// ── Translation dictionary ─────────────────────────────────────────────────
|
|
14
14
|
const TRANSLATIONS = {
|
|
15
15
|
en: {
|
|
16
|
+
// ── Phases ──
|
|
17
|
+
"phase.no_changes": "no changes needed",
|
|
18
|
+
|
|
16
19
|
// ── Sidebar ──
|
|
17
20
|
"sidebar.chat": "Chat",
|
|
18
21
|
"sidebar.projects": "Projects",
|
|
@@ -52,9 +55,11 @@ const I18n = (() => {
|
|
|
52
55
|
"projects.menu.rename": "Rename",
|
|
53
56
|
"projects.menu.edit": "Edit",
|
|
54
57
|
"projects.menu.delete": "Delete",
|
|
58
|
+
"projects.menu.deleteSessions": "Delete Sessions",
|
|
55
59
|
"projects.edit.title": "Edit Project",
|
|
56
60
|
"projects.edit.submit": "Save",
|
|
57
61
|
"projects.delete.confirm": "Delete project \"{{name}}\"? All sessions in this project will also be deleted.",
|
|
62
|
+
"projects.deleteSessions.confirm": "Delete all sessions in project \"{{name}}\"? The project itself will be kept.",
|
|
58
63
|
"projects.modal.name": "Project Name",
|
|
59
64
|
|
|
60
65
|
// ── Welcome screen ──
|
|
@@ -210,10 +215,12 @@ const I18n = (() => {
|
|
|
210
215
|
"sib.reasoning.max": "Max",
|
|
211
216
|
"sessions.thinking": "Thinking…",
|
|
212
217
|
"sessions.default_name": "Session {{n}}",
|
|
213
|
-
"sessions.
|
|
214
|
-
"sessions.
|
|
215
|
-
"sessions.
|
|
218
|
+
"sessions.group.cron": "Scheduled Tasks",
|
|
219
|
+
"sessions.group.ext": "Extensions",
|
|
220
|
+
"sessions.groupMeta": "{{n}} sessions",
|
|
221
|
+
"sessions.groupLoading": "Loading sessions...",
|
|
216
222
|
"sessions.badge.cron": "Auto",
|
|
223
|
+
"sessions.badge.ext": "Ext",
|
|
217
224
|
"sessions.badge.channel": "Channel",
|
|
218
225
|
"sessions.badge.coding": "Coding",
|
|
219
226
|
"sessions.badge.setup": "Setup",
|
|
@@ -766,7 +773,7 @@ const I18n = (() => {
|
|
|
766
773
|
"settings.network.cleared": "Cleared — direct connection",
|
|
767
774
|
"settings.network.invalidUrl": "Invalid URL — use http://host:port or http://user:pass@host:port",
|
|
768
775
|
"settings.search.title": "Web Search",
|
|
769
|
-
"settings.search.desc": "
|
|
776
|
+
"settings.search.desc": "Free built-in search (DuckDuckGo / Bing) works out of the box. Connect an API provider below for higher-quality results — falls back to built-in if unavailable.",
|
|
770
777
|
"settings.search.provider": "Provider",
|
|
771
778
|
"settings.search.builtin": "Built-in (DuckDuckGo / Bing)",
|
|
772
779
|
"settings.search.key": "API Key",
|
|
@@ -1086,6 +1093,9 @@ const I18n = (() => {
|
|
|
1086
1093
|
"billing.disclaimer": "Costs are estimated based on token usage and may differ from your provider's actual bill.",
|
|
1087
1094
|
},
|
|
1088
1095
|
zh: {
|
|
1096
|
+
// ── Phases ──
|
|
1097
|
+
"phase.no_changes": "无需改动",
|
|
1098
|
+
|
|
1089
1099
|
// ── Sidebar ──
|
|
1090
1100
|
"sidebar.chat": "会话",
|
|
1091
1101
|
"sidebar.projects": "项目",
|
|
@@ -1124,9 +1134,11 @@ const I18n = (() => {
|
|
|
1124
1134
|
"projects.menu.rename": "重命名",
|
|
1125
1135
|
"projects.menu.edit": "编辑",
|
|
1126
1136
|
"projects.menu.delete": "删除",
|
|
1137
|
+
"projects.menu.deleteSessions": "删除会话",
|
|
1127
1138
|
"projects.edit.title": "编辑项目",
|
|
1128
1139
|
"projects.edit.submit": "保存",
|
|
1129
1140
|
"projects.delete.confirm": "删除项目「{{name}}」?该项目下的所有会话也将被删除。",
|
|
1141
|
+
"projects.deleteSessions.confirm": "删除项目「{{name}}」下的所有会话?项目本身会被保留。",
|
|
1130
1142
|
"projects.modal.name": "项目名称",
|
|
1131
1143
|
|
|
1132
1144
|
// ── Welcome screen ──
|
|
@@ -1280,10 +1292,12 @@ const I18n = (() => {
|
|
|
1280
1292
|
"sib.reasoning.max": "Max",
|
|
1281
1293
|
"sessions.thinking": "思考中…",
|
|
1282
1294
|
"sessions.default_name": "会话 {{n}}",
|
|
1283
|
-
"sessions.
|
|
1284
|
-
"sessions.
|
|
1285
|
-
"sessions.
|
|
1295
|
+
"sessions.group.cron": "定时任务",
|
|
1296
|
+
"sessions.group.ext": "扩展",
|
|
1297
|
+
"sessions.groupMeta": "{{n}} 个会话",
|
|
1298
|
+
"sessions.groupLoading": "正在加载会话...",
|
|
1286
1299
|
"sessions.badge.cron": "定时",
|
|
1300
|
+
"sessions.badge.ext": "扩展",
|
|
1287
1301
|
"sessions.badge.channel": "频道",
|
|
1288
1302
|
"sessions.badge.coding": "Coding",
|
|
1289
1303
|
"sessions.badge.setup": "配置",
|
|
@@ -1842,7 +1856,7 @@ const I18n = (() => {
|
|
|
1842
1856
|
"settings.network.cleared": "已清除 — 直连",
|
|
1843
1857
|
"settings.network.invalidUrl": "地址格式不正确,请使用 http://host:port 或 http://user:pass@host:port",
|
|
1844
1858
|
"settings.search.title": "网页搜索",
|
|
1845
|
-
"settings.search.desc": "
|
|
1859
|
+
"settings.search.desc": "内置免费搜索(DuckDuckGo / Bing)开箱即用。在下方接入 API 服务商可获得更高质量的搜索结果,不可用时自动回退到内置引擎。",
|
|
1846
1860
|
"settings.search.provider": "服务商",
|
|
1847
1861
|
"settings.search.builtin": "内置(DuckDuckGo / Bing)",
|
|
1848
1862
|
"settings.search.key": "API 密钥",
|