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.
- 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 +23 -0
- data/lib/clacky/agent/llm_caller.rb +40 -25
- data/lib/clacky/agent/memory_updater.rb +12 -0
- 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.rb +9 -1
- data/lib/clacky/agent_config.rb +59 -15
- data/lib/clacky/cli.rb +55 -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/openai_stream_aggregator.rb +4 -1
- data/lib/clacky/providers.rb +40 -12
- data/lib/clacky/server/http_server.rb +117 -3
- data/lib/clacky/server/session_registry.rb +30 -8
- data/lib/clacky/server/web_ui_controller.rb +24 -1
- data/lib/clacky/session_manager.rb +120 -0
- 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 +209 -4
- data/lib/clacky/web/app.js +6 -5
- data/lib/clacky/web/i18n.js +18 -4
- data/lib/clacky/web/index.html +2 -1
- data/lib/clacky/web/sessions.js +408 -80
- data/lib/clacky/web/settings.js +213 -51
- data/lib/clacky/web/skills.js +5 -14
- data/lib/clacky/web/utils.js +57 -0
- data/lib/clacky/web/ws-dispatcher.js +136 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f2e02be3208e8ffa6a857da34c1c3bff2db9cc52d075f67481e3f85d2b5fe8be
|
|
4
|
+
data.tar.gz: 6be5d6844301671cb3f3521248a7091c2978b9e4423d02b6bcc60d9ffbb60a97
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
616
|
-
# empty args or
|
|
617
|
-
#
|
|
618
|
-
#
|
|
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
|
|
657
|
-
#
|
|
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
|
|
675
|
+
# - nil / non-String / empty string → truncated
|
|
662
676
|
# - parses to {} (empty object) → truncated (placeholder only)
|
|
663
|
-
# -
|
|
664
|
-
#
|
|
665
|
-
# truncated by this detector
|
|
677
|
+
# - JSON::ParserError (partial JSON) → truncated
|
|
678
|
+
# - valid non-empty JSON object → NOT truncated
|
|
666
679
|
#
|
|
667
|
-
#
|
|
668
|
-
#
|
|
669
|
-
#
|
|
670
|
-
#
|
|
671
|
-
#
|
|
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
|
-
|
|
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. **
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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"
|
|
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
|
|
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
|
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
|
|