openclacky 0.7.0 → 0.7.2
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/commit/SKILL.md +29 -4
- data/.clackyrules +3 -1
- data/CHANGELOG.md +103 -2
- data/README.md +70 -161
- data/bin/clarky +11 -0
- data/docs/HOW-TO-USE-CN.md +96 -0
- data/docs/HOW-TO-USE.md +94 -0
- data/docs/config.example.yml +27 -0
- data/docs/deploy_subagent_design.md +540 -0
- data/docs/time_machine_design.md +247 -0
- data/docs/why-openclacky.md +0 -1
- data/lib/clacky/agent/cost_tracker.rb +180 -0
- data/lib/clacky/agent/llm_caller.rb +54 -0
- data/lib/clacky/{message_compressor.rb → agent/message_compressor.rb} +12 -36
- data/lib/clacky/agent/message_compressor_helper.rb +534 -0
- data/lib/clacky/agent/session_serializer.rb +152 -0
- data/lib/clacky/agent/skill_manager.rb +138 -0
- data/lib/clacky/agent/system_prompt_builder.rb +96 -0
- data/lib/clacky/agent/time_machine.rb +199 -0
- data/lib/clacky/agent/tool_executor.rb +434 -0
- data/lib/clacky/{tool_registry.rb → agent/tool_registry.rb} +1 -1
- data/lib/clacky/agent.rb +260 -1370
- data/lib/clacky/agent_config.rb +447 -10
- data/lib/clacky/cli.rb +275 -98
- data/lib/clacky/client.rb +12 -2
- data/lib/clacky/default_skills/code-explorer/SKILL.md +34 -0
- data/lib/clacky/default_skills/deploy/SKILL.md +13 -0
- data/lib/clacky/default_skills/deploy/scripts/rails_deploy.rb +383 -0
- data/lib/clacky/default_skills/deploy/tools/check_health.rb +116 -0
- data/lib/clacky/default_skills/deploy/tools/execute_deployment.rb +174 -0
- data/lib/clacky/default_skills/deploy/tools/fetch_runtime_logs.rb +67 -0
- data/lib/clacky/default_skills/deploy/tools/list_services.rb +80 -0
- data/lib/clacky/default_skills/deploy/tools/report_deploy_status.rb +67 -0
- data/lib/clacky/default_skills/deploy/tools/set_deploy_variables.rb +138 -0
- data/lib/clacky/default_skills/new/SKILL.md +2 -2
- data/lib/clacky/json_ui_controller.rb +195 -0
- data/lib/clacky/providers.rb +107 -0
- data/lib/clacky/skill.rb +48 -7
- data/lib/clacky/skill_loader.rb +7 -0
- data/lib/clacky/tools/edit.rb +105 -48
- data/lib/clacky/tools/file_reader.rb +44 -73
- data/lib/clacky/tools/invoke_skill.rb +89 -0
- data/lib/clacky/tools/list_tasks.rb +54 -0
- data/lib/clacky/tools/redo_task.rb +41 -0
- data/lib/clacky/tools/safe_shell.rb +1 -1
- data/lib/clacky/tools/shell.rb +74 -62
- data/lib/clacky/tools/trash_manager.rb +1 -1
- data/lib/clacky/tools/undo_task.rb +32 -0
- data/lib/clacky/tools/web_fetch.rb +2 -1
- data/lib/clacky/ui2/components/command_suggestions.rb +13 -3
- data/lib/clacky/ui2/components/inline_input.rb +23 -2
- data/lib/clacky/ui2/components/input_area.rb +65 -21
- data/lib/clacky/ui2/components/modal_component.rb +199 -62
- data/lib/clacky/ui2/layout_manager.rb +75 -25
- data/lib/clacky/ui2/line_editor.rb +23 -2
- data/lib/clacky/ui2/markdown_renderer.rb +31 -10
- data/lib/clacky/ui2/screen_buffer.rb +2 -0
- data/lib/clacky/ui2/ui_controller.rb +316 -37
- data/lib/clacky/ui2.rb +2 -0
- data/lib/clacky/ui_interface.rb +50 -0
- data/lib/clacky/utils/arguments_parser.rb +31 -3
- data/lib/clacky/utils/file_processor.rb +13 -18
- data/lib/clacky/version.rb +1 -1
- data/lib/clacky.rb +19 -9
- data/scripts/install.sh +274 -97
- data/scripts/uninstall.sh +12 -12
- metadata +40 -13
- data/.clacky/skills/test-skill/SKILL.md +0 -15
- data/lib/clacky/compression/base.rb +0 -231
- data/lib/clacky/compression/standard.rb +0 -339
- data/lib/clacky/config.rb +0 -117
- /data/lib/clacky/{hook_manager.rb → agent/hook_manager.rb} +0 -0
- /data/lib/clacky/{progress_indicator.rb → ui2/progress_indicator.rb} +0 -0
- /data/lib/clacky/{thinking_verbs.rb → ui2/thinking_verbs.rb} +0 -0
- /data/lib/clacky/{gitignore_parser.rb → utils/gitignore_parser.rb} +0 -0
- /data/lib/clacky/{model_pricing.rb → utils/model_pricing.rb} +0 -0
- /data/lib/clacky/{trash_directory.rb → utils/trash_directory.rb} +0 -0
data/lib/clacky/skill.rb
CHANGED
|
@@ -18,12 +18,17 @@ module Clacky
|
|
|
18
18
|
agent
|
|
19
19
|
argument-hint
|
|
20
20
|
hooks
|
|
21
|
+
fork_agent
|
|
22
|
+
model
|
|
23
|
+
forbidden_tools
|
|
24
|
+
auto_summarize
|
|
21
25
|
].freeze
|
|
22
26
|
|
|
23
27
|
attr_reader :directory, :frontmatter, :source_path
|
|
24
28
|
attr_reader :name, :description, :content
|
|
25
29
|
attr_reader :disable_model_invocation, :user_invocable
|
|
26
30
|
attr_reader :allowed_tools, :context, :agent_type, :argument_hint, :hooks
|
|
31
|
+
attr_reader :fork_agent, :model, :forbidden_tools, :auto_summarize
|
|
27
32
|
|
|
28
33
|
# @param directory [Pathname, String] Path to the skill directory
|
|
29
34
|
# @param source_path [Pathname, String, nil] Optional source path for priority resolution
|
|
@@ -52,10 +57,28 @@ module Clacky
|
|
|
52
57
|
!@disable_model_invocation
|
|
53
58
|
end
|
|
54
59
|
|
|
55
|
-
# Check if skill
|
|
60
|
+
# Check if this skill should fork a subagent
|
|
56
61
|
# @return [Boolean]
|
|
57
|
-
def
|
|
58
|
-
@
|
|
62
|
+
def fork_agent?
|
|
63
|
+
@fork_agent == true
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Get the model to use for the subagent (if fork_agent is true)
|
|
67
|
+
# @return [String, nil]
|
|
68
|
+
def subagent_model
|
|
69
|
+
@model
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Get the list of forbidden tools for the subagent
|
|
73
|
+
# @return [Array<String>]
|
|
74
|
+
def forbidden_tools_list
|
|
75
|
+
@forbidden_tools || []
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Check if subagent should auto-summarize results
|
|
79
|
+
# @return [Boolean]
|
|
80
|
+
def auto_summarize?
|
|
81
|
+
@auto_summarize != false
|
|
59
82
|
end
|
|
60
83
|
|
|
61
84
|
# Get the slash command for this skill
|
|
@@ -101,6 +124,16 @@ module Clacky
|
|
|
101
124
|
processed_content.gsub!(placeholder, output.to_s)
|
|
102
125
|
end
|
|
103
126
|
|
|
127
|
+
# Append supporting files list if any exist
|
|
128
|
+
if has_supporting_files?
|
|
129
|
+
processed_content += "\n\n## Supporting Files\n\n"
|
|
130
|
+
processed_content += "The following files are available in this skill's directory:\n\n"
|
|
131
|
+
supporting_files.each do |file|
|
|
132
|
+
relative_path = file.relative_path_from(@directory)
|
|
133
|
+
processed_content += "- `#{relative_path}`\n"
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
104
137
|
processed_content
|
|
105
138
|
end
|
|
106
139
|
|
|
@@ -114,7 +147,9 @@ module Clacky
|
|
|
114
147
|
source_path: @source_path.to_s,
|
|
115
148
|
user_invocable: user_invocable?,
|
|
116
149
|
model_invocation_allowed: model_invocation_allowed?,
|
|
117
|
-
|
|
150
|
+
fork_agent: fork_agent?,
|
|
151
|
+
subagent_model: @model,
|
|
152
|
+
forbidden_tools: @forbidden_tools,
|
|
118
153
|
allowed_tools: @allowed_tools,
|
|
119
154
|
argument_hint: @argument_hint,
|
|
120
155
|
content_length: @content.length
|
|
@@ -179,6 +214,12 @@ module Clacky
|
|
|
179
214
|
@agent_type = @frontmatter["agent"]
|
|
180
215
|
@argument_hint = @frontmatter["argument-hint"]
|
|
181
216
|
@hooks = @frontmatter["hooks"]
|
|
217
|
+
|
|
218
|
+
# Subagent configuration
|
|
219
|
+
@fork_agent = @frontmatter["fork_agent"]
|
|
220
|
+
@model = @frontmatter["model"]
|
|
221
|
+
@forbidden_tools = @frontmatter["forbidden_tools"]
|
|
222
|
+
@auto_summarize = @frontmatter["auto_summarize"]
|
|
182
223
|
end
|
|
183
224
|
|
|
184
225
|
def validate_frontmatter
|
|
@@ -193,9 +234,9 @@ module Clacky
|
|
|
193
234
|
end
|
|
194
235
|
end
|
|
195
236
|
|
|
196
|
-
# Validate
|
|
197
|
-
if @
|
|
198
|
-
raise Clacky::AgentError, "
|
|
237
|
+
# Validate forbidden_tools format
|
|
238
|
+
if @forbidden_tools && !@forbidden_tools.is_a?(Array)
|
|
239
|
+
raise Clacky::AgentError, "forbidden_tools must be an array of tool names"
|
|
199
240
|
end
|
|
200
241
|
|
|
201
242
|
# Validate allowed-tools format
|
data/lib/clacky/skill_loader.rb
CHANGED
|
@@ -127,6 +127,13 @@ module Clacky
|
|
|
127
127
|
@skills_by_command[command]
|
|
128
128
|
end
|
|
129
129
|
|
|
130
|
+
# Find a skill by its name (identifier)
|
|
131
|
+
# @param name [String] Skill identifier (e.g., "code-explorer", "pptx")
|
|
132
|
+
# @return [Skill, nil]
|
|
133
|
+
def find_by_name(name)
|
|
134
|
+
@skills[name]
|
|
135
|
+
end
|
|
136
|
+
|
|
130
137
|
# Get skills that can be invoked by user
|
|
131
138
|
# @return [Array<Skill>]
|
|
132
139
|
def user_invocable_skills
|
data/lib/clacky/tools/edit.rb
CHANGED
|
@@ -46,22 +46,16 @@ module Clacky
|
|
|
46
46
|
content = File.read(path)
|
|
47
47
|
original_content = content.dup
|
|
48
48
|
|
|
49
|
-
#
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
match_result = try_smart_match(content, old_string)
|
|
56
|
-
|
|
57
|
-
if match_result
|
|
58
|
-
actual_old_string = match_result[:matched_string]
|
|
59
|
-
occurrences = match_result[:occurrences]
|
|
60
|
-
else
|
|
61
|
-
# Provide helpful error with context
|
|
62
|
-
return build_helpful_error(content, old_string, path)
|
|
63
|
-
end
|
|
49
|
+
# Find matching string using layered strategy
|
|
50
|
+
match_result = find_match(content, old_string)
|
|
51
|
+
|
|
52
|
+
unless match_result
|
|
53
|
+
# Provide helpful error with context
|
|
54
|
+
return build_helpful_error(content, old_string, path)
|
|
64
55
|
end
|
|
56
|
+
|
|
57
|
+
actual_old_string = match_result[:matched_string]
|
|
58
|
+
occurrences = match_result[:occurrences]
|
|
65
59
|
|
|
66
60
|
# If not replace_all and multiple occurrences, warn about ambiguity
|
|
67
61
|
if !replace_all && occurrences > 1
|
|
@@ -93,54 +87,117 @@ module Clacky
|
|
|
93
87
|
end
|
|
94
88
|
end
|
|
95
89
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
90
|
+
# Find matching string using layered strategy
|
|
91
|
+
private def find_match(content, old_string)
|
|
92
|
+
# Generate candidate strings with different transformations
|
|
93
|
+
candidates = generate_candidates(old_string)
|
|
99
94
|
|
|
100
|
-
#
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
95
|
+
# Try simple string matching for each candidate
|
|
96
|
+
candidates.each do |candidate|
|
|
97
|
+
next if candidate.empty?
|
|
98
|
+
if content.include?(candidate)
|
|
99
|
+
return {
|
|
100
|
+
matched_string: candidate,
|
|
101
|
+
occurrences: content.scan(candidate).length
|
|
102
|
+
}
|
|
103
|
+
end
|
|
104
|
+
end
|
|
104
105
|
|
|
105
|
-
|
|
106
|
+
# If simple matching fails, try smart line-by-line matching (allows leading whitespace differences)
|
|
107
|
+
try_smart_match(content, old_string)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Generate candidate strings by applying different transformations
|
|
111
|
+
private def generate_candidates(old_string)
|
|
112
|
+
trimmed = old_string.strip
|
|
113
|
+
unescaped = unescape_over_escaped(old_string)
|
|
114
|
+
unescaped_trimmed = unescape_over_escaped(trimmed)
|
|
106
115
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
116
|
+
[
|
|
117
|
+
old_string, # Original
|
|
118
|
+
trimmed, # Trim leading/trailing whitespace
|
|
119
|
+
unescaped, # Unescape over-escaped sequences
|
|
120
|
+
unescaped_trimmed # Combined: trim + unescape
|
|
121
|
+
].uniq # Remove duplicates
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private def try_smart_match(content, old_string)
|
|
125
|
+
# Smart matching: allows leading whitespace differences (tabs vs spaces)
|
|
126
|
+
# Also tries with unescaped versions of old_string
|
|
127
|
+
|
|
128
|
+
candidates = generate_candidates(old_string)
|
|
129
|
+
|
|
130
|
+
candidates.each do |candidate|
|
|
131
|
+
next if candidate.empty?
|
|
132
|
+
|
|
133
|
+
candidate_lines = candidate.lines
|
|
134
|
+
next if candidate_lines.empty?
|
|
111
135
|
|
|
112
|
-
#
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
136
|
+
# Find all potential matches in content with normalized whitespace
|
|
137
|
+
matches = []
|
|
138
|
+
content_lines = content.lines
|
|
139
|
+
|
|
140
|
+
# Scan through content to find matches
|
|
141
|
+
(0..content_lines.length - candidate_lines.length).each do |start_idx|
|
|
142
|
+
slice = content_lines[start_idx, candidate_lines.length]
|
|
143
|
+
next unless slice
|
|
144
|
+
|
|
145
|
+
# Check if this slice matches when normalized
|
|
146
|
+
if lines_match_normalized?(slice, candidate_lines)
|
|
147
|
+
matched_string = slice.join
|
|
148
|
+
matches << { start: start_idx, matched_string: matched_string }
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# If we found matches with this candidate, return it
|
|
153
|
+
unless matches.empty?
|
|
154
|
+
return {
|
|
155
|
+
matched_string: matches.first[:matched_string],
|
|
156
|
+
occurrences: matches.length
|
|
157
|
+
}
|
|
116
158
|
end
|
|
117
159
|
end
|
|
118
160
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
# Return the first match and count total occurrences
|
|
122
|
-
{
|
|
123
|
-
matched_string: matches.first[:matched_string],
|
|
124
|
-
occurrences: matches.length
|
|
125
|
-
}
|
|
161
|
+
# No matches found
|
|
162
|
+
nil
|
|
126
163
|
end
|
|
127
164
|
|
|
128
|
-
private def normalize_leading_whitespace(text)
|
|
129
|
-
# Normalize each line's leading whitespace
|
|
130
|
-
text.lines.map { |line| line.sub(/^\s+/, ' ') }.join
|
|
131
|
-
end
|
|
132
165
|
|
|
133
166
|
private def lines_match_normalized?(lines1, lines2)
|
|
134
167
|
return false unless lines1.length == lines2.length
|
|
135
168
|
|
|
136
169
|
lines1.zip(lines2).all? do |line1, line2|
|
|
137
|
-
# Normalize leading whitespace and
|
|
138
|
-
norm1 = line1.sub(/^\s+/, ' ')
|
|
139
|
-
norm2 = line2.sub(/^\s+/, ' ')
|
|
140
|
-
|
|
170
|
+
# Normalize leading whitespace and trailing newlines for comparison
|
|
171
|
+
norm1 = line1.sub(/^\s+/, ' ').chomp
|
|
172
|
+
norm2 = line2.sub(/^\s+/, ' ').chomp
|
|
173
|
+
|
|
174
|
+
# Try exact match first, then try with unescaping over-escaped sequences
|
|
175
|
+
norm1 == norm2 || norm1 == unescape_over_escaped(norm2)
|
|
141
176
|
end
|
|
142
177
|
end
|
|
143
178
|
|
|
179
|
+
private def unescape_over_escaped(str)
|
|
180
|
+
# Convert over-escaped sequences back to normal escape sequences
|
|
181
|
+
# This handles common cases where AI double-escapes backslashes
|
|
182
|
+
result = str.dup
|
|
183
|
+
|
|
184
|
+
# Handle Unicode escapes: \uXXXX -> actual Unicode character
|
|
185
|
+
# Example: "\u000C" (literal backslash-u) -> form feed character
|
|
186
|
+
result = result.gsub(/\\u([0-9a-fA-F]{4})/) { [$1.hex].pack('U') }
|
|
187
|
+
|
|
188
|
+
# Handle common escape sequences
|
|
189
|
+
result = result.gsub('\\n', "\n")
|
|
190
|
+
result = result.gsub('\\t', "\t")
|
|
191
|
+
result = result.gsub('\\r', "\r")
|
|
192
|
+
result = result.gsub('\\f', "\f")
|
|
193
|
+
result = result.gsub('\\b', "\b")
|
|
194
|
+
result = result.gsub('\\v', "\v")
|
|
195
|
+
result = result.gsub('\\"', '"')
|
|
196
|
+
result = result.gsub('\\\\', '\\')
|
|
197
|
+
|
|
198
|
+
result
|
|
199
|
+
end
|
|
200
|
+
|
|
144
201
|
private def build_helpful_error(content, old_string, path)
|
|
145
202
|
# Find similar content to help debug
|
|
146
203
|
old_lines = old_string.lines
|
|
@@ -174,7 +231,7 @@ module Clacky
|
|
|
174
231
|
error: "String to replace not found in file. The first line of old_string exists at line #{similar_locations.first[:line_number]}, " \
|
|
175
232
|
"but the full multi-line string doesn't match. This is often caused by whitespace differences (tabs vs spaces). " \
|
|
176
233
|
"\n\nContext around line #{similar_locations.first[:line_number]}:\n#{context_display}\n\n" \
|
|
177
|
-
"TIP:
|
|
234
|
+
"TIP: Use file_reader to see the actual content, then retry. No need to explain, just execute the tools."
|
|
178
235
|
}
|
|
179
236
|
end
|
|
180
237
|
end
|
|
@@ -183,7 +240,7 @@ module Clacky
|
|
|
183
240
|
{
|
|
184
241
|
error: "String to replace not found in file '#{File.basename(path)}'. " \
|
|
185
242
|
"Make sure old_string matches exactly (including all whitespace). " \
|
|
186
|
-
"TIP: Use file_reader to view the exact content first, then
|
|
243
|
+
"TIP: Use file_reader to view the exact content first, then retry. No need to explain, just execute the tools."
|
|
187
244
|
}
|
|
188
245
|
end
|
|
189
246
|
|
|
@@ -7,7 +7,7 @@ module Clacky
|
|
|
7
7
|
module Tools
|
|
8
8
|
class FileReader < Base
|
|
9
9
|
self.tool_name = "file_reader"
|
|
10
|
-
self.tool_description = "Read contents of a file from the filesystem"
|
|
10
|
+
self.tool_description = "Read contents of a file from the filesystem."
|
|
11
11
|
self.tool_category = "file_system"
|
|
12
12
|
self.tool_parameters = {
|
|
13
13
|
type: "object",
|
|
@@ -21,10 +21,6 @@ module Clacky
|
|
|
21
21
|
description: "Maximum number of lines to read from start (default: 500)",
|
|
22
22
|
default: 500
|
|
23
23
|
},
|
|
24
|
-
keyword: {
|
|
25
|
-
type: "string",
|
|
26
|
-
description: "Search keyword and return matching lines with context (recommended for large files)"
|
|
27
|
-
},
|
|
28
24
|
start_line: {
|
|
29
25
|
type: "integer",
|
|
30
26
|
description: "Start line number (1-indexed, e.g., 100 reads from line 100)"
|
|
@@ -36,16 +32,22 @@ module Clacky
|
|
|
36
32
|
},
|
|
37
33
|
required: ["path"]
|
|
38
34
|
}
|
|
39
|
-
|
|
35
|
+
|
|
40
36
|
|
|
41
37
|
|
|
42
38
|
# Maximum text file size (1MB)
|
|
43
39
|
MAX_TEXT_FILE_SIZE = 1 * 1024 * 1024
|
|
44
40
|
|
|
45
|
-
|
|
41
|
+
# Maximum content size to return (~10,000 tokens = ~40,000 characters)
|
|
42
|
+
MAX_CONTENT_CHARS = 40_000
|
|
43
|
+
|
|
44
|
+
# Maximum characters per line (prevent single huge lines from bloating tokens)
|
|
45
|
+
MAX_LINE_CHARS = 1000
|
|
46
|
+
|
|
47
|
+
def execute(path:, max_lines: 500, start_line: nil, end_line: nil)
|
|
46
48
|
# Expand ~ to home directory
|
|
47
49
|
expanded_path = File.expand_path(path)
|
|
48
|
-
|
|
50
|
+
|
|
49
51
|
unless File.exist?(expanded_path)
|
|
50
52
|
return {
|
|
51
53
|
path: expanded_path,
|
|
@@ -84,11 +86,6 @@ module Clacky
|
|
|
84
86
|
}
|
|
85
87
|
end
|
|
86
88
|
|
|
87
|
-
# Handle keyword search with context
|
|
88
|
-
if keyword && !keyword.empty?
|
|
89
|
-
return find_with_context(expanded_path, keyword)
|
|
90
|
-
end
|
|
91
|
-
|
|
92
89
|
# Read text file with optional line range
|
|
93
90
|
all_lines = File.readlines(expanded_path)
|
|
94
91
|
total_lines = all_lines.size
|
|
@@ -130,11 +127,30 @@ module Clacky
|
|
|
130
127
|
end
|
|
131
128
|
|
|
132
129
|
lines = all_lines[start_idx..end_idx] || []
|
|
133
|
-
|
|
130
|
+
|
|
131
|
+
# Truncate individual lines that are too long
|
|
132
|
+
lines = lines.map do |line|
|
|
133
|
+
if line.length > MAX_LINE_CHARS
|
|
134
|
+
line[0...MAX_LINE_CHARS] + "... [Line truncated - #{line.length} chars]\n"
|
|
135
|
+
else
|
|
136
|
+
line
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
content = lines.join
|
|
141
|
+
truncated = end_idx < (total_lines - 1)
|
|
142
|
+
|
|
143
|
+
# Truncate total content if it exceeds maximum size
|
|
144
|
+
if content.length > MAX_CONTENT_CHARS
|
|
145
|
+
content = content[0...MAX_CONTENT_CHARS] +
|
|
146
|
+
"\n\n[Content truncated - exceeded #{MAX_CONTENT_CHARS} characters (~10,000 tokens)]" +
|
|
147
|
+
"\nUse start_line/end_line parameters to read specific sections, or grep tool to search for keywords."
|
|
148
|
+
truncated = true
|
|
149
|
+
end
|
|
134
150
|
|
|
135
151
|
{
|
|
136
152
|
path: expanded_path,
|
|
137
|
-
content:
|
|
153
|
+
content: content,
|
|
138
154
|
lines_read: lines.size,
|
|
139
155
|
total_lines: total_lines,
|
|
140
156
|
truncated: truncated,
|
|
@@ -171,7 +187,7 @@ module Clacky
|
|
|
171
187
|
if result[:binary] || result['binary']
|
|
172
188
|
format_type = result[:format] || result['format'] || 'unknown'
|
|
173
189
|
size = result[:size_bytes] || result['size_bytes'] || 0
|
|
174
|
-
|
|
190
|
+
|
|
175
191
|
# Check if it has base64 data (LLM-compatible format)
|
|
176
192
|
if result[:base64_data] || result['base64_data']
|
|
177
193
|
size_warning = size > 5_000_000 ? " (WARNING: large file)" : ""
|
|
@@ -186,7 +202,7 @@ module Clacky
|
|
|
186
202
|
truncated = result[:truncated] || result['truncated']
|
|
187
203
|
"Read #{lines} lines#{truncated ? ' (truncated)' : ''}"
|
|
188
204
|
end
|
|
189
|
-
|
|
205
|
+
|
|
190
206
|
# Format result for LLM - handles both text and binary (image/PDF) content
|
|
191
207
|
# This method is called by the agent to format tool results before sending to LLM
|
|
192
208
|
def format_result_for_llm(result)
|
|
@@ -194,12 +210,12 @@ module Clacky
|
|
|
194
210
|
if result[:binary] && result[:base64_data]
|
|
195
211
|
# Create a text description
|
|
196
212
|
description = "File: #{result[:path]}\nType: #{result[:format]}\nSize: #{format_file_size(result[:size_bytes])}"
|
|
197
|
-
|
|
213
|
+
|
|
198
214
|
# Add size warning for large files
|
|
199
215
|
if result[:size_bytes] > 5_000_000
|
|
200
216
|
description += "\nWARNING: Large file (>5MB) - may consume significant tokens"
|
|
201
217
|
end
|
|
202
|
-
|
|
218
|
+
|
|
203
219
|
# For images, return both description and image content
|
|
204
220
|
if result[:mime_type]&.start_with?("image/")
|
|
205
221
|
return {
|
|
@@ -212,7 +228,7 @@ module Clacky
|
|
|
212
228
|
description: description
|
|
213
229
|
}
|
|
214
230
|
end
|
|
215
|
-
|
|
231
|
+
|
|
216
232
|
# For PDFs and other binary formats, just return metadata with base64
|
|
217
233
|
return {
|
|
218
234
|
type: "document",
|
|
@@ -224,56 +240,11 @@ module Clacky
|
|
|
224
240
|
description: description
|
|
225
241
|
}
|
|
226
242
|
end
|
|
227
|
-
|
|
243
|
+
|
|
228
244
|
# For other cases, return the result as-is (agent will JSON.generate it)
|
|
229
245
|
result
|
|
230
246
|
end
|
|
231
247
|
|
|
232
|
-
# Find lines matching keyword with context (5 lines before and after each match)
|
|
233
|
-
private def find_with_context(path, keyword)
|
|
234
|
-
context_lines_count = 5
|
|
235
|
-
all_lines = File.readlines(path)
|
|
236
|
-
total_lines = all_lines.size
|
|
237
|
-
matches = []
|
|
238
|
-
|
|
239
|
-
# Find all matching line indices (case-insensitive)
|
|
240
|
-
all_lines.each_with_index do |line, index|
|
|
241
|
-
if line.include?(keyword)
|
|
242
|
-
start_idx = [index - context_lines_count, 0].max
|
|
243
|
-
end_idx = [index + context_lines_count, total_lines - 1].min
|
|
244
|
-
matches << {
|
|
245
|
-
line_number: index + 1,
|
|
246
|
-
content: all_lines[start_idx..end_idx].join,
|
|
247
|
-
start_line: start_idx + 1,
|
|
248
|
-
end_line: end_idx + 1,
|
|
249
|
-
match_line: index + 1
|
|
250
|
-
}
|
|
251
|
-
end
|
|
252
|
-
end
|
|
253
|
-
|
|
254
|
-
if matches.empty?
|
|
255
|
-
{
|
|
256
|
-
path: path,
|
|
257
|
-
content: nil,
|
|
258
|
-
matches_count: 0,
|
|
259
|
-
error: "Keyword '#{keyword}' not found in file"
|
|
260
|
-
}
|
|
261
|
-
else
|
|
262
|
-
# Combine all matches with separator
|
|
263
|
-
combined_content = matches.map do |m|
|
|
264
|
-
"... Lines #{m[:start_line]}-#{m[:end_line]} (match at line #{m[:line_number]}):\n#{m[:content]}"
|
|
265
|
-
end.join("\n---\n")
|
|
266
|
-
|
|
267
|
-
{
|
|
268
|
-
path: path,
|
|
269
|
-
content: combined_content,
|
|
270
|
-
matches_count: matches.size,
|
|
271
|
-
keyword: keyword,
|
|
272
|
-
error: nil
|
|
273
|
-
}
|
|
274
|
-
end
|
|
275
|
-
end
|
|
276
|
-
|
|
277
248
|
private def handle_binary_file(path)
|
|
278
249
|
# Check if it's a supported format using FileProcessor
|
|
279
250
|
if Utils::FileProcessor.supported_binary_file?(path)
|
|
@@ -316,11 +287,11 @@ module Clacky
|
|
|
316
287
|
}
|
|
317
288
|
end
|
|
318
289
|
end
|
|
319
|
-
|
|
290
|
+
|
|
320
291
|
private def detect_mime_type(path, data)
|
|
321
292
|
Utils::FileProcessor.detect_mime_type(path, data)
|
|
322
293
|
end
|
|
323
|
-
|
|
294
|
+
|
|
324
295
|
private def format_file_size(bytes)
|
|
325
296
|
if bytes < 1024
|
|
326
297
|
"#{bytes} bytes"
|
|
@@ -337,11 +308,11 @@ module Clacky
|
|
|
337
308
|
private def list_directory_contents(path)
|
|
338
309
|
begin
|
|
339
310
|
entries = Dir.entries(path).reject { |entry| entry == "." || entry == ".." }
|
|
340
|
-
|
|
311
|
+
|
|
341
312
|
# Separate files and directories
|
|
342
313
|
files = []
|
|
343
314
|
directories = []
|
|
344
|
-
|
|
315
|
+
|
|
345
316
|
entries.each do |entry|
|
|
346
317
|
full_path = File.join(path, entry)
|
|
347
318
|
if File.directory?(full_path)
|
|
@@ -350,15 +321,15 @@ module Clacky
|
|
|
350
321
|
files << entry
|
|
351
322
|
end
|
|
352
323
|
end
|
|
353
|
-
|
|
324
|
+
|
|
354
325
|
# Sort directories and files separately, then combine
|
|
355
326
|
directories.sort!
|
|
356
327
|
files.sort!
|
|
357
328
|
all_entries = directories + files
|
|
358
|
-
|
|
329
|
+
|
|
359
330
|
# Format as a tree-like structure
|
|
360
331
|
content = all_entries.map { |entry| " #{entry}" }.join("\n")
|
|
361
|
-
|
|
332
|
+
|
|
362
333
|
{
|
|
363
334
|
path: path,
|
|
364
335
|
content: "Directory listing:\n#{content}",
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
module Tools
|
|
5
|
+
# Tool for invoking skills within the agent
|
|
6
|
+
# This allows the AI to call skills as tools rather than requiring explicit user commands
|
|
7
|
+
class InvokeSkill < Base
|
|
8
|
+
self.tool_name = "invoke_skill"
|
|
9
|
+
self.tool_description = "Invoke a specialized skill to handle specific tasks. Use this when user's request matches a skill's purpose (e.g., code exploration, document creation, etc.). This will read the skill's instructions and execute them appropriately (either inline or in a subagent)."
|
|
10
|
+
self.tool_category = "skill_management"
|
|
11
|
+
self.tool_parameters = {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
skill_name: {
|
|
15
|
+
type: "string",
|
|
16
|
+
description: "Name of the skill to invoke (e.g., 'code-explorer', 'pptx', 'pdf')"
|
|
17
|
+
},
|
|
18
|
+
task: {
|
|
19
|
+
type: "string",
|
|
20
|
+
description: "The task or query to pass to the skill"
|
|
21
|
+
}
|
|
22
|
+
},
|
|
23
|
+
required: ["skill_name", "task"]
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
# Execute the skill invocation
|
|
27
|
+
# @param skill_name [String] Name of the skill to invoke
|
|
28
|
+
# @param task [String] Task description to pass to the skill
|
|
29
|
+
# @param agent [Clacky::Agent] Agent instance (injected)
|
|
30
|
+
# @param skill_loader [Clacky::SkillLoader] Skill loader instance (injected)
|
|
31
|
+
# @return [Hash] Result of skill execution
|
|
32
|
+
def execute(skill_name:, task:, agent: nil, skill_loader: nil)
|
|
33
|
+
# Validate injected dependencies
|
|
34
|
+
return { error: "Agent context is required" } unless agent
|
|
35
|
+
return { error: "Skill loader is required" } unless skill_loader
|
|
36
|
+
|
|
37
|
+
# Find skill by name
|
|
38
|
+
skill = skill_loader.find_by_name(skill_name)
|
|
39
|
+
return { error: "Skill not found: #{skill_name}" } unless skill
|
|
40
|
+
|
|
41
|
+
# Check if skill allows model invocation
|
|
42
|
+
unless skill.model_invocation_allowed?
|
|
43
|
+
return { error: "Skill '#{skill_name}' does not allow model invocation" }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Execute skill based on its configuration
|
|
47
|
+
if skill.fork_agent?
|
|
48
|
+
# Execute in subagent - use private method via send
|
|
49
|
+
result = agent.send(:execute_skill_with_subagent, skill, task)
|
|
50
|
+
{
|
|
51
|
+
message: "Skill '#{skill_name}' executed in subagent",
|
|
52
|
+
result: result,
|
|
53
|
+
skill_type: "subagent"
|
|
54
|
+
}
|
|
55
|
+
else
|
|
56
|
+
# Expand skill content inline
|
|
57
|
+
expanded = skill.process_content(task)
|
|
58
|
+
{
|
|
59
|
+
message: "Skill '#{skill_name}' content expanded",
|
|
60
|
+
content: expanded,
|
|
61
|
+
skill_type: "inline",
|
|
62
|
+
note: "The expanded content has been added to the conversation. Continue following its instructions."
|
|
63
|
+
}
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# Format the tool call for display
|
|
68
|
+
# @param args [Hash] Tool arguments
|
|
69
|
+
# @return [String] Formatted call description
|
|
70
|
+
def format_call(args)
|
|
71
|
+
skill = args[:skill_name] || args["skill_name"]
|
|
72
|
+
"InvokeSkill(#{skill})"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Format the tool result for display
|
|
76
|
+
# @param result [Hash] Tool execution result
|
|
77
|
+
# @return [String] Formatted result summary
|
|
78
|
+
def format_result(result)
|
|
79
|
+
if result[:error]
|
|
80
|
+
"Error: #{result[:error]}"
|
|
81
|
+
elsif result[:skill_type] == "subagent"
|
|
82
|
+
"Subagent executed successfully"
|
|
83
|
+
else
|
|
84
|
+
"Skill content expanded"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|