openclacky 1.2.12 → 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.
Files changed (40) hide show
  1. checksums.yaml +4 -4
  2. data/.clacky/skills/gem-release/SKILL.md +1 -1
  3. data/.clacky/skills/gem-release/scripts/release.sh +4 -1
  4. data/CHANGELOG.md +23 -0
  5. data/lib/clacky/agent/llm_caller.rb +40 -25
  6. data/lib/clacky/agent/memory_updater.rb +12 -0
  7. data/lib/clacky/agent/skill_auto_creator.rb +7 -4
  8. data/lib/clacky/agent/skill_evolution.rb +23 -5
  9. data/lib/clacky/agent/skill_manager.rb +86 -1
  10. data/lib/clacky/agent/skill_reflector.rb +18 -23
  11. data/lib/clacky/agent.rb +9 -1
  12. data/lib/clacky/agent_config.rb +59 -15
  13. data/lib/clacky/cli.rb +55 -0
  14. data/lib/clacky/default_skills/persist-memory/SKILL.md +4 -3
  15. data/lib/clacky/default_skills/search-skills/SKILL.md +61 -0
  16. data/lib/clacky/idle_compression_timer.rb +1 -1
  17. data/lib/clacky/message_format/open_ai.rb +7 -1
  18. data/lib/clacky/openai_stream_aggregator.rb +4 -1
  19. data/lib/clacky/providers.rb +40 -12
  20. data/lib/clacky/server/http_server.rb +117 -3
  21. data/lib/clacky/server/session_registry.rb +30 -8
  22. data/lib/clacky/server/web_ui_controller.rb +24 -1
  23. data/lib/clacky/session_manager.rb +120 -0
  24. data/lib/clacky/tools/web_search.rb +59 -8
  25. data/lib/clacky/ui2/layout_manager.rb +15 -5
  26. data/lib/clacky/ui2/progress_handle.rb +7 -1
  27. data/lib/clacky/ui2/ui_controller.rb +27 -0
  28. data/lib/clacky/ui_interface.rb +22 -0
  29. data/lib/clacky/utils/model_pricing.rb +96 -0
  30. data/lib/clacky/version.rb +1 -1
  31. data/lib/clacky/web/app.css +209 -4
  32. data/lib/clacky/web/app.js +6 -5
  33. data/lib/clacky/web/i18n.js +18 -4
  34. data/lib/clacky/web/index.html +2 -1
  35. data/lib/clacky/web/sessions.js +408 -80
  36. data/lib/clacky/web/settings.js +213 -51
  37. data/lib/clacky/web/skills.js +5 -14
  38. data/lib/clacky/web/utils.js +57 -0
  39. data/lib/clacky/web/ws-dispatcher.js +136 -0
  40. metadata +4 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 451817565cffdf7b1efcdf5e741cea76af0451a8d9900804e2aa3c6a5384ba4a
4
- data.tar.gz: 0232ede01332162004abc1638a8a03b41095c44c68198ce6327e5f5fc815f49a
3
+ metadata.gz: f2e02be3208e8ffa6a857da34c1c3bff2db9cc52d075f67481e3f85d2b5fe8be
4
+ data.tar.gz: 6be5d6844301671cb3f3521248a7091c2978b9e4423d02b6bcc60d9ffbb60a97
5
5
  SHA512:
6
- metadata.gz: 3a88a963b238a35fc25d5791b752981179290a88b27d176285647668711b91360a1d4c656677536fda84d18d0fa05fe67ad8364bdf0f7dbbba0a31a007156cbd
7
- data.tar.gz: 124b77cbeec34494c8d35d58f1735459ba50da7c112a13a35c8038bbe89ee81412cf60107f4f926ad870efbbf65621b369f4bb5f1de67b477032b14dbad338d3
6
+ metadata.gz: 429dc77e88fa2f1febb7177a903c3229b2f382e4f52a02be5c3980fbaa3f64e9a81aca82a108b1e1d2f11481b768c86384e5e4d827f2ddea6f7c7067e0ef2db4
7
+ data.tar.gz: 2c2a3774f968f2d3f53ce1632470686b66c1d0d903617549f9cad2650a865ffd39821934591978d4df89a3c4e18eb769bbabe90b3826d557ab71fa90b4eb361b
@@ -25,7 +25,7 @@ Automates the complete openclacky gem release workflow via `SKILL_DIR/scripts/re
25
25
  The release script (`SKILL_DIR/scripts/release.sh`) handles everything end-to-end:
26
26
 
27
27
  1. Pre-release checks (clean working directory, required tools)
28
- 2. Run test suite (`bundle exec rspec`)
28
+ 2. Run test suite (`bundle exec rspec`) + web search smoke tests (real network — verifies Bing/DDG parsers still work against live HTML)
29
29
  3. Bump version in `lib/clacky/version.rb`
30
30
  4. Update `Gemfile.lock` via `bundle install`
31
31
  5. Commit and push to origin, wait for CI
@@ -116,10 +116,13 @@ step 2 "Running test suite"
116
116
 
117
117
  if [[ "$DRY_RUN" == true ]]; then
118
118
  echo -e " ${YELLOW}[dry-run]${NC} bundle exec rspec"
119
+ echo -e " ${YELLOW}[dry-run]${NC} bundle exec rspec spec/integration/web_search_smoke_spec.rb --tag smoke"
119
120
  else
120
121
  bundle exec rspec || die "Tests failed — aborting release"
122
+ bundle exec rspec spec/integration/web_search_smoke_spec.rb --tag smoke \
123
+ || die "Web search smoke tests failed — a provider parser may be broken on real network. Aborting release."
121
124
  fi
122
- success "All tests passed"
125
+ success "All tests passed (including web search smoke)"
123
126
 
124
127
  # ════════════════════════════════════════════════════════════════════════
125
128
  # Step 3: Bump version
data/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.2.13] - 2026-06-08
9
+
10
+ ### Added
11
+ - Session forking capability (Fork any message to a new session)
12
+ - Gemini Flash 3.5 support and MIMO model pricing
13
+ - Web search content capability and search skill LRU caching
14
+ - Token usage visibility after tool calls
15
+ - Subagent UI formatting for better readability
16
+
17
+ ### Improved
18
+ - Web search performance using Bing race search strategy
19
+ - Input box automatically clears when switching sessions
20
+ - Skill evolution info display simplified
21
+ - TUI adds an extra progress bar for better visual feedback
22
+
23
+ ### Fixed
24
+ - Dir-picker path input synchronization on directory navigation
25
+ - Thinking mode silent retries
26
+ - IME (Input Method Editor) input check issues
27
+ - WebUI reflect bug
28
+ - Upstream JSON loading stability
29
+ - Prevent skill evolution when the last message is incomplete
30
+
8
31
  ## [1.2.12] - 2026-06-05
9
32
 
10
33
  ### Fixed
@@ -144,6 +144,28 @@ module Clacky
144
144
  raise RetryableError, "[LLM] Model returned empty response (no content, no tool_calls), retrying..."
145
145
  end
146
146
 
147
+ # Thinking-mode silent response detector. DeepSeek V4 / Kimi K2 /
148
+ # other reasoning models occasionally spend all output tokens inside
149
+ # `reasoning_content` and emit `content=""` + no tool_calls +
150
+ # `finish_reason="stop"`. Protocol-legal under OpenAI semantics
151
+ # (stop = model done), but semantically the model "thought and went
152
+ # silent" — agent main loop would treat it as task completion and
153
+ # exit. Reuse RetryableError so the existing retry + fallback
154
+ # pipeline handles it identically to 5xx/429.
155
+ if response[:content].to_s.strip.empty? &&
156
+ (response[:tool_calls].nil? || response[:tool_calls].empty?) &&
157
+ response[:reasoning_content].to_s.strip.length > 0 &&
158
+ response[:finish_reason].to_s == "stop"
159
+ reasoning_str = response[:reasoning_content].to_s
160
+ Clacky::Logger.warn("llm.thinking_mode_silent_response_detected",
161
+ model: api_call_model,
162
+ reasoning_len: reasoning_str.length,
163
+ reasoning_tail: reasoning_str[-200, 200] || reasoning_str,
164
+ completion_tokens: response.dig(:token_usage, :completion_tokens)
165
+ )
166
+ raise RetryableError, "[LLM] Thinking-mode model produced reasoning but empty content/tool_calls, retrying..."
167
+ end
168
+
147
169
  rescue Faraday::TimeoutError => e
148
170
  # Faraday::TimeoutError on our non-streaming POST almost always means
149
171
  # the *response* took longer than the 300s read-timeout to come back —
@@ -612,17 +634,10 @@ module Clacky
612
634
  # stream mid-tool_use (observed with Anthropic at ~127 s TTFT under
613
635
  # load), OpenRouter does NOT surface an error — it emits a valid
614
636
  # `tool_calls[]` whose `arguments` is empty, `"{}"`, or non-parseable
615
- # JSON. Without this check the agent would either execute the tool with
616
- # empty args or (worse) silently exit thinking the task finished.
617
- #
618
- # Rule is deliberately narrow: we only intercept the case where the
619
- # model streamed literally nothing into the tool_call arguments —
620
- # i.e. `nil`, empty string, or the placeholder `"{}"`. Partial/invalid
621
- # JSON (e.g. `{"path": "/tmp/x"`) is left to the existing
622
- # ArgumentsParser → BadArgumentsError path, because the model already
623
- # committed to specific values and feeding the parse error back as a
624
- # tool_result lets it self-correct in one round-trip (faster than a
625
- # blind retry from scratch).
637
+ # JSON. Without this check the agent would either execute the tool
638
+ # with empty args, or write the broken arguments string back into
639
+ # history and have the NEXT request rejected by the upstream proxy
640
+ # with a 400 BadRequest at the json.loads boundary.
626
641
  private def detect_upstream_truncation!(response)
627
642
  tool_calls = response[:tool_calls]
628
643
  return if tool_calls.nil? || tool_calls.empty?
@@ -653,22 +668,23 @@ module Clacky
653
668
  "(args=#{args_str[0, 40].inspect}). Retrying..."
654
669
  end
655
670
 
656
- # True when a tool_call's arguments field looks COMPLETELY empty
657
- # i.e. the upstream stream was cut before the model wrote any real
658
- # content into the arguments JSON.
671
+ # True when a tool_call's arguments field is unusable — either empty
672
+ # or not a complete, parseable JSON object.
659
673
  #
660
674
  # Rules:
661
- # - nil / non-String / empty string → truncated (nothing at all)
675
+ # - nil / non-String / empty string → truncated
662
676
  # - parses to {} (empty object) → truncated (placeholder only)
663
- # - anything else (including partial/invalid JSON like `{"path":
664
- # "/tmp/x"` where the model already started writing) → NOT
665
- # truncated by this detector
677
+ # - JSON::ParserError (partial JSON) truncated
678
+ # - valid non-empty JSON object → NOT truncated
666
679
  #
667
- # Partial-JSON cases are deliberately left to the existing
668
- # ArgumentsParser BadArgumentsError path, which surfaces the parse
669
- # error back to the LLM as a tool_result so it can self-correct. That
670
- # is more efficient than a blind retry when the model already wrote
671
- # most of the args.
680
+ # Why partial JSON counts as truncated: even though ArgumentsParser
681
+ # could repair it for the current turn, the original broken string
682
+ # still ends up in history (agent.rb#format_tool_calls_for_api keeps
683
+ # arguments verbatim). The next turn's request body would then carry
684
+ # an invalid JSON in tool_calls[].function.arguments, which upstream
685
+ # proxies (LiteLLM, OpenRouter, etc.) reject with a 400 BadRequest
686
+ # before the model ever sees it. Retrying from a clean state is the
687
+ # only path that actually recovers.
672
688
  private def tool_call_args_truncated?(args)
673
689
  return true if args.nil?
674
690
  return true unless args.is_a?(String)
@@ -677,8 +693,7 @@ module Clacky
677
693
  parsed = begin
678
694
  JSON.parse(args)
679
695
  rescue JSON::ParserError
680
- # Partial/invalid JSON — let ArgumentsParser handle it downstream.
681
- return false
696
+ return true
682
697
  end
683
698
 
684
699
  parsed.is_a?(Hash) && parsed.empty?
@@ -68,6 +68,18 @@ module Clacky
68
68
  def run_memory_update_subagent
69
69
  return unless should_update_memory?
70
70
 
71
+ with_memory_update_phase do
72
+ run_memory_update_subagent_inner
73
+ end
74
+ end
75
+
76
+ private def with_memory_update_phase
77
+ return yield unless @ui.respond_to?(:with_phase)
78
+
79
+ @ui.with_phase(kind: "memory_update", label: "Updating long-term memory") { yield }
80
+ end
81
+
82
+ private def run_memory_update_subagent_inner
71
83
  handle = @ui&.start_progress(message: "Updating long-term memory…", style: :primary)
72
84
 
73
85
  # Fork subagent inheriting main agent's model, tools, and history.
@@ -73,11 +73,14 @@ module Clacky
73
73
 
74
74
  ## Decision Criteria (ALL must be true)
75
75
 
76
- 1. **Reusable**: The workflow could apply to similar tasks in the future
76
+ 1. **Turn is actually finished**: The assistant's last message is
77
+ not a question back to the user, and the user wasn't just asking
78
+ /discussing/exploring (Q&A is not work to capture).
79
+ 2. **Reusable**: The workflow could apply to similar tasks in the future
77
80
  (not a one-off, project-specific task)
78
- 2. **Well-defined**: Clear steps with consistent logic, not just exploratory conversation
79
- 3. **Valuable**: Would save more than 5 minutes of work if reused
80
- 4. **Generalizable**: Can be parameterized for different inputs/contexts
81
+ 3. **Well-defined**: Clear steps with consistent logic, not just exploratory conversation
82
+ 4. **Valuable**: Would save more than 5 minutes of work if reused
83
+ 5. **Generalizable**: Can be parameterized for different inputs/contexts
81
84
 
82
85
  ## Action
83
86
 
@@ -26,17 +26,35 @@ module Clacky
26
26
  def run_skill_evolution_hooks
27
27
  return unless skill_evolution_enabled?
28
28
  return if @is_subagent
29
+ return unless skill_evolution_visible? || skill_evolution_has_work?
29
30
 
31
+ with_skill_evolution_phase do
32
+ if @skill_execution_context
33
+ maybe_reflect_on_skill
34
+ else
35
+ maybe_create_skill_from_task
36
+ end
37
+ end
38
+ end
39
+
40
+ private def skill_evolution_visible?
41
+ @config.respond_to?(:verbose) && @config.verbose
42
+ end
43
+
44
+ private def skill_evolution_has_work?
30
45
  if @skill_execution_context
31
- # Scenario 2: Reflect on executed skill (may invoke skill-creator
32
- # to UPDATE the existing skill, but will not create a new one).
33
- maybe_reflect_on_skill
46
+ should_reflect_on_skill?
34
47
  else
35
- # Scenario 1: Auto-create new skill from complex task.
36
- maybe_create_skill_from_task
48
+ should_auto_create_skill?
37
49
  end
38
50
  end
39
51
 
52
+ private def with_skill_evolution_phase
53
+ return yield unless @ui.respond_to?(:with_phase)
54
+
55
+ @ui.with_phase(kind: "skill_evolution", label: "Reflecting on this task") { yield }
56
+ end
57
+
40
58
  # Check if skill evolution is enabled in config
41
59
  # @return [Boolean]
42
60
  private def skill_evolution_enabled?
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "fileutils"
4
+
3
5
  module Clacky
4
6
  class Agent
5
7
  # Skill management and execution
@@ -128,6 +130,32 @@ module Clacky
128
130
  s.identifier.to_s.start_with?("mcp:")
129
131
  end
130
132
 
133
+ # Sort normal skills so AVAILABLE SKILLS prioritises what the user
134
+ # actually relies on:
135
+ # 1. default skills first (alphabetical, stable) — the always-present
136
+ # built-in baseline; they don't participate in LRU.
137
+ # 2. user-installed (project + brand + global) after, ordered by the
138
+ # skill directory's mtime descending (LRU). touch_skill_for_lru
139
+ # bumps mtime on every invocation; freshly installed skills also
140
+ # naturally float to the top.
141
+ # 3. search-skills is pinned to the very end (after truncation) so it
142
+ # sits next to the "(N more skills installed)" hint and is the
143
+ # last thing the LLM sees when scanning the list — maximising the
144
+ # chance it remembers to search before building a duplicate skill.
145
+ default_skills, user_skills = normal_skills.partition { |s| s.source == :default }
146
+ search_skill, default_skills = default_skills.partition { |s| s.identifier.to_s == "search-skills" }
147
+ default_skills = default_skills.sort_by { |s| s.identifier.to_s }
148
+ user_skills = user_skills.sort_by { |s|
149
+ mt = File.mtime(s.directory.to_s).to_f rescue 0.0
150
+ [-mt, s.identifier.to_s]
151
+ }
152
+ normal_skills = default_skills + user_skills
153
+
154
+ # Track total before truncation so we can hint the agent that more
155
+ # skills exist beyond the window.
156
+ total_normal_skills = normal_skills.size
157
+ truncated_skill_count = 0
158
+
131
159
  # Enforce system prompt injection limit to control token usage.
132
160
  # Warn at most once per process per dropped-set signature — build_skill_context
133
161
  # runs on every system-prompt assembly and is invoked from many short-lived
@@ -135,6 +163,7 @@ module Clacky
135
163
  if normal_skills.size > MAX_CONTEXT_SKILLS
136
164
  kept = normal_skills.first(MAX_CONTEXT_SKILLS)
137
165
  dropped = normal_skills.drop(MAX_CONTEXT_SKILLS)
166
+ truncated_skill_count = dropped.size
138
167
  dropped_names = dropped.map(&:identifier)
139
168
  signature = dropped_names.sort.join(",")
140
169
 
@@ -150,6 +179,8 @@ module Clacky
150
179
  normal_skills = kept
151
180
  end
152
181
 
182
+ normal_skills += search_skill unless search_skill.empty?
183
+
153
184
  if mcp_skills.size > MAX_CONTEXT_MCP_SERVERS
154
185
  dropped = mcp_skills.drop(MAX_CONTEXT_MCP_SERVERS).map(&:identifier)
155
186
  signature = "mcp:" + dropped.sort.join(",")
@@ -194,6 +225,12 @@ module Clacky
194
225
  end
195
226
  end
196
227
 
228
+ if truncated_skill_count > 0
229
+ context += "(#{truncated_skill_count} more skill(s) installed but not shown here. " \
230
+ "If the listed skills don't fit the task, invoke the `search-skills` skill " \
231
+ "to look them up by keyword BEFORE deciding to build a new skill.)\n\n"
232
+ end
233
+
197
234
  context += "\n"
198
235
  sections << context
199
236
  end
@@ -296,6 +333,8 @@ module Clacky
296
333
  # @param task_id [Integer] Current task ID (for message tagging)
297
334
  # @return [void]
298
335
  def inject_skill_as_assistant_message(skill, arguments, task_id, slash_command: false)
336
+ touch_skill_for_lru(skill)
337
+
299
338
  # Track skill execution context for self-evolution system
300
339
  @skill_execution_context = {
301
340
  skill_name: skill.identifier,
@@ -413,10 +452,42 @@ module Clacky
413
452
  # @return [Hash<String, Proc>]
414
453
  def build_template_context
415
454
  {
416
- "memories_meta" => -> { load_memories_meta }
455
+ "memories_meta" => -> { load_memories_meta },
456
+ "all_skills_meta" => -> { load_all_skills_meta }
417
457
  }
418
458
  end
419
459
 
460
+ # Render a complete list of installed skills (no MAX_CONTEXT_SKILLS cap)
461
+ # for skills like `search-skills` that need to see every available skill.
462
+ # Brand skill names + descriptions are pulled from cached_metadata so this
463
+ # is safe to inject without touching encrypted SKILL.md.enc content.
464
+ # @return [String]
465
+ def load_all_skills_meta
466
+ all = @skill_loader.load_all
467
+ all = filter_skills_by_profile(all)
468
+ all = all.reject(&:invalid?)
469
+ all = all.reject { |s| s.identifier.to_s.start_with?("mcp:") }
470
+
471
+ return "(No skills installed.)" if all.empty?
472
+
473
+ default_skills, user_skills = all.partition { |s| s.source == :default }
474
+ default_skills = default_skills.sort_by { |s| s.identifier.to_s }
475
+ user_skills = user_skills.sort_by { |s|
476
+ mt = File.mtime(s.directory.to_s).to_f rescue 0.0
477
+ [-mt, s.identifier.to_s]
478
+ }
479
+ ordered = default_skills + user_skills
480
+
481
+ lines = ["All installed skills (#{ordered.size} total):", ""]
482
+ ordered.each do |skill|
483
+ lines << "- name: #{skill.identifier}"
484
+ lines << " source: #{skill.source}"
485
+ lines << " description: #{skill.context_description}"
486
+ lines << ""
487
+ end
488
+ lines.join("\n")
489
+ end
490
+
420
491
  # Scan ~/.clacky/memories/ and return a formatted summary of all memory files.
421
492
  # Parses YAML frontmatter (same pattern as Skill#parse_frontmatter) for each file.
422
493
  # @return [String] Formatted list of memory topics and descriptions
@@ -488,11 +559,25 @@ module Clacky
488
559
  FileUtils.remove_dir(dir, true) rescue nil
489
560
  end
490
561
 
562
+ # Bump a skill's directory mtime so user-installed skills sort by recent
563
+ # use (LRU) when assembling AVAILABLE SKILLS. Touches the directory, NOT
564
+ # SKILL.md — the WebUI creator center uses SKILL.md mtime to detect local
565
+ # edits, and we must not produce false positives there.
566
+ # default-source skills are skipped: they don't participate in LRU and
567
+ # often live in a read-only gem path.
568
+ def touch_skill_for_lru(skill)
569
+ return if skill.source == :default
570
+ FileUtils.touch(skill.directory.to_s)
571
+ rescue StandardError
572
+ nil
573
+ end
574
+
491
575
  # Execute a skill in a forked subagent
492
576
  # @param skill [Skill] The skill to execute
493
577
  # @param arguments [String] Arguments for the skill
494
578
  # @return [String] Summary of subagent execution
495
579
  def execute_skill_with_subagent(skill, arguments)
580
+ touch_skill_for_lru(skill)
496
581
  # For encrypted brand skills with supporting scripts: decrypt to a tmpdir.
497
582
  # Subagent path has a clear boundary (subagent.run returns), so we shred inline
498
583
  # rather than registering on the parent agent.
@@ -19,45 +19,35 @@ module Clacky
19
19
  # Check if we should reflect on the skill that just executed
20
20
  # Called from SkillEvolution#run_skill_evolution_hooks
21
21
  def maybe_reflect_on_skill
22
- return unless @skill_execution_context
23
-
24
- # Only reflect on skills that the user explicitly invoked via slash command.
25
- # Skills triggered by the LLM itself (e.g. as part of a broader task) or
26
- # platform-management skills invoked incidentally should not be reflected on.
27
- return unless @skill_execution_context[:slash_command]
28
-
29
- # Skip default and brand skills — they are system-owned and should not be
30
- # auto-improved by the evolution system.
31
- source = @skill_execution_context[:source]
32
- return if source == :default || source == :brand
22
+ return unless should_reflect_on_skill?
33
23
 
34
24
  skill_name = @skill_execution_context[:skill_name]
35
- start_iteration = @skill_execution_context[:start_iteration]
36
-
37
- # Calculate iterations within the skill execution (not session-cumulative)
38
- iterations = @iterations - start_iteration
39
-
40
- # Only reflect if the skill actually ran for a meaningful number of iterations
41
- return if iterations < MIN_SKILL_ITERATIONS
42
25
 
43
- # Fork an isolated subagent to reflect + improve — does NOT touch main history
44
26
  @ui&.show_info("Reflecting on skill execution: #{skill_name}")
45
27
  subagent = fork_subagent
46
28
  result = subagent.run(build_skill_reflection_prompt(skill_name))
47
29
 
48
- # Merge subagent cost into parent's cumulative session spend so the
49
- # sessionbar reflects the real total. Without this, reflection cost
50
- # silently disappears from the user's visible total.
51
30
  if result
52
31
  subagent_cost = result[:total_cost_usd] || 0.0
53
32
  @total_cost += subagent_cost
54
33
  @ui&.update_sessionbar(cost: @total_cost, cost_source: @cost_source)
55
34
  end
56
35
 
57
- # Clear the context so we don't reflect again
58
36
  @skill_execution_context = nil
59
37
  end
60
38
 
39
+ private def should_reflect_on_skill?
40
+ return false unless @skill_execution_context
41
+ return false unless @skill_execution_context[:slash_command]
42
+
43
+ source = @skill_execution_context[:source]
44
+ return false if source == :default || source == :brand
45
+
46
+ start_iteration = @skill_execution_context[:start_iteration]
47
+ iterations = @iterations - start_iteration
48
+ iterations >= MIN_SKILL_ITERATIONS
49
+ end
50
+
61
51
  # Build the reflection prompt content
62
52
  # @param skill_name [String]
63
53
  # @return [String]
@@ -79,6 +69,11 @@ module Clacky
79
69
 
80
70
  ## Decision
81
71
 
72
+ If the assistant's last message is a question back to the user
73
+ (the turn isn't actually finished), or the user was just asking/
74
+ discussing rather than finishing a task:
75
+ → Respond briefly: "Skill #{skill_name} worked well, no improvements needed."
76
+
82
77
  If you identified **concrete, actionable improvements**:
83
78
  → Call invoke_skill("skill-creator", task: "Improve skill #{skill_name}: [describe specific improvements needed]")
84
79
 
data/lib/clacky/agent.rb CHANGED
@@ -533,6 +533,11 @@ module Clacky
533
533
  end
534
534
  end
535
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
+
536
541
  break
537
542
  end
538
543
 
@@ -656,6 +661,7 @@ module Clacky
656
661
  # Safety net: ensure any lingering progress spinner is stopped.
657
662
  # Normal paths close their own spinners; this guards against exceptions
658
663
  # raised between a progress slot's active/done pair.
664
+ Clacky::Logger.warn("[ph_debug] agent_run_ensure")
659
665
  @ui&.show_progress(phase: "done")
660
666
 
661
667
  # Shred any decrypted-script tmpdirs created during this run for encrypted brand skills.
@@ -1236,7 +1242,7 @@ module Clacky
1236
1242
  # Skip malformed tool calls with nil name or arguments
1237
1243
  next if name.nil? || arguments.nil?
1238
1244
 
1239
- {
1245
+ formatted = {
1240
1246
  id: call[:id],
1241
1247
  type: call[:type] || "function",
1242
1248
  function: {
@@ -1244,6 +1250,8 @@ module Clacky
1244
1250
  arguments: arguments
1245
1251
  }
1246
1252
  }
1253
+ formatted[:extra_content] = call[:extra_content] if call[:extra_content]
1254
+ formatted
1247
1255
  end
1248
1256
 
1249
1257
  valid.any? ? valid : nil
@@ -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
- custom = @models.find { |m| m["type"] == kind }
619
- return custom if custom
620
- return derive_media_model(kind)
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
- model_name = Clacky::Providers.default_media_model(provider_id, kind)
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
- custom = @models.find { |m| m["type"] == kind }
666
- auto = custom ? nil : derive_media_model(kind)
667
- entry = custom || auto
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 custom
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" => custom ? "custom" : (auto ? "auto" : "off"),
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