clacky 0.5.0
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 +7 -0
- data/.clackyrules +80 -0
- data/.rspec +3 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +74 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +272 -0
- data/Rakefile +12 -0
- data/lib/clacky/agent.rb +964 -0
- data/lib/clacky/agent_config.rb +47 -0
- data/lib/clacky/cli.rb +666 -0
- data/lib/clacky/client.rb +159 -0
- data/lib/clacky/config.rb +43 -0
- data/lib/clacky/conversation.rb +41 -0
- data/lib/clacky/hook_manager.rb +61 -0
- data/lib/clacky/progress_indicator.rb +53 -0
- data/lib/clacky/session_manager.rb +124 -0
- data/lib/clacky/thinking_verbs.rb +26 -0
- data/lib/clacky/tool_registry.rb +44 -0
- data/lib/clacky/tools/base.rb +64 -0
- data/lib/clacky/tools/edit.rb +100 -0
- data/lib/clacky/tools/file_reader.rb +79 -0
- data/lib/clacky/tools/glob.rb +93 -0
- data/lib/clacky/tools/grep.rb +169 -0
- data/lib/clacky/tools/run_project.rb +287 -0
- data/lib/clacky/tools/safe_shell.rb +397 -0
- data/lib/clacky/tools/shell.rb +305 -0
- data/lib/clacky/tools/todo_manager.rb +228 -0
- data/lib/clacky/tools/trash_manager.rb +367 -0
- data/lib/clacky/tools/web_fetch.rb +161 -0
- data/lib/clacky/tools/web_search.rb +138 -0
- data/lib/clacky/tools/write.rb +65 -0
- data/lib/clacky/utils/arguments_parser.rb +139 -0
- data/lib/clacky/utils/limit_stack.rb +80 -0
- data/lib/clacky/utils/path_helper.rb +15 -0
- data/lib/clacky/version.rb +5 -0
- data/lib/clacky.rb +38 -0
- data/sig/clacky.rbs +4 -0
- metadata +152 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "faraday"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
class Client
|
|
8
|
+
MAX_RETRIES = 10
|
|
9
|
+
RETRY_DELAY = 5 # seconds
|
|
10
|
+
|
|
11
|
+
def initialize(api_key, base_url:)
|
|
12
|
+
@api_key = api_key
|
|
13
|
+
@base_url = base_url
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def send_message(content, model:, max_tokens:)
|
|
17
|
+
response = connection.post("chat/completions") do |req|
|
|
18
|
+
req.body = {
|
|
19
|
+
model: model,
|
|
20
|
+
max_tokens: max_tokens,
|
|
21
|
+
messages: [
|
|
22
|
+
{
|
|
23
|
+
role: "user",
|
|
24
|
+
content: content
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}.to_json
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
handle_response(response)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def send_messages(messages, model:, max_tokens:)
|
|
34
|
+
response = connection.post("chat/completions") do |req|
|
|
35
|
+
req.body = {
|
|
36
|
+
model: model,
|
|
37
|
+
max_tokens: max_tokens,
|
|
38
|
+
messages: messages
|
|
39
|
+
}.to_json
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
handle_response(response)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Send messages with function calling (tools) support
|
|
46
|
+
def send_messages_with_tools(messages, model:, tools:, max_tokens:, verbose: false)
|
|
47
|
+
body = {
|
|
48
|
+
model: model,
|
|
49
|
+
max_tokens: max_tokens,
|
|
50
|
+
messages: messages
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
# Add tools if provided
|
|
54
|
+
body[:tools] = tools if tools&.any?
|
|
55
|
+
|
|
56
|
+
# Debug output
|
|
57
|
+
if verbose || ENV["CLACKY_DEBUG"]
|
|
58
|
+
puts "\n[DEBUG] Current directory: #{Dir.pwd}"
|
|
59
|
+
puts "[DEBUG] Request to API:"
|
|
60
|
+
|
|
61
|
+
# Create a simplified version of the body for display
|
|
62
|
+
display_body = body.dup
|
|
63
|
+
if display_body[:tools]&.any?
|
|
64
|
+
tool_names = display_body[:tools].map { |t| t.dig(:function, :name) }.compact
|
|
65
|
+
display_body[:tools] = "use tools: #{tool_names.join(', ')}"
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
puts JSON.pretty_generate(display_body)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
response = connection.post("chat/completions") do |req|
|
|
72
|
+
req.body = body.to_json
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
handle_tool_response(response)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
private
|
|
79
|
+
|
|
80
|
+
def connection
|
|
81
|
+
@connection ||= Faraday.new(url: @base_url) do |conn|
|
|
82
|
+
conn.headers["Content-Type"] = "application/json"
|
|
83
|
+
conn.headers["Authorization"] = "Bearer #{@api_key}"
|
|
84
|
+
conn.options.timeout = 120 # Read timeout in seconds
|
|
85
|
+
conn.options.open_timeout = 10 # Connection timeout in seconds
|
|
86
|
+
conn.adapter Faraday.default_adapter
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_response(response)
|
|
91
|
+
case response.status
|
|
92
|
+
when 200
|
|
93
|
+
data = JSON.parse(response.body)
|
|
94
|
+
data["choices"].first["message"]["content"]
|
|
95
|
+
when 401
|
|
96
|
+
raise Error, "Invalid API key"
|
|
97
|
+
when 429
|
|
98
|
+
raise Error, "Rate limit exceeded"
|
|
99
|
+
when 500..599
|
|
100
|
+
raise Error, "Server error: #{response.status}"
|
|
101
|
+
else
|
|
102
|
+
raise Error, "Unexpected error: #{response.status} - #{response.body}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def handle_tool_response(response)
|
|
107
|
+
case response.status
|
|
108
|
+
when 200
|
|
109
|
+
data = JSON.parse(response.body)
|
|
110
|
+
message = data["choices"].first["message"]
|
|
111
|
+
usage = data["usage"]
|
|
112
|
+
|
|
113
|
+
# Debug: show raw API response content
|
|
114
|
+
if ENV["CLACKY_DEBUG"]
|
|
115
|
+
puts "\n[DEBUG] Raw API response content:"
|
|
116
|
+
puts " content: #{message["content"].inspect}"
|
|
117
|
+
puts " content length: #{message["content"]&.length || 0}"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
{
|
|
121
|
+
content: message["content"],
|
|
122
|
+
tool_calls: parse_tool_calls(message["tool_calls"]),
|
|
123
|
+
finish_reason: data["choices"].first["finish_reason"],
|
|
124
|
+
usage: {
|
|
125
|
+
prompt_tokens: usage["prompt_tokens"],
|
|
126
|
+
completion_tokens: usage["completion_tokens"],
|
|
127
|
+
total_tokens: usage["total_tokens"]
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
when 401
|
|
131
|
+
raise Error, "Invalid API key"
|
|
132
|
+
when 429
|
|
133
|
+
raise Error, "Rate limit exceeded"
|
|
134
|
+
when 500..599
|
|
135
|
+
error_body = begin
|
|
136
|
+
JSON.parse(response.body)
|
|
137
|
+
rescue JSON::ParserError
|
|
138
|
+
response.body
|
|
139
|
+
end
|
|
140
|
+
raise Error, "Server error: #{response.status}\nResponse: #{error_body.inspect}"
|
|
141
|
+
else
|
|
142
|
+
raise Error, "Unexpected error: #{response.status} - #{response.body}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def parse_tool_calls(tool_calls)
|
|
147
|
+
return nil if tool_calls.nil? || tool_calls.empty?
|
|
148
|
+
|
|
149
|
+
tool_calls.map do |call|
|
|
150
|
+
{
|
|
151
|
+
id: call["id"],
|
|
152
|
+
type: call["type"],
|
|
153
|
+
name: call["function"]["name"],
|
|
154
|
+
arguments: call["function"]["arguments"]
|
|
155
|
+
}
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
class Config
|
|
8
|
+
CONFIG_DIR = File.join(Dir.home, ".clacky")
|
|
9
|
+
CONFIG_FILE = File.join(CONFIG_DIR, "config.yml")
|
|
10
|
+
|
|
11
|
+
attr_accessor :api_key, :model, :base_url
|
|
12
|
+
|
|
13
|
+
def initialize(data = {})
|
|
14
|
+
@api_key = data["api_key"]
|
|
15
|
+
@model = data["model"] || "gpt-3.5-turbo"
|
|
16
|
+
@base_url = data["base_url"] || "https://api.openai.com"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.load(config_file = CONFIG_FILE)
|
|
20
|
+
if File.exist?(config_file)
|
|
21
|
+
data = YAML.load_file(config_file) || {}
|
|
22
|
+
new(data)
|
|
23
|
+
else
|
|
24
|
+
new
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def save(config_file = CONFIG_FILE)
|
|
29
|
+
config_dir = File.dirname(config_file)
|
|
30
|
+
FileUtils.mkdir_p(config_dir)
|
|
31
|
+
File.write(config_file, to_yaml)
|
|
32
|
+
FileUtils.chmod(0o600, config_file)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def to_yaml
|
|
36
|
+
YAML.dump({
|
|
37
|
+
"api_key" => @api_key,
|
|
38
|
+
"model" => @model,
|
|
39
|
+
"base_url" => @base_url
|
|
40
|
+
})
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
class Conversation
|
|
5
|
+
attr_reader :messages
|
|
6
|
+
|
|
7
|
+
def initialize(api_key, model:, base_url:, max_tokens:)
|
|
8
|
+
@client = Client.new(api_key, base_url: base_url)
|
|
9
|
+
@model = model
|
|
10
|
+
@max_tokens = max_tokens
|
|
11
|
+
@messages = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def send_message(content)
|
|
15
|
+
# Add user message to history
|
|
16
|
+
@messages << {
|
|
17
|
+
role: "user",
|
|
18
|
+
content: content
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
# Get response from Claude
|
|
22
|
+
response_text = @client.send_messages(@messages, model: @model, max_tokens: @max_tokens)
|
|
23
|
+
|
|
24
|
+
# Add assistant response to history
|
|
25
|
+
@messages << {
|
|
26
|
+
role: "assistant",
|
|
27
|
+
content: response_text
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
response_text
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def clear
|
|
34
|
+
@messages = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def history
|
|
38
|
+
@messages.dup
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
class HookManager
|
|
5
|
+
HOOK_EVENTS = [
|
|
6
|
+
:before_tool_use,
|
|
7
|
+
:after_tool_use,
|
|
8
|
+
:on_tool_error,
|
|
9
|
+
:on_start,
|
|
10
|
+
:on_complete,
|
|
11
|
+
:on_iteration
|
|
12
|
+
].freeze
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@hooks = Hash.new { |h, k| h[k] = [] }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add(event, &block)
|
|
19
|
+
validate_event!(event)
|
|
20
|
+
@hooks[event] << block
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def trigger(event, *args)
|
|
24
|
+
validate_event!(event)
|
|
25
|
+
result = { action: :allow }
|
|
26
|
+
|
|
27
|
+
@hooks[event].each do |hook|
|
|
28
|
+
begin
|
|
29
|
+
hook_result = hook.call(*args)
|
|
30
|
+
result.merge!(hook_result) if hook_result.is_a?(Hash)
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
# Log error but don't fail
|
|
33
|
+
warn "Hook error in #{event}: #{e.message}"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
result
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def has_hooks?(event)
|
|
41
|
+
@hooks[event].any?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def clear(event = nil)
|
|
45
|
+
if event
|
|
46
|
+
validate_event!(event)
|
|
47
|
+
@hooks[event].clear
|
|
48
|
+
else
|
|
49
|
+
@hooks.clear
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def validate_event!(event)
|
|
56
|
+
return if HOOK_EVENTS.include?(event)
|
|
57
|
+
|
|
58
|
+
raise ArgumentError, "Invalid hook event: #{event}. Must be one of #{HOOK_EVENTS.join(', ')}"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
class ProgressIndicator
|
|
5
|
+
def initialize(verbose: false, message: nil)
|
|
6
|
+
@verbose = verbose
|
|
7
|
+
@start_time = nil
|
|
8
|
+
@custom_message = message
|
|
9
|
+
@thinking_verb = message || THINKING_VERBS.sample
|
|
10
|
+
@running = false
|
|
11
|
+
@update_thread = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def start
|
|
15
|
+
@start_time = Time.now
|
|
16
|
+
@running = true
|
|
17
|
+
print_status("#{@thinking_verb}… (ctrl+c to interrupt)")
|
|
18
|
+
|
|
19
|
+
# Start background thread to update elapsed time
|
|
20
|
+
@update_thread = Thread.new do
|
|
21
|
+
while @running
|
|
22
|
+
sleep 1
|
|
23
|
+
update if @running
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def update
|
|
29
|
+
return unless @start_time
|
|
30
|
+
|
|
31
|
+
elapsed = (Time.now - @start_time).to_i
|
|
32
|
+
print_status("#{@thinking_verb}… (ctrl+c to interrupt · #{elapsed}s)")
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def finish
|
|
36
|
+
@running = false
|
|
37
|
+
@update_thread&.join
|
|
38
|
+
clear_line
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def print_status(text)
|
|
44
|
+
print "\r\033[K#{text}" # \r moves to start of line, \033[K clears to end of line
|
|
45
|
+
$stdout.flush
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def clear_line
|
|
49
|
+
print "\r\033[K" # Clear the entire line
|
|
50
|
+
$stdout.flush
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Clacky
|
|
7
|
+
class SessionManager
|
|
8
|
+
SESSIONS_DIR = File.join(Dir.home, ".clacky", "sessions")
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
ensure_sessions_dir
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# Save a session
|
|
15
|
+
def save(session_data)
|
|
16
|
+
filename = generate_filename(session_data[:session_id], session_data[:created_at])
|
|
17
|
+
filepath = File.join(SESSIONS_DIR, filename)
|
|
18
|
+
|
|
19
|
+
File.write(filepath, JSON.pretty_generate(session_data))
|
|
20
|
+
FileUtils.chmod(0o600, filepath)
|
|
21
|
+
|
|
22
|
+
@last_saved_path = filepath
|
|
23
|
+
filepath
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Get the path of the last saved session
|
|
27
|
+
def last_saved_path
|
|
28
|
+
@last_saved_path
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Load a specific session by ID
|
|
32
|
+
def load(session_id)
|
|
33
|
+
sessions = all_sessions
|
|
34
|
+
session = sessions.find { |s| s[:session_id].start_with?(session_id) }
|
|
35
|
+
session
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get the most recent session for a specific working directory
|
|
39
|
+
def latest_for_directory(working_dir)
|
|
40
|
+
sessions = all_sessions
|
|
41
|
+
sessions
|
|
42
|
+
.select { |s| s[:working_dir] == working_dir }
|
|
43
|
+
.max_by { |s| Time.parse(s[:updated_at]) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# List recent sessions, prioritizing those from current directory
|
|
47
|
+
def list(current_dir: nil, limit: 5)
|
|
48
|
+
sessions = all_sessions.sort_by { |s| Time.parse(s[:updated_at]) }.reverse
|
|
49
|
+
|
|
50
|
+
if current_dir
|
|
51
|
+
current_sessions = sessions.select { |s| s[:working_dir] == current_dir }
|
|
52
|
+
other_sessions = sessions.reject { |s| s[:working_dir] == current_dir }
|
|
53
|
+
(current_sessions + other_sessions).first(limit)
|
|
54
|
+
else
|
|
55
|
+
sessions.first(limit)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Delete old sessions (older than days)
|
|
60
|
+
def cleanup(days: 30)
|
|
61
|
+
cutoff_time = Time.now - (days * 24 * 60 * 60)
|
|
62
|
+
deleted_count = 0
|
|
63
|
+
|
|
64
|
+
Dir.glob(File.join(SESSIONS_DIR, "*.json")).each do |filepath|
|
|
65
|
+
session = load_session_file(filepath)
|
|
66
|
+
next unless session
|
|
67
|
+
|
|
68
|
+
updated_at = Time.parse(session[:updated_at])
|
|
69
|
+
if updated_at < cutoff_time
|
|
70
|
+
File.delete(filepath)
|
|
71
|
+
deleted_count += 1
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
deleted_count
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# Keep only the most recent N sessions, delete older ones
|
|
79
|
+
def cleanup_by_count(keep:)
|
|
80
|
+
sessions = all_sessions.sort_by { |s| Time.parse(s[:updated_at]) }.reverse
|
|
81
|
+
|
|
82
|
+
return 0 if sessions.size <= keep
|
|
83
|
+
|
|
84
|
+
sessions_to_delete = sessions[keep..]
|
|
85
|
+
deleted_count = 0
|
|
86
|
+
|
|
87
|
+
sessions_to_delete.each do |session|
|
|
88
|
+
filename = generate_filename(session[:session_id], session[:created_at])
|
|
89
|
+
filepath = File.join(SESSIONS_DIR, filename)
|
|
90
|
+
|
|
91
|
+
if File.exist?(filepath)
|
|
92
|
+
File.delete(filepath)
|
|
93
|
+
deleted_count += 1
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
deleted_count
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def ensure_sessions_dir
|
|
103
|
+
FileUtils.mkdir_p(SESSIONS_DIR) unless Dir.exist?(SESSIONS_DIR)
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def generate_filename(session_id, created_at)
|
|
107
|
+
date = Time.parse(created_at).strftime("%Y-%m-%d")
|
|
108
|
+
short_id = session_id[0..7]
|
|
109
|
+
"#{date}_#{short_id}.json"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def all_sessions
|
|
113
|
+
Dir.glob(File.join(SESSIONS_DIR, "*.json")).map do |filepath|
|
|
114
|
+
load_session_file(filepath)
|
|
115
|
+
end.compact
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def load_session_file(filepath)
|
|
119
|
+
JSON.parse(File.read(filepath), symbolize_names: true)
|
|
120
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
121
|
+
nil
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
THINKING_VERBS = [
|
|
5
|
+
"Cogitating",
|
|
6
|
+
"Pondering",
|
|
7
|
+
"Ruminating",
|
|
8
|
+
"Deliberating",
|
|
9
|
+
"Contemplating",
|
|
10
|
+
"Flibbertigibbeting",
|
|
11
|
+
"Percolating",
|
|
12
|
+
"Noodling",
|
|
13
|
+
"Brewing",
|
|
14
|
+
"Marinating",
|
|
15
|
+
"Stewing",
|
|
16
|
+
"Mulling",
|
|
17
|
+
"Processing",
|
|
18
|
+
"Computing",
|
|
19
|
+
"Calculating",
|
|
20
|
+
"Analyzing",
|
|
21
|
+
"Synthesizing",
|
|
22
|
+
"Ideating",
|
|
23
|
+
"Brainstorming",
|
|
24
|
+
"Reasoning"
|
|
25
|
+
].freeze
|
|
26
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
class ToolRegistry
|
|
5
|
+
def initialize
|
|
6
|
+
@tools = {}
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def register(tool)
|
|
10
|
+
@tools[tool.name] = tool
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def get(name)
|
|
14
|
+
# Handle shell alias to safe_shell for backward compatibility
|
|
15
|
+
name = 'safe_shell' if name == 'shell' && @tools.key?('safe_shell') && !@tools.key?('shell')
|
|
16
|
+
|
|
17
|
+
@tools[name] || raise(Error, "Tool not found: #{name}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def all
|
|
21
|
+
@tools.values
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def all_definitions
|
|
25
|
+
@tools.values.map(&:to_function_definition)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def allowed_definitions(allowed_tools = nil)
|
|
29
|
+
return all_definitions if allowed_tools.nil? || allowed_tools.include?("all")
|
|
30
|
+
|
|
31
|
+
@tools.select { |name, _| allowed_tools.include?(name) }
|
|
32
|
+
.values
|
|
33
|
+
.map(&:to_function_definition)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def tool_names
|
|
37
|
+
@tools.keys
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def by_category(category)
|
|
41
|
+
@tools.values.select { |tool| tool.category == category }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Clacky
|
|
4
|
+
module Tools
|
|
5
|
+
class Base
|
|
6
|
+
class << self
|
|
7
|
+
attr_accessor :tool_name, :tool_description, :tool_parameters, :tool_category
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def name
|
|
11
|
+
self.class.tool_name
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def description
|
|
15
|
+
self.class.tool_description
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def parameters
|
|
19
|
+
self.class.tool_parameters
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def category
|
|
23
|
+
self.class.tool_category || "general"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# Execute the tool - must be implemented by subclasses
|
|
27
|
+
def execute(**_args)
|
|
28
|
+
raise NotImplementedError, "#{self.class.name} must implement #execute"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Format tool call for display - can be overridden by subclasses
|
|
32
|
+
# @param args [Hash] The arguments passed to the tool
|
|
33
|
+
# @return [String] Formatted call description (e.g., "Read(file.rb)")
|
|
34
|
+
def format_call(args)
|
|
35
|
+
"#{name}(...)"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Format tool result for display - can be overridden by subclasses
|
|
39
|
+
# @param result [Object] The result returned by execute
|
|
40
|
+
# @return [String] Formatted result summary (e.g., "Read 150 lines")
|
|
41
|
+
def format_result(result)
|
|
42
|
+
if result.is_a?(Hash) && result[:message]
|
|
43
|
+
result[:message]
|
|
44
|
+
elsif result.is_a?(String)
|
|
45
|
+
result.length > 100 ? "#{result[0..100]}..." : result
|
|
46
|
+
else
|
|
47
|
+
"Done"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# Convert to OpenAI function calling format
|
|
52
|
+
def to_function_definition
|
|
53
|
+
{
|
|
54
|
+
type: "function",
|
|
55
|
+
function: {
|
|
56
|
+
name: name,
|
|
57
|
+
description: description,
|
|
58
|
+
parameters: parameters
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|