Commit d63e1d1
Changed files (15)
lib/elelem/toolbox/exec.rb
@@ -1,61 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module Toolbox
- class Exec < ::Elelem::Tool
- attr_reader :tui
-
- def initialize(configuration)
- @tui = configuration.tui
- super("exec", "Execute shell commands with pipe support", {
- type: "object",
- properties: {
- command: {
- type: "string",
- description: "Shell command to execute (supports pipes, redirects, etc.)"
- }
- },
- required: ["command"]
- })
- end
-
- def call(args)
- command = args["command"]
- output_buffer = []
-
- tui.say(command, newline: true)
- Open3.popen3(command) do |stdin, stdout, stderr, wait_thread|
- stdin.close
- streams = [stdout, stderr]
-
- until streams.empty?
- ready = IO.select(streams, nil, nil, 0.1)
-
- if ready
- ready[0].each do |io|
- data = io.read_nonblock(4096)
- output_buffer << data
-
- if io == stderr
- tui.say(data, colour: :red, newline: false)
- else
- tui.say(data, newline: false)
- end
- rescue IO::WaitReadable
- next
- rescue EOFError
- streams.delete(io)
- end
- elsif !wait_thread.alive?
- break
- end
- end
-
- wait_thread.value
- end
-
- output_buffer.join
- end
- end
- end
-end
lib/elelem/toolbox/file.rb
@@ -1,66 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module Toolbox
- class File < Tool
- def initialize(configuration)
- @configuration = configuration
- @tui = configuration.tui
-
- super("file", "Read and write files", {
- type: :object,
- properties: {
- action: {
- type: :string,
- enum: ["read", "write"],
- description: "Action to perform: read or write"
- },
- path: {
- type: :string,
- description: "File path"
- },
- content: {
- type: :string,
- description: "Content to write (only for write action)"
- }
- },
- required: [:action, :path]
- })
- end
-
- def call(args)
- action = args["action"]
- path = args["path"]
- content = args["content"]
-
- case action
- when "read"
- read_file(path)
- when "write"
- write_file(path, content)
- else
- "Invalid action: #{action}"
- end
- end
-
- private
-
- attr_reader :configuration, :tui
-
- def read_file(path)
- tui.say("Read: #{path}", newline: true)
- ::File.read(path)
- rescue => e
- "Error reading file: #{e.message}"
- end
-
- def write_file(path, content)
- tui.say("Write: #{path}", newline: true)
- ::File.write(path, content)
- "File written successfully"
- rescue => e
- "Error writing file: #{e.message}"
- end
- end
- end
-end
lib/elelem/toolbox/mcp.rb
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- module Toolbox
- class MCP < ::Elelem::Tool
- attr_reader :client, :tui
-
- def initialize(client, tui, tool)
- @client = client
- @tui = tui
- super(tool["name"], tool["description"], tool["inputSchema"] || {})
- end
-
- def call(args)
- unless client.connected?
- tui.say("MCP connection lost", colour: :red)
- return ""
- end
-
- result = client.call(name, args)
- tui.say(JSON.pretty_generate(result), newline: true)
-
- if result.nil? || result.empty?
- tui.say("Tool call failed: no response from MCP server", colour: :red)
- return result
- end
-
- if result["error"]
- tui.say(result["error"], colour: :red)
- return result
- end
-
- result.dig("content", 0, "text") || result.to_s
- end
- end
- end
-end
lib/elelem/agent.rb
@@ -2,16 +2,11 @@
module Elelem
class Agent
- attr_reader :conversation, :model, :tui, :client, :tools
+ attr_reader :conversation, :tui, :client, :tools
- def initialize(configuration)
- @tui = TUI.new
- @configuration = configuration
+ def initialize(client)
@conversation = Conversation.new
- @client = Net::Llm::Ollama.new(
- host: configuration.host,
- model: configuration.model,
- )
+ @client = client
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", description: "Working directory for command execution (defaults to current directory if not specified)" }, stdin: { type: "string" } }, ["cmd"])
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"])
@@ -25,25 +20,31 @@ module Elelem
write: [patch_tool, write_tool],
execute: [exec_tool]
}
-
- at_exit { cleanup }
end
def repl
- mode = Set.new([:read, :write, :execute])
+ mode = Set.new([:read])
loop do
- input = tui.ask?("User> ")
+ input = ask?("User> ")
break if input.nil?
if input.start_with?("/")
case input
+ when "/mode auto" then mode = Set[:read, :write, :execute]
+ when "/mode build" then mode = Set[:read, :write]
+ when "/mode plan" then mode = Set[:read]
+ when "/mode verify" then mode = Set[:read, :execute]
+ when "/mode"
+ puts(" Mode: #{mode.to_a.inspect}")
+ puts(" Tools: #{tools_for(mode).map { |t| t.dig(:function, :name) }}")
when "/exit" then exit
when "/clear" then conversation.clear
- when "/context" then tui.say(conversation.dump)
+ when "/context" then puts conversation.dump
else
- tui.say(help_banner)
+ puts help_banner
end
else
+ conversation.set_system_prompt(system_prompt_for(mode))
conversation.add(role: :user, content: input)
result = execute_turn(conversation.history, tools: tools_for(mode))
conversation.add(role: result[:role], content: result[:content])
@@ -51,27 +52,20 @@ module Elelem
end
end
- def quit
- cleanup
- exit
- end
-
- def cleanup
- configuration.cleanup
- end
-
private
- attr_reader :configuration
+ def ask?(text)
+ Reline.readline(text, true)&.strip
+ end
def help_banner
<<~HELP
+ /chmod (+|-)rwx
/mode auto build plan verify
/clear
/context
/exit
/help
- /shell
HELP
end
@@ -79,6 +73,29 @@ module Elelem
modes.map { |mode| tools[mode] }.flatten
end
+ def system_prompt_for(mode)
+ base = "You are a reasoning coding and system agent."
+
+ case mode.to_a.sort
+ when [:read]
+ "#{base}\n\nRead and analyze. Understand before suggesting action."
+ when [:write]
+ "#{base}\n\nWrite clean, thoughtful code."
+ when [:execute]
+ "#{base}\n\nUse shell commands creatively to understand and manipulate the system."
+ when [:read, :write]
+ "#{base}\n\nFirst understand, then build solutions that integrate well."
+ when [:read, :execute]
+ "#{base}\n\nUse commands to deeply understand the system."
+ when [:write, :execute]
+ "#{base}\n\nCreate and execute freely. Have fun. Be kind."
+ when [:read, :write, :execute]
+ "#{base}\n\nYou have all tools. Use them wisely."
+ else
+ base
+ end
+ end
+
def execute_turn(messages, tools:)
turn_context = []
@@ -159,6 +176,8 @@ module Elelem
else
{ error: "Unknown tool", name: name, args: args }
end
+ rescue => error
+ { error: error.message, name: name, args: args }
end
def build_tool(name, description, properties, required = [])
lib/elelem/application.rb
@@ -13,20 +13,15 @@ module Elelem
type: :string,
desc: "Ollama model",
default: ENV.fetch("OLLAMA_MODEL", "gpt-oss")
- method_option :token,
- aliases: "--token",
- type: :string,
- desc: "Ollama token",
- default: ENV.fetch("OLLAMA_API_KEY", nil)
def chat(*)
- configuration = Configuration.new(
+ client = Net::Llm::Ollama.new(
host: options[:host],
model: options[:model],
- token: options[:token],
)
- say "Agent (#{configuration.model})", :green
- agent = Agent.new(configuration)
+ say "Agent (#{options[:model]})", :green
+ agent = Agent.new(client)
+
agent.repl
end
lib/elelem/configuration.rb
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class Configuration
- attr_reader :host, :model, :token, :tui
-
- def initialize(host:, model:, token:)
- @host = host
- @model = model
- @token = token
- @tui = TUI.new
- end
-
- def tools
- @tools ||= Tools.new([
- Toolbox::Exec.new(self),
- Toolbox::File.new(self),
- ] + mcp_tools)
- end
-
- def cleanup
- @mcp_clients&.each(&:shutdown)
- end
-
- private
-
- def mcp_tools
- @mcp_tools ||= mcp_clients.map do |client|
- client.tools.map do |tool|
- Toolbox::MCP.new(client, tui, tool)
- end
- end.flatten
- end
-
- def mcp_clients
- @mcp_clients ||= begin
- config = Pathname.pwd.join(".mcp.json")
- return [] unless config.exist?
-
- JSON.parse(config.read).map do |_key, value|
- MCPClient.new([value["command"]] + value["args"])
- end
- end
- end
- end
-end
lib/elelem/conversation.rb
@@ -12,7 +12,6 @@ module Elelem
@items
end
- # :TODO truncate conversation history
def add(role: :user, content: "")
role = role.to_sym
raise "unknown role: #{role}" unless ROLES.include?(role)
@@ -29,6 +28,10 @@ module Elelem
@items = default_context
end
+ def set_system_prompt(prompt)
+ @items[0] = { role: :system, content: prompt }
+ end
+
def dump
JSON.pretty_generate(@items)
end
lib/elelem/mcp_client.rb
@@ -1,128 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class MCPClient
- attr_reader :tools, :resources
-
- def initialize(command = [])
- @stdin, @stdout, @stderr, @worker = Open3.popen3(*command, pgroup: true)
-
- # 1. Send initialize request
- send_request(
- method: "initialize",
- params: {
- protocolVersion: "2025-06-08",
- capabilities: {
- tools: {}
- },
- clientInfo: {
- name: "Elelem",
- version: Elelem::VERSION
- }
- }
- )
-
- # 2. Send initialized notification (optional for some MCP servers)
- send_notification(method: "notifications/initialized")
-
- # 3. Now we can request tools
- @tools = send_request(method: "tools/list")&.dig("tools") || []
- @resources = send_request(method: "resources/list")&.dig("resources") || []
- end
-
- def connected?
- return false unless @worker&.alive?
- return false unless @stdin && !@stdin.closed?
- return false unless @stdout && !@stdout.closed?
-
- begin
- Process.getpgid(@worker.pid)
- true
- rescue Errno::ESRCH
- false
- end
- end
-
- def call(name, arguments = {})
- send_request(
- method: "tools/call",
- params: {
- name: name,
- arguments: arguments
- }
- )
- end
-
- def shutdown
- return unless connected?
-
- [@stdin, @stdout, @stderr].each do |stream|
- stream&.close unless stream&.closed?
- end
-
- return unless @worker&.alive?
-
- begin
- Process.kill("TERM", @worker.pid)
- # Give it 2 seconds to terminate gracefully
- Timeout.timeout(2) { @worker.value }
- rescue Timeout::Error
- # Force kill if it doesn't respond
- begin
- Process.kill("KILL", @worker.pid)
- rescue StandardError
- nil
- end
- rescue Errno::ESRCH
- # Process already dead
- end
- end
-
- private
-
- attr_reader :stdin, :stdout, :stderr, :worker
-
- def send_request(method:, params: {})
- return {} unless connected?
-
- request = {
- jsonrpc: "2.0",
- id: Time.now.to_i,
- method: method
- }
- request[:params] = params unless params.empty?
-
- @stdin.puts(JSON.generate(request))
- @stdin.flush
-
- response_line = @stdout.gets&.strip
- return {} if response_line.nil? || response_line.empty?
-
- response = JSON.parse(response_line)
-
- if response["error"]
- { error: response["error"]["message"] }
- else
- response["result"]
- end
- end
-
- def send_notification(method:, params: {})
- return unless connected?
-
- notification = {
- jsonrpc: "2.0",
- method: method
- }
- notification[:params] = params unless params.empty?
- @stdin.puts(JSON.generate(notification))
- @stdin.flush
-
- response_line = @stdout.gets&.strip
- return {} if response_line.nil? || response_line.empty?
-
- response = JSON.parse(response_line)
- response
- end
- end
-end
lib/elelem/tool.rb
@@ -4,16 +4,21 @@ module Elelem
class Tool
attr_reader :name, :description, :parameters
- def initialize(name, description, parameters)
+ def initialize(name, description, parameters, &block)
@name = name
@description = description
@parameters = parameters
+ @block = block
end
def valid?(args)
JSON::Validator.validate(parameters, args, insert_defaults: true)
end
+ def call(*args)
+ @block.call(*args)
+ end
+
def to_h
{
type: "function",
lib/elelem/toolbox.rb
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-require_relative "toolbox/exec"
-require_relative "toolbox/file"
-require_relative "toolbox/mcp"
lib/elelem/tools.rb
@@ -6,6 +6,10 @@ module Elelem
@tools = tools
end
+ def add(name, description, parameters, &block)
+ @tools << Tool.new(name, description, parameters, &block)
+ end
+
def execute(tool_call)
name, args = parse(tool_call)
@@ -13,9 +17,7 @@ module Elelem
return "Invalid function name: #{name}" if tool.nil?
return "Invalid function arguments: #{args}" unless tool.valid?(args)
- CLI::UI::Frame.open(name) do
- tool.call(args)
- end
+ tool.call(args)
end
def to_h
lib/elelem/tui.rb
@@ -1,66 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class TUI
- attr_reader :stdin, :stdout
-
- def initialize(stdin = $stdin, stdout = $stdout)
- @stdin = stdin
- @stdout = stdout
- end
-
- def ask?(text)
- Reline.readline(text, true)&.strip
- end
-
- def say(message, colour: :default, newline: false)
- if newline
- stdout.puts(colourize(message, colour: colour))
- else
- stdout.print(colourize(message, colour: colour))
- end
- stdout.flush
- end
-
- def show_progress(message, icon = ".", colour: :gray)
- timestamp = Time.now.strftime("%H:%M:%S")
- say("\n[#{icon}] #{timestamp} #{message}", colour: colour, newline: true)
- end
-
- def clear_line
- say("\r#{" " * 80}\r", newline: false)
- end
-
- def complete_progress(message = "Completed")
- clear_line
- show_progress(message, "✓", colour: :green)
- end
-
- private
-
- def colourize(text, colour: :default)
- case colour
- when :black
- "\e[30m#{text}\e[0m"
- when :red
- "\e[31m#{text}\e[0m"
- when :green
- "\e[32m#{text}\e[0m"
- when :yellow
- "\e[33m#{text}\e[0m"
- when :blue
- "\e[34m#{text}\e[0m"
- when :magenta
- "\e[35m#{text}\e[0m"
- when :cyan
- "\e[36m#{text}\e[0m"
- when :white
- "\e[37m#{text}\e[0m"
- when :gray
- "\e[90m#{text}\e[0m"
- else
- text
- end
- end
- end
-end
lib/elelem.rb
@@ -1,28 +1,25 @@
# frozen_string_literal: true
-require "cli/ui"
require "erb"
+require "fileutils"
require "json"
require "json-schema"
require "logger"
require "net/llm"
require "open3"
+require "pathname"
require "reline"
+require "set"
require "thor"
require "timeout"
require_relative "elelem/agent"
require_relative "elelem/application"
-require_relative "elelem/configuration"
require_relative "elelem/conversation"
-require_relative "elelem/mcp_client"
require_relative "elelem/tool"
-require_relative "elelem/toolbox"
require_relative "elelem/tools"
-require_relative "elelem/tui"
require_relative "elelem/version"
-CLI::UI::StdoutRouter.enable
Reline.input = $stdin
Reline.output = $stdout
elelem.gemspec
@@ -36,16 +36,9 @@ Gem::Specification.new do |spec|
"lib/elelem.rb",
"lib/elelem/agent.rb",
"lib/elelem/application.rb",
- "lib/elelem/configuration.rb",
"lib/elelem/conversation.rb",
- "lib/elelem/mcp_client.rb",
"lib/elelem/system_prompt.erb",
"lib/elelem/tool.rb",
- "lib/elelem/toolbox.rb",
- "lib/elelem/toolbox/exec.rb",
- "lib/elelem/toolbox/file.rb",
- "lib/elelem/toolbox/mcp.rb",
- "lib/elelem/toolbox/web.rb",
"lib/elelem/tools.rb",
"lib/elelem/tui.rb",
"lib/elelem/version.rb",
@@ -54,7 +47,6 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]
- spec.add_dependency "cli-ui"
spec.add_dependency "erb"
spec.add_dependency "json"
spec.add_dependency "json-schema"
Gemfile.lock
@@ -2,7 +2,6 @@ PATH
remote: .
specs:
elelem (0.2.1)
- cli-ui
erb
json
json-schema
@@ -20,7 +19,6 @@ GEM
public_suffix (>= 2.0.2, < 7.0)
base64 (0.3.0)
bigdecimal (3.2.2)
- cli-ui (2.4.0)
date (3.4.1)
diff-lcs (1.6.2)
erb (5.0.2)