Commit bce2f1f
Changed files (20)
lib
exe/elelem
@@ -3,8 +3,5 @@
require "elelem"
-Signal.trap("INT") do
- exit(1)
-end
-
-Elelem::Application.start
+Signal.trap("INT") { exit 1 }
+Elelem.start(Net::Llm::Ollama.new(model: "gpt-oss"))
exe/elelem-anthropic
@@ -0,0 +1,7 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+ENV["ANTHROPIC_API_KEY"] || abort("ANTHROPIC_API_KEY not set")
+require "elelem"
+Signal.trap("INT") { exit 1 }
+Elelem.start(Net::Llm::Anthropic.new(model: "claude-sonnet-4-20250514"))
exe/elelem-files
@@ -0,0 +1,14 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+puts "<documents>"
+$stdin.each_line.with_index(1) do |line, i|
+ path = line.strip
+ next if path.empty? || !File.file?(path)
+ content = File.read(path)
+ puts %Q{<document index="#{i}">}
+ puts %Q{<source>#{path}</source>}
+ puts %Q{<content><![CDATA[#{content}]]></content>}
+ puts "</document>"
+end
+puts "</documents>"
exe/elelem-ollama
@@ -0,0 +1,6 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+require "elelem"
+Signal.trap("INT") { exit 1 }
+Elelem.start(Net::Llm::Ollama.new(model: "gpt-oss"))
exe/elelem-openai
@@ -0,0 +1,7 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+ENV["OPENAI_API_KEY"] || abort("OPENAI_API_KEY not set")
+require "elelem"
+Signal.trap("INT") { exit 1 }
+Elelem.start(Net::Llm::OpenAI.new(model: "gpt-4"))
exe/elelem-vertex-ai
@@ -0,0 +1,8 @@
+#!/usr/bin/env ruby
+# frozen_string_literal: true
+
+ENV["GOOGLE_CLOUD_PROJECT"] || abort("GOOGLE_CLOUD_PROJECT not set")
+ENV["GOOGLE_CLOUD_REGION"] || abort("GOOGLE_CLOUD_REGION not set")
+require "elelem"
+Signal.trap("INT") { exit 1 }
+Elelem.start(Net::Llm::VertexAI.new(model: "claude-sonnet-4@20250514"))
lib/elelem/agent.rb
@@ -2,298 +2,104 @@
module Elelem
class Agent
- PROVIDERS = %w[ollama anthropic openai vertex-ai].freeze
- ANTHROPIC_MODELS = %w[claude-sonnet-4-20250514 claude-opus-4-20250514 claude-haiku-3-5-20241022].freeze
- VERTEX_MODELS = %w[claude-sonnet-4@20250514 claude-opus-4-5@20251101].freeze
- COMMANDS = %w[/env /provider /model /shell /clear /context /exit /help].freeze
- ENV_VARS = %w[ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL OLLAMA_HOST GOOGLE_CLOUD_PROJECT GOOGLE_CLOUD_REGION].freeze
+ COMMANDS = %w[/clear /context /exit /help].freeze
- attr_reader :conversation, :client, :toolbox, :provider, :terminal
+ attr_reader :history, :client, :toolbox, :terminal
- def initialize(provider, model, toolbox, terminal: nil)
- @conversation = Conversation.new
- @provider = provider
+ def initialize(client, toolbox, terminal: nil)
+ @client = client
@toolbox = toolbox
- @client = build_client(provider, model)
- @terminal = terminal || default_terminal
+ @history = [{ role: "system", content: system_prompt }]
+ @terminal = terminal || Terminal.new(commands: COMMANDS)
end
def repl
+ terminal.say "elelem v#{VERSION}"
loop do
input = terminal.ask("> ")
break if input.nil?
- if input.start_with?("/")
- handle_slash_command(input)
- else
- conversation.add(role: :user, content: input)
- result = execute_turn(conversation.history)
- conversation.add(role: result[:role], content: result[:content])
- end
+ next if input.empty?
+ input.start_with?("/") ? command(input) : turn(input)
end
end
private
- def default_terminal
- Terminal.new(
- commands: COMMANDS,
- env_vars: ENV_VARS,
- providers: PROVIDERS
- )
- end
-
- def handle_slash_command(input)
+ def command(input)
case input
- when "/exit" then exit
+ when "/exit" then exit(0)
when "/clear"
- conversation.clear
- terminal.say " → Conversation cleared"
+ @history = [{ role: "system", content: system_prompt }]
+ terminal.say " → context cleared"
when "/context"
- terminal.say conversation.dump, markdown: true
- when "/shell"
- transcript = start_shell
- conversation.add(role: :user, content: transcript) unless transcript.strip.empty?
- terminal.say " → Shell session captured"
- when "/provider"
- terminal.select("Provider?", PROVIDERS) do |selected_provider|
- terminal.select("Model?", models_for(selected_provider)) do |m|
- switch_client(selected_provider, m)
- end
- end
- when "/model"
- terminal.select("Model?", models_for(provider)) do |m|
- switch_model(m)
- end
- when "/env"
- terminal.say " Usage: /env VAR cmd..."
- terminal.say ""
- ENV_VARS.each do |var|
- value = ENV[var]
- if value
- masked = value.length > 8 ? "#{value[0..3]}...#{value[-4..]}" : "****"
- terminal.say " #{var}=#{masked}"
- else
- terminal.say " #{var}=(not set)"
- end
- end
- when %r{^/env\s+(\w+)\s+(.+)$}
- var_name = $1
- command = $2
- result = Elelem.shell.execute("sh", args: ["-c", command])
- if result["exit_status"].zero?
- value = result["stdout"].lines.first&.strip
- if value && !value.empty?
- ENV[var_name] = value
- terminal.say " → Set #{var_name}"
- else
- terminal.say " ⚠ Command produced no output"
- end
- else
- terminal.say " ⚠ Command failed: #{result['stderr']}"
- end
+ terminal.say JSON.pretty_generate(history)
else
- terminal.say help_banner
+ terminal.say "/clear /context /exit"
end
end
- def strip_ansi(text)
- text.gsub(/^Script started.*?\n/, '')
- .gsub(/\nScript done.*$/, '')
- .gsub(/\e\[[0-9;]*[a-zA-Z]/, '')
- .gsub(/\e\[\?[0-9]+[hl]/, '')
- .gsub(/[\b]/, '')
- .gsub(/\r/, '')
- end
+ def turn(input)
+ history << { role: "user", content: input }
+ ctx, errors = [], 0
+
+ loop do
+ terminal.waiting
+ content, tool_calls = fetch_response(ctx)
+ terminal.newline
+ return if content.nil?
+
+ terminal.say(terminal.markdown(content)) unless content.empty?
+ ctx << { role: "assistant", content: content, tool_calls: tool_calls.empty? ? nil : tool_calls }.compact
+
+ break if tool_calls.empty?
+
+ tool_calls.each do |tc|
+ name, args = tc[:name], tc[:arguments]
+ terminal.say "\n#{format_tool_display(name, args)}"
+ result = toolbox.run(name, args)
+ terminal.say format_tool_result(name, result)
+ ctx << { role: "tool", tool_call_id: tc[:id], content: result.to_json }
+ errors += 1 if result[:error]
+ end
- def start_shell
- Tempfile.create do |file|
- system("script -q #{file.path}", chdir: Dir.pwd)
- strip_ansi(File.read(file.path))
+ break if errors >= 3
end
- end
- def help_banner
- <<~HELP
- /env VAR cmd...
- /provider
- /model
- /shell
- /clear
- /context
- /exit
- /help
- HELP
+ history << { role: "assistant", content: ctx.map { |c| c[:content] }.join("\n") }
end
- def build_client(provider_name, model = nil)
- model_opts = model ? { model: model } : {}
+ def fetch_response(ctx)
+ content, tool_calls = "", []
+ client.fetch(history + ctx, toolbox.to_h) do |chunk|
+ terminal.print(terminal.dim(chunk[:thinking])) if chunk[:thinking]
- case provider_name
- when "ollama" then Net::Llm::Ollama.new(**model_opts)
- when "anthropic" then Net::Llm::Anthropic.new(**model_opts)
- when "openai" then Net::Llm::OpenAI.new(**model_opts)
- when "vertex-ai" then Net::Llm::VertexAI.new(**model_opts)
- else
- raise Error, "Unknown provider: #{provider_name}"
- end
- end
-
- def models_for(provider_name)
- case provider_name
- when "ollama"
- client_for_models = provider_name == provider ? client : build_client(provider_name)
- client_for_models.tags["models"]&.map { |m| m["name"] } || []
- when "openai"
- client_for_models = provider_name == provider ? client : build_client(provider_name)
- client_for_models.models["data"]&.map { |m| m["id"] } || []
- when "anthropic"
- ANTHROPIC_MODELS
- when "vertex-ai"
- VERTEX_MODELS
- else
- []
+ case chunk[:type]
+ when :delta then content += chunk[:content].to_s
+ when :complete then content, tool_calls = chunk[:content].to_s, chunk[:tool_calls] || []
+ end
end
- rescue KeyError => e
- terminal.say " ⚠ Missing credentials: #{e.message}"
- []
+ [content, tool_calls]
rescue => e
- terminal.say " ⚠ Could not fetch models: #{e.message}"
- []
- end
-
- def switch_client(new_provider, model)
- @provider = new_provider
- @client = build_client(new_provider, model)
- terminal.say " → Switched to #{new_provider}/#{client.model}"
- end
-
- def switch_model(model)
- @client = build_client(provider, model)
- terminal.say " → Switched to #{provider}/#{client.model}"
+ terminal.say "\n ✗ #{e.message}"
+ [nil, []]
end
def format_tool_display(name, args)
- display_name = name.split("_").map(&:capitalize).join(" ")
- formatted_args = case name
- when "exec"
- [args["cmd"], *Array(args["args"])].join(" ")
- when "read", "write"
- args["path"]
- when "list"
- args["path"] || "."
- when "grep", "web_search"
- args["query"]
- when "fetch"
- args["url"]
- when "patch"
- "diff"
- when "eval"
- args["ruby"].to_s.lines.first&.strip&.slice(0, 40) || "..."
- else
- args.values.first.to_s
- end
- "+ #{display_name}(#{formatted_args})"
+ "+ #{name}(#{args})"
end
def format_tool_result(name, result)
- text = extract_result_text(result)
- return nil if text.nil? || text.strip.empty?
-
- if result[:error]
- " ! #{text.lines.first&.strip}"
- else
- format_output(name, text)
- end
- end
-
- def extract_result_text(result)
- return if result.nil?
- return result["stdout"] if result["stdout"]
- return result["stderr"] if result["stderr"]
- return result[:error] if result[:error]
- return result[:content] if result[:content]
- ""
- end
-
- def format_output(name, text)
- lines = text.to_s.lines
- case name
- when "read"
- " = #{lines.size} lines"
- when "write"
- " = Wrote file"
- else
- truncate_lines(lines)
- end
- end
-
- def truncate_lines(lines, max: 10)
- if lines.size > max
- lines.first(max).join.rstrip + "\n... (#{lines.size - max} more lines)"
- else
- lines.join.rstrip
- end
- end
+ text = result["stdout"] || result["stderr"] || result[:content] || result[:error] || ""
+ return nil if text.strip.empty?
- def format_tool_calls_for_api(tool_calls)
- tool_calls.map do |tc|
- args = openai_client? ? JSON.dump(tc[:arguments]) : tc[:arguments]
- {
- id: tc[:id],
- type: "function",
- function: { name: tc[:name], arguments: args }
- }
- end
- end
-
- def openai_client?
- client.is_a?(Net::Llm::OpenAI)
+ result[:error] ? " ! #{text.lines.first&.strip}" : text
end
- def execute_turn(messages)
- tools = toolbox.tools
- turn_context = []
- errors = 0
-
- loop do
- content = ""
- tool_calls = []
-
- terminal.waiting
- begin
- client.fetch(messages + turn_context, tools) do |chunk|
- case chunk[:type]
- when :delta
- content += chunk[:content] if chunk[:content]
- when :complete
- content = chunk[:content] if chunk[:content]
- tool_calls = chunk[:tool_calls] || []
- end
- end
- rescue => e
- terminal.say "\n ✗ API Error: #{e.message}"
- return { role: "assistant", content: "[Error: #{e.message}]" }
- end
-
- terminal.say("\n#{content}", markdown: true) unless content.to_s.empty?
- api_tool_calls = tool_calls.any? ? format_tool_calls_for_api(tool_calls) : nil
- turn_context << { role: "assistant", content: content, tool_calls: api_tool_calls }.compact
-
- if tool_calls.any?
- tool_calls.each do |call|
- name, args = call[:name], call[:arguments]
- terminal.say "\n#{format_tool_display(name, args)}"
- result = toolbox.run_tool(name, args)
- terminal.say format_tool_result(name, result)
- turn_context << { role: "tool", tool_call_id: call[:id], content: JSON.dump(result) }
- errors += 1 if result[:error]
- end
- return { role: "assistant", content: "[Stopped: too many errors]" } if errors >= 3
- next
- end
-
- return { role: "assistant", content: content }
- end
+ def system_prompt
+ <<~PROMPT.strip
+ Terminal agent. Act directly, verify your work. Stay grounded - only respond to what is asked.
+ pwd: #{Dir.pwd}
+ PROMPT
end
end
end
lib/elelem/application.rb
@@ -1,45 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class Application < Thor
- PROVIDERS = %w[ollama anthropic openai vertex-ai].freeze
-
- desc "chat", "Start the REPL"
- method_option :provider,
- aliases: "-p",
- type: :string,
- desc: "LLM provider (#{PROVIDERS.join(', ')})",
- default: ENV.fetch("ELELEM_PROVIDER", "ollama")
- method_option :model,
- aliases: "-m",
- type: :string,
- desc: "Model name (uses provider default if not specified)"
- def chat(*)
- provider = options[:provider]
- model = options[:model]
- say "Agent (#{provider})", :green
- agent = Agent.new(provider, model, Toolbox.new)
- agent.repl
- end
-
- desc "files", "Generate CXML of the files"
- def files
- puts '<documents>'
- $stdin.read.split("\n").map(&:strip).reject(&:empty?).each_with_index do |file, i|
- next unless File.file?(file)
-
- puts " <document index=\"#{i + 1}\">"
- puts " <source><![CDATA[#{file}]]></source>"
- puts " <document_content><![CDATA[#{File.read(file)}]]></document_content>"
- puts " </document>"
- end
- puts '</documents>'
- end
-
- desc "version", "The version of this CLI"
- def version
- say "v#{Elelem::VERSION}"
- end
- map %w[--version -v] => :version
- end
-end
lib/elelem/conversation.rb
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class Conversation
- ROLES = %i[system assistant user tool].freeze
-
- def initialize(items = default_context)
- @items = items
- end
-
- def history
- @items.dup
- end
-
- def add(role: :user, content: "")
- role = role.to_sym
- raise "unknown role: #{role}" unless ROLES.include?(role)
- return if content.nil? || content.empty?
-
- if @items.last && @items.last[:role] == role
- @items.last[:content] += content
- else
- @items.push({ role: role, content: normalize(content) })
- end
- end
-
- def clear
- @items = default_context
- end
-
- def dump
- history.map do |item|
- "## #{item[:role].to_s.capitalize}\n\n#{item[:content]}"
- end.join("\n\n---\n\n")
- end
-
- private
-
- def default_context
- [{ role: "system", content: system_prompt }]
- end
-
- def system_prompt
- ERB.new(Pathname.new(__dir__).join("system_prompt.erb").read).result(binding)
- end
-
- def normalize(content)
- if content.is_a?(Array)
- content.join(", ")
- else
- content.to_s
- end
- end
- end
-end
lib/elelem/system_prompt.erb
@@ -1,14 +0,0 @@
-You are a trusted terminal agent. You act on behalf of the user - executing tasks directly through bash, files, and git. Be capable, be direct, be done.
-
-## Principles
-
-- Act, don't explain. Execute the task.
-- Read before write. Understand existing code first.
-- Small focused changes. One thing at a time.
-- Verify your work. Run tests, check output.
-- Stay grounded. Only respond to what the user asks. Never invent problems or scenarios.
-- Be concise. Give direct answers based on tool output.
-
-## System
-
-<%= `uname -s`.strip %> · <%= ENV['PWD'] %>
lib/elelem/terminal.rb
@@ -2,12 +2,9 @@
module Elelem
class Terminal
- def initialize(commands: [], providers: [], env_vars: [])
+ def initialize(commands: [])
@commands = commands
- @providers = providers
- @env_vars = env_vars
- @spinner_thread = nil
- @glow_available = system("which glow > /dev/null 2>&1")
+ @dots_thread = nil
setup_completion
end
@@ -15,49 +12,53 @@ module Elelem
Reline.readline(prompt, true)&.strip
end
- def say(message, markdown: false)
- stop_spinner
- if markdown && @glow_available
- IO.popen("glow -", "w") { |io| io.puts message }
- else
- $stdout.puts message
+ def dim(text)
+ "\e[2m#{text}\e[0m"
+ end
+
+ def markdown(text)
+ width = $stdout.winsize[1] rescue 80
+ IO.popen(["glow", "-s", "dark", "-w", width.to_s, "-"], "r+") do |io|
+ io.write(text)
+ io.close_write
+ io.read
end
+ rescue Errno::ENOENT
+ text
end
- def write(message)
- stop_spinner
+ def print(message)
+ stop_dots
$stdout.print message
end
+ def say(message)
+ stop_dots
+ $stdout.puts message
+ end
+
+ def newline
+ say("")
+ end
+
def waiting
- @spinner_thread = Thread.new do
- frames = %w[| / - \\]
- i = 0
+ @dots_thread = Thread.new do
loop do
- $stdout.print "\r#{frames[i % frames.length]} "
+ $stdout.print "."
$stdout.flush
- i += 1
sleep 0.1
end
end
end
- def select(question, options, &block)
- CLI::UI::Prompt.ask(question) do |handler|
- options.each do |option|
- handler.option(option) { |selected| block.call(selected) }
- end
- end
- end
-
private
- def stop_spinner
- return unless @spinner_thread
+ def stop_dots
+ return unless @dots_thread
- @spinner_thread.kill
- @spinner_thread = nil
- $stdout.print "\r \r"
+ @dots_thread.kill
+ @dots_thread = nil
+ newline
end
def setup_completion
@@ -67,43 +68,13 @@ module Elelem
def complete(target, preposing)
line = "#{preposing}#{target}"
-
- if line.start_with?('/') && !preposing.include?(' ')
- return @commands.select { |c| c.start_with?(line) }
- end
-
- case preposing.strip
- when '/provider'
- @providers.select { |p| p.start_with?(target) }
- when '/env'
- @env_vars.select { |v| v.start_with?(target) }
- when %r{^/env\s+\w+\s+pass(\s+show)?\s*$}
- subcommands = %w[show ls insert generate edit rm]
- matches = subcommands.select { |c| c.start_with?(target) }
- matches.any? ? matches : complete_pass_entries(target)
- when %r{^/env\s+\w+$}
- complete_commands(target)
- else
- complete_files(target)
- end
- end
-
- def complete_commands(target)
- result = Elelem.shell.execute("bash", args: ["-c", "compgen -c #{target}"])
- result["stdout"].lines.map(&:strip).first(20)
+ return @commands.select { |c| c.start_with?(line) } if line.start_with?("/") && !preposing.include?(" ")
+ complete_files(target)
end
def complete_files(target)
- result = Elelem.shell.execute("bash", args: ["-c", "compgen -f #{target}"])
+ result = Elelem.sh("bash", args: ["-c", "compgen -f #{target}"])
result["stdout"].lines.map(&:strip).first(20)
end
-
- def complete_pass_entries(target)
- store = ENV.fetch("PASSWORD_STORE_DIR", File.expand_path("~/.password-store"))
- result = Elelem.shell.execute("find", args: ["-L", store, "-name", "*.gpg"])
- result["stdout"].lines.map { |l|
- l.strip.sub("#{store}/", "").sub(/\.gpg$/, "")
- }.select { |e| e.start_with?(target) }.first(20)
- end
end
end
lib/elelem/tool.rb
@@ -1,50 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class Tool
- attr_reader :name
-
- def initialize(schema, &block)
- @name = schema.dig(:function, :name)
- @schema = schema
- @block = block
- end
-
- def call(args)
- unless valid?(args)
- actual = args.keys
- expected = @schema.dig(:function, :parameters)
- return { error: "Invalid args for #{@name}.", actual: actual, expected: expected }
- end
-
- @block.call(args)
- end
-
- def valid?(args)
- JSON::Validator.validate(@schema.dig(:function, :parameters), args)
- end
-
- def to_h
- @schema&.to_h
- end
-
- class << self
- def build(name, description, properties, required = [])
- new({
- type: "function",
- function: {
- name: name,
- description: description,
- parameters: {
- type: "object",
- properties: properties,
- required: required
- }
- }
- }) do |args|
- yield args
- end
- end
- end
- end
-end
lib/elelem/toolbox.rb
@@ -2,112 +2,79 @@
module Elelem
class Toolbox
- READ_TOOL = Tool.build("read", "Read complete contents of a file. Requires exact file path.", { path: { type: "string" } }, ["path"]) do |args|
- path = args["path"]
- full_path = Pathname.new(path).expand_path
- full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
+ TOOLS = {
+ "read" => {
+ desc: "Read file contents",
+ params: { path: { type: "string" } },
+ required: ["path"],
+ fn: ->(a) { p = Pathname.new(a["path"]).expand_path; p.exist? ? { content: p.read } : { error: "not found" } }
+ },
+ "write" => {
+ desc: "Write file",
+ params: { path: { type: "string" }, content: { type: "string" } },
+ required: ["path", "content"],
+ fn: ->(a) { p = Pathname.new(a["path"]).expand_path; FileUtils.mkdir_p(p.dirname); { bytes: p.write(a["content"]) } }
+ },
+ "exec" => {
+ desc: "Run shell command",
+ params: { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, stdin: { type: "string" } },
+ required: ["cmd"],
+ fn: ->(a) { Elelem.sh(a["cmd"], args: a["args"] || [], stdin: a["stdin"]) }
+ },
+ "web_fetch" => {
+ desc: "Fetch URL content",
+ params: { url: { type: "string" } },
+ required: ["url"],
+ fn: ->(a) { r = Net::Hippie::Client.new.get(a["url"]); { status: r.code.to_i, body: r.body } }
+ },
+ "web_search" => {
+ desc: "Search web via DuckDuckGo",
+ params: { query: { type: "string" } },
+ required: ["query"],
+ fn: ->(a) { q = CGI.escape(a["query"]); JSON.parse(Net::Hippie::Client.new.get("https://api.duckduckgo.com/?q=#{q}&format=json&no_html=1").body) }
+ },
+ "eval" => {
+ desc: "Execute Ruby code",
+ params: { ruby: { type: "string" } },
+ required: ["ruby"],
+ fn: nil
+ }
+ }.freeze
+
+ ALIASES = { "bash" => "exec", "sh" => "exec", "open" => "read" }.freeze
+
+ attr_reader :tools
+
+ def initialize(tools = TOOLS.dup)
+ @tools = tools
end
- EXEC_TOOL = Tool.build("exec", "Run shell commands. Returns stdout/stderr/exit_status.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string", description: "Working directory (defaults to current)" }, stdin: { type: "string" } }, ["cmd"]) do |args|
- Elelem.shell.execute(
- args["cmd"],
- args: args["args"] || [],
- env: args["env"] || {},
- cwd: args["cwd"].to_s.empty? ? Dir.pwd : args["cwd"],
- stdin: args["stdin"]
- )
- end
-
- GREP_TOOL = Tool.build("grep", "Search all git-tracked files using git grep. Returns file paths with matching line numbers.", { query: { type: "string" } }, ["query"]) do |args|
- Elelem.shell.execute("git", args: ["grep", "-nI", args["query"]])
- end
-
- LIST_TOOL = Tool.build("list", "List all git-tracked files in the repository, optionally filtered by path.", { path: { type: "string" } }) do |args|
- Elelem.shell.execute("git", args: args["path"] ? ["ls-files", "--", args["path"]] : ["ls-files"])
- end
-
- PATCH_TOOL = Tool.build("patch", "Apply a unified diff patch via 'git apply'. Use for surgical edits to existing files.", { diff: { type: "string" } }, ["diff"]) do |args|
- Elelem.shell.execute("git", args: ["apply", "--index", "--whitespace=nowarn", "-p1"], stdin: args["diff"])
- end
-
- WRITE_TOOL = Tool.build("write", "Write complete file contents (overwrites existing files). Creates parent directories automatically.", { path: { type: "string" }, content: { type: "string" } }, ["path", "content"]) do |args|
- full_path = Pathname.new(args["path"]).expand_path
- FileUtils.mkdir_p(full_path.dirname)
- { bytes_written: full_path.write(args["content"]) }
- end
-
- FETCH_TOOL = Tool.build("fetch", "Fetch content from a URL. Returns status, headers, and body.", { url: { type: "string", description: "The URL to fetch" } }, ["url"]) do |args|
- client = Net::Hippie::Client.new
- response = client.get(args["url"])
- { status: response.code.to_i, body: response.body }
- end
-
- WEB_SEARCH_TOOL = Tool.build("web_search", "Search the web using DuckDuckGo. Returns raw API response.", { query: { type: "string", description: "The search query" } }, ["query"]) do |args|
- query = CGI.escape(args["query"])
- url = "https://api.duckduckgo.com/?q=#{query}&format=json&no_html=1"
- client = Net::Hippie::Client.new
- response = client.get(url)
- JSON.parse(response.body)
- end
-
- TOOL_ALIASES = {
- "bash" => "exec",
- "duckduckgo" => "web_search",
- "ddg" => "web_search",
- "search_engine" => "web_search",
- "execute" => "exec",
- "get" => "fetch",
- "open" => "read",
- "search" => "grep",
- "sh" => "exec",
- "web" => "fetch",
- }
-
- def initialize
- @tools_by_name = {}
- add_tool(eval_tool(binding))
- add_tool(EXEC_TOOL)
- add_tool(FETCH_TOOL)
- add_tool(GREP_TOOL)
- add_tool(LIST_TOOL)
- add_tool(PATCH_TOOL)
- add_tool(READ_TOOL)
- add_tool(WEB_SEARCH_TOOL)
- add_tool(WRITE_TOOL)
- end
-
- def add_tool(tool)
- @tools_by_name[tool.name] = tool
- end
-
- def register_tool(name, description, properties = {}, required = [], &block)
- add_tool(Tool.build(name, description, properties, required, &block))
- end
-
- def tools
- @tools_by_name.values.map(&:to_h)
- end
-
- def run_tool(name, args)
- resolved_name = TOOL_ALIASES.fetch(name, name)
- tool = @tools_by_name[resolved_name]
- return { error: "Unknown tool", name: name, args: args } unless tool
-
- tool.call(args)
- rescue => error
- { error: error.message, name: name, args: args, backtrace: error.backtrace.first(5) }
- end
-
- def tool_schema(name)
- @tools_by_name[name]&.to_h
+ def to_h
+ tools.map do |name, t|
+ {
+ type: "function",
+ function: {
+ name: name,
+ description: t[:desc],
+ parameters: {
+ type: "object",
+ properties: t[:params],
+ required: t[:required]
+ }
+ }
+ }
+ end
end
- private
+ def run(name, args)
+ name = ALIASES.fetch(name, name)
+ tool = tools[name]
+ return { error: "unknown tool: #{name}" } unless tool
+ return { result: binding.eval(args["ruby"]) } if name == "eval"
- def eval_tool(target_binding)
- Tool.build("eval", "Evaluates Ruby code with full access to register new tools via the `register_tool(name, desc, properties, required) { |args| ... }` method.", { ruby: { type: "string" } }, ["ruby"]) do |args|
- { result: target_binding.eval(args["ruby"]) }
- end
+ tool[:fn].call(args)
+ rescue => e
+ { error: e.message }
end
end
end
lib/elelem.rb
@@ -1,26 +1,16 @@
# frozen_string_literal: true
require "cgi"
-require "cli/ui"
-require "erb"
require "fileutils"
require "json"
-require "json-schema"
-require "logger"
require "net/hippie"
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/conversation"
require_relative "elelem/terminal"
-require_relative "elelem/tool"
require_relative "elelem/toolbox"
require_relative "elelem/version"
@@ -30,28 +20,12 @@ Reline.output = $stdout
module Elelem
class Error < StandardError; end
- class Shell
- def execute(command, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
- cmd = command.is_a?(Array) ? command.first : command
- cmd_args = command.is_a?(Array) ? command[1..] + args : args
- stdout, stderr, status = Open3.capture3(
- env,
- cmd,
- *cmd_args,
- chdir: cwd,
- stdin_data: stdin
- )
- {
- "exit_status" => status.exitstatus,
- "stdout" => stdout.to_s,
- "stderr" => stderr.to_s
- }
- end
+ def self.sh(cmd, args: [], env: {}, cwd: Dir.pwd, stdin: nil)
+ stdout, stderr, status = Open3.capture3(env, cmd, *args, chdir: cwd, stdin_data: stdin)
+ { "exit_status" => status.exitstatus, "stdout" => stdout, "stderr" => stderr }
end
- class << self
- def shell
- @shell ||= Shell.new
- end
+ def self.start(client)
+ Agent.new(client, Toolbox.new).repl
end
end
spec/elelem/agent_e2e_spec.rb
@@ -1,59 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Elelem::Agent do
- let(:toolbox) { Elelem::Toolbox.new }
- let(:fake_client) { instance_double(Net::Llm::Ollama, model: "test-model") }
-
- before do
- allow(Net::Llm::Ollama).to receive(:new).and_return(fake_client)
- end
-
- describe "slash commands" do
- describe "/clear" do
- it "clears the conversation" do
- terminal = Elelem::FakeTerminal.new(inputs: ["/clear", nil])
- agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
- agent.conversation.add(role: :user, content: "hello")
-
- agent.repl
-
- expect(terminal.output).to include(" → Conversation cleared")
- end
- end
-
- describe "/env" do
- it "shows help and env vars when called without arguments" do
- terminal = Elelem::FakeTerminal.new(inputs: ["/env", nil])
- agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
-
- agent.repl
-
- expect(terminal.output).to include(" Usage: /env VAR cmd...")
- expect(terminal.output.any? { |line| line.include?("ANTHROPIC_API_KEY") }).to be true
- end
-
- it "sets environment variable from command output" do
- terminal = Elelem::FakeTerminal.new(inputs: ["/env TEST_VAR echo hello", nil])
- agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
-
- agent.repl
-
- expect(terminal.output).to include(" → Set TEST_VAR")
- expect(ENV["TEST_VAR"]).to eq("hello")
- end
- end
-
- describe "/help" do
- it "shows help banner" do
- terminal = Elelem::FakeTerminal.new(inputs: ["/help", nil])
- agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
-
- agent.repl
-
- expect(terminal.output.join).to include("/env VAR cmd...")
- expect(terminal.output.join).to include("/provider")
- expect(terminal.output.join).to include("/clear")
- end
- end
- end
-end
spec/elelem/agent_spec.rb
@@ -1,28 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Elelem::Agent do
- let(:mock_client) { double("client", model: "test-model") }
- let(:agent) do
- agent = described_class.allocate
- agent.instance_variable_set(:@conversation, Elelem::Conversation.new)
- agent.instance_variable_set(:@provider, "ollama")
- agent.instance_variable_set(:@toolbox, Elelem::Toolbox.new)
- agent.instance_variable_set(:@client, mock_client)
- agent
- end
-
- describe "#initialize" do
- it "creates a new conversation" do
- expect(agent.conversation).to be_a(Elelem::Conversation)
- end
-
- it "stores the client" do
- expect(agent.client).to eq(mock_client)
- end
-
- it "initializes toolbox with all tools" do
- tool_names = agent.toolbox.tools.map { |t| t.dig(:function, :name) }
- expect(tool_names).to include("read", "write", "exec", "grep", "list")
- end
- end
-end
spec/elelem/conversation_spec.rb
@@ -1,99 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Elelem::Conversation do
- let(:conversation) { described_class.new }
-
- describe "#history" do
- it "returns history with system prompt" do
- history = conversation.history
-
- expect(history.length).to eq(1)
- expect(history[0][:role]).to eq("system")
- expect(history[0][:content]).to be_a(String)
- end
-
- context "with populated conversation" do
- before do
- conversation.add(role: :user, content: "Hello")
- conversation.add(role: :assistant, content: "Hi there")
- end
-
- it "preserves all conversation items" do
- history = conversation.history
-
- expect(history.length).to eq(3)
- expect(history[1][:role]).to eq(:user)
- expect(history[1][:content]).to eq("Hello")
- expect(history[2][:role]).to eq(:assistant)
- expect(history[2][:content]).to eq("Hi there")
- end
-
- it "returns a copy, not the original array" do
- history = conversation.history
- original_items = conversation.instance_variable_get(:@items)
-
- expect(history).not_to be(original_items)
- end
- end
- end
-
- describe "#add" do
- it "adds user message to conversation" do
- conversation.add(role: :user, content: "test message")
- history = conversation.history
-
- expect(history.length).to eq(2)
- expect(history[1][:content]).to eq("test message")
- end
-
- it "merges consecutive messages with same role" do
- conversation.add(role: :user, content: "part 1")
- conversation.add(role: :user, content: "part 2")
- history = conversation.history
-
- expect(history.length).to eq(2)
- expect(history[1][:content]).to eq("part 1part 2")
- end
-
- it "ignores nil content" do
- conversation.add(role: :user, content: nil)
- history = conversation.history
-
- expect(history.length).to eq(1)
- end
-
- it "ignores empty content" do
- conversation.add(role: :user, content: "")
- history = conversation.history
-
- expect(history.length).to eq(1)
- end
-
- it "raises error for unknown role" do
- expect {
- conversation.add(role: :unknown, content: "test")
- }.to raise_error(/unknown role/)
- end
- end
-
- describe "#clear" do
- it "resets conversation to default context" do
- conversation.add(role: :user, content: "test")
- conversation.clear
- history = conversation.history
-
- expect(history.length).to eq(1)
- expect(history[0][:role]).to eq("system")
- end
- end
-
- describe "#dump" do
- it "returns markdown representation" do
- conversation.add(role: :user, content: "test")
- result = conversation.dump
-
- expect(result).to include("## System")
- expect(result).to include("## User")
- end
- end
-end
spec/elelem/toolbox_spec.rb
@@ -3,97 +3,32 @@
RSpec.describe Elelem::Toolbox do
subject { described_class.new }
- describe "#tools" do
- it "returns all tools" do
- tool_names = subject.tools.map { |t| t.dig(:function, :name) }
- expect(tool_names).to include("grep", "list", "read", "patch", "write", "exec", "fetch", "web_search", "eval")
+ describe "#to_h" do
+ it "returns all tools in API format" do
+ tool_names = subject.to_h.map { |t| t.dig(:function, :name) }
+ expect(tool_names).to include("read", "write", "exec", "web_fetch", "web_search", "eval")
end
end
- describe "aliases" do
- it "resolves web and get aliases to fetch" do
- expect(Elelem::Toolbox::TOOL_ALIASES["web"]).to eq("fetch")
- expect(Elelem::Toolbox::TOOL_ALIASES["get"]).to eq("fetch")
- end
-
- it "resolves duckduckgo alias to web_search" do
- expect(Elelem::Toolbox::TOOL_ALIASES["duckduckgo"]).to eq("web_search")
- end
-
- it "resolves bash alias to exec" do
- expect(Elelem::Toolbox::TOOL_ALIASES["bash"]).to eq("exec")
- end
- end
-
- describe "#run_tool" do
- it "executes tools" do
- result = subject.run_tool("read", { "path" => __FILE__ })
+ describe "#run" do
+ it "executes read tool" do
+ result = subject.run("read", { "path" => __FILE__ })
expect(result[:content]).to include("RSpec.describe")
end
- it "resolves aliases" do
- result = subject.run_tool("open", { "path" => __FILE__ })
+ it "resolves open alias to read" do
+ result = subject.run("open", { "path" => __FILE__ })
expect(result[:content]).to include("RSpec.describe")
end
- it "returns unknown tool error for non-existent tools" do
- result = subject.run_tool("nonexistent", {})
- expect(result[:error]).to include("Unknown tool")
- end
- end
-
- describe "meta-programming with eval tool" do
- it "allows LLM to register new tools dynamically" do
- subject.run_tool("eval", {
- "ruby" => <<~RUBY
- register_tool("hello", "Says hello to a name", { name: { type: "string" } }, ["name"]) do |args|
- { greeting: "Hello, " + args['name']+ "!" }
- end
- RUBY
- })
-
- expect(subject.tools).to include(hash_including({
- type: "function",
- function: {
- name: "hello",
- description: "Says hello to a name",
- parameters: {
- type: "object",
- properties: { name: { type: "string" } },
- required: ["name"]
- }
- }
- }))
- end
-
- it "allows LLM to call dynamically created tools" do
- subject.run_tool("eval", {
- "ruby" => <<~RUBY
- register_tool("add", "Adds two numbers", { a: { type: "number" }, b: { type: "number" } }, ["a", "b"]) do |args|
- { sum: args["a"] + args["b"] }
- end
- RUBY
- })
-
- result = subject.run_tool("add", { "a" => 5, "b" => 3 })
- expect(result[:sum]).to eq(8)
+ it "returns error for unknown tools" do
+ result = subject.run("nonexistent", {})
+ expect(result[:error]).to include("unknown tool")
end
- it "allows LLM to inspect tool schemas" do
- result = subject.run_tool("eval", { "ruby" => "tool_schema('read')" })
- expect(result[:result]).to be_a(Hash)
- expect(result[:result].dig(:function, :name)).to eq("read")
- end
-
- it "executes arbitrary Ruby code" do
- result = subject.run_tool("eval", { "ruby" => "2 + 2" })
+ it "executes eval tool" do
+ result = subject.run("eval", { "ruby" => "2 + 2" })
expect(result[:result]).to eq(4)
end
-
- it "handles errors gracefully" do
- result = subject.run_tool("eval", { "ruby" => "undefined_variable" })
- expect(result[:error]).to include("undefined")
- expect(result[:backtrace]).to be_an(Array)
- end
end
end
elelem.gemspec
@@ -25,13 +25,14 @@ Gem::Specification.new do |spec|
"README.md",
"Rakefile",
"exe/elelem",
+ "exe/elelem-anthropic",
+ "exe/elelem-files",
+ "exe/elelem-ollama",
+ "exe/elelem-openai",
+ "exe/elelem-vertex-ai",
"lib/elelem.rb",
"lib/elelem/agent.rb",
- "lib/elelem/application.rb",
- "lib/elelem/conversation.rb",
- "lib/elelem/system_prompt.erb",
"lib/elelem/terminal.rb",
- "lib/elelem/tool.rb",
"lib/elelem/toolbox.rb",
"lib/elelem/version.rb",
]
@@ -40,18 +41,11 @@ Gem::Specification.new do |spec|
spec.require_paths = ["lib"]
spec.add_dependency "cgi", "~> 0.1"
- spec.add_dependency "cli-ui", "~> 2.0"
- spec.add_dependency "erb", "~> 6.0"
spec.add_dependency "fileutils", "~> 1.0"
spec.add_dependency "json", "~> 2.0"
- spec.add_dependency "json-schema", "~> 6.0"
- spec.add_dependency "logger", "~> 1.0"
spec.add_dependency "net-hippie", "~> 1.0"
spec.add_dependency "net-llm", "~> 0.5", ">= 0.5.0"
spec.add_dependency "open3", "~> 0.1"
spec.add_dependency "pathname", "~> 0.1"
spec.add_dependency "reline", "~> 0.6"
- spec.add_dependency "set", "~> 1.0"
- spec.add_dependency "thor", "~> 1.0"
- spec.add_dependency "timeout", "~> 0.1"
end
Gemfile.lock
@@ -3,30 +3,19 @@ PATH
specs:
elelem (0.8.0)
cgi (~> 0.1)
- cli-ui (~> 2.0)
- erb (~> 6.0)
fileutils (~> 1.0)
json (~> 2.0)
- json-schema (~> 6.0)
- logger (~> 1.0)
net-hippie (~> 1.0)
net-llm (~> 0.5, >= 0.5.0)
open3 (~> 0.1)
pathname (~> 0.1)
reline (~> 0.6)
- set (~> 1.0)
- thor (~> 1.0)
- timeout (~> 0.1)
GEM
remote: https://rubygems.org/
specs:
- addressable (2.8.8)
- public_suffix (>= 2.0.2, < 8.0)
base64 (0.3.0)
- bigdecimal (4.0.1)
cgi (0.5.1)
- cli-ui (2.7.0)
date (3.5.1)
diff-lcs (1.6.2)
erb (6.0.1)
@@ -37,9 +26,6 @@ GEM
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.18.0)
- json-schema (6.1.0)
- addressable (~> 2.8)
- bigdecimal (>= 3.1, < 5)
logger (1.7.0)
net-hippie (1.4.0)
base64 (~> 0.1)
@@ -62,7 +48,6 @@ GEM
psych (5.3.1)
date
stringio
- public_suffix (7.0.2)
rake (13.3.1)
rdoc (7.1.0)
erb
@@ -83,10 +68,7 @@ GEM
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.6)
- set (1.1.2)
stringio (3.2.0)
- thor (1.5.0)
- timeout (0.6.0)
tsort (0.2.0)
uri (1.1.1)