elelem 0.2.0 → 0.2.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/CHANGELOG.md +6 -0
- data/exe/llm-ollama +358 -0
- data/exe/llm-openai +339 -0
- data/lib/elelem/version.rb +1 -1
- metadata +5 -1
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 7d165866e64423e182a5497407deba0249b4c73ca4fc5c3af36a979925aade9f
|
4
|
+
data.tar.gz: 4b8f6b384a901781514bd085c106672489676b065eab7c2c6057ffff49842b71
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 356ff6e3bbadda54bc3ae9663d67879ea3fc1cd52418eaaa228f8aa1b7a6bf2a9db847dfa482e840fb0f772130753d5d417814d649e75f5c530bca467ef6f2df
|
7
|
+
data.tar.gz: 67313588f14536711acf61e566394466a34513cf52259c286e522648ccc1f47fcc898f4f2856a0072bf79cb63462948931c202bf972b7c29e94494aa3efb4a0f
|
data/CHANGELOG.md
CHANGED
data/exe/llm-ollama
ADDED
@@ -0,0 +1,358 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
=begin
|
4
|
+
Fast, correct, autonomous - Pick two
|
5
|
+
|
6
|
+
PURPOSE:
|
7
|
+
|
8
|
+
This script is a minimal coding agent written in Ruby. It is intended to
|
9
|
+
assist me (a software engineer and computer science student) with writing,
|
10
|
+
editing, and managing code and text files from the command line. It acts
|
11
|
+
as a direct interface to an LLM, providing it with a simple text-based
|
12
|
+
UI and access to the local filesystem.
|
13
|
+
|
14
|
+
DESIGN PRINCIPLES:
|
15
|
+
|
16
|
+
- Follows the Unix philosophy: simple, composable, minimal.
|
17
|
+
- Convention over configuration.
|
18
|
+
- Avoids unnecessary defensive checks, or complexity.
|
19
|
+
- Assumes a mature and responsible LLM that behaves like a capable engineer.
|
20
|
+
- Designed for my workflow and preferences.
|
21
|
+
- Efficient and minimal like aider - https://aider.chat/
|
22
|
+
- UX like Claude Code - https://docs.claude.com/en/docs/claude-code/overview
|
23
|
+
|
24
|
+
SYSTEM ASSUMPTIONS:
|
25
|
+
|
26
|
+
- This script is used on a Linux system with the following tools: Alacritty, tmux, Bash, and Vim.
|
27
|
+
- It is always run inside a Git repository.
|
28
|
+
- All project work is assumed to be version-controlled with Git.
|
29
|
+
- Git is expected to be available and working; no checks are necessary.
|
30
|
+
|
31
|
+
SCOPE:
|
32
|
+
|
33
|
+
- This program operates only on code and plain-text files.
|
34
|
+
- It does not need to support binary files.
|
35
|
+
- The LLM has full access to execute system commands.
|
36
|
+
- There are no sandboxing, permission, or validation layers.
|
37
|
+
- Execution is not restricted or monitored — responsibility is delegated to the LLM.
|
38
|
+
|
39
|
+
CONFIGURATION:
|
40
|
+
|
41
|
+
- Avoid adding configuration options unless absolutely necessary.
|
42
|
+
- Prefer hard-coded values that can be changed later if needed.
|
43
|
+
- Only introduce environment variables after repeated usage proves them worthwhile.
|
44
|
+
|
45
|
+
UI EXPECTATIONS:
|
46
|
+
|
47
|
+
- The TUI must remain simple, fast, and predictable.
|
48
|
+
- No mouse support or complex UI components are required.
|
49
|
+
- Interaction is strictly keyboard-driven.
|
50
|
+
|
51
|
+
CODING STANDARDS FOR LLM:
|
52
|
+
|
53
|
+
- Do not add error handling or logging unless it is essential for functionality.
|
54
|
+
- Keep methods short and single-purpose.
|
55
|
+
- Use descriptive, conventional names.
|
56
|
+
- Stick to Ruby's standard library whenever possible.
|
57
|
+
|
58
|
+
HELPFUL LINKS:
|
59
|
+
|
60
|
+
- https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents
|
61
|
+
- https://www.anthropic.com/engineering/writing-tools-for-agents
|
62
|
+
- https://simonwillison.net/2025/Sep/30/designing-agentic-loops/
|
63
|
+
|
64
|
+
=end
|
65
|
+
|
66
|
+
require "bundler/inline"
|
67
|
+
|
68
|
+
gemfile do
|
69
|
+
source "https://rubygems.org"
|
70
|
+
|
71
|
+
gem "fileutils", "~> 1.0"
|
72
|
+
gem "json", "~> 2.0"
|
73
|
+
gem "net-llm", "~> 0.4"
|
74
|
+
gem "open3", "~> 0.1"
|
75
|
+
gem "ostruct", "~> 0.1"
|
76
|
+
gem "reline", "~> 0.1"
|
77
|
+
gem "set", "~> 1.0"
|
78
|
+
gem "uri", "~> 1.0"
|
79
|
+
end
|
80
|
+
|
81
|
+
STDOUT.set_encoding(Encoding::UTF_8)
|
82
|
+
STDERR.set_encoding(Encoding::UTF_8)
|
83
|
+
|
84
|
+
OLLAMA_HOST = ENV["OLLAMA_HOST"] || "localhost:11434"
|
85
|
+
OLLAMA_MODEL = ENV["OLLAMA_MODEL"] || "gpt-oss:latest"
|
86
|
+
SYSTEM_PROMPT="You are a reasoning coding and system agent."
|
87
|
+
|
88
|
+
def build_tool(name, description, properties, required = [])
|
89
|
+
{
|
90
|
+
type: "function",
|
91
|
+
function: {
|
92
|
+
name: name,
|
93
|
+
description: description,
|
94
|
+
parameters: {
|
95
|
+
type: "object",
|
96
|
+
properties: properties,
|
97
|
+
required: required
|
98
|
+
}
|
99
|
+
}
|
100
|
+
}
|
101
|
+
end
|
102
|
+
|
103
|
+
EXEC_TOOL = build_tool("execute", "Execute shell commands. Returns stdout, stderr, and exit code. Use for: checking system state, running tests, managing services. Common Unix tools available: git, bash, grep, etc. Tip: Check exit_status in response to determine success.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string" }, stdin: { type: "string" } }, ["cmd"])
|
104
|
+
GREP_TOOL = build_tool("grep", "Search all git-tracked files using git grep. Returns file paths with matching line numbers. Use this to discover where code/configuration exists before reading files. Examples: search 'def method_name' to find method definitions. Much faster than reading multiple files.", { query: { type: "string" } }, ["query"])
|
105
|
+
LS_TOOL = build_tool("list", "List all git-tracked files in the repository, optionally filtered by path. Use this to explore project structure or find files in a directory. Returns relative paths from repo root. Tip: Use this before reading if you need to discover what files exist.", { path: { type: "string" } })
|
106
|
+
PATCH_TOOL = build_tool("patch", "Apply a unified diff patch via 'git apply'. Use this for surgical edits to existing files rather than rewriting entire files. Generates proper git diffs. Format: standard unified diff with --- and +++ headers. Tip: More efficient than write for small changes to large files.", { diff: { type: "string" } }, ["diff"])
|
107
|
+
READ_TOOL = build_tool("read", "Read complete contents of a file. Requires exact file path. Use grep or list first if you don't know the path. Best for: understanding existing code, reading config files, reviewing implementation details. Tip: For large files, grep first to confirm relevance.", { path: { type: "string" } }, ["path"])
|
108
|
+
WRITE_TOOL = build_tool("write", "Write complete file contents (overwrites existing files). Creates parent directories automatically. Best for: creating new files, replacing entire file contents. For small edits to existing files, consider using patch instead.", { path: { type: "string" }, content: { type: "string" } }, ["path", "content"])
|
109
|
+
|
110
|
+
TOOLS = {
|
111
|
+
read: [GREP_TOOL, LS_TOOL, READ_TOOL],
|
112
|
+
write: [PATCH_TOOL, WRITE_TOOL],
|
113
|
+
execute: [EXEC_TOOL]
|
114
|
+
}
|
115
|
+
|
116
|
+
trap("INT") do
|
117
|
+
puts "\nExiting."
|
118
|
+
exit
|
119
|
+
end
|
120
|
+
|
121
|
+
def run_exec(command, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
|
122
|
+
stdout, stderr, status = Open3.capture3(env, command, *args, chdir: cwd, stdin_data: stdin)
|
123
|
+
{
|
124
|
+
"exit_status" => status.exitstatus,
|
125
|
+
"stdout" => stdout.to_s,
|
126
|
+
"stderr" => stderr.to_s
|
127
|
+
}
|
128
|
+
end
|
129
|
+
|
130
|
+
def expand_path(path)
|
131
|
+
Pathname.new(path).expand_path
|
132
|
+
end
|
133
|
+
|
134
|
+
def read_file(path)
|
135
|
+
full_path = expand_path(path)
|
136
|
+
full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
|
137
|
+
end
|
138
|
+
|
139
|
+
def write_file(path, content)
|
140
|
+
full_path = expand_path(path)
|
141
|
+
FileUtils.mkdir_p(full_path.dirname)
|
142
|
+
{ bytes_written: full_path.write(content) }
|
143
|
+
end
|
144
|
+
|
145
|
+
def run_tool(name, args)
|
146
|
+
case name
|
147
|
+
when "execute" then run_exec(args["cmd"], args: args["args"] || [], env: args["env"] || {}, cwd: args["cwd"] || Dir.pwd, stdin: args["stdin"])
|
148
|
+
when "grep" then run_exec("git", args: ["grep", "-nI", args["query"]])
|
149
|
+
when "list" then run_exec("git", args: args["path"] ? ["ls-files", "--", args["path"]] : ["ls-files"])
|
150
|
+
when "patch" then run_exec("git", args: ["apply", "--index", "--whitespace=nowarn", "-p1"], stdin: args["diff"])
|
151
|
+
when "read" then read_file(args["path"])
|
152
|
+
when "write" then write_file(args["path"], args["content"])
|
153
|
+
else
|
154
|
+
{ error: "Unknown tool", name: name, args: args }
|
155
|
+
end
|
156
|
+
end
|
157
|
+
|
158
|
+
def format_tool_call(name, args)
|
159
|
+
case name
|
160
|
+
when "execute" then "execute(#{args["cmd"]})"
|
161
|
+
when "grep" then "grep(#{args["query"]})"
|
162
|
+
when "list" then "list(#{args["path"] || '.'})"
|
163
|
+
when "patch" then "patch(#{args["diff"].lines.count} lines)"
|
164
|
+
when "read" then "read(#{args["path"]})"
|
165
|
+
when "write" then "write(#{args["path"]})"
|
166
|
+
else
|
167
|
+
"▶ #{name}(#{args.to_s[0...70]})"
|
168
|
+
end
|
169
|
+
end
|
170
|
+
|
171
|
+
def system_prompt_for(mode)
|
172
|
+
base = "You are a reasoning coding and system agent."
|
173
|
+
|
174
|
+
case mode.sort
|
175
|
+
when [:read]
|
176
|
+
"#{base}\n\nRead and analyze. Understand before suggesting action."
|
177
|
+
when [:write]
|
178
|
+
"#{base}\n\nWrite clean, thoughtful code."
|
179
|
+
when [:execute]
|
180
|
+
"#{base}\n\nUse shell commands creatively to understand and manipulate the system."
|
181
|
+
when [:read, :write]
|
182
|
+
"#{base}\n\nFirst understand, then build solutions that integrate well."
|
183
|
+
when [:read, :execute]
|
184
|
+
"#{base}\n\nUse commands to deeply understand the system."
|
185
|
+
when [:write, :execute]
|
186
|
+
"#{base}\n\nCreate and execute freely. Have fun. Be kind."
|
187
|
+
when [:read, :write, :execute]
|
188
|
+
"#{base}\n\nYou have all tools. Use them wisely."
|
189
|
+
else
|
190
|
+
base
|
191
|
+
end
|
192
|
+
end
|
193
|
+
|
194
|
+
def tools_for(modes)
|
195
|
+
modes.map { |mode| TOOLS[mode] }.flatten
|
196
|
+
end
|
197
|
+
|
198
|
+
def prune_context(messages, keep_recent: 5)
|
199
|
+
return messages if messages.length <= keep_recent + 1
|
200
|
+
|
201
|
+
default_context + messages.last(keep_recent)
|
202
|
+
end
|
203
|
+
|
204
|
+
def execute_turn(client, messages, tools:)
|
205
|
+
turn_context = []
|
206
|
+
|
207
|
+
loop do
|
208
|
+
content = ""
|
209
|
+
tool_calls = nil
|
210
|
+
role = "assistant"
|
211
|
+
first_content = true
|
212
|
+
|
213
|
+
print "Thinking..."
|
214
|
+
client.chat(messages + turn_context, tools) do |chunk|
|
215
|
+
if chunk["message"]
|
216
|
+
msg = chunk["message"]
|
217
|
+
role = msg["role"] if msg["role"]
|
218
|
+
|
219
|
+
if msg["thinking"] && !msg["thinking"].empty?
|
220
|
+
print "."
|
221
|
+
end
|
222
|
+
|
223
|
+
if msg["content"] && !msg["content"].empty?
|
224
|
+
if first_content
|
225
|
+
print "\r\e[KAssistant> "
|
226
|
+
first_content = false
|
227
|
+
end
|
228
|
+
print msg["content"]
|
229
|
+
$stdout.flush
|
230
|
+
content += msg["content"]
|
231
|
+
end
|
232
|
+
|
233
|
+
tool_calls = msg["tool_calls"] if msg["tool_calls"]
|
234
|
+
end
|
235
|
+
end
|
236
|
+
puts
|
237
|
+
|
238
|
+
turn_context << { role: role, content: content, tool_calls: tool_calls }.compact
|
239
|
+
|
240
|
+
if tool_calls
|
241
|
+
tool_calls.each do |call|
|
242
|
+
name = call.dig("function", "name")
|
243
|
+
args_raw = call.dig("function", "arguments")
|
244
|
+
|
245
|
+
begin
|
246
|
+
args = args_raw.is_a?(String) ? JSON.parse(args_raw) : args_raw
|
247
|
+
rescue JSON::ParserError => e
|
248
|
+
turn_context << {
|
249
|
+
role: "tool",
|
250
|
+
content: JSON.dump({
|
251
|
+
error: "Invalid JSON in arguments: #{e.message}",
|
252
|
+
received: args_raw
|
253
|
+
})
|
254
|
+
}
|
255
|
+
next
|
256
|
+
end
|
257
|
+
|
258
|
+
puts "Tool> #{format_tool_call(name, args)}"
|
259
|
+
result = run_tool(name, args)
|
260
|
+
turn_context << { role: "tool", content: JSON.dump(result) }
|
261
|
+
end
|
262
|
+
next
|
263
|
+
end
|
264
|
+
|
265
|
+
return { role: "assistant", content: content } unless content.strip.empty?
|
266
|
+
end
|
267
|
+
end
|
268
|
+
|
269
|
+
def dump_context(messages)
|
270
|
+
puts JSON.pretty_generate(messages)
|
271
|
+
end
|
272
|
+
|
273
|
+
def print_status(mode, messages)
|
274
|
+
puts "Mode: #{mode.inspect}"
|
275
|
+
puts "Tools: #{tools_for(mode).map { |x| x.dig(:function, :name) }}"
|
276
|
+
end
|
277
|
+
|
278
|
+
def strip_ansi(text)
|
279
|
+
text.gsub(/^Script started.*?\n/, '')
|
280
|
+
.gsub(/\nScript done.*$/, '')
|
281
|
+
.gsub(/\e\[[0-9;]*[a-zA-Z]/, '') # Standard ANSI codes
|
282
|
+
.gsub(/\e\[\?[0-9]+[hl]/, '') # Bracketed paste mode
|
283
|
+
.gsub(/[\b]/, '') # Backspace chars
|
284
|
+
.gsub(/\r/, '') # Carriage returns
|
285
|
+
end
|
286
|
+
|
287
|
+
def start_shell
|
288
|
+
Tempfile.create do |file|
|
289
|
+
system("script -q #{file.path}")
|
290
|
+
{ role: "user", content: strip_ansi(File.read(file.path)) }
|
291
|
+
end
|
292
|
+
end
|
293
|
+
|
294
|
+
def ask?(text)
|
295
|
+
input = Reline.readline(text, true)&.strip
|
296
|
+
exit if input.nil? || input.downcase == "exit"
|
297
|
+
|
298
|
+
input
|
299
|
+
end
|
300
|
+
|
301
|
+
def print_help
|
302
|
+
puts <<~HELP
|
303
|
+
/chmod - (+|-)rwx auto build plan
|
304
|
+
/clear
|
305
|
+
/context
|
306
|
+
/exit
|
307
|
+
/help
|
308
|
+
/shell
|
309
|
+
/status
|
310
|
+
HELP
|
311
|
+
end
|
312
|
+
|
313
|
+
def default_context
|
314
|
+
[{ role: "system", content: SYSTEM_PROMPT }]
|
315
|
+
end
|
316
|
+
|
317
|
+
def main
|
318
|
+
client = Net::Llm::Ollama.new(
|
319
|
+
host: OLLAMA_HOST,
|
320
|
+
model: OLLAMA_MODEL
|
321
|
+
)
|
322
|
+
|
323
|
+
messages = default_context
|
324
|
+
mode = Set.new([:read])
|
325
|
+
|
326
|
+
loop do
|
327
|
+
input = ask?("User> ")
|
328
|
+
if input.start_with?("/")
|
329
|
+
case input
|
330
|
+
when "/chmod +r" then mode.add(:read)
|
331
|
+
when "/chmod +w" then mode.add(:write)
|
332
|
+
when "/chmod +x" then mode.add(:execute)
|
333
|
+
when "/chmod -r" then mode.add(:read)
|
334
|
+
when "/chmod -w" then mode.add(:write)
|
335
|
+
when "/chmod -x" then mode.add(:execute)
|
336
|
+
when "/clear" then messages = default_context
|
337
|
+
when "/compact" then messages = prune_context(messages, keep_recent: 10)
|
338
|
+
when "/context" then dump_context(messages)
|
339
|
+
when "/exit" then exit
|
340
|
+
when "/help" then print_help
|
341
|
+
when "/mode auto" then mode = Set[:read, :write, :execute]
|
342
|
+
when "/mode build" then mode = Set[:read, :write]
|
343
|
+
when "/mode plan" then mode = Set[:read]
|
344
|
+
when "/mode verify" then mode = Set[:read, :execute]
|
345
|
+
when "/mode" then print_status(mode, messages)
|
346
|
+
when "/shell" then messages << start_shell
|
347
|
+
else
|
348
|
+
print_help
|
349
|
+
end
|
350
|
+
else
|
351
|
+
messages[0] = { role: "system", content: system_prompt_for(mode) }
|
352
|
+
messages << { role: "user", content: input }
|
353
|
+
messages << execute_turn(client, messages, tools: tools_for(mode))
|
354
|
+
end
|
355
|
+
end
|
356
|
+
end
|
357
|
+
|
358
|
+
main
|
data/exe/llm-openai
ADDED
@@ -0,0 +1,339 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
|
3
|
+
=begin
|
4
|
+
Fast, correct, autonomous - Pick two
|
5
|
+
|
6
|
+
PURPOSE:
|
7
|
+
|
8
|
+
This script is a minimal coding agent written in Ruby. It is intended to
|
9
|
+
assist me (a software engineer and computer science student) with writing,
|
10
|
+
editing, and managing code and text files from the command line. It acts
|
11
|
+
as a direct interface to an LLM, providing it with a simple text-based
|
12
|
+
UI and access to the local filesystem.
|
13
|
+
|
14
|
+
DESIGN PRINCIPLES:
|
15
|
+
|
16
|
+
- Follows the Unix philosophy: simple, composable, minimal.
|
17
|
+
- Convention over configuration.
|
18
|
+
- Avoids unnecessary defensive checks, or complexity.
|
19
|
+
- Assumes a mature and responsible LLM that behaves like a capable engineer.
|
20
|
+
- Designed for my workflow and preferences.
|
21
|
+
- Efficient and minimal like aider - https://aider.chat/
|
22
|
+
- UX like Claude Code - https://docs.claude.com/en/docs/claude-code/overview
|
23
|
+
|
24
|
+
SYSTEM ASSUMPTIONS:
|
25
|
+
|
26
|
+
- This script is used on a Linux system with the following tools: Alacritty, tmux, Bash, and Vim.
|
27
|
+
- It is always run inside a Git repository.
|
28
|
+
- All project work is assumed to be version-controlled with Git.
|
29
|
+
- Git is expected to be available and working; no checks are necessary.
|
30
|
+
|
31
|
+
SCOPE:
|
32
|
+
|
33
|
+
- This program operates only on code and plain-text files.
|
34
|
+
- It does not need to support binary files.
|
35
|
+
- The LLM has full access to execute system commands.
|
36
|
+
- There are no sandboxing, permission, or validation layers.
|
37
|
+
- Execution is not restricted or monitored — responsibility is delegated to the LLM.
|
38
|
+
|
39
|
+
CONFIGURATION:
|
40
|
+
|
41
|
+
- Avoid adding configuration options unless absolutely necessary.
|
42
|
+
- Prefer hard-coded values that can be changed later if needed.
|
43
|
+
- Only introduce environment variables after repeated usage proves them worthwhile.
|
44
|
+
|
45
|
+
UI EXPECTATIONS:
|
46
|
+
|
47
|
+
- The TUI must remain simple, fast, and predictable.
|
48
|
+
- No mouse support or complex UI components are required.
|
49
|
+
- Interaction is strictly keyboard-driven.
|
50
|
+
|
51
|
+
CODING STANDARDS FOR LLM:
|
52
|
+
|
53
|
+
- Do not add error handling or logging unless it is essential for functionality.
|
54
|
+
- Keep methods short and single-purpose.
|
55
|
+
- Use descriptive, conventional names.
|
56
|
+
- Stick to Ruby's standard library whenever possible.
|
57
|
+
|
58
|
+
HELPFUL LINKS:
|
59
|
+
|
60
|
+
- https://www.anthropic.com/engineering/effective-context-engineering-for-ai-agents
|
61
|
+
- https://www.anthropic.com/engineering/writing-tools-for-agents
|
62
|
+
- https://simonwillison.net/2025/Sep/30/designing-agentic-loops/
|
63
|
+
|
64
|
+
=end
|
65
|
+
|
66
|
+
require "bundler/inline"
|
67
|
+
|
68
|
+
gemfile do
|
69
|
+
source "https://rubygems.org"
|
70
|
+
|
71
|
+
gem "fileutils", "~> 1.0"
|
72
|
+
gem "json", "~> 2.0"
|
73
|
+
gem "net-llm", "0.3.1"
|
74
|
+
gem "open3", "~> 0.1"
|
75
|
+
gem "ostruct", "~> 0.1"
|
76
|
+
gem "reline", "~> 0.1"
|
77
|
+
gem "set", "~> 1.0"
|
78
|
+
gem "uri", "~> 1.0"
|
79
|
+
end
|
80
|
+
|
81
|
+
STDOUT.set_encoding(Encoding::UTF_8)
|
82
|
+
STDERR.set_encoding(Encoding::UTF_8)
|
83
|
+
|
84
|
+
API_KEY = ENV["OPENAI_API_KEY"] or abort("Set OPENAI_API_KEY")
|
85
|
+
SYSTEM_PROMPT="You are a reasoning coding and system agent."
|
86
|
+
|
87
|
+
def build_tool(name, description, properties, required = [])
|
88
|
+
{
|
89
|
+
type: "function",
|
90
|
+
function: {
|
91
|
+
name: name,
|
92
|
+
description: description,
|
93
|
+
parameters: {
|
94
|
+
type: "object",
|
95
|
+
properties: properties,
|
96
|
+
required: required
|
97
|
+
}
|
98
|
+
}
|
99
|
+
}
|
100
|
+
end
|
101
|
+
|
102
|
+
EXEC_TOOL = build_tool("execute", "Execute shell commands. Returns stdout, stderr, and exit code. Use for: checking system state, running tests, managing services. Common Unix tools available: git, bash, grep, etc. Tip: Check exit_status in response to determine success.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string" }, stdin: { type: "string" } }, ["cmd"])
|
103
|
+
GREP_TOOL = build_tool("grep", "Search all git-tracked files using git grep. Returns file paths with matching line numbers. Use this to discover where code/configuration exists before reading files. Examples: search 'def method_name' to find method definitions. Much faster than reading multiple files.", { query: { type: "string" } }, ["query"])
|
104
|
+
LS_TOOL = build_tool("list", "List all git-tracked files in the repository, optionally filtered by path. Use this to explore project structure or find files in a directory. Returns relative paths from repo root. Tip: Use this before reading if you need to discover what files exist.", { path: { type: "string" } })
|
105
|
+
PATCH_TOOL = build_tool("patch", "Apply a unified diff patch via 'git apply'. Use this for surgical edits to existing files rather than rewriting entire files. Generates proper git diffs. Format: standard unified diff with --- and +++ headers. Tip: More efficient than write for small changes to large files.", { diff: { type: "string" } }, ["diff"])
|
106
|
+
READ_TOOL = build_tool("read", "Read complete contents of a file. Requires exact file path. Use grep or list first if you don't know the path. Best for: understanding existing code, reading config files, reviewing implementation details. Tip: For large files, grep first to confirm relevance.", { path: { type: "string" } }, ["path"])
|
107
|
+
WRITE_TOOL = build_tool("write", "Write complete file contents (overwrites existing files). Creates parent directories automatically. Best for: creating new files, replacing entire file contents. For small edits to existing files, consider using patch instead.", { path: { type: "string" }, content: { type: "string" } }, ["path", "content"])
|
108
|
+
|
109
|
+
TOOLS = {
|
110
|
+
read: [GREP_TOOL, LS_TOOL, READ_TOOL],
|
111
|
+
write: [PATCH_TOOL, WRITE_TOOL],
|
112
|
+
execute: [EXEC_TOOL]
|
113
|
+
}
|
114
|
+
|
115
|
+
trap("INT") do
|
116
|
+
puts "\nExiting."
|
117
|
+
exit
|
118
|
+
end
|
119
|
+
|
120
|
+
def run_exec(command, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
|
121
|
+
stdout, stderr, status = Open3.capture3(env, command, *args, chdir: cwd, stdin_data: stdin)
|
122
|
+
{
|
123
|
+
"exit_status" => status.exitstatus,
|
124
|
+
"stdout" => stdout.to_s,
|
125
|
+
"stderr" => stderr.to_s
|
126
|
+
}
|
127
|
+
end
|
128
|
+
|
129
|
+
def expand_path(path)
|
130
|
+
Pathname.new(path).expand_path
|
131
|
+
end
|
132
|
+
|
133
|
+
def read_file(path)
|
134
|
+
full_path = expand_path(path)
|
135
|
+
full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
|
136
|
+
end
|
137
|
+
|
138
|
+
def write_file(path, content)
|
139
|
+
full_path = expand_path(path)
|
140
|
+
FileUtils.mkdir_p(full_path.dirname)
|
141
|
+
{ bytes_written: full_path.write(content) }
|
142
|
+
end
|
143
|
+
|
144
|
+
def run_tool(name, args)
|
145
|
+
case name
|
146
|
+
when "execute" then run_exec(args["cmd"], args: args["args"] || [], env: args["env"] || {}, cwd: args["cwd"] || Dir.pwd, stdin: args["stdin"])
|
147
|
+
when "grep" then run_exec("git", args: ["grep", "-nI", args["query"]])
|
148
|
+
when "list" then run_exec("git", args: args["path"] ? ["ls-files", "--", args["path"]] : ["ls-files"])
|
149
|
+
when "patch" then run_exec("git", args: ["apply", "--index", "--whitespace=nowarn", "-p1"], stdin: args["diff"])
|
150
|
+
when "read" then read_file(args["path"])
|
151
|
+
when "write" then write_file(args["path"], args["content"])
|
152
|
+
else
|
153
|
+
{ error: "Unknown tool", name: name, args: args }
|
154
|
+
end
|
155
|
+
end
|
156
|
+
|
157
|
+
def format_tool_call(name, args)
|
158
|
+
case name
|
159
|
+
when "execute" then "execute(#{args["cmd"]})"
|
160
|
+
when "grep" then "grep(#{args["query"]})"
|
161
|
+
when "list" then "list(#{args["path"] || '.'})"
|
162
|
+
when "patch" then "patch(#{args["diff"].lines.count} lines)"
|
163
|
+
when "read" then "read(#{args["path"]})"
|
164
|
+
when "write" then "write(#{args["path"]})"
|
165
|
+
else
|
166
|
+
"▶ #{name}(#{args.to_s[0...70]})"
|
167
|
+
end
|
168
|
+
end
|
169
|
+
|
170
|
+
def system_prompt_for(mode)
|
171
|
+
base = "You are a reasoning coding and system agent."
|
172
|
+
|
173
|
+
case mode.sort
|
174
|
+
when [:read]
|
175
|
+
"#{base}\n\nRead and analyze. Understand before suggesting action."
|
176
|
+
when [:write]
|
177
|
+
"#{base}\n\nWrite clean, thoughtful code."
|
178
|
+
when [:execute]
|
179
|
+
"#{base}\n\nUse shell commands creatively to understand and manipulate the system."
|
180
|
+
when [:read, :write]
|
181
|
+
"#{base}\n\nFirst understand, then build solutions that integrate well."
|
182
|
+
when [:read, :execute]
|
183
|
+
"#{base}\n\nUse commands to deeply understand the system."
|
184
|
+
when [:write, :execute]
|
185
|
+
"#{base}\n\nCreate and execute freely. Have fun. Be kind."
|
186
|
+
when [:read, :write, :execute]
|
187
|
+
"#{base}\n\nYou have all tools. Use them wisely."
|
188
|
+
else
|
189
|
+
base
|
190
|
+
end
|
191
|
+
end
|
192
|
+
|
193
|
+
def tools_for(modes)
|
194
|
+
modes.map { |mode| TOOLS[mode] }.flatten
|
195
|
+
end
|
196
|
+
|
197
|
+
def prune_context(messages, keep_recent: 5)
|
198
|
+
return messages if messages.length <= keep_recent + 1
|
199
|
+
|
200
|
+
default_context + messages.last(keep_recent)
|
201
|
+
end
|
202
|
+
|
203
|
+
def execute_turn(client, messages, tools:)
|
204
|
+
turn_context = []
|
205
|
+
|
206
|
+
loop do
|
207
|
+
puts "Thinking..."
|
208
|
+
response = client.chat(messages + turn_context, tools)
|
209
|
+
abort "API Error #{response['code']}: #{response['body']}" if response["code"]
|
210
|
+
message = response.dig("choices", 0, "message")
|
211
|
+
turn_context << message
|
212
|
+
|
213
|
+
if message["tool_calls"]
|
214
|
+
message["tool_calls"].each do |call|
|
215
|
+
name = call.dig("function", "name")
|
216
|
+
# args = JSON.parse(call.dig("function", "arguments"))
|
217
|
+
begin
|
218
|
+
args = JSON.parse(call.dig("function", "arguments"))
|
219
|
+
rescue JSON::ParserError => e
|
220
|
+
# Feed the error back to the LLM as a tool result
|
221
|
+
turn_context << {
|
222
|
+
role: "tool",
|
223
|
+
tool_call_id: call["id"],
|
224
|
+
content: JSON.dump({
|
225
|
+
error: "Invalid JSON in arguments: #{e.message}",
|
226
|
+
received: call.dig("function", "arguments")
|
227
|
+
})
|
228
|
+
}
|
229
|
+
next # Continue the loop, giving the LLM a chance to correct itself
|
230
|
+
end
|
231
|
+
|
232
|
+
puts "Tool> #{format_tool_call(name, args)}"
|
233
|
+
result = run_tool(name, args)
|
234
|
+
turn_context << { role: "tool", tool_call_id: call["id"], content: JSON.dump(result) }
|
235
|
+
end
|
236
|
+
next
|
237
|
+
end
|
238
|
+
|
239
|
+
if message["content"] && !message["content"].strip.empty?
|
240
|
+
puts "\nAssistant>\n#{message['content']}"
|
241
|
+
|
242
|
+
unless message["tool_calls"]
|
243
|
+
return { role: "assistant", content: message["content"] }
|
244
|
+
end
|
245
|
+
end
|
246
|
+
end
|
247
|
+
end
|
248
|
+
|
249
|
+
def dump_context(messages)
|
250
|
+
puts JSON.pretty_generate(messages)
|
251
|
+
end
|
252
|
+
|
253
|
+
def print_status(mode, messages)
|
254
|
+
puts "Mode: #{mode.inspect}"
|
255
|
+
puts "Tools: #{tools_for(mode).map { |x| x.dig(:function, :name) }}"
|
256
|
+
end
|
257
|
+
|
258
|
+
def strip_ansi(text)
|
259
|
+
text.gsub(/^Script started.*?\n/, '')
|
260
|
+
.gsub(/\nScript done.*$/, '')
|
261
|
+
.gsub(/\e\[[0-9;]*[a-zA-Z]/, '') # Standard ANSI codes
|
262
|
+
.gsub(/\e\[\?[0-9]+[hl]/, '') # Bracketed paste mode
|
263
|
+
.gsub(/[\b]/, '') # Backspace chars
|
264
|
+
.gsub(/\r/, '') # Carriage returns
|
265
|
+
end
|
266
|
+
|
267
|
+
def start_shell
|
268
|
+
Tempfile.create do |file|
|
269
|
+
system("script -q #{file.path}")
|
270
|
+
{ role: "user", content: strip_ansi(File.read(file.path)) }
|
271
|
+
end
|
272
|
+
end
|
273
|
+
|
274
|
+
def ask?(text)
|
275
|
+
input = Reline.readline(text, true)&.strip
|
276
|
+
exit if input.nil? || input.downcase == "exit"
|
277
|
+
|
278
|
+
input
|
279
|
+
end
|
280
|
+
|
281
|
+
def print_help
|
282
|
+
puts <<~HELP
|
283
|
+
/chmod - (+|-)rwx auto build plan
|
284
|
+
/clear
|
285
|
+
/context
|
286
|
+
/exit
|
287
|
+
/help
|
288
|
+
/shell
|
289
|
+
/status
|
290
|
+
HELP
|
291
|
+
end
|
292
|
+
|
293
|
+
def default_context
|
294
|
+
[{ role: "system", content: SYSTEM_PROMPT }]
|
295
|
+
end
|
296
|
+
|
297
|
+
def main
|
298
|
+
client = Net::Llm::OpenAI.new(
|
299
|
+
api_key: API_KEY,
|
300
|
+
base_url: ENV["BASE_URL"] || "https://api.openai.com/v1",
|
301
|
+
model: ENV["MODEL"] || "gpt-4o-mini"
|
302
|
+
)
|
303
|
+
|
304
|
+
messages = default_context
|
305
|
+
mode = Set.new([:read])
|
306
|
+
|
307
|
+
loop do
|
308
|
+
input = ask?("User> ")
|
309
|
+
if input.start_with?("/")
|
310
|
+
case input
|
311
|
+
when "/chmod +r" then mode.add(:read)
|
312
|
+
when "/chmod +w" then mode.add(:write)
|
313
|
+
when "/chmod +x" then mode.add(:execute)
|
314
|
+
when "/chmod -r" then mode.add(:read)
|
315
|
+
when "/chmod -w" then mode.add(:write)
|
316
|
+
when "/chmod -x" then mode.add(:execute)
|
317
|
+
when "/clear" then messages = default_context
|
318
|
+
when "/compact" then messages = prune_context(messages, keep_recent: 10)
|
319
|
+
when "/context" then dump_context(messages)
|
320
|
+
when "/exit" then exit
|
321
|
+
when "/help" then print_help
|
322
|
+
when "/mode auto" then mode = Set[:read, :write, :execute]
|
323
|
+
when "/mode build" then mode = Set[:read, :write]
|
324
|
+
when "/mode plan" then mode = Set[:read]
|
325
|
+
when "/mode verify" then mode = Set[:read, :execute]
|
326
|
+
when "/mode" then print_status(mode, messages)
|
327
|
+
when "/shell" then messages << start_shell
|
328
|
+
else
|
329
|
+
print_help
|
330
|
+
end
|
331
|
+
else
|
332
|
+
messages[0] = { role: "system", content: system_prompt_for(mode) }
|
333
|
+
messages << { role: "user", content: input }
|
334
|
+
messages << execute_turn(client, messages, tools: tools_for(mode))
|
335
|
+
end
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
main
|
data/lib/elelem/version.rb
CHANGED
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: elelem
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.2.
|
4
|
+
version: 0.2.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- mo khan
|
@@ -154,6 +154,8 @@ email:
|
|
154
154
|
- mo@mokhan.ca
|
155
155
|
executables:
|
156
156
|
- elelem
|
157
|
+
- llm-ollama
|
158
|
+
- llm-openai
|
157
159
|
extensions: []
|
158
160
|
extra_rdoc_files: []
|
159
161
|
files:
|
@@ -162,6 +164,8 @@ files:
|
|
162
164
|
- README.md
|
163
165
|
- Rakefile
|
164
166
|
- exe/elelem
|
167
|
+
- exe/llm-ollama
|
168
|
+
- exe/llm-openai
|
165
169
|
- lib/elelem.rb
|
166
170
|
- lib/elelem/agent.rb
|
167
171
|
- lib/elelem/api.rb
|