ruboto-ai 0.1.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/LICENSE.txt +21 -0
- data/README.md +228 -0
- data/bin/ruboto-ai +6 -0
- data/lib/ruboto/version.rb +5 -0
- data/lib/ruboto.rb +1056 -0
- metadata +50 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: aab699c78829f06e755d96f99c0dfcb39053d209e4ee99d61f198a6c33b650c5
|
|
4
|
+
data.tar.gz: be0851dcdfb649041a4c75bea567f3369253687e5a4e1fe562f1461c69343ee0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 6fb78c938b1bbc219b5debd6fcd1e0038a6bfbe176d0abada304aceed5379888274a2106f10e922f8edd63e3c4149d12ad0abad7dc0c0bea78882dc95f6db8f0
|
|
7
|
+
data.tar.gz: 7846fad4648aca6329c9db9845624b8c4e2224e5f578c620fea1bae19528be5e2bffb3c07f1faedcbe04dc79f4af5f1c5513755d058ea62ed0955c811ed1f0dc
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Akhil Gautam
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
# Ruboto
|
|
2
|
+
|
|
3
|
+
A minimal agentic coding assistant for the terminal. Built in Ruby, powered by multiple LLM providers via OpenRouter.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Multi-model support**: GPT-4o, Claude Sonnet, Gemini, Llama, DeepSeek
|
|
8
|
+
- **Agentic tools**: Read, write, edit files, run shell commands, search codebases
|
|
9
|
+
- **Meta-tools**: High-level tools for exploration, verification, and patching
|
|
10
|
+
- **Conversation history**: Persisted in SQLite with session tracking
|
|
11
|
+
- **Autonomous operation**: Acts first, asks questions only when needed
|
|
12
|
+
- **Zero dependencies**: Pure Ruby stdlib, no external gems required
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
### From RubyGems
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
gem install ruboto-ai
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
### From Source
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
git clone https://github.com/akhilgautam/ruboto-ai.git
|
|
26
|
+
cd ruboto-ai
|
|
27
|
+
gem build ruboto.gemspec
|
|
28
|
+
gem install ruboto-ai-0.1.0.gem
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
### Configuration
|
|
32
|
+
|
|
33
|
+
Set your OpenRouter API key:
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
export OPENROUTER_API_KEY="your-api-key-here"
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
Add this to your shell profile (`~/.bashrc`, `~/.zshrc`, etc.) to persist it.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
ruboto-ai
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
### Starting a Session
|
|
48
|
+
|
|
49
|
+
```
|
|
50
|
+
$ ruboto-ai
|
|
51
|
+
|
|
52
|
+
██████╗ ██╗ ██╗██████╗ ██████╗ ████████╗ ██████╗
|
|
53
|
+
██╔══██╗██║ ██║██╔══██╗██╔═══██╗╚══██╔══╝██╔═══██╗
|
|
54
|
+
██████╔╝██║ ██║██████╔╝██║ ██║ ██║ ██║ ██║
|
|
55
|
+
██╔══██╗██║ ██║██╔══██╗██║ ██║ ██║ ██║ ██║
|
|
56
|
+
██║ ██║╚██████╔╝██████╔╝╚██████╔╝ ██║ ╚██████╔╝
|
|
57
|
+
╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝
|
|
58
|
+
|
|
59
|
+
Professional YAML indenter
|
|
60
|
+
|
|
61
|
+
myproject
|
|
62
|
+
|
|
63
|
+
Select a model:
|
|
64
|
+
|
|
65
|
+
1. GPT-4o OpenAI flagship
|
|
66
|
+
2. GPT-4o Mini Fast & cheap
|
|
67
|
+
3. Claude Sonnet 4 Anthropic's latest
|
|
68
|
+
4. Claude 3.5 Sonnet Great for code
|
|
69
|
+
5. Gemini 2.0 Flash Google's fast model
|
|
70
|
+
6. Llama 3.3 70B Open source
|
|
71
|
+
7. DeepSeek Chat Strong reasoning
|
|
72
|
+
|
|
73
|
+
Enter number (1-7):
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Select a model by number, then start chatting.
|
|
77
|
+
|
|
78
|
+
### Example Interactions
|
|
79
|
+
|
|
80
|
+
**Exploring a codebase:**
|
|
81
|
+
```
|
|
82
|
+
> where is the authentication logic?
|
|
83
|
+
|
|
84
|
+
⏺ Exploring: where is the authentication logic?
|
|
85
|
+
⎿ Question: where is the authentication logic? (+15 more lines)
|
|
86
|
+
|
|
87
|
+
Authentication is handled in two places:
|
|
88
|
+
- src/auth/login.js - main login logic with JWT token generation
|
|
89
|
+
- src/middleware/auth.js - route protection middleware
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Debugging a bug:**
|
|
93
|
+
```
|
|
94
|
+
> the todo app is not working, clicking the add button does nothing
|
|
95
|
+
|
|
96
|
+
⏺ Listing directory
|
|
97
|
+
⎿ ├── app.js (+5 more)
|
|
98
|
+
|
|
99
|
+
⏺ Reading app.js
|
|
100
|
+
⎿ 1| import { loadTodos, saveTodos } from "./storage"; ...
|
|
101
|
+
|
|
102
|
+
⏺ Patching app.js
|
|
103
|
+
⎿ ok: applied 1 hunk(s)
|
|
104
|
+
|
|
105
|
+
⏺ Verifying: npm test
|
|
106
|
+
⎿ {"status":"success","attempts":1,"output":"PASS"}
|
|
107
|
+
|
|
108
|
+
Fixed! The addBtn click handler was missing. Added it and verified tests pass.
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
**Creating a new file:**
|
|
112
|
+
```
|
|
113
|
+
> create a utility function to format dates in utils.js
|
|
114
|
+
|
|
115
|
+
⏺ Writing to utils.js
|
|
116
|
+
⎿ ok
|
|
117
|
+
|
|
118
|
+
Created utils.js with a formatDate function that handles ISO strings and Date objects.
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
**Running commands with verification:**
|
|
122
|
+
```
|
|
123
|
+
> run the tests and make sure they all pass
|
|
124
|
+
|
|
125
|
+
⏺ Verifying: npm test
|
|
126
|
+
⎿ {"status":"success","attempts":1,"output":"PASS src/todo.test.js..."}
|
|
127
|
+
|
|
128
|
+
All 5 tests passed.
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
**Applying multi-line changes:**
|
|
132
|
+
```
|
|
133
|
+
> add error handling to the fetchUser function
|
|
134
|
+
|
|
135
|
+
⏺ Reading api.js
|
|
136
|
+
⎿ 1| export async function fetchUser(id) { ...
|
|
137
|
+
|
|
138
|
+
⏺ Patching api.js
|
|
139
|
+
⎿ ok: applied 1 hunk(s)
|
|
140
|
+
|
|
141
|
+
⏺ Verifying: npm test
|
|
142
|
+
⎿ {"status":"success","attempts":1,"output":"PASS"}
|
|
143
|
+
|
|
144
|
+
Added try/catch with proper error handling. Tests still pass.
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### Commands
|
|
148
|
+
|
|
149
|
+
| Command | Description |
|
|
150
|
+
|---------|-------------|
|
|
151
|
+
| `/h` | Show help |
|
|
152
|
+
| `/c` | Clear conversation context |
|
|
153
|
+
| `/q` | Quit |
|
|
154
|
+
| `/history` | Show recent commands |
|
|
155
|
+
| `Ctrl+C` | Exit |
|
|
156
|
+
|
|
157
|
+
## Available Tools
|
|
158
|
+
|
|
159
|
+
### Meta-Tools (Preferred)
|
|
160
|
+
|
|
161
|
+
| Tool | Description |
|
|
162
|
+
|------|-------------|
|
|
163
|
+
| `explore` | Answer "where is X?" / "how does Y work?" questions automatically |
|
|
164
|
+
| `patch` | Apply unified diffs for multi-line edits (more reliable than string replace) |
|
|
165
|
+
| `verify` | Run commands and check success/failure with optional retries |
|
|
166
|
+
|
|
167
|
+
### Primitive Tools
|
|
168
|
+
|
|
169
|
+
| Tool | Description |
|
|
170
|
+
|------|-------------|
|
|
171
|
+
| `read` | Read file contents with line numbers |
|
|
172
|
+
| `write` | Create or overwrite a file |
|
|
173
|
+
| `edit` | Modify a file (find & replace, must be unique match) |
|
|
174
|
+
| `glob` | Find files by pattern (`*.js`, `**/*.test.rb`) |
|
|
175
|
+
| `grep` | Search file contents with regex |
|
|
176
|
+
| `find` | Locate files by name substring |
|
|
177
|
+
| `tree` | Show directory structure |
|
|
178
|
+
| `bash` | Run shell commands (git, npm, python, etc.) |
|
|
179
|
+
|
|
180
|
+
## Supported Models
|
|
181
|
+
|
|
182
|
+
| Model | Provider | Best For |
|
|
183
|
+
|-------|----------|----------|
|
|
184
|
+
| GPT-4o | OpenAI | General coding tasks |
|
|
185
|
+
| GPT-4o Mini | OpenAI | Fast, cheap tasks |
|
|
186
|
+
| Claude Sonnet 4 | Anthropic | Complex reasoning |
|
|
187
|
+
| Claude 3.5 Sonnet | Anthropic | Code generation |
|
|
188
|
+
| Gemini 2.0 Flash | Google | Fast responses |
|
|
189
|
+
| Llama 3.3 70B | Meta | Open source option |
|
|
190
|
+
| DeepSeek Chat | DeepSeek | Strong reasoning |
|
|
191
|
+
|
|
192
|
+
## Data Storage
|
|
193
|
+
|
|
194
|
+
Ruboto stores data in `~/.ruboto/`:
|
|
195
|
+
|
|
196
|
+
| File | Purpose |
|
|
197
|
+
|------|---------|
|
|
198
|
+
| `history.db` | Conversation history (SQLite) |
|
|
199
|
+
|
|
200
|
+
## Requirements
|
|
201
|
+
|
|
202
|
+
- Ruby 3.0+
|
|
203
|
+
- SQLite3 (usually pre-installed on macOS/Linux)
|
|
204
|
+
- OpenRouter API key ([get one here](https://openrouter.ai/keys))
|
|
205
|
+
|
|
206
|
+
## Development
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
# Clone the repo
|
|
210
|
+
git clone https://github.com/akhilgautam/ruboto-ai.git
|
|
211
|
+
cd ruboto-ai
|
|
212
|
+
|
|
213
|
+
# Run directly without installing
|
|
214
|
+
ruby -Ilib bin/ruboto-ai
|
|
215
|
+
|
|
216
|
+
# Build the gem
|
|
217
|
+
gem build ruboto.gemspec
|
|
218
|
+
|
|
219
|
+
# Install locally
|
|
220
|
+
gem install ruboto-ai-0.1.0.gem
|
|
221
|
+
|
|
222
|
+
# Uninstall
|
|
223
|
+
gem uninstall ruboto-ai
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## License
|
|
227
|
+
|
|
228
|
+
MIT - See [LICENSE.txt](LICENSE.txt)
|
data/bin/ruboto-ai
ADDED
data/lib/ruboto.rb
ADDED
|
@@ -0,0 +1,1056 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "net/http"
|
|
5
|
+
require "uri"
|
|
6
|
+
require "openssl"
|
|
7
|
+
require "readline"
|
|
8
|
+
require "open3"
|
|
9
|
+
|
|
10
|
+
require_relative "ruboto/version"
|
|
11
|
+
|
|
12
|
+
module Ruboto
|
|
13
|
+
API_URL = "https://openrouter.ai/api/v1/chat/completions"
|
|
14
|
+
|
|
15
|
+
MODELS = [
|
|
16
|
+
{ id: "openai/gpt-4o", name: "GPT-4o", desc: "OpenAI flagship" },
|
|
17
|
+
{ id: "openai/gpt-4o-mini", name: "GPT-4o Mini", desc: "Fast & cheap" },
|
|
18
|
+
{ id: "anthropic/claude-sonnet-4", name: "Claude Sonnet 4", desc: "Anthropic's latest" },
|
|
19
|
+
{ id: "anthropic/claude-3.5-sonnet", name: "Claude 3.5 Sonnet", desc: "Great for code" },
|
|
20
|
+
{ id: "google/gemini-2.0-flash-001", name: "Gemini 2.0 Flash", desc: "Google's fast model" },
|
|
21
|
+
{ id: "meta-llama/llama-3.3-70b-instruct", name: "Llama 3.3 70B", desc: "Open source" },
|
|
22
|
+
{ id: "deepseek/deepseek-chat", name: "DeepSeek Chat", desc: "Strong reasoning" }
|
|
23
|
+
].freeze
|
|
24
|
+
|
|
25
|
+
# ANSI colors
|
|
26
|
+
RESET = "\033[0m"
|
|
27
|
+
BOLD = "\033[1m"
|
|
28
|
+
DIM = "\033[2m"
|
|
29
|
+
BLUE = "\033[34m"
|
|
30
|
+
CYAN = "\033[36m"
|
|
31
|
+
GREEN = "\033[32m"
|
|
32
|
+
YELLOW = "\033[33m"
|
|
33
|
+
RED = "\033[31m"
|
|
34
|
+
|
|
35
|
+
MAX_OUTPUT = 4000
|
|
36
|
+
IGNORE_DIRS = [".git", "node_modules", "__pycache__", ".venv", "venv", ".bundle", "vendor", "tmp", "log", "coverage"].freeze
|
|
37
|
+
|
|
38
|
+
# History configuration
|
|
39
|
+
RUBOTO_DIR = File.expand_path("~/.ruboto")
|
|
40
|
+
DB_PATH = File.join(RUBOTO_DIR, "history.db")
|
|
41
|
+
MAX_HISTORY_LOAD = 100
|
|
42
|
+
|
|
43
|
+
# Spinner frames (braille dots for smooth animation)
|
|
44
|
+
SPINNER_FRAMES = %w[⠋ ⠙ ⠹ ⠸ ⠼ ⠴ ⠦ ⠧ ⠇ ⠏].freeze
|
|
45
|
+
|
|
46
|
+
LOGO = <<~'ASCII'
|
|
47
|
+
██████╗ ██╗ ██╗██████╗ ██████╗ ████████╗ ██████╗
|
|
48
|
+
██╔══██╗██║ ██║██╔══██╗██╔═══██╗╚══██╔══╝██╔═══██╗
|
|
49
|
+
██████╔╝██║ ██║██████╔╝██║ ██║ ██║ ██║ ██║
|
|
50
|
+
██╔══██╗██║ ██║██╔══██╗██║ ██║ ██║ ██║ ██║
|
|
51
|
+
██║ ██║╚██████╔╝██████╔╝╚██████╔╝ ██║ ╚██████╔╝
|
|
52
|
+
╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═╝ ╚═════╝
|
|
53
|
+
ASCII
|
|
54
|
+
|
|
55
|
+
TAGLINES = [
|
|
56
|
+
"Your mass-produced artisanal code monkey",
|
|
57
|
+
"Writes code. Breaks things. Blames the compiler.",
|
|
58
|
+
"50% AI, 50% chaos, 100% overconfident",
|
|
59
|
+
"I've read Stack Overflow so you don't have to",
|
|
60
|
+
"Will code for electricity",
|
|
61
|
+
"Professional YAML indenter",
|
|
62
|
+
"I put the 'pro' in 'probably works'",
|
|
63
|
+
"Powered by mass compute and strong opinions"
|
|
64
|
+
].freeze
|
|
65
|
+
|
|
66
|
+
class << self
|
|
67
|
+
# Human-readable tool action messages
|
|
68
|
+
def tool_message(name, args)
|
|
69
|
+
case name
|
|
70
|
+
when "read"
|
|
71
|
+
path = args["path"] || "file"
|
|
72
|
+
"Reading #{File.basename(path)}"
|
|
73
|
+
when "write"
|
|
74
|
+
path = args["path"] || "file"
|
|
75
|
+
"Writing to #{File.basename(path)}"
|
|
76
|
+
when "edit"
|
|
77
|
+
path = args["path"] || "file"
|
|
78
|
+
"Editing #{File.basename(path)}"
|
|
79
|
+
when "glob"
|
|
80
|
+
pattern = args["pattern"] || "*"
|
|
81
|
+
"Searching for #{pattern}"
|
|
82
|
+
when "grep"
|
|
83
|
+
pattern = args["pattern"] || "pattern"
|
|
84
|
+
"Searching for '#{pattern[0, 30]}'"
|
|
85
|
+
when "bash"
|
|
86
|
+
cmd = args["cmd"] || ""
|
|
87
|
+
cmd_preview = cmd.split.first(2).join(" ")
|
|
88
|
+
"Running #{cmd_preview}"
|
|
89
|
+
when "tree"
|
|
90
|
+
path = args["path"] || "."
|
|
91
|
+
"Listing #{path == "." ? "directory" : path}"
|
|
92
|
+
when "find"
|
|
93
|
+
name_arg = args["name"] || ""
|
|
94
|
+
"Finding files matching '#{name_arg}'"
|
|
95
|
+
when "explore"
|
|
96
|
+
question = args["question"] || "codebase"
|
|
97
|
+
"Exploring: #{question[0, 40]}#{question.length > 40 ? '...' : ''}"
|
|
98
|
+
when "verify"
|
|
99
|
+
cmd = args["command"] || ""
|
|
100
|
+
cmd_preview = cmd.split.first(2).join(" ")
|
|
101
|
+
"Verifying: #{cmd_preview}"
|
|
102
|
+
when "patch"
|
|
103
|
+
path = args["path"] || "file"
|
|
104
|
+
"Patching #{File.basename(path)}"
|
|
105
|
+
else
|
|
106
|
+
name.capitalize.to_s
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Run a block with a spinner, returns the block's result
|
|
111
|
+
def with_spinner(message)
|
|
112
|
+
result = nil
|
|
113
|
+
done = false
|
|
114
|
+
spinner_thread = Thread.new do
|
|
115
|
+
i = 0
|
|
116
|
+
while !done
|
|
117
|
+
print "\r#{YELLOW}#{SPINNER_FRAMES[i % SPINNER_FRAMES.length]}#{RESET} #{message}"
|
|
118
|
+
$stdout.flush
|
|
119
|
+
sleep 0.08
|
|
120
|
+
i += 1
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
begin
|
|
125
|
+
result = yield
|
|
126
|
+
ensure
|
|
127
|
+
done = true
|
|
128
|
+
spinner_thread.join
|
|
129
|
+
print "\r#{GREEN}⏺#{RESET} #{message}\n"
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
result
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# --- Tool implementations ---
|
|
136
|
+
|
|
137
|
+
def tool_read(args)
|
|
138
|
+
path = args["path"]
|
|
139
|
+
offset = args["offset"] || 0
|
|
140
|
+
limit = args["limit"]
|
|
141
|
+
|
|
142
|
+
lines = File.readlines(path)
|
|
143
|
+
limit ||= lines.length
|
|
144
|
+
selected = lines[offset, limit] || []
|
|
145
|
+
|
|
146
|
+
selected.each_with_index.map { |line, idx| format("%4d| %s", offset + idx + 1, line) }.join
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def tool_write(args)
|
|
150
|
+
File.write(args["path"], args["content"])
|
|
151
|
+
"ok"
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def tool_edit(args)
|
|
155
|
+
path, old, new_str = args["path"], args["old"], args["new"]
|
|
156
|
+
replace_all = args["all"] || false
|
|
157
|
+
|
|
158
|
+
text = File.read(path)
|
|
159
|
+
|
|
160
|
+
return "error: old_string not found" unless text.include?(old)
|
|
161
|
+
|
|
162
|
+
count = text.scan(old).length
|
|
163
|
+
if !replace_all && count > 1
|
|
164
|
+
return "error: old_string appears #{count} times, must be unique (use all=true)"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
replacement = replace_all ? text.gsub(old, new_str) : text.sub(old, new_str)
|
|
168
|
+
File.write(path, replacement)
|
|
169
|
+
"ok"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def tool_glob(args)
|
|
173
|
+
base_path = args["path"] || "."
|
|
174
|
+
pattern = File.join(base_path, args["pattern"]).gsub("//", "/")
|
|
175
|
+
|
|
176
|
+
files = Dir.glob(pattern, File::FNM_DOTMATCH)
|
|
177
|
+
files = files.select { |f| File.file?(f) }
|
|
178
|
+
.sort_by { |f| -File.mtime(f).to_i }
|
|
179
|
+
|
|
180
|
+
files.empty? ? "none" : files.join("\n")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def tool_grep(args)
|
|
184
|
+
pattern = Regexp.new(args["pattern"])
|
|
185
|
+
path = args["path"] || "."
|
|
186
|
+
type = args["type"]
|
|
187
|
+
limit = args["limit"] || 30
|
|
188
|
+
|
|
189
|
+
glob_pattern = type ? File.join(path, "**", "*.#{type}") : File.join(path, "**", "*")
|
|
190
|
+
hits = []
|
|
191
|
+
|
|
192
|
+
Dir.glob(glob_pattern).each do |filepath|
|
|
193
|
+
next unless File.file?(filepath)
|
|
194
|
+
begin
|
|
195
|
+
File.readlines(filepath).each_with_index do |line, idx|
|
|
196
|
+
if pattern.match?(line)
|
|
197
|
+
hits << "#{filepath}:#{idx + 1}:#{line.rstrip}"
|
|
198
|
+
break if hits.length >= limit
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
rescue
|
|
202
|
+
# Skip unreadable files
|
|
203
|
+
end
|
|
204
|
+
break if hits.length >= limit
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
hits.empty? ? "none" : hits.join("\n")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def tool_bash(args)
|
|
211
|
+
cmd = args["cmd"]
|
|
212
|
+
|
|
213
|
+
# Allowlist of valid command prefixes
|
|
214
|
+
valid_commands = %w[
|
|
215
|
+
git npm npx node ruby python python3 pip pip3 cargo rustc go
|
|
216
|
+
ls cat head tail less more file wc grep awk sed find xargs
|
|
217
|
+
cd pwd mkdir rmdir rm cp mv ln chmod chown touch
|
|
218
|
+
curl wget ssh scp rsync tar zip unzip gzip gunzip
|
|
219
|
+
docker docker-compose kubectl helm
|
|
220
|
+
make cmake gcc g++ clang javac java
|
|
221
|
+
bundle gem rake rails yarn pnpm bun deno
|
|
222
|
+
brew apt yum dnf pacman
|
|
223
|
+
echo printf test expr date cal
|
|
224
|
+
ps top kill pkill htop df du free
|
|
225
|
+
open code vim nano
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
first_word = cmd.strip.split(/\s+/).first&.downcase || ""
|
|
229
|
+
|
|
230
|
+
unless valid_commands.include?(first_word)
|
|
231
|
+
return "error: '#{first_word}' is not a recognized command. Use bash only for shell commands like: git, npm, node, python, ls, etc."
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# Reject backticks as a safety measure
|
|
235
|
+
if cmd.include?("`")
|
|
236
|
+
return "error: backticks not allowed in commands (causes shell command substitution)"
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
output = `#{cmd} 2>&1`
|
|
240
|
+
output.strip.empty? ? "(empty)" : output.strip
|
|
241
|
+
rescue => e
|
|
242
|
+
"error: #{e.message}"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def tool_tree(args)
|
|
246
|
+
path = args["path"] || "."
|
|
247
|
+
depth = args["depth"] || 3
|
|
248
|
+
result = get_file_tree(path, depth)
|
|
249
|
+
result.empty? ? "(empty)" : result
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def tool_find(args)
|
|
253
|
+
name = args["name"]
|
|
254
|
+
path = args["path"] || "."
|
|
255
|
+
|
|
256
|
+
matches = Dir.glob(File.join(path, "**", "*"))
|
|
257
|
+
.reject { |f| IGNORE_DIRS.any? { |i| f.split("/").include?(i) } }
|
|
258
|
+
.select { |f| File.file?(f) && File.basename(f).downcase.include?(name.downcase) }
|
|
259
|
+
.first(20)
|
|
260
|
+
|
|
261
|
+
matches.empty? ? "none" : matches.join("\n")
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def extract_keywords(question)
|
|
265
|
+
stop_words = %w[the a an is are was were what where how does do did can could would should this that these those]
|
|
266
|
+
words = question.downcase.gsub(/[^\w\s]/, '').split
|
|
267
|
+
words.reject { |w| stop_words.include?(w) || w.length < 3 }
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
def tool_explore(args)
|
|
271
|
+
question = args["question"]
|
|
272
|
+
scope = args["scope"] || "."
|
|
273
|
+
|
|
274
|
+
keywords = extract_keywords(question)
|
|
275
|
+
return "error: couldn't extract keywords from question" if keywords.empty?
|
|
276
|
+
|
|
277
|
+
# Phase 1: Get structure overview
|
|
278
|
+
structure = get_file_tree(scope, 2)
|
|
279
|
+
|
|
280
|
+
# Phase 2: Search for keywords
|
|
281
|
+
pattern = keywords.first(3).join("|")
|
|
282
|
+
hits = tool_grep("pattern" => pattern, "path" => scope, "limit" => 15)
|
|
283
|
+
|
|
284
|
+
if hits == "none"
|
|
285
|
+
# Fallback: try glob for filenames
|
|
286
|
+
keywords.each do |kw|
|
|
287
|
+
file_hits = tool_find("name" => kw, "path" => scope)
|
|
288
|
+
next if file_hits == "none"
|
|
289
|
+
hits = file_hits
|
|
290
|
+
break
|
|
291
|
+
end
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
return "No matches found for: #{question}\n\nDirectory structure:\n#{structure}" if hits == "none"
|
|
295
|
+
|
|
296
|
+
# Phase 3: Extract unique files and read top 3
|
|
297
|
+
files = hits.lines.map { |l| l.split(":").first }.uniq.first(3)
|
|
298
|
+
|
|
299
|
+
context = files.map do |f|
|
|
300
|
+
content = tool_read("path" => f, "limit" => 50)
|
|
301
|
+
"=== #{f} ===\n#{content}"
|
|
302
|
+
end.join("\n\n")
|
|
303
|
+
|
|
304
|
+
"Question: #{question}\n\nFiles found: #{files.join(', ')}\n\n#{context}"
|
|
305
|
+
rescue => e
|
|
306
|
+
"error: #{e.message}"
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
def tool_verify(args)
|
|
310
|
+
cmd = args["command"]
|
|
311
|
+
expect_pattern = args["expect_pattern"]
|
|
312
|
+
fail_pattern = args["fail_pattern"]
|
|
313
|
+
retries = args["retries"] || 0
|
|
314
|
+
retries = [retries, 10].min
|
|
315
|
+
|
|
316
|
+
# Validate command against allowlist
|
|
317
|
+
first_word = cmd.strip.split(/\s+/).first&.downcase || ""
|
|
318
|
+
valid_commands = %w[
|
|
319
|
+
git npm npx node ruby python python3 pip pip3 cargo rustc go
|
|
320
|
+
ls cat head tail less more file wc grep awk sed find xargs
|
|
321
|
+
bundle gem rake rails yarn pnpm bun deno
|
|
322
|
+
make cmake gcc g++ clang javac java
|
|
323
|
+
pytest rspec jest mocha
|
|
324
|
+
echo test expr
|
|
325
|
+
]
|
|
326
|
+
|
|
327
|
+
unless valid_commands.include?(first_word)
|
|
328
|
+
return { status: "error", message: "command '#{first_word}' not in allowlist" }.to_json
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
if cmd.include?("`")
|
|
332
|
+
return { status: "error", message: "backticks not allowed in commands" }.to_json
|
|
333
|
+
end
|
|
334
|
+
|
|
335
|
+
attempts = 0
|
|
336
|
+
output = ""
|
|
337
|
+
exit_code = 0
|
|
338
|
+
|
|
339
|
+
loop do
|
|
340
|
+
attempts += 1
|
|
341
|
+
output = `#{cmd} 2>&1`
|
|
342
|
+
exit_code = $?.exitstatus
|
|
343
|
+
|
|
344
|
+
passed = exit_code == 0
|
|
345
|
+
passed &&= output.match?(Regexp.new(expect_pattern)) if expect_pattern
|
|
346
|
+
passed = false if fail_pattern && output.match?(Regexp.new(fail_pattern))
|
|
347
|
+
|
|
348
|
+
if passed
|
|
349
|
+
return {
|
|
350
|
+
status: "success",
|
|
351
|
+
attempts: attempts,
|
|
352
|
+
output: truncate_output(output, 1000)
|
|
353
|
+
}.to_json
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
break if attempts > retries
|
|
357
|
+
sleep 0.5
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
{
|
|
361
|
+
status: "failed",
|
|
362
|
+
attempts: attempts,
|
|
363
|
+
exit_code: exit_code,
|
|
364
|
+
output: truncate_output(output, 2000)
|
|
365
|
+
}.to_json
|
|
366
|
+
rescue => e
|
|
367
|
+
{ status: "error", message: e.message }.to_json
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
def parse_unified_diff(diff)
|
|
371
|
+
hunks = []
|
|
372
|
+
current_hunk = nil
|
|
373
|
+
|
|
374
|
+
diff.lines.each do |line|
|
|
375
|
+
if line.start_with?("@@")
|
|
376
|
+
match = line.match(/@@ -(\d+),?(\d*) \+(\d+),?(\d*) @@/)
|
|
377
|
+
if match
|
|
378
|
+
current_hunk = {
|
|
379
|
+
old_start: match[1].to_i,
|
|
380
|
+
old_count: match[2].empty? ? 1 : match[2].to_i,
|
|
381
|
+
new_start: match[3].to_i,
|
|
382
|
+
new_count: match[4].empty? ? 1 : match[4].to_i,
|
|
383
|
+
old_lines: [],
|
|
384
|
+
new_lines: []
|
|
385
|
+
}
|
|
386
|
+
hunks << current_hunk
|
|
387
|
+
end
|
|
388
|
+
elsif current_hunk
|
|
389
|
+
case line[0]
|
|
390
|
+
when "-"
|
|
391
|
+
current_hunk[:old_lines] << line[1..].chomp
|
|
392
|
+
when "+"
|
|
393
|
+
current_hunk[:new_lines] << line[1..].chomp
|
|
394
|
+
when " "
|
|
395
|
+
current_hunk[:old_lines] << line[1..].chomp
|
|
396
|
+
current_hunk[:new_lines] << line[1..].chomp
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
hunks
|
|
402
|
+
end
|
|
403
|
+
|
|
404
|
+
def fuzzy_find_hunk(lines, old_lines, expected_start, tolerance: 20)
|
|
405
|
+
return expected_start - 1 if old_lines.empty?
|
|
406
|
+
|
|
407
|
+
search_start = [expected_start - tolerance - 1, 0].max
|
|
408
|
+
search_end = [expected_start + tolerance - 1, lines.length - 1].min
|
|
409
|
+
|
|
410
|
+
(search_start..search_end).each do |idx|
|
|
411
|
+
match = old_lines.each_with_index.all? do |old_line, offset|
|
|
412
|
+
lines[idx + offset]&.chomp == old_line
|
|
413
|
+
end
|
|
414
|
+
return idx if match
|
|
415
|
+
end
|
|
416
|
+
|
|
417
|
+
# Fallback: search entire file
|
|
418
|
+
lines.each_with_index do |_, idx|
|
|
419
|
+
match = old_lines.each_with_index.all? do |old_line, offset|
|
|
420
|
+
lines[idx + offset]&.chomp == old_line
|
|
421
|
+
end
|
|
422
|
+
return idx if match
|
|
423
|
+
end
|
|
424
|
+
|
|
425
|
+
nil
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
def tool_patch(args)
|
|
429
|
+
path = args["path"]
|
|
430
|
+
diff = args["diff"]
|
|
431
|
+
|
|
432
|
+
return "error: file not found: #{path}" unless File.exist?(path)
|
|
433
|
+
|
|
434
|
+
lines = File.readlines(path)
|
|
435
|
+
hunks = parse_unified_diff(diff)
|
|
436
|
+
|
|
437
|
+
return "error: no valid hunks found in diff" if hunks.empty?
|
|
438
|
+
|
|
439
|
+
# Apply hunks in reverse order to preserve line numbers
|
|
440
|
+
hunks.reverse.each_with_index do |hunk, idx|
|
|
441
|
+
actual_start = fuzzy_find_hunk(lines, hunk[:old_lines], hunk[:old_start])
|
|
442
|
+
|
|
443
|
+
unless actual_start
|
|
444
|
+
return "error: couldn't locate hunk #{hunks.length - idx} near line #{hunk[:old_start]}"
|
|
445
|
+
end
|
|
446
|
+
|
|
447
|
+
lines.slice!(actual_start, hunk[:old_lines].length)
|
|
448
|
+
hunk[:new_lines].reverse.each do |new_line|
|
|
449
|
+
lines.insert(actual_start, new_line + "\n")
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
File.write(path, lines.join)
|
|
454
|
+
"ok: applied #{hunks.length} hunk(s)"
|
|
455
|
+
rescue => e
|
|
456
|
+
"error: #{e.message}"
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# --- Tool definitions ---
|
|
460
|
+
|
|
461
|
+
def tools
|
|
462
|
+
@tools ||= {
|
|
463
|
+
"read" => {
|
|
464
|
+
impl: method(:tool_read),
|
|
465
|
+
schema: {
|
|
466
|
+
type: "function",
|
|
467
|
+
name: "read",
|
|
468
|
+
description: "Read file with line numbers (file path, not directory)",
|
|
469
|
+
parameters: {
|
|
470
|
+
type: "object",
|
|
471
|
+
properties: {
|
|
472
|
+
path: { type: "string", description: "Path to the file" },
|
|
473
|
+
offset: { type: "integer", description: "Line offset to start from" },
|
|
474
|
+
limit: { type: "integer", description: "Number of lines to read" }
|
|
475
|
+
},
|
|
476
|
+
required: ["path"]
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
"write" => {
|
|
481
|
+
impl: method(:tool_write),
|
|
482
|
+
schema: {
|
|
483
|
+
type: "function",
|
|
484
|
+
name: "write",
|
|
485
|
+
description: "Write content to file",
|
|
486
|
+
parameters: {
|
|
487
|
+
type: "object",
|
|
488
|
+
properties: {
|
|
489
|
+
path: { type: "string", description: "Path to the file" },
|
|
490
|
+
content: { type: "string", description: "Content to write" }
|
|
491
|
+
},
|
|
492
|
+
required: ["path", "content"]
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
},
|
|
496
|
+
"edit" => {
|
|
497
|
+
impl: method(:tool_edit),
|
|
498
|
+
schema: {
|
|
499
|
+
type: "function",
|
|
500
|
+
name: "edit",
|
|
501
|
+
description: "Replace old with new in file (old must be unique unless all=true)",
|
|
502
|
+
parameters: {
|
|
503
|
+
type: "object",
|
|
504
|
+
properties: {
|
|
505
|
+
path: { type: "string", description: "Path to the file" },
|
|
506
|
+
old: { type: "string", description: "String to find and replace" },
|
|
507
|
+
new: { type: "string", description: "Replacement string" },
|
|
508
|
+
all: { type: "boolean", description: "Replace all occurrences" }
|
|
509
|
+
},
|
|
510
|
+
required: ["path", "old", "new"]
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
},
|
|
514
|
+
"glob" => {
|
|
515
|
+
impl: method(:tool_glob),
|
|
516
|
+
schema: {
|
|
517
|
+
type: "function",
|
|
518
|
+
name: "glob",
|
|
519
|
+
description: "Find files by pattern, sorted by mtime",
|
|
520
|
+
parameters: {
|
|
521
|
+
type: "object",
|
|
522
|
+
properties: {
|
|
523
|
+
pattern: { type: "string", description: "Glob pattern (e.g., **/*.rb)" },
|
|
524
|
+
path: { type: "string", description: "Base path to search from" }
|
|
525
|
+
},
|
|
526
|
+
required: ["pattern"]
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
"grep" => {
|
|
531
|
+
impl: method(:tool_grep),
|
|
532
|
+
schema: {
|
|
533
|
+
type: "function",
|
|
534
|
+
name: "grep",
|
|
535
|
+
description: "Search file contents for regex pattern",
|
|
536
|
+
parameters: {
|
|
537
|
+
type: "object",
|
|
538
|
+
properties: {
|
|
539
|
+
pattern: { type: "string", description: "Regex pattern to search for" },
|
|
540
|
+
path: { type: "string", description: "Directory to search (default: current)" },
|
|
541
|
+
type: { type: "string", description: "File extension filter (e.g., 'rb', 'js')" },
|
|
542
|
+
limit: { type: "integer", description: "Max results (default: 30)" }
|
|
543
|
+
},
|
|
544
|
+
required: ["pattern"]
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
"bash" => {
|
|
549
|
+
impl: method(:tool_bash),
|
|
550
|
+
schema: {
|
|
551
|
+
type: "function",
|
|
552
|
+
name: "bash",
|
|
553
|
+
description: "Run shell command",
|
|
554
|
+
parameters: {
|
|
555
|
+
type: "object",
|
|
556
|
+
properties: {
|
|
557
|
+
cmd: { type: "string", description: "Command to execute" }
|
|
558
|
+
},
|
|
559
|
+
required: ["cmd"]
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
},
|
|
563
|
+
"tree" => {
|
|
564
|
+
impl: method(:tool_tree),
|
|
565
|
+
schema: {
|
|
566
|
+
type: "function",
|
|
567
|
+
name: "tree",
|
|
568
|
+
description: "Show directory structure (use to orient yourself)",
|
|
569
|
+
parameters: {
|
|
570
|
+
type: "object",
|
|
571
|
+
properties: {
|
|
572
|
+
path: { type: "string", description: "Directory to show (default: current)" },
|
|
573
|
+
depth: { type: "integer", description: "Max depth (default: 3)" }
|
|
574
|
+
},
|
|
575
|
+
required: []
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
"find" => {
|
|
580
|
+
impl: method(:tool_find),
|
|
581
|
+
schema: {
|
|
582
|
+
type: "function",
|
|
583
|
+
name: "find",
|
|
584
|
+
description: "Find files by name (fast, no content reading)",
|
|
585
|
+
parameters: {
|
|
586
|
+
type: "object",
|
|
587
|
+
properties: {
|
|
588
|
+
name: { type: "string", description: "Filename substring to search for" },
|
|
589
|
+
path: { type: "string", description: "Directory to search (default: current)" }
|
|
590
|
+
},
|
|
591
|
+
required: ["name"]
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
"explore" => {
|
|
596
|
+
impl: method(:tool_explore),
|
|
597
|
+
schema: {
|
|
598
|
+
type: "function",
|
|
599
|
+
name: "explore",
|
|
600
|
+
description: "Answer questions about the codebase (where is X, how does Y work). Searches and reads relevant files automatically.",
|
|
601
|
+
parameters: {
|
|
602
|
+
type: "object",
|
|
603
|
+
properties: {
|
|
604
|
+
question: { type: "string", description: "What you want to know about the codebase" },
|
|
605
|
+
scope: { type: "string", description: "Directory to focus on (optional)" }
|
|
606
|
+
},
|
|
607
|
+
required: ["question"]
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
},
|
|
611
|
+
"verify" => {
|
|
612
|
+
impl: method(:tool_verify),
|
|
613
|
+
schema: {
|
|
614
|
+
type: "function",
|
|
615
|
+
name: "verify",
|
|
616
|
+
description: "Run a command and check if it succeeds. Use after code changes to verify they work.",
|
|
617
|
+
parameters: {
|
|
618
|
+
type: "object",
|
|
619
|
+
properties: {
|
|
620
|
+
command: { type: "string", description: "Command to run" },
|
|
621
|
+
expect_pattern: { type: "string", description: "Regex that should match output on success" },
|
|
622
|
+
fail_pattern: { type: "string", description: "Regex indicating failure" },
|
|
623
|
+
retries: { type: "integer", description: "Number of retries (default: 0)" }
|
|
624
|
+
},
|
|
625
|
+
required: ["command"]
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
},
|
|
629
|
+
"patch" => {
|
|
630
|
+
impl: method(:tool_patch),
|
|
631
|
+
schema: {
|
|
632
|
+
type: "function",
|
|
633
|
+
name: "patch",
|
|
634
|
+
description: "Apply a unified diff to a file. More reliable than string replacement for multi-line changes.",
|
|
635
|
+
parameters: {
|
|
636
|
+
type: "object",
|
|
637
|
+
properties: {
|
|
638
|
+
path: { type: "string", description: "File to patch" },
|
|
639
|
+
diff: { type: "string", description: "Unified diff format (like git diff output)" }
|
|
640
|
+
},
|
|
641
|
+
required: ["path", "diff"]
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
end
|
|
647
|
+
|
|
648
|
+
def truncate_output(result, max = MAX_OUTPUT)
|
|
649
|
+
return result if result.length <= max
|
|
650
|
+
result[0, max] + "\n... (truncated, #{result.length - max} chars omitted)"
|
|
651
|
+
end
|
|
652
|
+
|
|
653
|
+
def run_tool(name, args)
|
|
654
|
+
tool = tools[name]
|
|
655
|
+
return "error: unknown tool '#{name}'" unless tool
|
|
656
|
+
result = tool[:impl].call(args)
|
|
657
|
+
truncate_output(result)
|
|
658
|
+
rescue => e
|
|
659
|
+
"error: #{e.message}"
|
|
660
|
+
end
|
|
661
|
+
|
|
662
|
+
def tool_schemas
|
|
663
|
+
tools.values.map do |t|
|
|
664
|
+
schema = t[:schema]
|
|
665
|
+
{
|
|
666
|
+
type: "function",
|
|
667
|
+
function: {
|
|
668
|
+
name: schema[:name],
|
|
669
|
+
description: schema[:description],
|
|
670
|
+
parameters: schema[:parameters]
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
end
|
|
674
|
+
end
|
|
675
|
+
|
|
676
|
+
def call_api(messages, model)
|
|
677
|
+
uri = URI(API_URL)
|
|
678
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
679
|
+
http.use_ssl = true
|
|
680
|
+
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
|
|
681
|
+
http.cert_store = OpenSSL::X509::Store.new.tap(&:set_default_paths)
|
|
682
|
+
http.read_timeout = 120
|
|
683
|
+
|
|
684
|
+
request = Net::HTTP::Post.new(uri)
|
|
685
|
+
request["Content-Type"] = "application/json"
|
|
686
|
+
api_key = ENV['OPENROUTER_API_KEY']
|
|
687
|
+
raise "OPENROUTER_API_KEY environment variable is required" unless api_key
|
|
688
|
+
request["Authorization"] = "Bearer #{api_key}"
|
|
689
|
+
request["HTTP-Referer"] = "https://github.com/ruboto"
|
|
690
|
+
request["X-Title"] = "Ruboto"
|
|
691
|
+
|
|
692
|
+
body = {
|
|
693
|
+
model: model,
|
|
694
|
+
messages: messages,
|
|
695
|
+
tools: tool_schemas
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
request.body = body.to_json
|
|
699
|
+
|
|
700
|
+
response = http.request(request)
|
|
701
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
702
|
+
puts "#{RED}Debug - Response body: #{response.body}#{RESET}"
|
|
703
|
+
return { "error" => { "message" => "HTTP #{response.code}: #{response.message}" } }
|
|
704
|
+
end
|
|
705
|
+
JSON.parse(response.body)
|
|
706
|
+
end
|
|
707
|
+
|
|
708
|
+
def get_file_tree(path = ".", depth = 3, prefix = "")
|
|
709
|
+
return "" if depth <= 0
|
|
710
|
+
|
|
711
|
+
entries = Dir.entries(path) - [".", ".."]
|
|
712
|
+
entries = entries.reject { |e| e.start_with?(".") || IGNORE_DIRS.include?(e) }
|
|
713
|
+
entries = entries.sort_by { |e| [File.directory?(File.join(path, e)) ? 0 : 1, e.downcase] }
|
|
714
|
+
|
|
715
|
+
lines = []
|
|
716
|
+
entries.each_with_index do |entry, idx|
|
|
717
|
+
full_path = File.join(path, entry)
|
|
718
|
+
is_last = idx == entries.length - 1
|
|
719
|
+
connector = is_last ? "└── " : "├── "
|
|
720
|
+
|
|
721
|
+
if File.directory?(full_path)
|
|
722
|
+
lines << "#{prefix}#{connector}#{entry}/"
|
|
723
|
+
extension = is_last ? " " : "│ "
|
|
724
|
+
lines << get_file_tree(full_path, depth - 1, prefix + extension)
|
|
725
|
+
else
|
|
726
|
+
lines << "#{prefix}#{connector}#{entry}"
|
|
727
|
+
end
|
|
728
|
+
end
|
|
729
|
+
|
|
730
|
+
lines.reject(&:empty?).join("\n")
|
|
731
|
+
end
|
|
732
|
+
|
|
733
|
+
def separator
|
|
734
|
+
width = [`tput cols`.to_i, 80].min
|
|
735
|
+
width = 80 if width <= 0
|
|
736
|
+
"#{DIM}#{'─' * width}#{RESET}"
|
|
737
|
+
end
|
|
738
|
+
|
|
739
|
+
def render_markdown(text)
|
|
740
|
+
text.gsub(/\*\*(.+?)\*\*/m, "#{BOLD}\\1#{RESET}")
|
|
741
|
+
end
|
|
742
|
+
|
|
743
|
+
# --- History persistence ---
|
|
744
|
+
|
|
745
|
+
def ensure_db_exists
|
|
746
|
+
Dir.mkdir(RUBOTO_DIR) unless Dir.exist?(RUBOTO_DIR)
|
|
747
|
+
|
|
748
|
+
schema = <<~SQL
|
|
749
|
+
CREATE TABLE IF NOT EXISTS messages (
|
|
750
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
751
|
+
role TEXT NOT NULL,
|
|
752
|
+
content TEXT NOT NULL,
|
|
753
|
+
session_id TEXT,
|
|
754
|
+
working_dir TEXT,
|
|
755
|
+
created_at TEXT DEFAULT (datetime('now'))
|
|
756
|
+
);
|
|
757
|
+
SQL
|
|
758
|
+
|
|
759
|
+
run_sql(schema)
|
|
760
|
+
end
|
|
761
|
+
|
|
762
|
+
def run_sql(sql)
|
|
763
|
+
output, _status = Open3.capture2('sqlite3', DB_PATH, sql)
|
|
764
|
+
output.strip
|
|
765
|
+
rescue => e
|
|
766
|
+
""
|
|
767
|
+
end
|
|
768
|
+
|
|
769
|
+
def save_message(role, content, session_id = nil)
|
|
770
|
+
escaped_content = content.gsub('"', '""').gsub('$', '\$')
|
|
771
|
+
escaped_dir = Dir.pwd.gsub('"', '""')
|
|
772
|
+
session_part = session_id ? "'#{session_id}'" : "NULL"
|
|
773
|
+
|
|
774
|
+
sql = "INSERT INTO messages (role, content, session_id, working_dir) " \
|
|
775
|
+
"VALUES ('#{role}', \"#{escaped_content}\", #{session_part}, \"#{escaped_dir}\");"
|
|
776
|
+
run_sql(sql)
|
|
777
|
+
end
|
|
778
|
+
|
|
779
|
+
def load_readline_history
|
|
780
|
+
sql = "SELECT content FROM messages WHERE role='user' ORDER BY id DESC LIMIT #{MAX_HISTORY_LOAD};"
|
|
781
|
+
entries = run_sql(sql).split("\n").reverse
|
|
782
|
+
entries.each { |cmd| Readline::HISTORY << cmd }
|
|
783
|
+
rescue
|
|
784
|
+
# Ignore history load errors
|
|
785
|
+
end
|
|
786
|
+
|
|
787
|
+
def terminal_width
|
|
788
|
+
width = `tput cols 2>/dev/null`.to_i
|
|
789
|
+
width > 0 ? width : 80
|
|
790
|
+
end
|
|
791
|
+
|
|
792
|
+
def center_text(text, width)
|
|
793
|
+
padding = [(width - text.gsub(/\e\[[0-9;]*m/, '').length) / 2, 0].max
|
|
794
|
+
" " * padding + text
|
|
795
|
+
end
|
|
796
|
+
|
|
797
|
+
def print_startup
|
|
798
|
+
width = terminal_width
|
|
799
|
+
colors = [RED, YELLOW, GREEN, CYAN, BLUE, "\033[35m"]
|
|
800
|
+
|
|
801
|
+
# Clear screen and hide cursor
|
|
802
|
+
print "\033[2J\033[H\033[?25l"
|
|
803
|
+
|
|
804
|
+
# Animate logo reveal line by line with color wave
|
|
805
|
+
logo_lines = LOGO.lines.map(&:chomp)
|
|
806
|
+
|
|
807
|
+
logo_lines.each_with_index do |line, idx|
|
|
808
|
+
color = colors[idx % colors.length]
|
|
809
|
+
centered = center_text(line, width)
|
|
810
|
+
print "\r#{color}#{centered}#{RESET}"
|
|
811
|
+
puts
|
|
812
|
+
sleep 0.06
|
|
813
|
+
end
|
|
814
|
+
|
|
815
|
+
# Pause then show tagline with typewriter effect
|
|
816
|
+
sleep 0.3
|
|
817
|
+
tagline = TAGLINES.sample
|
|
818
|
+
centered_tag = center_text(tagline, width)
|
|
819
|
+
|
|
820
|
+
puts
|
|
821
|
+
print " " * [(width - tagline.length) / 2, 0].max
|
|
822
|
+
tagline.each_char do |c|
|
|
823
|
+
print "#{DIM}#{c}#{RESET}"
|
|
824
|
+
$stdout.flush
|
|
825
|
+
sleep 0.02
|
|
826
|
+
end
|
|
827
|
+
puts
|
|
828
|
+
|
|
829
|
+
# Show directory
|
|
830
|
+
sleep 0.2
|
|
831
|
+
info = File.basename(Dir.pwd).to_s
|
|
832
|
+
puts center_text("#{DIM}#{info}#{RESET}", width)
|
|
833
|
+
|
|
834
|
+
# Show cursor again
|
|
835
|
+
print "\033[?25h"
|
|
836
|
+
|
|
837
|
+
# Brief pause before prompt
|
|
838
|
+
sleep 0.3
|
|
839
|
+
puts
|
|
840
|
+
end
|
|
841
|
+
|
|
842
|
+
def select_model
|
|
843
|
+
width = terminal_width
|
|
844
|
+
|
|
845
|
+
puts center_text("#{CYAN}Select a model:#{RESET}", width)
|
|
846
|
+
puts
|
|
847
|
+
|
|
848
|
+
MODELS.each_with_index do |model, idx|
|
|
849
|
+
num = "#{BOLD}#{idx + 1}#{RESET}"
|
|
850
|
+
name = "#{CYAN}#{model[:name]}#{RESET}"
|
|
851
|
+
desc = "#{DIM}#{model[:desc]}#{RESET}"
|
|
852
|
+
puts " #{num}. #{name} #{desc}"
|
|
853
|
+
end
|
|
854
|
+
|
|
855
|
+
puts
|
|
856
|
+
print " #{DIM}Enter number (1-#{MODELS.length}):#{RESET} "
|
|
857
|
+
|
|
858
|
+
loop do
|
|
859
|
+
input = gets&.strip
|
|
860
|
+
return MODELS[0][:id] if input.nil? || input.empty?
|
|
861
|
+
|
|
862
|
+
num = input.to_i
|
|
863
|
+
if num >= 1 && num <= MODELS.length
|
|
864
|
+
selected = MODELS[num - 1]
|
|
865
|
+
puts "\n #{GREEN}✓#{RESET} Using #{BOLD}#{selected[:name]}#{RESET}"
|
|
866
|
+
puts
|
|
867
|
+
return selected[:id]
|
|
868
|
+
else
|
|
869
|
+
print " #{RED}Invalid choice.#{RESET} Enter 1-#{MODELS.length}: "
|
|
870
|
+
end
|
|
871
|
+
end
|
|
872
|
+
end
|
|
873
|
+
|
|
874
|
+
def print_help
|
|
875
|
+
puts <<~HELP
|
|
876
|
+
#{CYAN}Examples:#{RESET}
|
|
877
|
+
#{DIM}•#{RESET} "Find all TODO comments in this project"
|
|
878
|
+
#{DIM}•#{RESET} "Explain what the main function does"
|
|
879
|
+
#{DIM}•#{RESET} "Add error handling to tool_read"
|
|
880
|
+
#{DIM}•#{RESET} "Run the tests and fix any failures"
|
|
881
|
+
|
|
882
|
+
#{CYAN}Commands:#{RESET}
|
|
883
|
+
#{BOLD}/q#{RESET} #{DIM}quit#{RESET}
|
|
884
|
+
#{BOLD}/c#{RESET} #{DIM}clear conversation context#{RESET}
|
|
885
|
+
#{BOLD}/h#{RESET} #{DIM}show this help#{RESET}
|
|
886
|
+
#{BOLD}/history#{RESET} #{DIM}show recent commands#{RESET}
|
|
887
|
+
HELP
|
|
888
|
+
end
|
|
889
|
+
|
|
890
|
+
def run
|
|
891
|
+
ensure_db_exists
|
|
892
|
+
load_readline_history
|
|
893
|
+
print_startup
|
|
894
|
+
|
|
895
|
+
# Model selection
|
|
896
|
+
model = select_model
|
|
897
|
+
|
|
898
|
+
session_id = Time.now.strftime("%Y%m%d_%H%M%S")
|
|
899
|
+
|
|
900
|
+
system_prompt = <<~PROMPT
|
|
901
|
+
You are a fast, autonomous coding assistant. Working directory: #{Dir.pwd}
|
|
902
|
+
|
|
903
|
+
TOOL HIERARCHY - Use highest-level tool that fits:
|
|
904
|
+
|
|
905
|
+
1. META-TOOLS (prefer these):
|
|
906
|
+
- explore: Answer "where is X?" / "how does Y work?" questions
|
|
907
|
+
- patch: Multi-line edits using unified diff format
|
|
908
|
+
- verify: Check if command succeeds (use after code changes)
|
|
909
|
+
|
|
910
|
+
2. PRIMITIVES (when meta-tools don't fit):
|
|
911
|
+
- read/write/edit: Single, targeted file operations
|
|
912
|
+
- grep/glob/find: When you know exactly what to search for
|
|
913
|
+
- tree: See directory structure
|
|
914
|
+
- bash: Run shell commands (only real commands, not prose)
|
|
915
|
+
|
|
916
|
+
AUTONOMY RULES:
|
|
917
|
+
- ACT FIRST. Never ask "should I...?" or "would you like me to...?" - just do it
|
|
918
|
+
- After ANY code change → immediately use verify to check it works
|
|
919
|
+
- If verify fails → read the error, fix it, verify again
|
|
920
|
+
- Keep using tools until you have a complete answer
|
|
921
|
+
- Only ask questions when genuinely choosing between approaches
|
|
922
|
+
|
|
923
|
+
CRITICAL - BASH TOOL RULES:
|
|
924
|
+
- ONLY use bash for executable commands: git, npm, python, node, ls, etc.
|
|
925
|
+
- NEVER put prose, explanations, or markdown in bash
|
|
926
|
+
- To communicate with user, just respond with text - no tool needed
|
|
927
|
+
- NEVER put backticks in bash commands
|
|
928
|
+
|
|
929
|
+
EFFICIENCY:
|
|
930
|
+
- Use explore instead of multiple grep/read cycles
|
|
931
|
+
- Use patch for multi-line changes (more reliable than edit)
|
|
932
|
+
- Don't re-read files you just read
|
|
933
|
+
|
|
934
|
+
Be concise. Act, don't narrate.
|
|
935
|
+
PROMPT
|
|
936
|
+
|
|
937
|
+
# Initialize conversation with system message
|
|
938
|
+
messages = [{ role: "system", content: system_prompt }]
|
|
939
|
+
|
|
940
|
+
puts "#{DIM}Type your request, or /h for help#{RESET}"
|
|
941
|
+
|
|
942
|
+
loop do
|
|
943
|
+
begin
|
|
944
|
+
puts separator
|
|
945
|
+
user_input = Readline.readline("#{BOLD}#{BLUE}> #{RESET}", false)&.strip
|
|
946
|
+
puts separator
|
|
947
|
+
|
|
948
|
+
break if user_input.nil?
|
|
949
|
+
next if user_input.empty?
|
|
950
|
+
break if ["/q", "exit"].include?(user_input)
|
|
951
|
+
|
|
952
|
+
if user_input == "/c"
|
|
953
|
+
messages = [{ role: "system", content: system_prompt }]
|
|
954
|
+
puts "#{GREEN}⏺ Cleared conversation#{RESET}"
|
|
955
|
+
next
|
|
956
|
+
end
|
|
957
|
+
|
|
958
|
+
if user_input == "/h" || user_input == "/help"
|
|
959
|
+
print_help
|
|
960
|
+
next
|
|
961
|
+
end
|
|
962
|
+
|
|
963
|
+
if user_input == "/history"
|
|
964
|
+
sql = "SELECT content FROM messages WHERE role='user' ORDER BY id DESC LIMIT 10;"
|
|
965
|
+
entries = run_sql(sql).split("\n")
|
|
966
|
+
if entries.empty?
|
|
967
|
+
puts "#{DIM}No history yet.#{RESET}"
|
|
968
|
+
else
|
|
969
|
+
puts "#{DIM}Recent commands:#{RESET}"
|
|
970
|
+
entries.each_with_index { |cmd, i| puts " #{DIM}#{i + 1}.#{RESET} #{cmd[0, 60]}" }
|
|
971
|
+
end
|
|
972
|
+
next
|
|
973
|
+
end
|
|
974
|
+
|
|
975
|
+
# Save to history
|
|
976
|
+
Readline::HISTORY << user_input
|
|
977
|
+
save_message("user", user_input, session_id)
|
|
978
|
+
|
|
979
|
+
# Add user message to conversation
|
|
980
|
+
messages << { role: "user", content: user_input }
|
|
981
|
+
|
|
982
|
+
# Agentic loop
|
|
983
|
+
loop do
|
|
984
|
+
response = with_spinner("Thinking...") do
|
|
985
|
+
call_api(messages, model)
|
|
986
|
+
end
|
|
987
|
+
|
|
988
|
+
if response["error"]
|
|
989
|
+
puts "#{RED}⏺ API Error: #{response["error"]["message"]}#{RESET}"
|
|
990
|
+
break
|
|
991
|
+
end
|
|
992
|
+
|
|
993
|
+
# Parse Chat Completions response
|
|
994
|
+
choice = response.dig("choices", 0)
|
|
995
|
+
unless choice
|
|
996
|
+
puts "#{RED}⏺ Error: No response from model#{RESET}"
|
|
997
|
+
break
|
|
998
|
+
end
|
|
999
|
+
|
|
1000
|
+
message = choice["message"]
|
|
1001
|
+
text_content = message["content"]
|
|
1002
|
+
tool_calls = message["tool_calls"] || []
|
|
1003
|
+
|
|
1004
|
+
# Add assistant message to conversation
|
|
1005
|
+
messages << message
|
|
1006
|
+
|
|
1007
|
+
if text_content && !text_content.empty?
|
|
1008
|
+
puts "\n#{CYAN}⏺#{RESET} #{render_markdown(text_content)}"
|
|
1009
|
+
save_message("assistant", text_content, session_id)
|
|
1010
|
+
end
|
|
1011
|
+
|
|
1012
|
+
break if tool_calls.empty?
|
|
1013
|
+
|
|
1014
|
+
# Execute tool calls and add results to messages
|
|
1015
|
+
tool_calls.each do |tc|
|
|
1016
|
+
tool_name = tc.dig("function", "name")
|
|
1017
|
+
tool_args = JSON.parse(tc.dig("function", "arguments") || "{}")
|
|
1018
|
+
call_id = tc["id"]
|
|
1019
|
+
|
|
1020
|
+
label = tool_message(tool_name, tool_args)
|
|
1021
|
+
|
|
1022
|
+
print "\n"
|
|
1023
|
+
result = with_spinner(label) do
|
|
1024
|
+
run_tool(tool_name, tool_args)
|
|
1025
|
+
end
|
|
1026
|
+
|
|
1027
|
+
result_lines = result.split("\n")
|
|
1028
|
+
preview = result_lines.first.to_s[0, 60]
|
|
1029
|
+
if result_lines.length > 1
|
|
1030
|
+
preview += " #{DIM}(+#{result_lines.length - 1} more lines)#{RESET}"
|
|
1031
|
+
elsif result_lines.first.to_s.length > 60
|
|
1032
|
+
preview += "..."
|
|
1033
|
+
end
|
|
1034
|
+
puts " #{DIM}⎿ #{preview}#{RESET}"
|
|
1035
|
+
|
|
1036
|
+
# Add tool result to conversation
|
|
1037
|
+
messages << {
|
|
1038
|
+
role: "tool",
|
|
1039
|
+
tool_call_id: call_id,
|
|
1040
|
+
content: result
|
|
1041
|
+
}
|
|
1042
|
+
end
|
|
1043
|
+
end
|
|
1044
|
+
|
|
1045
|
+
puts
|
|
1046
|
+
|
|
1047
|
+
rescue Interrupt
|
|
1048
|
+
break
|
|
1049
|
+
rescue => e
|
|
1050
|
+
puts "#{RED}⏺ Error: #{e.message}#{RESET}"
|
|
1051
|
+
puts e.backtrace.first(3).join("\n") if ENV["DEBUG"]
|
|
1052
|
+
end
|
|
1053
|
+
end
|
|
1054
|
+
end
|
|
1055
|
+
end
|
|
1056
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: ruboto-ai
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Akhil Gautam
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: A fast, autonomous coding assistant built in Ruby, powered by multiple
|
|
13
|
+
LLM providers via OpenRouter API. Features agentic tools for file manipulation,
|
|
14
|
+
command execution, and codebase exploration.
|
|
15
|
+
email: []
|
|
16
|
+
executables:
|
|
17
|
+
- ruboto-ai
|
|
18
|
+
extensions: []
|
|
19
|
+
extra_rdoc_files: []
|
|
20
|
+
files:
|
|
21
|
+
- LICENSE.txt
|
|
22
|
+
- README.md
|
|
23
|
+
- bin/ruboto-ai
|
|
24
|
+
- lib/ruboto.rb
|
|
25
|
+
- lib/ruboto/version.rb
|
|
26
|
+
homepage: https://github.com/akhilgautam/ruboto-ai
|
|
27
|
+
licenses:
|
|
28
|
+
- MIT
|
|
29
|
+
metadata:
|
|
30
|
+
homepage_uri: https://github.com/akhilgautam/ruboto-ai
|
|
31
|
+
source_code_uri: https://github.com/akhilgautam/ruboto-ai
|
|
32
|
+
changelog_uri: https://github.com/akhilgautam/ruboto-ai/blob/main/CHANGELOG.md
|
|
33
|
+
rdoc_options: []
|
|
34
|
+
require_paths:
|
|
35
|
+
- lib
|
|
36
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
37
|
+
requirements:
|
|
38
|
+
- - ">="
|
|
39
|
+
- !ruby/object:Gem::Version
|
|
40
|
+
version: 3.0.0
|
|
41
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
42
|
+
requirements:
|
|
43
|
+
- - ">="
|
|
44
|
+
- !ruby/object:Gem::Version
|
|
45
|
+
version: '0'
|
|
46
|
+
requirements: []
|
|
47
|
+
rubygems_version: 3.6.9
|
|
48
|
+
specification_version: 4
|
|
49
|
+
summary: Minimal agentic coding assistant for the terminal
|
|
50
|
+
test_files: []
|