rubycode 0.1.0 ā 0.1.1
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/USAGE.md +93 -0
- data/lib/rubycode/adapters/base.rb +15 -0
- data/lib/rubycode/adapters/ollama.rb +58 -0
- data/lib/rubycode/client.rb +222 -0
- data/lib/rubycode/configuration.rb +22 -0
- data/lib/rubycode/context_builder.rb +20 -0
- data/lib/rubycode/history.rb +37 -0
- data/lib/rubycode/tools/bash.rb +75 -0
- data/lib/rubycode/tools/done.rb +32 -0
- data/lib/rubycode/tools/read.rb +75 -0
- data/lib/rubycode/tools/search.rb +89 -0
- data/lib/rubycode/tools.rb +32 -0
- data/lib/rubycode/version.rb +1 -1
- data/lib/rubycode.rb +20 -1
- data/rubycode-0.1.0.gem +0 -0
- data/rubycode_cli.rb +89 -0
- metadata +15 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8da87e3aaf4fd3001f396d9394df720eff277f65a2074ab639f5ca881490fb5f
|
|
4
|
+
data.tar.gz: b5ef26c288070a2b9a2a01914765b95e9778b71473a118e6d0cc7ec9084afafd
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 24f9e8016eb87418822608cfd72cdc158c45c3712767b6b18da41b4be52fa58bf49f402e4e0b9c3a5d761a0625eec0e90fb41301c340e51987201d467d4b51fb
|
|
7
|
+
data.tar.gz: 607b13e698d45693ef2041aa89095c91c9286e1939bbb800f823e1e1c64e9d2c8e538ddde11e9f41cbec56447ab12585ff2df5c0b0bc3df236ec0ce35654614c
|
data/USAGE.md
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# RubyCode - Interactive AI Code Assistant
|
|
2
|
+
|
|
3
|
+
An OpenCode-inspired AI agent for Ruby/Rails development that can autonomously explore codebases and suggest changes.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
ruby rubycode_cli.rb
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
You'll be prompted for:
|
|
12
|
+
1. **Directory**: Path to your Rails project (default: current directory)
|
|
13
|
+
2. **Debug mode**: Enable to see JSON requests/responses (default: no)
|
|
14
|
+
|
|
15
|
+
## Example Usage
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
š¬ You: Change the button color to red
|
|
19
|
+
|
|
20
|
+
š¤ Agent:
|
|
21
|
+
š» ls -la
|
|
22
|
+
š button
|
|
23
|
+
š app/assets/stylesheets/buttons.css
|
|
24
|
+
|
|
25
|
+
I found the button styling in `app/assets/stylesheets/buttons.css:15`
|
|
26
|
+
|
|
27
|
+
To change the button color to red, modify line 15:
|
|
28
|
+
|
|
29
|
+
```css
|
|
30
|
+
/* Change this: */
|
|
31
|
+
background-color: blue;
|
|
32
|
+
|
|
33
|
+
/* To this: */
|
|
34
|
+
background-color: red;
|
|
35
|
+
```
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Available Commands
|
|
39
|
+
|
|
40
|
+
- **Regular prompts**: Ask questions or request code changes
|
|
41
|
+
- **`clear`**: Clear conversation history
|
|
42
|
+
- **`exit`** or **`quit`**: Exit the CLI
|
|
43
|
+
|
|
44
|
+
## How It Works
|
|
45
|
+
|
|
46
|
+
The agent has access to three safe tools:
|
|
47
|
+
|
|
48
|
+
1. **bash**: Execute read-only commands (ls, find, cat, etc.)
|
|
49
|
+
2. **read**: Read file contents with line numbers
|
|
50
|
+
3. **search**: Search for patterns using grep
|
|
51
|
+
|
|
52
|
+
The agent autonomously decides which tools to use to:
|
|
53
|
+
- Understand your codebase
|
|
54
|
+
- Find relevant files
|
|
55
|
+
- Suggest specific code changes
|
|
56
|
+
|
|
57
|
+
## Features
|
|
58
|
+
|
|
59
|
+
ā
**Safe**: Only read-only operations, no destructive commands
|
|
60
|
+
ā
**Autonomous**: Agent explores codebase on its own
|
|
61
|
+
ā
**Transparent**: See what the agent is doing (enable debug mode)
|
|
62
|
+
ā
**Rails-aware**: Optimized for Ruby on Rails projects
|
|
63
|
+
ā
**Conversational**: Maintains history across multiple questions
|
|
64
|
+
|
|
65
|
+
## Architecture
|
|
66
|
+
|
|
67
|
+
Built following OpenCode's design:
|
|
68
|
+
- **Tools**: Controlled, safe operations with validation
|
|
69
|
+
- **Agent Loop**: LLM calls tools, we execute, loop until done
|
|
70
|
+
- **History**: Maintains conversation context
|
|
71
|
+
- **JSON Visibility**: Full transparency in debug mode
|
|
72
|
+
|
|
73
|
+
## Workaround for Weak Tool-Calling Models
|
|
74
|
+
|
|
75
|
+
If you're testing with models that have poor tool-calling capabilities (like qwen3-coder), enable the injection workaround:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
Rubycode.configure do |config|
|
|
79
|
+
config.enable_tool_injection_workaround = true
|
|
80
|
+
end
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**What this does:**
|
|
84
|
+
- When the model generates text instead of calling a tool, it injects a reminder message
|
|
85
|
+
- Forces the model to keep calling tools until it finds the answer
|
|
86
|
+
- **OpenCode does NOT use this** - they rely on strong tool-calling models (Claude, GPT-4)
|
|
87
|
+
- This is ONLY for testing/development with weaker models
|
|
88
|
+
|
|
89
|
+
**When to disable:**
|
|
90
|
+
- When using Claude (Anthropic)
|
|
91
|
+
- When using GPT-4 (OpenAI)
|
|
92
|
+
- When using Gemini (Google)
|
|
93
|
+
- In production
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Rubycode
|
|
7
|
+
module Adapters
|
|
8
|
+
class Ollama < Base
|
|
9
|
+
def generate(messages:, system: nil, tools: nil)
|
|
10
|
+
uri = URI("#{@config.url}/api/chat")
|
|
11
|
+
|
|
12
|
+
request = Net::HTTP::Post.new(uri)
|
|
13
|
+
request["Content-Type"] = "application/json"
|
|
14
|
+
|
|
15
|
+
payload = {
|
|
16
|
+
model: @config.model,
|
|
17
|
+
messages: messages,
|
|
18
|
+
stream: false
|
|
19
|
+
}
|
|
20
|
+
payload[:system] = system if system
|
|
21
|
+
payload[:tools] = tools if tools
|
|
22
|
+
|
|
23
|
+
request.body = payload.to_json
|
|
24
|
+
|
|
25
|
+
# DEBUG: Show request if debug mode enabled
|
|
26
|
+
if @config.debug
|
|
27
|
+
puts "\n" + "=" * 80
|
|
28
|
+
puts "š¤ REQUEST TO LLM"
|
|
29
|
+
puts "=" * 80
|
|
30
|
+
puts "URL: #{uri}"
|
|
31
|
+
puts "Model: #{@config.model}"
|
|
32
|
+
puts "\nPayload:"
|
|
33
|
+
puts JSON.pretty_generate(payload)
|
|
34
|
+
puts "=" * 80
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
response = Net::HTTP.start(uri.hostname, uri.port) do |http|
|
|
38
|
+
http.request(request)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
body = JSON.parse(response.body)
|
|
42
|
+
|
|
43
|
+
# DEBUG: Show response if debug mode enabled
|
|
44
|
+
if @config.debug
|
|
45
|
+
puts "\n" + "=" * 80
|
|
46
|
+
puts "š„ RESPONSE FROM LLM"
|
|
47
|
+
puts "=" * 80
|
|
48
|
+
puts JSON.pretty_generate(body)
|
|
49
|
+
puts "=" * 80 + "\n"
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# /api/chat always returns this structure:
|
|
53
|
+
# { "message": { "role": "assistant", "content": "...", "tool_calls": [...] } }
|
|
54
|
+
body
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubycode
|
|
4
|
+
class Client
|
|
5
|
+
attr_reader :history
|
|
6
|
+
|
|
7
|
+
def initialize
|
|
8
|
+
@config = Rubycode.config
|
|
9
|
+
@adapter = build_adapter
|
|
10
|
+
@history = History.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
MAX_ITERATIONS = 25 # Maximum number of LLM calls per request
|
|
14
|
+
MAX_TOOL_CALLS = 50 # Maximum total tool calls
|
|
15
|
+
|
|
16
|
+
def ask(prompt:)
|
|
17
|
+
# Add user message to history
|
|
18
|
+
@history.add_message(role: "user", content: prompt)
|
|
19
|
+
|
|
20
|
+
# Build system prompt with environment context
|
|
21
|
+
system_prompt = build_system_prompt
|
|
22
|
+
|
|
23
|
+
iteration = 0
|
|
24
|
+
total_tool_calls = 0
|
|
25
|
+
|
|
26
|
+
# Agent loop: keep calling LLM until no more tool calls
|
|
27
|
+
loop do
|
|
28
|
+
iteration += 1
|
|
29
|
+
|
|
30
|
+
# Check iteration limit
|
|
31
|
+
if iteration > MAX_ITERATIONS
|
|
32
|
+
error_msg = "ā ļø Reached maximum iterations (#{MAX_ITERATIONS}). The agent may be stuck in a loop."
|
|
33
|
+
puts "\n#{error_msg}\n"
|
|
34
|
+
@history.add_message(role: "assistant", content: error_msg)
|
|
35
|
+
return error_msg
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Get messages in LLM format
|
|
39
|
+
messages = @history.to_llm_format
|
|
40
|
+
|
|
41
|
+
# Get response from LLM with tools
|
|
42
|
+
response_body = @adapter.generate(
|
|
43
|
+
messages: messages,
|
|
44
|
+
system: system_prompt,
|
|
45
|
+
tools: Tools.definitions
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Extract assistant message
|
|
49
|
+
assistant_message = response_body["message"]
|
|
50
|
+
content = assistant_message["content"] || ""
|
|
51
|
+
tool_calls = assistant_message["tool_calls"] || []
|
|
52
|
+
|
|
53
|
+
# Add assistant response to history
|
|
54
|
+
@history.add_message(role: "assistant", content: content)
|
|
55
|
+
|
|
56
|
+
# If no tool calls, we're done
|
|
57
|
+
if tool_calls.empty?
|
|
58
|
+
# ============================================================================
|
|
59
|
+
# WORKAROUND FOR WEAK TOOL-CALLING MODELS (e.g., qwen3-coder)
|
|
60
|
+
# This is ONLY for testing with models that have poor tool-calling capabilities.
|
|
61
|
+
# OpenCode does NOT do this - they rely on good models (Claude, GPT-4).
|
|
62
|
+
# Enable with: config.enable_tool_injection_workaround = true
|
|
63
|
+
# ============================================================================
|
|
64
|
+
if @config.enable_tool_injection_workaround && iteration < 10
|
|
65
|
+
puts " ā ļø No tool calls - injecting reminder (iteration #{iteration})" unless @config.debug
|
|
66
|
+
|
|
67
|
+
@history.add_message(
|
|
68
|
+
role: "user",
|
|
69
|
+
content: "You MUST call a tool. Do not respond with text. Call search, read, bash, or done tool now."
|
|
70
|
+
)
|
|
71
|
+
next # Continue the loop
|
|
72
|
+
end
|
|
73
|
+
# ============================================================================
|
|
74
|
+
# END WORKAROUND
|
|
75
|
+
# ============================================================================
|
|
76
|
+
|
|
77
|
+
puts "\nā
Agent finished (#{iteration} iterations, #{total_tool_calls} tool calls)\n" unless @config.debug
|
|
78
|
+
return content
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Check tool call limit
|
|
82
|
+
total_tool_calls += tool_calls.length
|
|
83
|
+
if total_tool_calls > MAX_TOOL_CALLS
|
|
84
|
+
error_msg = "ā ļø Reached maximum tool calls (#{MAX_TOOL_CALLS}). Stopping to prevent excessive operations."
|
|
85
|
+
puts "\n#{error_msg}\n"
|
|
86
|
+
@history.add_message(role: "assistant", content: error_msg)
|
|
87
|
+
return content.empty? ? error_msg : content
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Execute each tool call
|
|
91
|
+
unless @config.debug
|
|
92
|
+
puts "\nš¤ Iteration #{iteration}: Calling #{tool_calls.length} tool(s)..."
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
done_result = nil
|
|
96
|
+
tool_calls.each do |tool_call|
|
|
97
|
+
result = execute_tool(tool_call)
|
|
98
|
+
|
|
99
|
+
# Check if this was the "done" tool
|
|
100
|
+
if tool_call.dig("function", "name") == "done"
|
|
101
|
+
done_result = result
|
|
102
|
+
break
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# If done was called, return the result
|
|
107
|
+
if done_result
|
|
108
|
+
puts "\nā
Agent finished (#{iteration} iterations, #{total_tool_calls + 1} tool calls)\n" unless @config.debug
|
|
109
|
+
return done_result
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Loop continues - send tool results back to LLM
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
def clear_history
|
|
119
|
+
@history.clear
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
private
|
|
123
|
+
|
|
124
|
+
def execute_tool(tool_call)
|
|
125
|
+
tool_name = tool_call.dig("function", "name")
|
|
126
|
+
arguments = tool_call.dig("function", "arguments")
|
|
127
|
+
|
|
128
|
+
if @config.debug
|
|
129
|
+
puts "\nš§ Tool: #{tool_name}"
|
|
130
|
+
puts " Args: #{arguments.inspect}"
|
|
131
|
+
else
|
|
132
|
+
# Show minimal output
|
|
133
|
+
case tool_name
|
|
134
|
+
when "bash"
|
|
135
|
+
cmd = arguments.is_a?(Hash) ? arguments["command"] : JSON.parse(arguments)["command"]
|
|
136
|
+
puts " š» #{cmd}"
|
|
137
|
+
when "read"
|
|
138
|
+
file = arguments.is_a?(Hash) ? arguments["file_path"] : JSON.parse(arguments)["file_path"]
|
|
139
|
+
puts " š #{file}"
|
|
140
|
+
when "search"
|
|
141
|
+
pattern = arguments.is_a?(Hash) ? arguments["pattern"] : JSON.parse(arguments)["pattern"]
|
|
142
|
+
puts " š #{pattern}"
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
begin
|
|
147
|
+
# Arguments might be Hash or JSON string
|
|
148
|
+
params = arguments.is_a?(Hash) ? arguments : JSON.parse(arguments)
|
|
149
|
+
|
|
150
|
+
# Execute the tool
|
|
151
|
+
context = { root_path: @config.root_path }
|
|
152
|
+
result = Tools.execute(
|
|
153
|
+
tool_name: tool_name,
|
|
154
|
+
params: params,
|
|
155
|
+
context: context
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
if @config.debug
|
|
159
|
+
puts " ā Result: #{result.lines.first&.strip || '(empty)'}#{result.lines.count > 1 ? "... (#{result.lines.count} lines)" : ""}"
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
# Add tool result to history
|
|
163
|
+
@history.add_message(
|
|
164
|
+
role: "user",
|
|
165
|
+
content: "Tool '#{tool_name}' result:\n#{result}"
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Return the result so caller can check if it was "done"
|
|
169
|
+
result
|
|
170
|
+
|
|
171
|
+
rescue JSON::ParserError => e
|
|
172
|
+
error_msg = "Error parsing tool arguments: #{e.message}"
|
|
173
|
+
puts " ā #{error_msg}"
|
|
174
|
+
@history.add_message(role: "user", content: error_msg)
|
|
175
|
+
nil
|
|
176
|
+
rescue => e
|
|
177
|
+
error_msg = "Error executing tool: #{e.message}"
|
|
178
|
+
puts " ā #{error_msg}"
|
|
179
|
+
@history.add_message(role: "user", content: error_msg)
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def build_adapter
|
|
185
|
+
case @config.adapter
|
|
186
|
+
when :ollama
|
|
187
|
+
Adapters::Ollama.new(@config)
|
|
188
|
+
else
|
|
189
|
+
raise "Unknown Adapter"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
def build_system_prompt
|
|
194
|
+
context = ContextBuilder.new(root_path: @config.root_path).environment_context
|
|
195
|
+
|
|
196
|
+
<<~PROMPT.strip
|
|
197
|
+
You are a helpful Ruby on Rails coding assistant.
|
|
198
|
+
|
|
199
|
+
#{context}
|
|
200
|
+
|
|
201
|
+
# CRITICAL RULE
|
|
202
|
+
You MUST call a tool in EVERY response. You MUST NEVER respond with just text.
|
|
203
|
+
|
|
204
|
+
# Available tools
|
|
205
|
+
- bash: explore directories (ls, find)
|
|
206
|
+
- search: find text inside files (supports case_insensitive parameter)
|
|
207
|
+
- read: view file contents with line numbers
|
|
208
|
+
- done: call this when you have the answer (with your final answer as the parameter)
|
|
209
|
+
|
|
210
|
+
# Required workflow
|
|
211
|
+
1. Call search with the pattern
|
|
212
|
+
2. If "No matches found" ā call search again with case_insensitive: true
|
|
213
|
+
3. If still no matches ā call search with simpler pattern
|
|
214
|
+
4. Once found ā call read to see the file
|
|
215
|
+
5. Once you have the answer ā call done with your final answer
|
|
216
|
+
|
|
217
|
+
IMPORTANT: You cannot respond with plain text. You must ALWAYS call one of the tools.
|
|
218
|
+
When you're ready to provide your answer, call the "done" tool with your answer as the parameter.
|
|
219
|
+
PROMPT
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubycode
|
|
4
|
+
class Configuration
|
|
5
|
+
|
|
6
|
+
attr_accessor :adapter, :url, :model, :root_path, :debug, :enable_tool_injection_workaround
|
|
7
|
+
|
|
8
|
+
def initialize
|
|
9
|
+
@adapter = :ollama
|
|
10
|
+
@url = "http://localhost:11434"
|
|
11
|
+
@model = "qwen3-coder:480b-cloud"
|
|
12
|
+
@root_path = Dir.pwd
|
|
13
|
+
@debug = false # Set to true to see JSON requests/responses
|
|
14
|
+
|
|
15
|
+
# WORKAROUND for weak tool-calling models (qwen3-coder, etc.)
|
|
16
|
+
# When enabled, injects reminder messages if model generates text instead of calling tools
|
|
17
|
+
# OpenCode does NOT use this - they rely on strong tool-calling models (Claude, GPT-4)
|
|
18
|
+
# Set to true ONLY for testing with weak models
|
|
19
|
+
@enable_tool_injection_workaround = false
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubycode
|
|
4
|
+
class ContextBuilder
|
|
5
|
+
def initialize(root_path:)
|
|
6
|
+
@root_path = root_path
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def environment_context
|
|
10
|
+
<<~CONTEXT
|
|
11
|
+
<env>
|
|
12
|
+
Working directory: #{@root_path}
|
|
13
|
+
Platform: #{RUBY_PLATFORM}
|
|
14
|
+
Ruby version: #{RUBY_VERSION}
|
|
15
|
+
Today's date: #{Time.now.strftime('%Y-%m-%d')}
|
|
16
|
+
</env>
|
|
17
|
+
CONTEXT
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubycode
|
|
4
|
+
class History
|
|
5
|
+
def initialize
|
|
6
|
+
@messages = []
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def add_message(role:, content:)
|
|
10
|
+
@messages << {
|
|
11
|
+
role: role,
|
|
12
|
+
content: content,
|
|
13
|
+
timestamp: Time.now
|
|
14
|
+
}
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def to_llm_format
|
|
18
|
+
@messages.map { |msg| { role: msg[:role], content: msg[:content] } }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def clear
|
|
22
|
+
@messages = []
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def messages
|
|
26
|
+
@messages
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def last_user_message
|
|
30
|
+
@messages.reverse.find { |msg| msg[:role] == "user" }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def last_assistant_message
|
|
34
|
+
@messages.reverse.find { |msg| msg[:role] == "assistant" }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Rubycode
|
|
6
|
+
module Tools
|
|
7
|
+
class Bash
|
|
8
|
+
# Whitelist of safe commands
|
|
9
|
+
SAFE_COMMANDS = %w[
|
|
10
|
+
ls
|
|
11
|
+
pwd
|
|
12
|
+
find
|
|
13
|
+
tree
|
|
14
|
+
cat
|
|
15
|
+
head
|
|
16
|
+
tail
|
|
17
|
+
wc
|
|
18
|
+
file
|
|
19
|
+
which
|
|
20
|
+
echo
|
|
21
|
+
].freeze
|
|
22
|
+
|
|
23
|
+
def self.definition
|
|
24
|
+
{
|
|
25
|
+
type: "function",
|
|
26
|
+
function: {
|
|
27
|
+
name: "bash",
|
|
28
|
+
description: "Execute safe bash commands for exploring the filesystem and terminal operations.\n\nIMPORTANT: This tool is for terminal operations and directory exploration (ls, find, tree, etc.). DO NOT use it for file operations (reading, searching file contents) - use the specialized tools instead.\n\nWhitelisted commands: #{SAFE_COMMANDS.join(', ')}",
|
|
29
|
+
parameters: {
|
|
30
|
+
type: "object",
|
|
31
|
+
properties: {
|
|
32
|
+
command: {
|
|
33
|
+
type: "string",
|
|
34
|
+
description: "The bash command to execute (e.g., 'ls -la', 'find . -name \"*.rb\"', 'tree app')"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
required: ["command"]
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def self.execute(params:, context:)
|
|
44
|
+
command = params["command"].strip
|
|
45
|
+
|
|
46
|
+
# Extract the base command (first word)
|
|
47
|
+
base_command = command.split.first
|
|
48
|
+
|
|
49
|
+
unless SAFE_COMMANDS.include?(base_command)
|
|
50
|
+
return "Error: Command '#{base_command}' is not allowed. Safe commands: #{SAFE_COMMANDS.join(', ')}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Execute in the project's root directory
|
|
54
|
+
Dir.chdir(context[:root_path]) do
|
|
55
|
+
output = `#{command} 2>&1`
|
|
56
|
+
exit_code = $?.exitstatus
|
|
57
|
+
|
|
58
|
+
if exit_code == 0
|
|
59
|
+
# Limit output length
|
|
60
|
+
lines = output.split("\n")
|
|
61
|
+
if lines.length > 200
|
|
62
|
+
lines[0..199].join("\n") + "\n\n... (#{lines.length - 200} more lines truncated)"
|
|
63
|
+
else
|
|
64
|
+
output
|
|
65
|
+
end
|
|
66
|
+
else
|
|
67
|
+
"Command failed with exit code #{exit_code}:\n#{output}"
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
rescue => e
|
|
71
|
+
"Error executing command: #{e.message}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubycode
|
|
4
|
+
module Tools
|
|
5
|
+
class Done
|
|
6
|
+
def self.definition
|
|
7
|
+
{
|
|
8
|
+
type: "function",
|
|
9
|
+
function: {
|
|
10
|
+
name: "done",
|
|
11
|
+
description: "Call this when you have found the code and are ready to provide your final answer. This signals you are finished exploring.",
|
|
12
|
+
parameters: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
answer: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Your final answer showing the file, line number, current code, and suggested change"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
required: ["answer"]
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.execute(params:, context:)
|
|
27
|
+
# Just return the answer - this is the final response
|
|
28
|
+
params["answer"]
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Rubycode
|
|
4
|
+
module Tools
|
|
5
|
+
class Read
|
|
6
|
+
def self.definition
|
|
7
|
+
{
|
|
8
|
+
type: "function",
|
|
9
|
+
function: {
|
|
10
|
+
name: "read",
|
|
11
|
+
description: "Read a file or directory from the filesystem.\n\n- Use this when you know the file path and want to see its contents\n- Returns file contents with line numbers (format: 'line_number: content')\n- For directories, returns a listing of entries\n- Use the search tool to find specific content in files\n- Use bash with 'ls' or 'find' to discover what files exist",
|
|
12
|
+
parameters: {
|
|
13
|
+
type: "object",
|
|
14
|
+
properties: {
|
|
15
|
+
file_path: {
|
|
16
|
+
type: "string",
|
|
17
|
+
description: "Absolute or relative path to the file to read"
|
|
18
|
+
},
|
|
19
|
+
offset: {
|
|
20
|
+
type: "integer",
|
|
21
|
+
description: "Line number to start reading from (1-indexed). Optional."
|
|
22
|
+
},
|
|
23
|
+
limit: {
|
|
24
|
+
type: "integer",
|
|
25
|
+
description: "Maximum number of lines to read. Default 2000. Optional."
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
required: ["file_path"]
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def self.execute(params:, context:)
|
|
35
|
+
file_path = params["file_path"]
|
|
36
|
+
offset = params["offset"] || 1
|
|
37
|
+
limit = params["limit"] || 2000
|
|
38
|
+
|
|
39
|
+
# Resolve relative paths
|
|
40
|
+
full_path = if File.absolute_path?(file_path)
|
|
41
|
+
file_path
|
|
42
|
+
else
|
|
43
|
+
File.join(context[:root_path], file_path)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
unless File.exist?(full_path)
|
|
47
|
+
return "Error: File '#{file_path}' does not exist"
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
if File.directory?(full_path)
|
|
51
|
+
# List directory contents instead
|
|
52
|
+
entries = Dir.entries(full_path).reject { |e| e.start_with?(".") }.sort
|
|
53
|
+
return "Directory listing for '#{file_path}':\n" + entries.map { |e| " #{e}" }.join("\n")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
lines = File.readlines(full_path)
|
|
57
|
+
start_idx = [offset - 1, 0].max
|
|
58
|
+
end_idx = [start_idx + limit - 1, lines.length - 1].min
|
|
59
|
+
|
|
60
|
+
result = []
|
|
61
|
+
(start_idx..end_idx).each do |i|
|
|
62
|
+
line_num = i + 1
|
|
63
|
+
content = lines[i].chomp
|
|
64
|
+
# Truncate long lines
|
|
65
|
+
content = content[0..2000] + "..." if content.length > 2000
|
|
66
|
+
result << "#{line_num}: #{content}"
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
result.join("\n")
|
|
70
|
+
rescue => e
|
|
71
|
+
"Error reading file: #{e.message}"
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "shellwords"
|
|
4
|
+
|
|
5
|
+
module Rubycode
|
|
6
|
+
module Tools
|
|
7
|
+
class Search
|
|
8
|
+
def self.definition
|
|
9
|
+
{
|
|
10
|
+
type: "function",
|
|
11
|
+
function: {
|
|
12
|
+
name: "search",
|
|
13
|
+
description: "Search INSIDE file contents for patterns using grep. Returns matching lines with file paths and line numbers.\n\n- Searches file CONTENTS using regular expressions\n- Use this when you need to find WHERE specific text/code appears inside files\n- Returns file paths, line numbers, and the matching content\n- Example: search for 'button' to find files containing that text",
|
|
14
|
+
parameters: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
pattern: {
|
|
18
|
+
type: "string",
|
|
19
|
+
description: "The pattern to search for (supports regex)"
|
|
20
|
+
},
|
|
21
|
+
path: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Directory or file to search in. Defaults to '.' (current directory). Optional."
|
|
24
|
+
},
|
|
25
|
+
include: {
|
|
26
|
+
type: "string",
|
|
27
|
+
description: "File pattern to include (e.g., '*.rb', '*.js'). Optional."
|
|
28
|
+
},
|
|
29
|
+
case_insensitive: {
|
|
30
|
+
type: "boolean",
|
|
31
|
+
description: "Perform case-insensitive search. Optional."
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
required: ["pattern"]
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.execute(params:, context:)
|
|
41
|
+
pattern = params["pattern"]
|
|
42
|
+
path = params["path"] || "."
|
|
43
|
+
include_pattern = params["include"]
|
|
44
|
+
case_insensitive = params["case_insensitive"] || false
|
|
45
|
+
|
|
46
|
+
# Resolve relative paths
|
|
47
|
+
full_path = if File.absolute_path?(path)
|
|
48
|
+
path
|
|
49
|
+
else
|
|
50
|
+
File.join(context[:root_path], path)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
unless File.exist?(full_path)
|
|
54
|
+
return "Error: Path '#{path}' does not exist"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Build grep command safely using Shellwords to prevent injection
|
|
58
|
+
cmd_parts = ["grep", "-n", "-r"]
|
|
59
|
+
cmd_parts << "-i" if case_insensitive
|
|
60
|
+
cmd_parts << "--include=#{Shellwords.escape(include_pattern)}" if include_pattern
|
|
61
|
+
cmd_parts << Shellwords.escape(pattern)
|
|
62
|
+
cmd_parts << Shellwords.escape(full_path)
|
|
63
|
+
|
|
64
|
+
command = cmd_parts.join(" ")
|
|
65
|
+
|
|
66
|
+
output = `#{command} 2>&1`
|
|
67
|
+
exit_code = $?.exitstatus
|
|
68
|
+
|
|
69
|
+
if exit_code == 0
|
|
70
|
+
# Found matches
|
|
71
|
+
lines = output.split("\n")
|
|
72
|
+
if lines.length > 100
|
|
73
|
+
lines[0..99].join("\n") + "\n\n... (#{lines.length - 100} more matches truncated)"
|
|
74
|
+
else
|
|
75
|
+
output
|
|
76
|
+
end
|
|
77
|
+
elsif exit_code == 1
|
|
78
|
+
# No matches found
|
|
79
|
+
"No matches found for pattern: #{pattern}"
|
|
80
|
+
else
|
|
81
|
+
# Error occurred
|
|
82
|
+
"Error running search: #{output}"
|
|
83
|
+
end
|
|
84
|
+
rescue => e
|
|
85
|
+
"Error: #{e.message}"
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "tools/bash"
|
|
4
|
+
require_relative "tools/read"
|
|
5
|
+
require_relative "tools/search"
|
|
6
|
+
require_relative "tools/done"
|
|
7
|
+
|
|
8
|
+
module Rubycode
|
|
9
|
+
module Tools
|
|
10
|
+
# Registry of all available tools
|
|
11
|
+
TOOLS = [
|
|
12
|
+
Bash,
|
|
13
|
+
Read,
|
|
14
|
+
Search,
|
|
15
|
+
Done
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
def self.definitions
|
|
19
|
+
TOOLS.map(&:definition)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.execute(tool_name:, params:, context:)
|
|
23
|
+
tool_class = TOOLS.find { |t| t.definition[:function][:name] == tool_name }
|
|
24
|
+
|
|
25
|
+
unless tool_class
|
|
26
|
+
return "Error: Unknown tool '#{tool_name}'"
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
tool_class.execute(params: params, context: context)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/rubycode/version.rb
CHANGED
data/lib/rubycode.rb
CHANGED
|
@@ -1,8 +1,27 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "rubycode/version"
|
|
4
|
+
require_relative "rubycode/configuration"
|
|
5
|
+
require_relative "rubycode/history"
|
|
6
|
+
require_relative "rubycode/context_builder"
|
|
7
|
+
require_relative "rubycode/adapters/base"
|
|
8
|
+
require_relative "rubycode/adapters/ollama"
|
|
9
|
+
require_relative "rubycode/tools"
|
|
10
|
+
require_relative "rubycode/client"
|
|
4
11
|
|
|
5
12
|
module Rubycode
|
|
6
13
|
class Error < StandardError; end
|
|
7
|
-
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
attr_accessor :configuration
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.configure
|
|
20
|
+
self.configuration ||= Configuration.new
|
|
21
|
+
yield(configuration)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.config
|
|
25
|
+
self.configuration ||= Configuration.new
|
|
26
|
+
end
|
|
8
27
|
end
|
data/rubycode-0.1.0.gem
ADDED
|
Binary file
|
data/rubycode_cli.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require_relative "lib/rubycode"
|
|
5
|
+
require "readline"
|
|
6
|
+
|
|
7
|
+
puts "\n" + "=" * 80
|
|
8
|
+
puts "š RubyCode - AI Ruby/Rails Code Assistant"
|
|
9
|
+
puts "=" * 80
|
|
10
|
+
|
|
11
|
+
# Ask for directory
|
|
12
|
+
print "\nWhat directory do you want to work on? (default: current directory): "
|
|
13
|
+
directory = gets.chomp
|
|
14
|
+
directory = Dir.pwd if directory.empty?
|
|
15
|
+
|
|
16
|
+
# Resolve the full path
|
|
17
|
+
full_path = File.expand_path(directory)
|
|
18
|
+
|
|
19
|
+
unless Dir.exist?(full_path)
|
|
20
|
+
puts "\nā Error: Directory '#{full_path}' does not exist!"
|
|
21
|
+
exit 1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
puts "\nš Working directory: #{full_path}"
|
|
25
|
+
|
|
26
|
+
# Ask if debug mode should be enabled
|
|
27
|
+
print "Enable debug mode? (shows JSON requests/responses) [y/N]: "
|
|
28
|
+
debug_input = gets.chomp.downcase
|
|
29
|
+
debug_mode = debug_input == 'y' || debug_input == 'yes'
|
|
30
|
+
|
|
31
|
+
# Configure the client
|
|
32
|
+
Rubycode.configure do |config|
|
|
33
|
+
config.adapter = :ollama
|
|
34
|
+
config.url = "http://localhost:11434"
|
|
35
|
+
config.model = "deepseek-v3.1:671b-cloud"
|
|
36
|
+
config.root_path = full_path
|
|
37
|
+
config.debug = debug_mode
|
|
38
|
+
|
|
39
|
+
# Test deepseek WITHOUT workaround first - it should have better tool-calling than qwen3
|
|
40
|
+
# If it fails, enable this: config.enable_tool_injection_workaround = true
|
|
41
|
+
config.enable_tool_injection_workaround = false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
puts "š Debug mode: #{debug_mode ? 'ON' : 'OFF'}" if debug_mode
|
|
45
|
+
|
|
46
|
+
# Create a client
|
|
47
|
+
client = Rubycode::Client.new
|
|
48
|
+
|
|
49
|
+
puts "\n" + "=" * 80
|
|
50
|
+
puts "⨠Agent initialized! You can now ask questions or request code changes."
|
|
51
|
+
puts " Type 'exit' or 'quit' to exit, 'clear' to clear history"
|
|
52
|
+
puts "=" * 80
|
|
53
|
+
|
|
54
|
+
# Interactive loop
|
|
55
|
+
loop do
|
|
56
|
+
print "\nš¬ You: "
|
|
57
|
+
prompt = Readline.readline("", true)
|
|
58
|
+
|
|
59
|
+
# Handle empty input
|
|
60
|
+
next if prompt.nil? || prompt.strip.empty?
|
|
61
|
+
|
|
62
|
+
# Handle commands
|
|
63
|
+
case prompt.strip.downcase
|
|
64
|
+
when 'exit', 'quit'
|
|
65
|
+
puts "\nš Goodbye!"
|
|
66
|
+
break
|
|
67
|
+
when 'clear'
|
|
68
|
+
client.clear_history
|
|
69
|
+
puts "\nšļø History cleared!"
|
|
70
|
+
next
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
puts "\n" + "-" * 80
|
|
74
|
+
|
|
75
|
+
begin
|
|
76
|
+
# Get response from agent
|
|
77
|
+
response = client.ask(prompt: prompt)
|
|
78
|
+
|
|
79
|
+
puts "\nš¤ Agent:"
|
|
80
|
+
puts "-" * 80
|
|
81
|
+
puts response
|
|
82
|
+
puts "-" * 80
|
|
83
|
+
rescue Interrupt
|
|
84
|
+
puts "\n\nā ļø Interrupted! Type 'exit' to quit or continue chatting."
|
|
85
|
+
rescue => e
|
|
86
|
+
puts "\nā Error: #{e.message}"
|
|
87
|
+
puts e.backtrace.first(3).join("\n")
|
|
88
|
+
end
|
|
89
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: rubycode
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1.
|
|
4
|
+
version: 0.1.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jonas Medeiros
|
|
@@ -21,8 +21,22 @@ files:
|
|
|
21
21
|
- LICENSE.txt
|
|
22
22
|
- README.md
|
|
23
23
|
- Rakefile
|
|
24
|
+
- USAGE.md
|
|
24
25
|
- lib/rubycode.rb
|
|
26
|
+
- lib/rubycode/adapters/base.rb
|
|
27
|
+
- lib/rubycode/adapters/ollama.rb
|
|
28
|
+
- lib/rubycode/client.rb
|
|
29
|
+
- lib/rubycode/configuration.rb
|
|
30
|
+
- lib/rubycode/context_builder.rb
|
|
31
|
+
- lib/rubycode/history.rb
|
|
32
|
+
- lib/rubycode/tools.rb
|
|
33
|
+
- lib/rubycode/tools/bash.rb
|
|
34
|
+
- lib/rubycode/tools/done.rb
|
|
35
|
+
- lib/rubycode/tools/read.rb
|
|
36
|
+
- lib/rubycode/tools/search.rb
|
|
25
37
|
- lib/rubycode/version.rb
|
|
38
|
+
- rubycode-0.1.0.gem
|
|
39
|
+
- rubycode_cli.rb
|
|
26
40
|
- sig/rubycode.rbs
|
|
27
41
|
homepage: https://example.com
|
|
28
42
|
licenses:
|