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.
@@ -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