openclacky 1.2.10 → 1.2.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/.clacky/skills/gem-release/SKILL.md +1 -1
- data/.clacky/skills/gem-release/scripts/release.sh +4 -1
- data/CHANGELOG.md +56 -1
- data/lib/clacky/agent/llm_caller.rb +40 -25
- data/lib/clacky/agent/memory_updater.rb +12 -0
- data/lib/clacky/agent/session_serializer.rb +1 -1
- data/lib/clacky/agent/skill_auto_creator.rb +7 -4
- data/lib/clacky/agent/skill_evolution.rb +23 -5
- data/lib/clacky/agent/skill_manager.rb +86 -1
- data/lib/clacky/agent/skill_reflector.rb +18 -23
- data/lib/clacky/agent/tool_registry.rb +10 -0
- data/lib/clacky/agent.rb +68 -23
- data/lib/clacky/agent_config.rb +59 -15
- data/lib/clacky/anthropic_stream_aggregator.rb +17 -1
- data/lib/clacky/bedrock_stream_aggregator.rb +17 -1
- data/lib/clacky/cli.rb +55 -0
- data/lib/clacky/client.rb +25 -3
- data/lib/clacky/default_skills/channel-manager/SKILL.md +47 -42
- data/lib/clacky/default_skills/channel-manager/feishu_setup.rb +134 -0
- data/lib/clacky/default_skills/media-gen/SKILL.md +5 -0
- data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
- data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
- data/lib/clacky/idle_compression_timer.rb +1 -1
- data/lib/clacky/message_format/open_ai.rb +7 -1
- data/lib/clacky/message_history.rb +57 -0
- data/lib/clacky/openai_stream_aggregator.rb +30 -3
- data/lib/clacky/providers.rb +40 -12
- data/lib/clacky/server/channel/adapters/dingtalk/adapter.rb +10 -1
- data/lib/clacky/server/channel/adapters/discord/adapter.rb +8 -2
- data/lib/clacky/server/channel/adapters/feishu/adapter.rb +10 -1
- data/lib/clacky/server/channel/adapters/feishu/bot.rb +12 -0
- data/lib/clacky/server/channel/adapters/feishu/message_parser.rb +23 -3
- data/lib/clacky/server/channel/adapters/telegram/adapter.rb +12 -2
- data/lib/clacky/server/channel/adapters/wecom/adapter.rb +5 -1
- data/lib/clacky/server/channel/channel_manager.rb +65 -4
- data/lib/clacky/server/channel/group_message_buffer.rb +53 -0
- data/lib/clacky/server/http_server.rb +190 -10
- data/lib/clacky/server/session_registry.rb +34 -14
- data/lib/clacky/server/web_ui_controller.rb +24 -1
- data/lib/clacky/session_manager.rb +120 -0
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/tools/web_search.rb +59 -8
- data/lib/clacky/ui2/layout_manager.rb +15 -5
- data/lib/clacky/ui2/progress_handle.rb +7 -1
- data/lib/clacky/ui2/ui_controller.rb +27 -0
- data/lib/clacky/ui_interface.rb +22 -0
- data/lib/clacky/utils/model_pricing.rb +96 -0
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky/web/app.css +230 -7
- data/lib/clacky/web/app.js +6 -5
- data/lib/clacky/web/apple-touch-icon-180.png +0 -0
- data/lib/clacky/web/brand.js +22 -2
- data/lib/clacky/web/favicon.ico +0 -0
- data/lib/clacky/web/i18n.js +22 -4
- data/lib/clacky/web/index.html +6 -4
- data/lib/clacky/web/logo_nav_dark.png +0 -0
- data/lib/clacky/web/model-tester.js +8 -1
- data/lib/clacky/web/sessions.js +576 -120
- data/lib/clacky/web/settings.js +213 -51
- data/lib/clacky/web/skills.js +5 -14
- data/lib/clacky/web/theme.js +1 -0
- data/lib/clacky/web/utils.js +57 -0
- data/lib/clacky/web/ws-dispatcher.js +136 -0
- data/scripts/build/lib/gem.sh +9 -2
- data/scripts/build/src/install_full.sh.cc +2 -0
- data/scripts/build/src/uninstall.sh.cc +1 -1
- data/scripts/install.ps1 +19 -5
- data/scripts/install.sh +9 -2
- data/scripts/install_full.sh +11 -2
- data/scripts/install_rails_deps.sh +9 -2
- data/scripts/uninstall.sh +10 -3
- metadata +9 -2
data/lib/clacky/agent.rb
CHANGED
|
@@ -104,6 +104,7 @@ module Clacky
|
|
|
104
104
|
@pending_injections = [] # Pending inline skill injections to flush after observe()
|
|
105
105
|
@pending_script_tmpdirs = [] # Decrypted-script tmpdirs to shred when agent.run completes
|
|
106
106
|
@pending_error_rollback = false # Deferred rollback flag set by restore_session on error
|
|
107
|
+
@last_run_interrupted = false # Set when run() exits via AgentInterrupted; tells the next run() to keep the task-start snapshot (continuation of the same task across a relay, not a brand-new task)
|
|
107
108
|
|
|
108
109
|
# Compression tracking
|
|
109
110
|
@compression_level = 0 # Tracks how many times we've compressed (for progressive summarization)
|
|
@@ -251,7 +252,7 @@ module Clacky
|
|
|
251
252
|
@name = new_name.to_s.strip
|
|
252
253
|
end
|
|
253
254
|
|
|
254
|
-
def run(user_input, files: [])
|
|
255
|
+
def run(user_input, files: [], display_text: nil)
|
|
255
256
|
# Show the "thinking" indicator as early as possible so the user gets
|
|
256
257
|
# immediate feedback after sending a message. Without this the UI stays
|
|
257
258
|
# silent during synchronous setup work (system prompt assembly, file
|
|
@@ -263,24 +264,33 @@ module Clacky
|
|
|
263
264
|
# Start new task for Time Machine
|
|
264
265
|
task_id = start_new_task
|
|
265
266
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
267
|
+
# Continuation of a previously-interrupted task (e.g. user sent a
|
|
268
|
+
# supplementary message without stopping the running task) keeps the
|
|
269
|
+
# existing task-start snapshot so the completion summary accumulates
|
|
270
|
+
# iterations/cost/duration across the relay, instead of resetting and
|
|
271
|
+
# only counting the post-interrupt portion.
|
|
272
|
+
if @last_run_interrupted
|
|
273
|
+
@last_run_interrupted = false
|
|
274
|
+
else
|
|
275
|
+
@start_time = Time.now
|
|
276
|
+
@task_truncation_count = 0 # Reset truncation counter for each task
|
|
277
|
+
@task_timeout_hint_injected = false # Reset read-timeout hint injection (see LlmCaller)
|
|
278
|
+
@task_upstream_truncation_hint_injected = false # Reset upstream-truncation hint injection (see LlmCaller)
|
|
279
|
+
@task_cost_source = :estimated # Reset for new task
|
|
280
|
+
# Note: Do NOT reset @previous_total_tokens here - it should maintain the value from the last iteration
|
|
281
|
+
# across tasks to correctly calculate delta tokens in each iteration
|
|
282
|
+
@task_start_iterations = @iterations # Track starting iterations for this task
|
|
283
|
+
@task_start_cost = @total_cost # Track starting cost for this task
|
|
284
|
+
# Track cache stats for current task
|
|
285
|
+
@task_cache_stats = {
|
|
286
|
+
cache_creation_input_tokens: 0,
|
|
287
|
+
cache_read_input_tokens: 0,
|
|
288
|
+
prompt_tokens: 0,
|
|
289
|
+
completion_tokens: 0,
|
|
290
|
+
total_requests: 0,
|
|
291
|
+
cache_hit_requests: 0
|
|
292
|
+
}
|
|
293
|
+
end
|
|
284
294
|
|
|
285
295
|
# Deferred error rollback: if the previous session ended with an error,
|
|
286
296
|
# trim history back to just before that failed user message now — at the
|
|
@@ -348,6 +358,7 @@ module Clacky
|
|
|
348
358
|
end
|
|
349
359
|
|
|
350
360
|
@history.append({ role: "user", content: user_content, task_id: task_id, created_at: Time.now.to_f,
|
|
361
|
+
display_text: display_text,
|
|
351
362
|
display_files: display_files.empty? ? nil : display_files })
|
|
352
363
|
@total_tasks += 1
|
|
353
364
|
|
|
@@ -477,14 +488,34 @@ module Clacky
|
|
|
477
488
|
# truncation pattern, we still won't silently exit while the model
|
|
478
489
|
# is mid-tool_call.
|
|
479
490
|
if response[:tool_calls].nil? || response[:tool_calls].empty?
|
|
480
|
-
|
|
491
|
+
content_str = response[:content].to_s
|
|
492
|
+
stripped = content_str.strip
|
|
493
|
+
ends_with_question = stripped.end_with?("?", "?")
|
|
494
|
+
finish_reason_str = response[:finish_reason].to_s
|
|
495
|
+
completion_tokens = response.dig(:token_usage, :completion_tokens)
|
|
496
|
+
|
|
481
497
|
Clacky::Logger.info("agent.loop_break_normal",
|
|
482
498
|
session_id: @session_id,
|
|
483
499
|
iteration: @iterations,
|
|
484
500
|
branch: (response[:tool_calls].nil? ? "tool_calls_nil" : "tool_calls_empty"),
|
|
485
|
-
finish_reason:
|
|
486
|
-
tool_calls_count: (response[:tool_calls] || []).size
|
|
501
|
+
finish_reason: finish_reason_str,
|
|
502
|
+
tool_calls_count: (response[:tool_calls] || []).size,
|
|
503
|
+
completion_tokens: completion_tokens,
|
|
504
|
+
max_tokens: @config.max_tokens,
|
|
505
|
+
content_len: content_str.length,
|
|
506
|
+
content_ends_with_question: ends_with_question
|
|
487
507
|
)
|
|
508
|
+
|
|
509
|
+
if finish_reason_str == "length"
|
|
510
|
+
Clacky::Logger.warn("agent.loop_break_on_length",
|
|
511
|
+
session_id: @session_id,
|
|
512
|
+
iteration: @iterations,
|
|
513
|
+
completion_tokens: completion_tokens,
|
|
514
|
+
max_tokens: @config.max_tokens,
|
|
515
|
+
content_len: content_str.length,
|
|
516
|
+
content_tail: content_str[-200, 200]
|
|
517
|
+
)
|
|
518
|
+
end
|
|
488
519
|
if response[:content] && !response[:content].empty?
|
|
489
520
|
emit_assistant_message(response[:content], reasoning_content: response[:reasoning_content])
|
|
490
521
|
end
|
|
@@ -502,6 +533,11 @@ module Clacky
|
|
|
502
533
|
end
|
|
503
534
|
end
|
|
504
535
|
|
|
536
|
+
# If the assistant ended its turn with a question, treat this as
|
|
537
|
+
# an in-flight conversation (agent is awaiting the user's reply)
|
|
538
|
+
# and skip skill evolution — the task isn't truly complete yet.
|
|
539
|
+
awaiting_user_feedback = true if ends_with_question
|
|
540
|
+
|
|
505
541
|
break
|
|
506
542
|
end
|
|
507
543
|
|
|
@@ -595,6 +631,11 @@ module Clacky
|
|
|
595
631
|
@hooks.trigger(:on_complete, result)
|
|
596
632
|
result
|
|
597
633
|
rescue Clacky::AgentInterrupted
|
|
634
|
+
# Mark this run as interrupted so the next run() (e.g. user's
|
|
635
|
+
# supplementary message during a running task) keeps the existing
|
|
636
|
+
# task-start snapshot — the completion summary should reflect the
|
|
637
|
+
# entire task across the relay, not just the post-interrupt portion.
|
|
638
|
+
@last_run_interrupted = true
|
|
598
639
|
# Let CLI handle the interrupt message
|
|
599
640
|
raise
|
|
600
641
|
rescue StandardError => e
|
|
@@ -620,6 +661,7 @@ module Clacky
|
|
|
620
661
|
# Safety net: ensure any lingering progress spinner is stopped.
|
|
621
662
|
# Normal paths close their own spinners; this guards against exceptions
|
|
622
663
|
# raised between a progress slot's active/done pair.
|
|
664
|
+
Clacky::Logger.warn("[ph_debug] agent_run_ensure")
|
|
623
665
|
@ui&.show_progress(phase: "done")
|
|
624
666
|
|
|
625
667
|
# Shred any decrypted-script tmpdirs created during this run for encrypted brand skills.
|
|
@@ -808,6 +850,7 @@ module Clacky
|
|
|
808
850
|
# reasoning_content so every outgoing payload satisfies the provider's
|
|
809
851
|
# "reasoning_content must be passed back" contract.
|
|
810
852
|
msg[:reasoning_content] = response[:reasoning_content] if response[:reasoning_content]
|
|
853
|
+
check_stale!
|
|
811
854
|
@history.append(msg)
|
|
812
855
|
|
|
813
856
|
# Close the thinking spinner before returning. The caller (run loop)
|
|
@@ -1199,7 +1242,7 @@ module Clacky
|
|
|
1199
1242
|
# Skip malformed tool calls with nil name or arguments
|
|
1200
1243
|
next if name.nil? || arguments.nil?
|
|
1201
1244
|
|
|
1202
|
-
{
|
|
1245
|
+
formatted = {
|
|
1203
1246
|
id: call[:id],
|
|
1204
1247
|
type: call[:type] || "function",
|
|
1205
1248
|
function: {
|
|
@@ -1207,6 +1250,8 @@ module Clacky
|
|
|
1207
1250
|
arguments: arguments
|
|
1208
1251
|
}
|
|
1209
1252
|
}
|
|
1253
|
+
formatted[:extra_content] = call[:extra_content] if call[:extra_content]
|
|
1254
|
+
formatted
|
|
1210
1255
|
end
|
|
1211
1256
|
|
|
1212
1257
|
valid.any? ? valid : nil
|
data/lib/clacky/agent_config.rb
CHANGED
|
@@ -615,14 +615,17 @@ module Clacky
|
|
|
615
615
|
def find_model_by_type(type)
|
|
616
616
|
kind = type.to_s
|
|
617
617
|
if Clacky::Providers::MEDIA_KINDS.include?(kind)
|
|
618
|
-
|
|
619
|
-
return
|
|
620
|
-
|
|
618
|
+
entry = @models.find { |m| m["type"] == kind }
|
|
619
|
+
return nil if entry && entry["disabled"]
|
|
620
|
+
if entry && entry["base_url"].to_s.strip != "" && entry["api_key"].to_s.strip != ""
|
|
621
|
+
return entry
|
|
622
|
+
end
|
|
623
|
+
return derive_media_model(kind, model_override: entry && entry["model"])
|
|
621
624
|
end
|
|
622
625
|
@models.find { |m| m["type"] == type }
|
|
623
626
|
end
|
|
624
627
|
|
|
625
|
-
private def derive_media_model(kind)
|
|
628
|
+
private def derive_media_model(kind, model_override: nil)
|
|
626
629
|
default = find_model_by_type("default")
|
|
627
630
|
return nil unless default
|
|
628
631
|
|
|
@@ -632,7 +635,16 @@ module Clacky
|
|
|
632
635
|
)
|
|
633
636
|
return nil unless provider_id
|
|
634
637
|
|
|
635
|
-
|
|
638
|
+
if model_override && !model_override.to_s.strip.empty?
|
|
639
|
+
available = Clacky::Providers.media_models(provider_id, kind)
|
|
640
|
+
if available.include?(model_override)
|
|
641
|
+
model_name = model_override
|
|
642
|
+
else
|
|
643
|
+
model_name = Clacky::Providers.default_media_model(provider_id, kind)
|
|
644
|
+
end
|
|
645
|
+
else
|
|
646
|
+
model_name = Clacky::Providers.default_media_model(provider_id, kind)
|
|
647
|
+
end
|
|
636
648
|
return nil if model_name.nil? || model_name.to_s.empty?
|
|
637
649
|
|
|
638
650
|
{
|
|
@@ -662,35 +674,67 @@ module Clacky
|
|
|
662
674
|
# "available" [Array<String>] — auto-source candidates from preset
|
|
663
675
|
def media_state(kind)
|
|
664
676
|
kind = kind.to_s
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
677
|
+
raw_entry = @models.find { |m| m["type"] == kind }
|
|
678
|
+
|
|
679
|
+
if raw_entry && raw_entry["disabled"]
|
|
680
|
+
default = find_model_by_type("default")
|
|
681
|
+
default_provider = default && Clacky::Providers.resolve_provider(
|
|
682
|
+
base_url: default["base_url"], api_key: default["api_key"]
|
|
683
|
+
)
|
|
684
|
+
available = default_provider ? Clacky::Providers.media_models(default_provider, kind) : []
|
|
685
|
+
aliases = default_provider ? Clacky::Providers.media_model_aliases(default_provider, kind) : {}
|
|
686
|
+
return {
|
|
687
|
+
"configured" => false,
|
|
688
|
+
"source" => "off",
|
|
689
|
+
"model" => nil,
|
|
690
|
+
"base_url" => nil,
|
|
691
|
+
"provider" => nil,
|
|
692
|
+
"available" => available,
|
|
693
|
+
"aliases" => aliases,
|
|
694
|
+
"stale" => false
|
|
695
|
+
}
|
|
696
|
+
end
|
|
697
|
+
|
|
698
|
+
is_custom = raw_entry &&
|
|
699
|
+
raw_entry["base_url"].to_s.strip != "" &&
|
|
700
|
+
raw_entry["api_key"].to_s.strip != ""
|
|
701
|
+
override_model = raw_entry && !is_custom ? raw_entry["model"] : nil
|
|
702
|
+
|
|
703
|
+
entry = if is_custom
|
|
704
|
+
raw_entry
|
|
705
|
+
else
|
|
706
|
+
derive_media_model(kind, model_override: override_model)
|
|
707
|
+
end
|
|
668
708
|
|
|
669
709
|
provider_id = if entry
|
|
670
710
|
Clacky::Providers.resolve_provider(
|
|
671
|
-
base_url: entry["base_url"],
|
|
672
|
-
api_key: entry["api_key"]
|
|
711
|
+
base_url: entry["base_url"], api_key: entry["api_key"]
|
|
673
712
|
)
|
|
674
713
|
end
|
|
675
714
|
|
|
676
|
-
available_provider_id = if
|
|
715
|
+
available_provider_id = if is_custom
|
|
677
716
|
provider_id
|
|
678
717
|
else
|
|
679
718
|
default = find_model_by_type("default")
|
|
680
719
|
default && Clacky::Providers.resolve_provider(
|
|
681
|
-
base_url: default["base_url"],
|
|
682
|
-
api_key: default["api_key"]
|
|
720
|
+
base_url: default["base_url"], api_key: default["api_key"]
|
|
683
721
|
)
|
|
684
722
|
end
|
|
685
723
|
available = available_provider_id ? Clacky::Providers.media_models(available_provider_id, kind) : []
|
|
724
|
+
aliases = available_provider_id ? Clacky::Providers.media_model_aliases(available_provider_id, kind) : {}
|
|
725
|
+
|
|
726
|
+
stale = !!(override_model && entry && entry["model"] != override_model)
|
|
686
727
|
|
|
687
728
|
{
|
|
688
729
|
"configured" => !entry.nil?,
|
|
689
|
-
"source" =>
|
|
730
|
+
"source" => is_custom ? "custom" : (entry ? "auto" : "off"),
|
|
690
731
|
"model" => entry && entry["model"],
|
|
691
732
|
"base_url" => entry && entry["base_url"],
|
|
692
733
|
"provider" => provider_id,
|
|
693
|
-
"available" => available
|
|
734
|
+
"available" => available,
|
|
735
|
+
"aliases" => aliases,
|
|
736
|
+
"stale" => stale,
|
|
737
|
+
"requested_model" => stale ? override_model : nil
|
|
694
738
|
}
|
|
695
739
|
end
|
|
696
740
|
|
|
@@ -19,9 +19,16 @@ module Clacky
|
|
|
19
19
|
@usage = {}
|
|
20
20
|
@last_input_tokens = 0
|
|
21
21
|
@last_output_tokens = 0
|
|
22
|
+
@parse_failures = 0
|
|
23
|
+
@frames_seen = 0
|
|
24
|
+
@bytes_seen = 0
|
|
22
25
|
end
|
|
23
26
|
|
|
27
|
+
attr_reader :parse_failures, :frames_seen, :bytes_seen
|
|
28
|
+
|
|
24
29
|
def handle(event, data_str)
|
|
30
|
+
@bytes_seen += data_str.to_s.bytesize
|
|
31
|
+
@frames_seen += 1
|
|
25
32
|
data = parse_or_nil(data_str)
|
|
26
33
|
return unless data
|
|
27
34
|
|
|
@@ -99,7 +106,16 @@ module Clacky
|
|
|
99
106
|
|
|
100
107
|
private def parse_or_nil(s)
|
|
101
108
|
JSON.parse(s)
|
|
102
|
-
rescue JSON::ParserError
|
|
109
|
+
rescue JSON::ParserError => e
|
|
110
|
+
@parse_failures += 1
|
|
111
|
+
if @parse_failures == 1
|
|
112
|
+
Clacky::Logger.warn("stream.parse_failure",
|
|
113
|
+
provider: "anthropic",
|
|
114
|
+
error: "#{e.class}: #{e.message}",
|
|
115
|
+
frame_head: s.to_s[0, 200],
|
|
116
|
+
frame_bytes: s.to_s.bytesize
|
|
117
|
+
)
|
|
118
|
+
end
|
|
103
119
|
nil
|
|
104
120
|
end
|
|
105
121
|
|
|
@@ -29,9 +29,16 @@ module Clacky
|
|
|
29
29
|
@usage = {}
|
|
30
30
|
@last_input_tokens = 0
|
|
31
31
|
@last_output_tokens = 0
|
|
32
|
+
@parse_failures = 0
|
|
33
|
+
@frames_seen = 0
|
|
34
|
+
@bytes_seen = 0
|
|
32
35
|
end
|
|
33
36
|
|
|
37
|
+
attr_reader :parse_failures, :frames_seen, :bytes_seen
|
|
38
|
+
|
|
34
39
|
def handle(event, data_str)
|
|
40
|
+
@bytes_seen += data_str.to_s.bytesize
|
|
41
|
+
@frames_seen += 1
|
|
35
42
|
data = parse_or_nil(data_str)
|
|
36
43
|
return unless data
|
|
37
44
|
|
|
@@ -101,7 +108,16 @@ module Clacky
|
|
|
101
108
|
|
|
102
109
|
private def parse_or_nil(s)
|
|
103
110
|
JSON.parse(s)
|
|
104
|
-
rescue JSON::ParserError
|
|
111
|
+
rescue JSON::ParserError => e
|
|
112
|
+
@parse_failures += 1
|
|
113
|
+
if @parse_failures == 1
|
|
114
|
+
Clacky::Logger.warn("stream.parse_failure",
|
|
115
|
+
provider: "bedrock",
|
|
116
|
+
error: "#{e.class}: #{e.message}",
|
|
117
|
+
frame_head: s.to_s[0, 200],
|
|
118
|
+
frame_bytes: s.to_s.bytesize
|
|
119
|
+
)
|
|
120
|
+
end
|
|
105
121
|
nil
|
|
106
122
|
end
|
|
107
123
|
|
data/lib/clacky/cli.rb
CHANGED
|
@@ -50,6 +50,7 @@ module Clacky
|
|
|
50
50
|
option :verbose, type: :boolean, aliases: "-v", default: false, desc: "Show detailed output"
|
|
51
51
|
option :path, type: :string, desc: "Project directory path (defaults to current directory)"
|
|
52
52
|
option :continue, type: :boolean, aliases: "-c", desc: "Continue most recent session"
|
|
53
|
+
option :fork, type: :string, desc: "Fork a session by number or session ID prefix (creates a copy)"
|
|
53
54
|
option :list, type: :boolean, aliases: "-l", desc: "List recent sessions"
|
|
54
55
|
option :attach, type: :string, aliases: "-a", desc: "Attach to session by number or keyword"
|
|
55
56
|
option :json, type: :boolean, default: false, desc: "Output NDJSON to stdout (for scripting/piping)"
|
|
@@ -140,6 +141,9 @@ module Clacky
|
|
|
140
141
|
elsif options[:attach]
|
|
141
142
|
agent = load_session_by_number(client_factory.call, agent_config, session_manager, working_dir, options[:attach], profile: agent_profile)
|
|
142
143
|
is_session_load = !agent.nil?
|
|
144
|
+
elsif options[:fork]
|
|
145
|
+
agent = fork_session(client_factory.call, agent_config, session_manager, working_dir, options[:fork], profile: agent_profile)
|
|
146
|
+
is_session_load = !agent.nil?
|
|
143
147
|
end
|
|
144
148
|
|
|
145
149
|
# Create new agent if no session loaded
|
|
@@ -549,8 +553,59 @@ module Clacky
|
|
|
549
553
|
Clacky::Agent.from_session(client, agent_config, session_data, profile: resolved_profile)
|
|
550
554
|
end
|
|
551
555
|
|
|
556
|
+
def fork_session(client, agent_config, session_manager, working_dir, identifier, profile:)
|
|
557
|
+
# Get a larger list to search through (for ID prefix matching)
|
|
558
|
+
sessions = session_manager.all_sessions(current_dir: working_dir, limit: 100)
|
|
559
|
+
|
|
560
|
+
if sessions.empty?
|
|
561
|
+
say "No sessions found.", :yellow
|
|
562
|
+
return nil
|
|
563
|
+
end
|
|
564
|
+
|
|
565
|
+
session_data = nil
|
|
566
|
+
|
|
567
|
+
# Same resolution logic as load_session_by_number
|
|
568
|
+
if identifier.match?(/^\d+$/) && identifier.to_i <= 99
|
|
569
|
+
index = identifier.to_i - 1
|
|
570
|
+
if index < 0 || index >= sessions.size
|
|
571
|
+
say "Invalid session number. Use -l to list available sessions.", :red
|
|
572
|
+
exit 1
|
|
573
|
+
end
|
|
574
|
+
session_data = sessions[index]
|
|
575
|
+
else
|
|
576
|
+
matching_sessions = sessions.select { |s| s[:session_id].start_with?(identifier) }
|
|
577
|
+
if matching_sessions.empty?
|
|
578
|
+
say "No session found matching ID prefix: #{identifier}", :red
|
|
579
|
+
say "Use -l to list available sessions.", :yellow
|
|
580
|
+
exit 1
|
|
581
|
+
elsif matching_sessions.size > 1
|
|
582
|
+
say "Multiple sessions found matching '#{identifier}':", :yellow
|
|
583
|
+
matching_sessions.each_with_index do |session, idx|
|
|
584
|
+
created_at = Time.parse(session[:created_at]).strftime("%Y-%m-%d %H:%M")
|
|
585
|
+
s_id = session[:session_id][0..7]
|
|
586
|
+
name = session[:name].to_s.empty? ? "Unnamed session" : session[:name]
|
|
587
|
+
say " #{idx + 1}. [#{s_id}] #{created_at} - #{name}", :cyan
|
|
588
|
+
end
|
|
589
|
+
say "\nPlease use a more specific prefix.", :yellow
|
|
590
|
+
exit 1
|
|
591
|
+
else
|
|
592
|
+
session_data = matching_sessions.first
|
|
593
|
+
end
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
fork_data = session_manager.fork(session_data[:session_id])
|
|
597
|
+
return nil unless fork_data
|
|
598
|
+
|
|
599
|
+
# Fall back to CLI --agent flag for sessions that predate agent_profile
|
|
600
|
+
restored_profile = fork_data[:agent_profile].to_s
|
|
601
|
+
resolved_profile = restored_profile.empty? ? profile : restored_profile
|
|
602
|
+
|
|
603
|
+
Clacky::Agent.from_session(client, agent_config, fork_data, profile: resolved_profile)
|
|
604
|
+
end
|
|
605
|
+
|
|
552
606
|
# Handle agent error/interrupt with cleanup
|
|
553
607
|
def handle_agent_exception(ui_controller, agent, session_manager, exception)
|
|
608
|
+
Clacky::Logger.warn("[ph_debug] handle_agent_exception", klass: exception.class.name, msg: exception.message.to_s[0, 200])
|
|
554
609
|
ui_controller.show_progress(phase: "done")
|
|
555
610
|
ui_controller.set_idle_status
|
|
556
611
|
|
data/lib/clacky/client.rb
CHANGED
|
@@ -260,6 +260,7 @@ module Clacky
|
|
|
260
260
|
end
|
|
261
261
|
|
|
262
262
|
result = aggregator.to_h
|
|
263
|
+
log_stream_summary("bedrock", aggregator, result["stopReason"])
|
|
263
264
|
# A complete Converse stream always emits stopReason in its messageStop
|
|
264
265
|
# frame. Its absence means the upstream cut the stream mid-response,
|
|
265
266
|
# leaving a half-written message; retry rather than accept the truncation.
|
|
@@ -318,6 +319,7 @@ module Clacky
|
|
|
318
319
|
end
|
|
319
320
|
|
|
320
321
|
result = aggregator.to_h
|
|
322
|
+
log_stream_summary("anthropic", aggregator, result["stop_reason"])
|
|
321
323
|
# A complete Messages stream always emits stop_reason in its message_delta
|
|
322
324
|
# frame. Its absence means the upstream cut the stream mid-response,
|
|
323
325
|
# leaving a half-written message; retry rather than accept the truncation.
|
|
@@ -380,6 +382,7 @@ module Clacky
|
|
|
380
382
|
end
|
|
381
383
|
|
|
382
384
|
result = aggregator.to_h
|
|
385
|
+
log_stream_summary("openai", aggregator, result.dig("choices", 0, "finish_reason"))
|
|
383
386
|
# A complete chat-completion stream always terminates with a frame
|
|
384
387
|
# carrying finish_reason. Its absence means the upstream cut the stream
|
|
385
388
|
# mid-response (e.g. proxy idle-timeout, connection reset that Faraday
|
|
@@ -462,6 +465,24 @@ module Clacky
|
|
|
462
465
|
"/model/#{model}/converse-stream"
|
|
463
466
|
end
|
|
464
467
|
|
|
468
|
+
# Emit a one-line summary of a streaming response when something looks
|
|
469
|
+
# off (parse failures, missing terminal frame). No-op on the happy path
|
|
470
|
+
# to keep logs quiet.
|
|
471
|
+
private def log_stream_summary(provider, aggregator, terminal_marker)
|
|
472
|
+
parse_failures = aggregator.respond_to?(:parse_failures) ? aggregator.parse_failures.to_i : 0
|
|
473
|
+
missing_terminal = terminal_marker.nil?
|
|
474
|
+
return if parse_failures.zero? && !missing_terminal
|
|
475
|
+
|
|
476
|
+
Clacky::Logger.warn("stream.summary",
|
|
477
|
+
provider: provider,
|
|
478
|
+
frames_seen: aggregator.respond_to?(:frames_seen) ? aggregator.frames_seen : nil,
|
|
479
|
+
bytes_seen: aggregator.respond_to?(:bytes_seen) ? aggregator.bytes_seen : nil,
|
|
480
|
+
parse_failures: parse_failures,
|
|
481
|
+
saw_done: aggregator.respond_to?(:saw_done?) ? aggregator.saw_done? : nil,
|
|
482
|
+
terminal_marker_present: !missing_terminal
|
|
483
|
+
)
|
|
484
|
+
end
|
|
485
|
+
|
|
465
486
|
# Pull complete SSE frames out of a buffer and yield them as (event, data).
|
|
466
487
|
# An SSE frame ends at a blank line ("\n\n"); incomplete trailing data
|
|
467
488
|
# stays in the buffer for the next chunk. Frames without an explicit
|
|
@@ -565,9 +586,10 @@ module Clacky
|
|
|
565
586
|
|
|
566
587
|
error_body = JSON.parse(response.body) rescue nil
|
|
567
588
|
{
|
|
568
|
-
success:
|
|
569
|
-
status:
|
|
570
|
-
error:
|
|
589
|
+
success: false,
|
|
590
|
+
status: response.status,
|
|
591
|
+
error: extract_error_message(error_body, response.body),
|
|
592
|
+
error_code: extract_error_code(error_body)
|
|
571
593
|
}
|
|
572
594
|
end
|
|
573
595
|
|
|
@@ -99,90 +99,95 @@ Ask:
|
|
|
99
99
|
|
|
100
100
|
### Feishu setup
|
|
101
101
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
no permission JSON, no event subscription, no version/release steps. Just create
|
|
105
|
-
the app and copy the credentials. The connection mode is unchanged (long
|
|
106
|
-
connection / WebSocket), handled entirely by the server.
|
|
102
|
+
Use the setup script to create the Feishu app automatically via OAuth 2.0 Device Authorization Grant.
|
|
103
|
+
The user only needs to scan a QR code once.
|
|
107
104
|
|
|
108
|
-
#### Step 1 —
|
|
105
|
+
#### Step 1 — Run setup script as a background session
|
|
109
106
|
|
|
110
|
-
|
|
111
|
-
|
|
107
|
+
```
|
|
108
|
+
terminal(command: "ruby SKILL_DIR/feishu_setup.rb", background: true)
|
|
109
|
+
```
|
|
112
110
|
|
|
113
|
-
|
|
111
|
+
Keep polling the session. The script will print:
|
|
112
|
+
- `SCAN_URL:<url>` — the QR code URL
|
|
113
|
+
- `EXPIRE_IN:<seconds>` — how long the URL is valid
|
|
114
114
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
Wait for "done".
|
|
115
|
+
Once you see these lines, tell the user immediately:
|
|
116
|
+
- zh: "请在飞书中打开以下链接(或扫码)完成授权,链接 <expire_in> 秒内有效:\n<url>"
|
|
117
|
+
- en: "Open this link in Feishu (or scan the QR code) to authorize. Valid for <expire_in>s:\n<url>"
|
|
119
118
|
|
|
120
|
-
|
|
119
|
+
Continue polling until the response contains an `exit_code`. When the session ends successfully, stdout will contain:
|
|
120
|
+
- `APP_ID:<app_id>`
|
|
121
|
+
- `APP_SECRET:<app_secret>`
|
|
121
122
|
|
|
122
|
-
|
|
123
|
-
The Secret is masked by default. Guide the user: "Click the eye icon next to **App Secret** to reveal it,
|
|
124
|
-
then copy both values and paste here. Reply with: App ID: xxx, App Secret: xxx"
|
|
125
|
-
Wait for the reply. Parse `app_id` (starts with `cli_`) and `app_secret`. Trim whitespace and
|
|
126
|
-
make sure the two values are not swapped.
|
|
123
|
+
Parse both values.
|
|
127
124
|
|
|
128
|
-
#### Step
|
|
125
|
+
#### Step 2 — Save credentials
|
|
129
126
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
127
|
+
```bash
|
|
128
|
+
curl -X POST http://${CLACKY_SERVER_HOST}:${CLACKY_SERVER_PORT}/api/channels/feishu \
|
|
129
|
+
-H "Content-Type: application/json" \
|
|
130
|
+
-d '{"app_id":"<APP_ID>","app_secret":"<APP_SECRET>","domain":"https://open.feishu.cn"}'
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
**CRITICAL: This curl call is the ONLY way to save credentials. NEVER write `~/.clacky/channels.yml`
|
|
134
|
+
or any file under `~/.clacky/channels/` directly. The server API handles persistence, hot-reload,
|
|
135
|
+
and establishing the long connection.**
|
|
136
|
+
|
|
137
|
+
On success: tell the user the following (zh), then **continue to Step 3 (Feishu CLI)**:
|
|
139
138
|
|
|
140
|
-
|
|
139
|
+
zh: "✅ 飞书通道已配置成功!现在你可以通过飞书与智能助手进行私聊和群聊,也支持阅读飞书文档。"
|
|
140
|
+
en: "✅ Feishu channel configured! You can now chat with the assistant via Feishu DMs or group chats, and read Feishu Docs."
|
|
141
141
|
|
|
142
142
|
---
|
|
143
143
|
|
|
144
|
-
#### Step
|
|
144
|
+
#### Step 3 — Optional: install Feishu CLI
|
|
145
145
|
|
|
146
|
-
Reach here after the channel is configured (Step
|
|
146
|
+
Reach here after the channel is configured (Step 2 succeeded). Read `app_id` and `app_secret` from `~/.clacky/channels.yml` (under `channels.feishu`) for the install commands below.
|
|
147
147
|
|
|
148
148
|
Call `request_user_feedback`:
|
|
149
149
|
|
|
150
150
|
zh:
|
|
151
151
|
```json
|
|
152
152
|
{
|
|
153
|
-
\"question\": \"
|
|
154
|
-
"options": ["
|
|
153
|
+
\"question\": \"是否安装飞书 CLI?安装后将解锁更多飞书能力,例如创建、编辑、删除云文档。\",
|
|
154
|
+
"options": ["安装", "跳过"]
|
|
155
155
|
}
|
|
156
156
|
```
|
|
157
157
|
|
|
158
158
|
en:
|
|
159
159
|
```json
|
|
160
160
|
{
|
|
161
|
-
"question": "Install Feishu CLI?
|
|
162
|
-
"options": ["
|
|
161
|
+
"question": "Install Feishu CLI? It unlocks more Feishu capabilities, such as creating, editing, and deleting Docs.",
|
|
162
|
+
"options": ["Install", "Skip"]
|
|
163
163
|
}
|
|
164
164
|
```
|
|
165
165
|
|
|
166
166
|
If the user picks Skip, stop — setup is complete.
|
|
167
167
|
|
|
168
|
-
If the user picks Enable, run
|
|
168
|
+
If the user picks Enable, run the following **in order**:
|
|
169
169
|
|
|
170
|
+
**Step 3a** — Install and configure (single terminal call):
|
|
170
171
|
```bash
|
|
171
172
|
lark-cli --version > /dev/null 2>&1 || npm install -g @larksuite/cli
|
|
172
173
|
echo -n "<APP_SECRET>" | lark-cli config init --app-id <APP_ID> --app-secret-stdin --brand feishu
|
|
173
174
|
ruby "SKILL_DIR/install_feishu_skills.rb"
|
|
174
|
-
lark-cli auth login --recommend
|
|
175
175
|
```
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
**Step 3b** — Start authorization as a background session:
|
|
178
|
+
```
|
|
179
|
+
terminal(command: "lark-cli auth login --recommend", background: true)
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
This returns a `session_id`. Keep polling with `terminal(session_id: <id>, input: "")` every few seconds.
|
|
178
183
|
|
|
179
|
-
Once you see the authorization URL in the
|
|
184
|
+
Once you see the authorization URL appear in the output, tell the user immediately (do **not** wait for their reply):
|
|
180
185
|
- zh: "请在浏览器中打开下方链接完成授权:\n<URL>"
|
|
181
186
|
- en: "Open this URL in your browser to authorize:\n<URL>"
|
|
182
187
|
|
|
183
|
-
|
|
188
|
+
Continue polling until the response contains an `exit_code` (meaning the session has ended). **Do not kill the session** — restarting invalidates the device code.
|
|
184
189
|
|
|
185
|
-
When
|
|
190
|
+
When the session ends with `exit_code: 0`, tell the user:
|
|
186
191
|
- zh: "✅ 飞书 CLI 已就绪。"
|
|
187
192
|
- en: "✅ Feishu CLI is ready."
|
|
188
193
|
|