Comparing changes
v0.8.0
→
v0.9.0
114 commits
39 files changed
Commits
453c05a
feat: add note to system prompt to encourage LLM to new tools while working on tasks
2026-01-22 05:17:53
6aa5c44
refactor: delegate to write tool in order to allow syntax checkers to validate result
2026-01-22 00:41:42
98ca7fe
feat: italicize thinking text and use glow for markdown rendering in terminal
2026-01-18 05:48:58
Changed files (39)
.github
workflow
exe
lib
elelem
spec
.github/workflow/ci.yml
@@ -1,21 +0,0 @@
-name: CI
-on:
- push:
- branches: [ "main" ]
- pull_request:
- branches: [ "main" ]
-permissions:
- contents: read
-jobs:
- test:
- runs-on: ubuntu-latest
- strategy:
- matrix:
- ruby-version: ['3.4']
- steps:
- - uses: actions/checkout@v4
- - uses: ruby/setup-ruby@55283cc23133118229fd3f97f9336ee23a179fcf # v1.146.0
- with:
- ruby-version: ${{ matrix.ruby-version }}
- bundler-cache: true # runs 'bundle install' and caches installed gems automatically
- - run: sh bin/test
exe/elelem
@@ -2,9 +2,84 @@
# frozen_string_literal: true
require "elelem"
+require "optparse"
-Signal.trap("INT") do
- exit(1)
+Signal.trap("INT") { exit 1 }
+
+class App
+ MODELS = {
+ "ollama" => "gpt-oss:latest",
+ "anthropic" => "claude-opus-4-5-20250514",
+ "vertex" => "claude-opus-4-5@20251101",
+ "openai" => "gpt-4o"
+ }.freeze
+
+ PROVIDERS = {
+ "ollama" => ->(model) { Elelem::Net::Ollama.new(model: model, host: ENV.fetch("OLLAMA_HOST", "localhost:11434")) },
+ "anthropic" => ->(model) { Elelem::Net::Claude.anthropic(model: model, api_key: ENV.fetch("ANTHROPIC_API_KEY")) },
+ "vertex" => ->(model) { Elelem::Net::Claude.vertex(model: model, project: ENV.fetch("GOOGLE_CLOUD_PROJECT"), region: ENV.fetch("GOOGLE_CLOUD_REGION", "us-east5")) },
+ "openai" => ->(model) { Elelem::Net::OpenAI.new(model: model, api_key: ENV.fetch("OPENAI_API_KEY")) }
+ }.freeze
+
+ def initialize(args)
+ @provider = "ollama"
+ @model = nil
+ @args = parse(args)
+ end
+
+ def run
+ command = @args.shift || "chat"
+ send(command.tr("-", "_"))
+ rescue NoMethodError
+ abort "Unknown command: #{command}"
+ end
+
+ private
+
+ def parse(args)
+ @parser = OptionParser.new do |o|
+ o.banner = "Usage: elelem [command] [options] [args]"
+ o.separator "\nCommands:"
+ o.separator " chat Interactive REPL (default)"
+ o.separator " ask <prompt> One-shot query (reads stdin if piped)"
+ o.separator " files Output files as XML (no options)"
+ o.separator " help Show this help"
+ o.separator "\nOptions:"
+ o.on("-p", "--provider NAME", "ollama, anthropic, vertex, openai") { |p| @provider = p }
+ o.on("-m", "--model NAME", "Override default model") { |m| @model = m }
+ o.on("-h", "--help") { puts o; exit }
+ end
+ @parser.parse!(args)
+ end
+
+ def help
+ puts @parser
+ end
+
+ def client
+ model = @model || MODELS.fetch(@provider)
+ PROVIDERS.fetch(@provider).call(model)
+ end
+
+ def chat = Elelem.start(client)
+
+ def ask
+ abort "Usage: elelem ask <prompt>" if @args.empty?
+ prompt = @args.join(" ")
+ prompt = "#{prompt}\n\n```\n#{$stdin.read}\n```" if $stdin.stat.pipe?
+ Elelem::Terminal.new.markdown Elelem.ask(client, prompt)
+ end
+
+ def files
+ files = $stdin.stat.pipe? ? $stdin.readlines : `git ls-files`.lines
+ puts "<documents>"
+ files.each_with_index do |line, i|
+ path = line.strip
+ next if path.empty? || !File.file?(path)
+ puts %Q{<document index="#{i + 1}"><source>#{path}</source><content><![CDATA[#{File.read(path)}]]></content></document>}
+ end
+ puts "</documents>"
+ end
end
-Elelem::Application.start
+App.new(ARGV).run
lib/elelem/net/claude.rb
@@ -0,0 +1,200 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Net
+ class Claude
+ def self.anthropic(model:, api_key:, http: Elelem::Net.http)
+ new(
+ endpoint: "https://api.anthropic.com/v1/messages",
+ headers: { "x-api-key" => api_key, "anthropic-version" => "2023-06-01" },
+ model:,
+ http:
+ )
+ end
+
+ def self.vertex(model:, project:, region: "us-east5", http: Elelem::Net.http)
+ new(
+ endpoint: "https://#{region}-aiplatform.googleapis.com/v1/projects/#{project}/locations/#{region}/publishers/anthropic/models/#{model}:rawPredict",
+ headers: -> { { "Authorization" => "Bearer #{`gcloud auth application-default print-access-token`.strip}" } },
+ model:,
+ version: "vertex-2023-10-16",
+ http:
+ )
+ end
+
+ def initialize(endpoint:, headers:, model:, version: nil, http: Elelem::Net.http)
+ @endpoint = endpoint
+ @headers_source = headers
+ @model = model
+ @version = version
+ @http = http
+ end
+
+ def fetch(messages, tools = [], &block)
+ system_prompt, normalized_messages = extract_system(messages)
+ tool_calls = []
+
+ stream(normalized_messages, system_prompt, tools) do |event|
+ handle_event(event, tool_calls, &block)
+ end
+
+ finalize_tool_calls(tool_calls)
+ end
+
+ private
+
+ def headers
+ @headers_source.respond_to?(:call) ? @headers_source.call : @headers_source
+ end
+
+ def handle_event(event, tool_calls, &block)
+ case event["type"]
+ when "content_block_start"
+ handle_content_block_start(event, tool_calls)
+ when "content_block_delta"
+ handle_content_block_delta(event, tool_calls, &block)
+ end
+ end
+
+ def handle_content_block_start(event, tool_calls)
+ content_block = event["content_block"]
+ return unless content_block["type"] == "tool_use"
+
+ tool_calls << {
+ id: content_block["id"],
+ name: content_block["name"],
+ args: String.new
+ }
+ end
+
+ def handle_content_block_delta(event, tool_calls, &block)
+ delta = event["delta"]
+
+ case delta["type"]
+ when "text_delta"
+ block.call(content: delta["text"], thinking: nil)
+ when "thinking_delta"
+ block.call(content: nil, thinking: delta["thinking"])
+ when "input_json_delta"
+ tool_calls.last[:args] << delta["partial_json"].to_s if tool_calls.any?
+ end
+ end
+
+ def finalize_tool_calls(tool_calls)
+ tool_calls.each do |tool_call|
+ args = tool_call.delete(:args)
+ tool_call[:arguments] = args.empty? ? {} : JSON.parse(args)
+ end
+ end
+
+ def stream(messages, system_prompt, tools)
+ body = build_request_body(messages, system_prompt, tools)
+
+ @http.post(@endpoint, headers:, body:) do |response|
+ raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(::Net::HTTPSuccess)
+
+ read_sse_stream(response) { |event| yield event }
+ end
+ end
+
+ def build_request_body(messages, system_prompt, tools)
+ body = { max_tokens: 64000, messages:, stream: true }
+ body[:model] = @model unless @version
+ body[:anthropic_version] = @version if @version
+ body[:system] = system_prompt if system_prompt
+ body[:tools] = unwrap_tools(tools) unless tools.empty?
+ body
+ end
+
+ def read_sse_stream(response)
+ buffer = String.new
+
+ response.read_body do |chunk|
+ buffer << chunk
+
+ while (index = buffer.index("\n\n"))
+ raw_event = buffer.slice!(0, index + 2)
+ event = parse_sse(raw_event)
+ yield event if event
+ end
+ end
+ end
+
+ def parse_sse(raw)
+ line = raw.lines.find { |l| l.start_with?("data: ") }
+ return nil unless line
+
+ data = line.delete_prefix("data: ").strip
+ return nil if data == "[DONE]"
+
+ JSON.parse(data)
+ end
+
+ def extract_system(messages)
+ system_messages, other_messages = messages.partition { |message| message[:role] == "system" }
+ system_content = system_messages.first&.dig(:content)
+ [system_content, normalize(other_messages)]
+ end
+
+ def normalize(messages)
+ messages.map { |message| normalize_message(message) }
+ end
+
+ def normalize_message(message)
+ case message[:role]
+ when "tool"
+ tool_result_message(message)
+ when "assistant"
+ message[:tool_calls]&.any? ? assistant_with_tools_message(message) : message
+ else
+ message
+ end
+ end
+
+ def tool_result_message(message)
+ {
+ role: "user",
+ content: [{
+ type: "tool_result",
+ tool_use_id: message[:tool_call_id],
+ content: message[:content]
+ }]
+ }
+ end
+
+ def assistant_with_tools_message(message)
+ text_content = build_text_content(message[:content])
+ tool_content = build_tool_content(message[:tool_calls])
+
+ { role: "assistant", content: text_content + tool_content }
+ end
+
+ def build_text_content(content)
+ return [] if content.to_s.empty?
+
+ [{ type: "text", text: content }]
+ end
+
+ def build_tool_content(tool_calls)
+ tool_calls.map do |tool_call|
+ {
+ type: "tool_use",
+ id: tool_call[:id],
+ name: tool_call[:name],
+ input: tool_call[:arguments]
+ }
+ end
+ end
+
+ def unwrap_tools(tools)
+ tools.map do |tool|
+ {
+ name: tool.dig(:function, :name),
+ description: tool.dig(:function, :description),
+ input_schema: tool.dig(:function, :parameters)
+ }
+ end
+ end
+ end
+ end
+end
lib/elelem/net/ollama.rb
@@ -0,0 +1,78 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Net
+ class Ollama
+ def initialize(model:, host: "localhost:11434", http: Elelem::Net.http)
+ @url = normalize_url(host)
+ @model = model
+ @http = http
+ end
+
+ def fetch(messages, tools = [], &block)
+ tool_calls = []
+ body = build_request_body(messages, tools)
+
+ stream(body) do |event|
+ handle_event(event, tool_calls, &block)
+ end
+
+ tool_calls
+ end
+
+ private
+
+ def normalize_url(host)
+ base = host.start_with?("http") ? host : "http://#{host}"
+ "#{base}/api/chat"
+ end
+
+ def build_request_body(messages, tools)
+ { model: @model, messages:, tools:, stream: true }
+ end
+
+ def handle_event(event, tool_calls, &block)
+ message = event["message"] || {}
+
+ unless event["done"]
+ block.call(content: message["content"], thinking: message["thinking"])
+ end
+
+ if message["tool_calls"]
+ tool_calls.concat(parse_tool_calls(message["tool_calls"]))
+ end
+ end
+
+ def stream(body)
+ @http.post(@url, body:) do |response|
+ raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(::Net::HTTPSuccess)
+
+ read_ndjson_stream(response) { |event| yield event }
+ end
+ end
+
+ def read_ndjson_stream(response)
+ buffer = String.new
+
+ response.read_body do |chunk|
+ buffer << chunk
+
+ while (index = buffer.index("\n"))
+ line = buffer.slice!(0, index + 1)
+ yield JSON.parse(line)
+ end
+ end
+ end
+
+ def parse_tool_calls(tool_calls)
+ tool_calls.map do |tool_call|
+ {
+ id: tool_call["id"],
+ name: tool_call.dig("function", "name"),
+ arguments: tool_call.dig("function", "arguments") || {}
+ }
+ end
+ end
+ end
+ end
+end
lib/elelem/net/openai.rb
@@ -0,0 +1,86 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Net
+ class OpenAI
+ def initialize(model:, api_key:, base_url: "https://api.openai.com/v1", http: Elelem::Net.http)
+ @url = "#{base_url}/chat/completions"
+ @model = model
+ @api_key = api_key
+ @http = http
+ end
+
+ def fetch(messages, tools = [], &block)
+ tool_calls = {}
+ body = build_request_body(messages, tools)
+
+ stream(body) do |event|
+ handle_event(event, tool_calls, &block)
+ end
+
+ finalize_tool_calls(tool_calls)
+ end
+
+ private
+
+ def build_request_body(messages, tools)
+ { model: @model, messages:, stream: true, tools:, tool_choice: "auto" }
+ end
+
+ def handle_event(event, tool_calls, &block)
+ delta = event.dig("choices", 0, "delta") || {}
+
+ block.call(content: delta["content"], thinking: nil) if delta["content"]
+
+ accumulate_tool_calls(delta["tool_calls"], tool_calls) if delta["tool_calls"]
+ end
+
+ def accumulate_tool_calls(incoming_tool_calls, tool_calls)
+ incoming_tool_calls.each do |tool_call|
+ index = tool_call["index"]
+ tool_calls[index] ||= { id: nil, name: nil, args: String.new }
+ tool_calls[index][:id] ||= tool_call["id"]
+ tool_calls[index][:name] ||= tool_call.dig("function", "name")
+ tool_calls[index][:args] << tool_call.dig("function", "arguments").to_s
+ end
+ end
+
+ def stream(body)
+ @http.post(@url, headers: headers, body:) do |response|
+ raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(::Net::HTTPSuccess)
+
+ read_sse_stream(response) { |event| yield event }
+ end
+ end
+
+ def headers
+ { "Authorization" => "Bearer #{@api_key}" }
+ end
+
+ def read_sse_stream(response)
+ buffer = String.new
+
+ response.read_body do |chunk|
+ buffer << chunk
+
+ while (index = buffer.index("\n"))
+ line = buffer.slice!(0, index + 1).strip
+ next unless line.start_with?("data: ") && line != "data: [DONE]"
+
+ yield JSON.parse(line.delete_prefix("data: "))
+ end
+ end
+ end
+
+ def finalize_tool_calls(tool_calls)
+ tool_calls.values.map do |tool_call|
+ {
+ id: tool_call[:id],
+ name: tool_call[:name],
+ arguments: JSON.parse(tool_call[:args])
+ }
+ end
+ end
+ end
+ end
+end
lib/elelem/plugins/confirm.rb
@@ -0,0 +1,12 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:confirm) do |toolbox|
+ toolbox.before("execute") do |args|
+ next unless $stdin.tty?
+
+ cmd = args["command"]
+ $stdout.print " Allow? [Y/n] > "
+ answer = $stdin.gets&.strip&.downcase
+ raise "User denied permission to execute: #{cmd}" if answer == "n"
+ end
+end
lib/elelem/plugins/edit.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:edit) do |toolbox|
+ toolbox.add("edit",
+ description: "Replace first occurrence of text in file",
+ params: { path: { type: "string" }, old: { type: "string" }, new: { type: "string" } },
+ required: ["path", "old", "new"]
+ ) do |a|
+ path = Pathname.new(a["path"]).expand_path
+ content = path.read
+ toolbox
+ .run("write", { "path" => a["path"], "content" => content.sub(a["old"], a["new"]) })
+ .merge(replaced: a["old"], with: a["new"])
+ end
+end
lib/elelem/plugins/eval.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:eval) do |toolbox|
+ description = <<~'DESC'
+ Evaluate Ruby code. Available API:
+
+ name = "search"
+ toolbox.add(name, description: "Search using rg", params: { query: { type: "string" } }, required: ["query"], aliases: []) do |args|
+ toolbox.run("execute", { "command" => "rg --json -nI -F #{args["query"]}" })
+ end
+ DESC
+
+ toolbox.add("eval",
+ description: description,
+ params: { ruby: { type: "string" } },
+ required: ["ruby"]
+ ) do |args|
+ { result: binding.eval(args["ruby"]) }
+ end
+end
lib/elelem/plugins/execute.rb
@@ -0,0 +1,18 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:execute) do |toolbox|
+ toolbox.add("execute",
+ description: "Run shell command (supports pipes and redirections)",
+ params: { command: { type: "string" } },
+ required: ["command"],
+ aliases: ["bash", "sh", "exec", "execute<|channel|>"]
+ ) do |a|
+ Elelem.sh("bash", args: ["-c", a["command"]]) { |x| $stdout.print(x) }
+ end
+
+ toolbox.after("execute") do |args, result|
+ return if result[:exit_status] == 0
+
+ $stdout.puts toolbox.header("execute", args, state: "x")
+ end
+end
lib/elelem/plugins/mcp.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:mcp) do |toolbox|
+ mcp = Elelem::MCP.new
+ at_exit { mcp.close }
+ mcp.tools.each do |name, tool|
+ toolbox.add(name,
+ description: tool[:description],
+ params: tool[:params],
+ required: tool[:required],
+ &tool[:fn]
+ )
+ end
+end
lib/elelem/plugins/read.rb
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:read) do |toolbox|
+ toolbox.add("read",
+ description: "Read file",
+ params: { path: { type: "string" } },
+ required: ["path"],
+ aliases: ["open"]
+ ) do |a|
+ path = Pathname.new(a["path"]).expand_path
+ path.exist? ? { content: path.read, path: a["path"] } : { error: "not found" }
+ end
+
+ toolbox.after("read") do |_, result|
+ if result[:error]
+ $stdout.puts " ! #{result[:error]}"
+ elsif !system("bat", "--paging=never", result[:path])
+ $stdout.puts result[:content]
+ end
+ end
+end
lib/elelem/plugins/verify.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Verifiers
+ SYNTAX = {
+ ".rb" => "ruby -c %{path}",
+ ".erb" => "erb -x %{path} | ruby -c",
+ ".py" => "python -m py_compile %{path}",
+ ".go" => "go vet %{path}",
+ ".rs" => "cargo check --quiet",
+ ".ts" => "npx tsc --noEmit %{path}",
+ ".js" => "node --check %{path}",
+ }.freeze
+
+ def self.for(path)
+ return [] unless path
+
+ cmds = []
+ ext = File.extname(path)
+ cmds << (SYNTAX[ext] % { path: path }) if SYNTAX[ext]
+ cmds << test_runner
+ cmds.compact
+ end
+
+ def self.test_runner
+ %w[bin/test script/test].find { |s| File.executable?(s) }
+ end
+ end
+
+ Plugins.register(:verify) do |toolbox|
+ toolbox.add("verify",
+ description: "Verify file syntax and run tests",
+ params: { path: { type: "string" } },
+ required: ["path"]
+ ) do |a|
+ path = a["path"]
+ Verifiers.for(path).inject({verified: []}) do |memo, cmd|
+ $stdout.puts toolbox.header("execute", { "command" => cmd })
+ v = toolbox.run("execute", { "command" => cmd })
+ return v.merge(path: path, command: cmd) if v[:exit_status] != 0
+
+ memo[:verified] << cmd
+ memo
+ end
+ end
+ end
+end
lib/elelem/plugins/write.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:write) do |toolbox|
+ toolbox.add("write",
+ description: "Write file",
+ params: { path: { type: "string" }, content: { type: "string" } },
+ required: ["path", "content"],
+ aliases: ["write<|channel|>"]
+ ) do |a|
+ path = Pathname.new(a["path"]).expand_path
+ FileUtils.mkdir_p(path.dirname)
+ { bytes: path.write(a["content"]), path: a["path"] }
+ end
+
+ toolbox.after("write") do |_, result|
+ if result[:error]
+ $stdout.puts " ! #{result[:error]}"
+ else
+ system("bat", "--paging=never", result[:path]) || $stdout.puts(" -> #{result[:path]}")
+ toolbox.run("verify", { "path" => result[:path] })
+ end
+ end
+end
lib/elelem/templates/system_prompt.erb
@@ -0,0 +1,53 @@
+Terminal coding agent. Be concise. Verify your work.
+
+# Tools
+- read(path): file contents
+- write(path, content): create/overwrite file
+- execute(command): shell command
+- eval(ruby): execute Ruby code; use to create tools for repetitive tasks
+- task(prompt): delegate complex searches or multi-file analysis to a focused subagent
+
+# Editing
+Use execute(`patch -p1`) for multi-line changes: `echo "DIFF" | patch -p1`
+Use execute(`sed`) for single-line changes: `sed -i'' 's/old/new/' file`
+Use write for new files or full rewrites
+
+# Search
+Use execute(`rg`) for text search: `rg -n "pattern" .`
+Use execute(`fd`) for file discovery: `fd -e rb .`
+Use execute(`sg`) (ast-grep) for structural search: `sg -p 'def $NAME' -l ruby`
+
+# Task Management
+For complex tasks:
+1. State plan before acting
+2. Work through steps one at a time
+3. Summarize what was done
+
+# Long Tasks
+For complex multi-step work, write notes to .elelem/scratch.md
+
+# Policy
+- Explain before non-trivial commands
+- Verify changes (read file, run tests)
+- No interactive flags (-i, -p)
+- Use `man` when you need to understand how to execute a program
+
+# Environment
+pwd: <%= pwd %>
+platform: <%= platform %>
+date: <%= date %>
+self (this agent's source): <%= elelem_source %>
+<%= git_branch %>
+
+# Codebase
+<%= repo_map %>
+<% if agents_md %>
+
+# Project Instructions
+<%= agents_md %>
+<% end %>
+<% if memory %>
+
+# Earlier Context
+<%= memory %>
+<% end %>
lib/elelem/agent.rb
@@ -2,277 +2,184 @@
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 /mode /provider /model /shell /clear /context /exit /help].freeze
- MODES = %w[auto build plan verify].freeze
- ENV_VARS = %w[ANTHROPIC_API_KEY OPENAI_API_KEY OPENAI_BASE_URL OLLAMA_HOST GOOGLE_CLOUD_PROJECT GOOGLE_CLOUD_REGION].freeze
-
- attr_reader :conversation, :client, :toolbox, :provider, :terminal, :permissions
-
- def initialize(provider, model, toolbox, terminal: nil)
- @conversation = Conversation.new
- @provider = provider
+ COMMANDS = %w[/clear /context /init /reload /shell /exit /help].freeze
+ MAX_CONTEXT_MESSAGES = 50
+ INIT_PROMPT = <<~PROMPT
+ AGENTS.md generator. Analyze codebase and write AGENTS.md to project root.
+
+ # AGENTS.md Spec (https://agents.md/)
+ A file providing context and instructions for AI coding agents.
+
+ ## Recommended Sections
+ - Commands: build, test, lint commands
+ - Code Style: conventions, patterns
+ - Architecture: key components and flow
+ - Testing: how to run tests
+
+ ## Process
+ 1. Read README.md if present
+ 2. Identify language (Gemfile, package.json, go.mod)
+ 3. Find test scripts (bin/test, npm test)
+ 4. Check linter configs
+ 5. Write concise AGENTS.md
+
+ Keep it minimal. No fluff.
+ PROMPT
+
+ attr_reader :history, :client, :toolbox, :terminal
+
+ def initialize(client, toolbox, terminal: nil, history: nil, system_prompt: nil)
+ @client = client
@toolbox = toolbox
- @client = build_client(provider, model)
- @terminal = terminal || default_terminal
- @permissions = Set.new([:read])
+ @terminal = terminal || Terminal.new(commands: COMMANDS)
+ @history = history || []
+ @system_prompt = system_prompt
+ @memory = nil
+ register_task_tool
end
def repl
+ terminal.say "elelem v#{VERSION}"
loop do
- input = terminal.ask("User> ")
+ 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_for(permissions))
- 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,
- modes: MODES,
- providers: PROVIDERS
- )
- end
-
- def handle_slash_command(input)
+ def command(input)
case input
- when "/mode auto"
- permissions.replace([:read, :write, :execute])
- terminal.say " → Mode: auto (all tools enabled)"
- when "/mode build"
- permissions.replace([:read, :write])
- terminal.say " → Mode: build (read + write)"
- when "/mode plan"
- permissions.replace([:read])
- terminal.say " → Mode: plan (read-only)"
- when "/mode verify"
- permissions.replace([:read, :execute])
- terminal.say " → Mode: verify (read + execute)"
- when "/mode"
- terminal.say " Usage: /mode [auto|build|plan|verify]"
- terminal.say ""
- terminal.say " Provider: #{provider}/#{client.model}"
- terminal.say " Permissions: #{permissions.to_a.inspect}"
- terminal.say " Tools: #{toolbox.tools_for(permissions).map { |t| t.dig(:function, :name) }}"
- when "/exit" then exit
- when "/clear"
- conversation.clear
- terminal.say " → Conversation cleared"
- when "/context"
- terminal.say conversation.dump(permissions)
+ when "/exit" then exit(0)
+ when "/init" then init_agents_md
+ when "/reload" then reload_source!
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
+ history << { role: "user", content: transcript } unless transcript.strip.empty?
+ when "/clear"
+ @history = []
+ @memory = nil
+ terminal.say " → context cleared"
+ when "/context"
+ terminal.say JSON.pretty_generate(combined_history)
else
- terminal.say help_banner
+ terminal.say COMMANDS.join(" ")
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)
+ compact_if_needed
+ history << { role: "user", content: input }
+ ctx = []
+ content = nil
- def start_shell
- Tempfile.create do |file|
- system("script -q #{file.path}", chdir: Dir.pwd)
- strip_ansi(File.read(file.path))
+ loop do
+ terminal.waiting
+ content, tool_calls = fetch_response(ctx)
+ terminal.say(terminal.markdown(content))
+ break if tool_calls.empty?
+
+ ctx << { role: "assistant", content: content, tool_calls: tool_calls }.compact
+ tool_calls.each do |tool_call|
+ ctx << { role: "tool", tool_call_id: tool_call[:id], content: process(tool_call).to_json }
+ end
end
- end
- def help_banner
- <<~HELP
- /env VAR cmd...
- /mode auto build plan verify
- /provider
- /model
- /shell
- /clear
- /context
- /exit
- /help
- HELP
+ history << { role: "assistant", content: content }
+ content
end
- def build_client(provider_name, model = nil)
- model_opts = model ? { model: model } : {}
-
- 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
+ private
- 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
- []
+ def process(tool_call)
+ name, args = tool_call[:name], tool_call[:arguments]
+ terminal.say toolbox.header(name, args)
+ toolbox.run(name.to_s, args)
+ end
+
+ def register_task_tool
+ @toolbox.add("task",
+ description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
+ params: { prompt: { type: "string" } },
+ required: ["prompt"]
+ ) do |a|
+ sub = Agent.new(client, toolbox, terminal: terminal,
+ system_prompt: "Research agent. Search, analyze, report. Be concise.")
+ sub.turn(a["prompt"])
+ { result: sub.history.last[:content] }
end
- rescue KeyError => e
- terminal.say " ⚠ Missing credentials: #{e.message}"
- []
- 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}"
+ def init_agents_md
+ sub = Agent.new(client, toolbox, terminal: terminal, system_prompt: INIT_PROMPT)
+ sub.turn("Generate AGENTS.md for this project")
end
- def switch_model(model)
- @client = build_client(provider, model)
- terminal.say " → Switched to #{provider}/#{client.model}"
+ def reload_source!
+ lib_dir = File.expand_path("..", __dir__)
+ original_verbose, $VERBOSE = $VERBOSE, nil
+ Dir["#{lib_dir}/**/*.rb"].sort.each { |f| load(f) }
+ $VERBOSE = original_verbose
+ @toolbox = Toolbox.new
+ Plugins.reload!(@toolbox)
+ register_task_tool
end
- def format_tool_call_result(result)
- return if result.nil?
- return result["stdout"] if result["stdout"]
- return result["stderr"] if result["stderr"]
- return result[:error] if result[:error]
-
- ""
+ def start_shell
+ Tempfile.create do |file|
+ system("script", "-q", file.path, chdir: Dir.pwd)
+ strip_ansi(File.read(file.path))
+ end
end
- def truncate_output(text, max_lines: 30)
- return text if text.nil? || text.empty?
-
- lines = text.to_s.lines
- if lines.size > max_lines
- lines.first(max_lines).join + "\n... (#{lines.size - max_lines} more lines)"
- else
- text
+ 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 fetch_response(ctx)
+ content = ""
+ tool_calls = client.fetch(combined_history + ctx, toolbox.to_a) do |delta|
+ content += delta[:content].to_s
+ terminal.print(terminal.think(delta[:thinking])) if delta[:thinking]
end
+ [content, tool_calls]
+ rescue => e
+ terminal.say "\n ✗ #{e.message}"
+ ["Error: #{e.message} #{e.backtrace.join("\n")}", []]
end
- 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
+ def combined_history
+ [{ role: "system", content: system_prompt }] + history
end
- def openai_client?
- client.is_a?(Net::Llm::OpenAI)
+ def system_prompt
+ @system_prompt || SystemPrompt.new(memory: @memory).render
end
- def execute_turn(messages)
- tools = toolbox.tools_for(permissions)
- turn_context = []
- errors = 0
+ def compact_if_needed
+ return if history.length <= MAX_CONTEXT_MESSAGES
- loop do
- content = ""
- tool_calls = []
+ terminal.say " → compacting context"
+ keep = MAX_CONTEXT_MESSAGES / 2
+ old = history.first(history.length - keep)
- terminal.waiting
- begin
- client.fetch(messages + turn_context, tools) do |chunk|
- case chunk[:type]
- when :delta
- terminal.write chunk[:thinking] if chunk[:thinking]
- 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
+ to_summarize = @memory ? [{ role: "memory", content: @memory }, *old] : old
+ @memory = summarize(to_summarize)
+ @history = history.last(keep)
+ end
- terminal.say "\nAssistant> #{content}" 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
+ def summarize(messages)
+ text = messages.map { |message| { role: message[:role], content: message[:content] } }.to_json
- if tool_calls.any?
- tool_calls.each do |call|
- name, args = call[:name], call[:arguments]
- terminal.say "\nTool> #{name}(#{args})"
- result = toolbox.run_tool(name, args, permissions: permissions)
- terminal.say truncate_output(format_tool_call_result(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
+ String.new.tap do |buffer|
+ client.fetch([{ role: "user", content: "Summarize key facts:\n#{text}" }], []) do |d|
+ buffer << d[:content].to_s
end
-
- return { role: "assistant", content: content }
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,78 +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_for(permissions)
- history = @items.dup
- history[0] = { role: "system", content: system_prompt_for(permissions) }
- history
- 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(permissions)
- JSON.pretty_generate(history_for(permissions))
- end
-
- private
-
- def default_context(prompt = system_prompt_for([]))
- [{ role: "system", content: prompt }]
- end
-
- def system_prompt_for(permissions)
- base = system_prompt
-
- case permissions.sort
- when [:read]
- "#{base}\n\nYou may read files on the system."
- when [:write]
- "#{base}\n\nYou may write files on the system."
- when [:execute]
- "#{base}\n\nYou may execute shell commands on the system."
- when [:read, :write]
- "#{base}\n\nYou may read and write files on the system."
- when [:execute, :read]
- "#{base}\n\nYou may execute shell commands and read files on the system."
- when [:execute, :write]
- "#{base}\n\nYou may execute shell commands and write files on the system."
- when [:execute, :read, :write]
- "#{base}\n\nYou may read files, write files and execute shell commands on the system."
- else
- base
- end
- 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/git_context.rb
@@ -1,79 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class GitContext
- MAX_DIFF_LINES = 100
-
- def initialize(shell = Elelem.shell)
- @shell = shell
- end
-
- def to_s
- return "" unless git_repo?
-
- parts = []
- parts << "Branch: #{branch}" if branch
- parts << status_section if status.any?
- parts << diff_section if staged_diff.any? || unstaged_diff.any?
- parts << recent_commits_section if recent_commits.any?
- parts.join("\n\n")
- end
-
- private
-
- def git_repo?
- @shell.execute("git", args: ["rev-parse", "--git-dir"])["exit_status"].zero?
- end
-
- def branch
- @branch ||= @shell.execute("git", args: ["branch", "--show-current"])["stdout"].strip.then { |b| b.empty? ? nil : b }
- end
-
- def status
- @status ||= @shell.execute("git", args: ["status", "--porcelain"])["stdout"].lines.map(&:chomp)
- end
-
- def staged_diff
- @staged_diff ||= @shell.execute("git", args: ["diff", "--cached", "--stat"])["stdout"].lines
- end
-
- def unstaged_diff
- @unstaged_diff ||= @shell.execute("git", args: ["diff", "--stat"])["stdout"].lines
- end
-
- def recent_commits
- @recent_commits ||= @shell.execute("git", args: ["log", "--oneline", "-5"])["stdout"].lines.map(&:strip)
- end
-
- def status_section
- modified = status.select { |l| l[0] == "M" || l[1] == "M" }.map { |l| l[3..] }
- added = status.select { |l| l[0] == "A" || l.start_with?("??") }.map { |l| l[3..] }
- deleted = status.select { |l| l[0] == "D" || l[1] == "D" }.map { |l| l[3..] }
-
- lines = []
- lines << "Modified: #{modified.join(', ')}" if modified.any?
- lines << "Added: #{added.join(', ')}" if added.any?
- lines << "Deleted: #{deleted.join(', ')}" if deleted.any?
- lines.any? ? "Working tree:\n#{lines.join("\n")}" : nil
- end
-
- def diff_section
- lines = []
- lines << "Staged:\n#{truncate(staged_diff)}" if staged_diff.any?
- lines << "Unstaged:\n#{truncate(unstaged_diff)}" if unstaged_diff.any?
- lines.join("\n\n")
- end
-
- def recent_commits_section
- "Recent commits:\n#{recent_commits.join("\n")}"
- end
-
- def truncate(lines)
- if lines.size > MAX_DIFF_LINES
- lines.first(MAX_DIFF_LINES).join + "\n... (#{lines.size - MAX_DIFF_LINES} more lines)"
- else
- lines.join
- end
- end
- end
-end
lib/elelem/mcp.rb
@@ -0,0 +1,96 @@
+# frozen_string_literal: true
+
+module Elelem
+ class MCP
+ def initialize(config_path = ".mcp.json")
+ @config = File.exist?(config_path) ? JSON.parse(IO.read(config_path)) : {}
+ @servers = {}
+ end
+
+ def tools
+ @config.fetch("mcpServers", {}).flat_map do |name, _|
+ server(name).tools.map do |tool|
+ [
+ "#{name}_#{tool["name"]}",
+ {
+ description: tool["description"],
+ params: tool.dig("inputSchema", "properties") || {},
+ required: tool.dig("inputSchema", "required") || [],
+ fn: ->(a) { server(name).call(tool["name"], a) }
+ }
+ ]
+ end
+ end.to_h
+ end
+
+ def close
+ @servers.each_value(&:close)
+ end
+
+ private
+
+ def server(name)
+ @servers[name] ||= Server.new(**@config.dig("mcpServers", name).transform_keys(&:to_sym))
+ end
+
+ class Server
+ def initialize(command:, args: [], env: {})
+ resolved_env = env.transform_values { |v| v.gsub(/\$\{(\w+)\}/) { ENV[$1] } }
+ @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
+ @id = 0
+ initialize!
+ end
+
+ def tools
+ request("tools/list")["tools"]
+ end
+
+ def call(name, args)
+ result = request("tools/call", { name: name, arguments: args })
+ { content: result["content"]&.map { |c| c["text"] }&.join("\n") }
+ end
+
+ def close
+ @stdin.close rescue nil
+ @stdout.close rescue nil
+ @stderr.close rescue nil
+ @wait.kill rescue nil
+ end
+
+ private
+
+ def initialize!
+ request("initialize", {
+ protocolVersion: "2024-11-05",
+ capabilities: {},
+ clientInfo: { name: "elelem", version: VERSION }
+ })
+ notify("notifications/initialized")
+ end
+
+ def request(method, params = {})
+ send_msg(id: @id += 1, method: method, params: params)
+ read_response(@id)
+ end
+
+ def notify(method, params = {})
+ send_msg(method: method, params: params)
+ end
+
+ def send_msg(msg)
+ @stdin.puts({ jsonrpc: "2.0", **msg }.to_json)
+ @stdin.flush
+ end
+
+ def read_response(id)
+ loop do
+ line = @stdout.gets
+ raise "Server closed" unless line
+ msg = JSON.parse(line)
+ return msg["result"] if msg["id"] == id
+ raise msg["error"]["message"] if msg["error"]
+ end
+ end
+ end
+ end
+end
lib/elelem/net.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+require "net/hippie"
+require "json"
+
+require_relative "net/ollama"
+require_relative "net/openai"
+require_relative "net/claude"
+
+module Elelem
+ module Net
+ def self.http
+ @http ||= ::Net::Hippie::Client.new(read_timeout: 3600, open_timeout: 10)
+ end
+ end
+end
lib/elelem/plugins.rb
@@ -0,0 +1,43 @@
+# frozen_string_literal: true
+
+module Elelem
+ module Plugins
+ LOAD_PATHS = [
+ File.expand_path("plugins", __dir__),
+ "~/.elelem/plugins",
+ ".elelem/plugins"
+ ].freeze
+
+ def self.setup!(toolbox)
+ load_plugins
+ registry.each_value { |plugin| plugin.call(toolbox) }
+ end
+
+ def self.reload!(toolbox)
+ @registry = {}
+ load_plugins
+ registry.each_value { |plugin| plugin.call(toolbox) }
+ end
+
+ def self.load_plugins
+ LOAD_PATHS.each do |path|
+ dir = File.expand_path(path)
+ next unless File.directory?(dir)
+
+ Dir["#{dir}/*.rb"].sort.each do |file|
+ load(file)
+ rescue => e
+ warn "elelem: failed to load plugin #{file}: #{e.message}"
+ end
+ end
+ end
+
+ def self.register(name, &block)
+ (@registry ||= {})[name] = block
+ end
+
+ def self.registry
+ @registry ||= {}
+ end
+ end
+end
lib/elelem/system_prompt.erb
@@ -1,16 +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.
-
-## System
-
-<%= `uname -s`.strip %> · <%= ENV['PWD'] %>
-
-## Git State
-
-<%= Elelem::GitContext.new.to_s %>
lib/elelem/system_prompt.rb
@@ -0,0 +1,65 @@
+# frozen_string_literal: true
+
+module Elelem
+ class SystemPrompt
+ TEMPLATE_PATH = File.expand_path("templates/system_prompt.erb", __dir__)
+
+ attr_reader :memory
+
+ def initialize(memory: nil)
+ @memory = memory
+ end
+
+ def render
+ ERB.new(template, trim_mode: "-").result(binding)
+ end
+
+ private
+
+ def template
+ File.read(TEMPLATE_PATH)
+ end
+
+ def pwd
+ Dir.pwd
+ end
+
+ def elelem_source
+ File.expand_path("../..", __dir__)
+ end
+
+ def platform
+ RUBY_PLATFORM.split("-").last
+ end
+
+ def date
+ Date.today
+ end
+
+ def git_branch
+ return unless File.exist?(".git")
+
+ "branch: #{`git branch --show-current`.strip}"
+ rescue
+ nil
+ end
+
+ def repo_map
+ `ctags -x --sort=no --languages=Ruby,Python,JavaScript,TypeScript,Go,Rust -R . 2>/dev/null`
+ .lines
+ .reject { |l| l.include?("vendor/") || l.include?("node_modules/") || l.include?("spec/") }
+ .first(100)
+ .join
+ rescue
+ ""
+ end
+
+ def agents_md
+ Pathname.pwd.ascend.each do |dir|
+ file = dir / "AGENTS.md"
+ return file.read if file.exist?
+ end
+ nil
+ end
+ end
+end
lib/elelem/terminal.rb
@@ -2,58 +2,79 @@
module Elelem
class Terminal
- def initialize(commands: [], modes: [], providers: [], env_vars: [])
+ def initialize(commands: [], quiet: false)
@commands = commands
- @modes = modes
- @providers = providers
- @env_vars = env_vars
- @spinner_thread = nil
- setup_completion
+ @quiet = quiet
+ @dots_thread = nil
+ setup_completion unless @quiet
end
def ask(prompt)
Reline.readline(prompt, true)&.strip
end
- def say(message)
- stop_spinner
- $stdout.puts message
+ def think(text)
+ return if blank?(text)
+
+ "\e[2;3m#{text}\e[0m"
end
- def write(message)
- stop_spinner
- $stdout.print message
+ def markdown(text)
+ return if @quiet || blank?(text)
+
+ newline(n: 2)
+ 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 print(text)
+ return if @quiet || blank?(text)
+
+ stop_dots
+ $stdout.print text
+ end
+
+ def say(text)
+ return if @quiet || blank?(text)
+
+ stop_dots
+ $stdout.puts text
+ end
+
+ def newline(n: 1)
+ n.times { $stdout.puts("") }
end
def waiting
- @spinner_thread = Thread.new do
- frames = %w[| / - \\]
- i = 0
+ return if @quiet
+
+ @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 blank?(text)
+ text.nil? || text.strip.empty?
+ end
+
+ 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
@@ -63,45 +84,14 @@ module Elelem
def complete(target, preposing)
line = "#{preposing}#{target}"
+ return @commands.select { |c| c.start_with?(line) } if line.start_with?("/") && !preposing.include?(" ")
- if line.start_with?('/') && !preposing.include?(' ')
- return @commands.select { |c| c.start_with?(line) }
- end
-
- case preposing.strip
- when '/mode'
- @modes.select { |m| m.start_with?(target) }
- 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)
+ complete_files(target)
end
def complete_files(target)
- result = Elelem.shell.execute("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)
+ result = Elelem.sh("bash", args: ["-c", "compgen -f #{target}"])
+ result[:content].lines.map(&:strip).first(20)
end
end
end
lib/elelem/tool.rb
@@ -2,49 +2,47 @@
module Elelem
class Tool
- attr_reader :name
+ attr_reader :name, :description, :params, :required, :aliases
- def initialize(schema, &block)
- @name = schema.dig(:function, :name)
- @schema = schema
- @block = block
+ def initialize(name, description:, params: {}, required: [], aliases: [], &fn)
+ @name = name
+ @description = description
+ @params = params
+ @required = required
+ @aliases = aliases
+ @fn = fn
+ @schema = JSONSchemer.schema(schema_hash)
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)
+ @fn.call(args)
end
- def valid?(args)
- JSON::Validator.validate(@schema.dig(:function, :parameters), args)
+ def validate(args)
+ @schema.validate(args || {}).map do |error|
+ error["error"]
+ end
end
def to_h
- @schema&.to_h
+ {
+ type: "function",
+ function: {
+ name: name,
+ description: description,
+ parameters: schema_hash
+ }
+ }
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
+ private
+
+ def schema_hash
+ {
+ type: "object",
+ properties: params,
+ required: required
+ }
end
end
end
lib/elelem/toolbox.rb
@@ -2,122 +2,64 @@
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}" }
- 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
+ attr_reader :tools, :hooks, :aliases
- 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"])
+ def initialize
+ @tools = {}
+ @aliases = {}
+ @hooks = { before: Hash.new { |h, k| h[k] = [] }, after: Hash.new { |h, k| h[k] = [] } }
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"])
+ def add(name, description:, params: {}, required: [], aliases: [], &fn)
+ tool = Tool.new(name, description: description, params: params, required: required, aliases: aliases, &fn)
+ @tools[name] = tool
+ tool.aliases.each { |a| @aliases[a] = name }
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"]) }
+ def before(tool_name, &block)
+ @hooks[:before][tool_name] << block
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 }
+ def after(tool_name, &block)
+ @hooks[:after][tool_name] << block
end
- WEB_SEARCH_TOOL = Tool.build("search_engine", "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)
+ def header(name, args, state: "+")
+ name = tool_for(name)&.name || "#{name}?"
+ "\n#{state} #{name}(#{args})"
end
- TOOL_ALIASES = {
- "bash" => "exec",
- "duckduckgo" => "search_engine",
- "ddg" => "search_engine",
- "execute" => "exec",
- "get" => "fetch",
- "open" => "read",
- "search" => "grep",
- "sh" => "exec",
- "web" => "fetch",
- }
+ def run(name, args)
+ tool = tool_for(name)
+ return failure(error: "unknown tool: #{name}. Use 'execute' to run shell commands like rg, fd, git.", tools: to_a) unless tool
- attr_reader :tools
+ errors = tool.validate(args)
+ return failure(error: errors.join(", ")) if errors.any?
- def initialize
- @tools_by_name = {}
- @tool_permissions = {}
- @tools = { read: [], write: [], execute: [] }
- add_tool(eval_tool(binding), :execute)
- add_tool(WEB_SEARCH_TOOL, :read)
- add_tool(EXEC_TOOL, :execute)
- add_tool(FETCH_TOOL, :read)
- add_tool(GREP_TOOL, :read)
- add_tool(LIST_TOOL, :read)
- add_tool(PATCH_TOOL, :write)
- add_tool(READ_TOOL, :read)
- add_tool(WRITE_TOOL, :write)
+ @hooks[:before][tool.name].each { |h| h.call(args) }
+ result = tool.call(args)
+ @hooks[:after][tool.name].each { |h| h.call(args, result) }
+ result[:error] ? failure(result) : success(result)
+ rescue => e
+ failure(error: e.message, name: name, args: args)
end
- def add_tool(tool, permission)
- @tools[permission] << tool
- @tools_by_name[tool.name] = tool
- @tool_permissions[tool.name] = permission
+ def to_a
+ tools.values.map(&:to_h)
end
- def register_tool(name, description, properties = {}, required = [], mode: :execute, &block)
- add_tool(Tool.build(name, description, properties, required, &block), mode)
- end
-
- def tools_for(permissions)
- Array(permissions).map { |permission| tools[permission].map(&:to_h) }.flatten
- end
-
- def run_tool(name, args, permissions: [])
- resolved_name = TOOL_ALIASES.fetch(name, name)
- tool = @tools_by_name[resolved_name]
- return { error: "Unknown tool", name: name, args: args } unless tool
-
- tool_permission = @tool_permissions[resolved_name]
- unless Array(permissions).include?(tool_permission)
- return { error: "Tool '#{resolved_name}' not available in current mode", name: name }
- end
+ private
- tool.call(args)
- rescue => error
- { error: error.message, name: name, args: args, backtrace: error.backtrace.first(5) }
+ def tool_for(name)
+ tools[@aliases.fetch(name, name)]
end
- def tool_schema(name)
- @tools_by_name[name]&.to_h
+ def success(payload)
+ payload.merge(ok: true)
end
- private
-
- 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, mode: :execute) { |args| ... }` method.", { ruby: { type: "string" } }, ["ruby"]) do |args|
- { result: target_binding.eval(args["ruby"]) }
- end
+ def failure(payload)
+ payload.merge(ok: false)
end
end
end
lib/elelem/version.rb
@@ -1,5 +1,5 @@
# frozen_string_literal: true
module Elelem
- VERSION = "0.8.0"
+ VERSION = "0.9.0"
end
\ No newline at end of file
lib/elelem.rb
@@ -1,58 +1,50 @@
# frozen_string_literal: true
-require "cgi"
-require "cli/ui"
+require "date"
require "erb"
require "fileutils"
require "json"
-require "json-schema"
-require "logger"
-require "net/hippie"
-require "net/llm"
+require "json_schemer"
require "open3"
require "pathname"
require "reline"
-require "set"
-require "thor"
-require "timeout"
+require "stringio"
+require "tempfile"
require_relative "elelem/agent"
-require_relative "elelem/application"
-require_relative "elelem/conversation"
-require_relative "elelem/git_context"
+require_relative "elelem/mcp"
+require_relative "elelem/net"
+require_relative "elelem/plugins"
+require_relative "elelem/system_prompt"
require_relative "elelem/terminal"
require_relative "elelem/tool"
require_relative "elelem/toolbox"
require_relative "elelem/version"
-Reline.input = $stdin
-Reline.output = $stdout
-
module Elelem
- class Error < StandardError; end
+ def self.sh(cmd, args: [], cwd: Dir.pwd, env: {})
+ output = StringIO.new
+
+ Open3.popen2e(env, cmd, *args, chdir: cwd) do |stdin, out, wait_thr|
+ stdin.close
+ out.each_line do |line|
+ yield line if block_given?
+ output.write(line)
+ 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
- }
+ { exit_status: wait_thr.value.exitstatus, content: output.string }
end
end
- class << self
- def shell
- @shell ||= Shell.new
- end
+ def self.start(client, toolbox: Toolbox.new)
+ Plugins.setup!(toolbox)
+ Agent.new(client, toolbox).repl
+ end
+
+ def self.ask(client, prompt, toolbox: Toolbox.new)
+ Plugins.setup!(toolbox)
+ agent = Agent.new(client, toolbox, terminal: Terminal.new(quiet: true))
+ agent.turn(prompt)
+ agent.history.last[:content]
end
end
spec/elelem/agent_e2e_spec.rb
@@ -1,105 +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 "/mode" do
- it "shows help when called without arguments" do
- terminal = Elelem::FakeTerminal.new(inputs: ["/mode", nil])
- agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
-
- agent.repl
-
- expect(terminal.output).to include(" Usage: /mode [auto|build|plan|verify]")
- end
-
- it "switches to auto mode" do
- terminal = Elelem::FakeTerminal.new(inputs: ["/mode auto", nil])
- agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
-
- agent.repl
-
- expect(terminal.output).to include(" → Mode: auto (all tools enabled)")
- end
-
- it "switches to build mode" do
- terminal = Elelem::FakeTerminal.new(inputs: ["/mode build", nil])
- agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
-
- agent.repl
-
- expect(terminal.output).to include(" → Mode: build (read + write)")
- end
-
- it "switches to plan mode" do
- terminal = Elelem::FakeTerminal.new(inputs: ["/mode plan", nil])
- agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
-
- agent.repl
-
- expect(terminal.output).to include(" → Mode: plan (read-only)")
- end
-
- it "switches to verify mode" do
- terminal = Elelem::FakeTerminal.new(inputs: ["/mode verify", nil])
- agent = described_class.new("ollama", nil, toolbox, terminal: terminal)
-
- agent.repl
-
- expect(terminal.output).to include(" → Mode: verify (read + execute)")
- end
- end
-
- 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("/mode auto build plan verify")
- end
- end
- end
-end
spec/elelem/agent_spec.rb
@@ -1,43 +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 tools for all modes" do
- expect(agent.toolbox.tools[:read]).to be_an(Array)
- expect(agent.toolbox.tools[:write]).to be_an(Array)
- expect(agent.toolbox.tools[:execute]).to be_an(Array)
- end
- end
-
- describe "integration with conversation" do
- it "conversation uses mode-aware prompts" do
- conversation = agent.conversation
- conversation.add(role: :user, content: "test message")
-
- read_history = conversation.history_for([:read])
- write_history = conversation.history_for([:write])
-
- expect(read_history[0][:content]).to include("You may read files on the system")
- expect(write_history[0][:content]).to include("You may write files on the system")
- expect(read_history[0][:content]).not_to eq(write_history[0][:content])
- end
- end
-end
spec/elelem/conversation_spec.rb
@@ -1,188 +0,0 @@
-# frozen_string_literal: true
-
-RSpec.describe Elelem::Conversation do
- let(:conversation) { described_class.new }
-
- describe "#history_for" do
- context "with empty conversation" do
- it "returns history with mode-specific system prompt for read mode" do
- history = conversation.history_for([:read])
-
- expect(history.length).to eq(1)
- expect(history[0][:role]).to eq("system")
- expect(history[0][:content]).to include("You may read files on the system")
- end
-
- it "returns history with mode-specific system prompt for write mode" do
- history = conversation.history_for([:write])
-
- expect(history[0][:content]).to include("You may write files on the system")
- end
-
- it "returns history with mode-specific system prompt for execute mode" do
- history = conversation.history_for([:execute])
-
- expect(history[0][:content]).to include("You may execute shell commands on the system")
- end
-
- it "returns history with mode-specific system prompt for read+write mode" do
- history = conversation.history_for([:read, :write])
-
- expect(history[0][:content]).to include("You may read and write files on the system")
- end
-
- it "returns history with mode-specific system prompt for read+execute mode" do
- history = conversation.history_for([:read, :execute])
-
- expect(history[0][:content]).to include("You may execute shell commands and read files on the system")
- end
-
- it "returns history with mode-specific system prompt for write+execute mode" do
- history = conversation.history_for([:write, :execute])
-
- expect(history[0][:content]).to include("You may execute shell commands and write files on the system")
- end
-
- it "returns history with mode-specific system prompt for all tools mode" do
- history = conversation.history_for([:read, :write, :execute])
-
- expect(history[0][:content]).to include("You may read files, write files and execute shell commands on the system")
- end
-
- it "returns base system prompt for unknown mode" do
- history = conversation.history_for([:unknown])
-
- expect(history[0][:content]).not_to include("Read and analyze")
- expect(history[0][:content]).not_to include("Write clean")
- end
-
- it "returns base system prompt for empty mode" do
- history = conversation.history_for([])
-
- expect(history[0][:role]).to eq("system")
- expect(history[0][:content]).to be_a(String)
- end
- end
-
- context "with mode order independence" do
- it "returns same prompt for [:read, :write] and [:write, :read]" do
- history1 = conversation.history_for([:read, :write])
- history2 = conversation.history_for([:write, :read])
-
- expect(history1[0][:content]).to eq(history2[0][:content])
- end
-
- it "returns same prompt for [:read, :execute] and [:execute, :read]" do
- history1 = conversation.history_for([:read, :execute])
- history2 = conversation.history_for([:execute, :read])
-
- expect(history1[0][:content]).to eq(history2[0][:content])
- end
-
- it "returns same prompt for all permutations of [:read, :write, :execute]" do
- history1 = conversation.history_for([:read, :write, :execute])
- history2 = conversation.history_for([:execute, :read, :write])
- history3 = conversation.history_for([:write, :execute, :read])
-
- expect(history1[0][:content]).to eq(history2[0][:content])
- expect(history2[0][:content]).to eq(history3[0][:content])
- end
- 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_for([:read])
-
- 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 "updates system prompt without mutating original" do
- original_items = conversation.instance_variable_get(:@items)
- original_system_content = original_items[0][:content]
-
- history = conversation.history_for([:read])
-
- expect(history[0][:content]).not_to eq(original_system_content)
- expect(original_items[0][:content]).to eq(original_system_content)
- end
-
- it "returns a copy, not the original array" do
- history = conversation.history_for([:read])
- 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_for([])
-
- 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_for([])
-
- 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_for([])
-
- expect(history.length).to eq(1)
- end
-
- it "ignores empty content" do
- conversation.add(role: :user, content: "")
- history = conversation.history_for([])
-
- 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_for([])
-
- expect(history.length).to eq(1)
- expect(history[0][:role]).to eq("system")
- end
- end
-
- describe "#dump" do
- it "returns JSON representation with mode-specific prompt" do
- conversation.add(role: :user, content: "test")
- json = conversation.dump([:read])
-
- parsed = JSON.parse(json)
- expect(parsed).to be_an(Array)
- expect(parsed.length).to eq(2)
- expect(parsed[0]["content"]).to include("You may read files on the system")
- end
- end
-end
spec/elelem/toolbox_spec.rb
@@ -1,145 +1,51 @@
# frozen_string_literal: true
-#
+
RSpec.describe Elelem::Toolbox do
subject { described_class.new }
- describe "#tools_for" do
- it "returns read tools for read mode" do
- mode = Set[:read]
- tools = subject.tools_for(mode)
-
- tool_names = tools.map { |t| t.dig(:function, :name) }
- expect(tool_names).to include("grep", "list", "read", "fetch", "search_engine")
- expect(tool_names).not_to include("write", "patch", "exec")
- end
-
- it "returns write tools for write mode" do
- mode = Set[:write]
- tools = subject.tools_for(mode)
-
- tool_names = tools.map { |t| t.dig(:function, :name) }
- expect(tool_names).to include("patch", "write")
- expect(tool_names).not_to include("grep", "exec")
- end
-
- it "returns execute tools for execute mode" do
- mode = Set[:execute]
- tools = subject.tools_for(mode)
-
- tool_names = tools.map { |t| t.dig(:function, :name) }
- expect(tool_names).to include("exec")
- expect(tool_names).not_to include("grep", "write")
- end
-
- it "returns all tools for auto mode" do
- mode = Set[:read, :write, :execute]
- tools = subject.tools_for(mode)
-
- tool_names = tools.map { |t| t.dig(:function, :name) }
- expect(tool_names).to include("grep", "list", "read", "patch", "write", "exec", "fetch", "search_engine")
- end
-
- it "returns combined tools for build mode" do
- mode = Set[:read, :write]
- tools = subject.tools_for(mode)
-
- tool_names = tools.map { |t| t.dig(:function, :name) }
- expect(tool_names).to include("grep", "read", "write", "patch", "fetch", "search_engine")
- expect(tool_names).not_to include("exec")
- end
+ before do
+ subject.add("read",
+ description: "Read file",
+ params: { path: { type: "string" } },
+ required: ["path"],
+ aliases: ["open"]
+ ) { |a| { content: File.read(a["path"]) } }
+
+ subject.add("write",
+ description: "Write file",
+ params: { path: { type: "string" }, content: { type: "string" } },
+ required: ["path", "content"]
+ ) { |a| { bytes: File.write(a["path"], a["content"]) } }
+
+ subject.add("execute",
+ description: "Run shell command",
+ params: { command: { type: "string" } },
+ required: ["command"],
+ aliases: ["bash", "sh", "exec"]
+ ) { |a| { output: `#{a["command"]}` } }
end
- describe "web tools" do
- it "includes fetch and search_engine in read permissions" do
- tools = subject.tools_for([:read])
- names = tools.map { |t| t.dig(:function, :name) }
- expect(names).to include("fetch", "search_engine")
- end
-
- 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 search_engine" do
- expect(Elelem::Toolbox::TOOL_ALIASES["duckduckgo"]).to eq("search_engine")
+ describe "#to_a" do
+ it "returns all tools in API format" do
+ tool_names = subject.to_a.map { |t| t.dig(:function, :name) }
+ expect(tool_names).to include("read", "write", "execute")
end
end
- describe "#run_tool mode enforcement" do
- it "allows tool execution when mode matches" do
- result = subject.run_tool("read", { "path" => __FILE__ }, permissions: [:read])
+ describe "#run" do
+ it "executes read tool" do
+ result = subject.run("read", { "path" => __FILE__ })
expect(result[:content]).to include("RSpec.describe")
end
- it "blocks tool execution when mode does not match" do
- result = subject.run_tool("exec", { "cmd" => "echo hello" }, permissions: [:read])
- expect(result[:error]).to include("not available in current mode")
- end
-
- it "resolves aliases and enforces mode" do
- result = subject.run_tool("bash", { "cmd" => "echo hello" }, permissions: [:read])
- expect(result[:error]).to include("not available in current mode")
- end
-
- it "returns unknown tool error for non-existent tools" do
- result = subject.run_tool("nonexistent", {}, permissions: [:read])
- 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
- }, permissions: [:execute])
-
- expect(subject.tools_for(:execute)).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
- }, permissions: [:execute])
-
- result = subject.run_tool("add", { "a" => 5, "b" => 3 }, permissions: [:execute])
- expect(result[:sum]).to eq(8)
- end
-
- it "allows LLM to inspect tool schemas" do
- result = subject.run_tool("eval", { "ruby" => "tool_schema('read')" }, permissions: [:execute])
- 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" }, permissions: [:execute])
- expect(result[:result]).to eq(4)
+ it "resolves open alias to read" do
+ result = subject.run("open", { "path" => __FILE__ })
+ expect(result[:content]).to include("RSpec.describe")
end
- it "handles errors gracefully" do
- result = subject.run_tool("eval", { "ruby" => "undefined_variable" }, permissions: [:execute])
- expect(result[:error]).to include("undefined")
- expect(result[:backtrace]).to be_an(Array)
+ it "returns error for unknown tools" do
+ result = subject.run("nonexistent", {})
+ expect(result[:error]).to include("unknown tool")
end
end
end
spec/support/fake_terminal.rb
@@ -1,30 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
- class FakeTerminal
- attr_reader :output, :selections
-
- def initialize(inputs: [], selections: {})
- @inputs = inputs
- @selections = selections
- @output = []
- end
-
- def ask(_prompt)
- @inputs.shift
- end
-
- def say(message)
- @output << message
- end
-
- def write(message)
- @output << message
- end
-
- def select(question, _options, &block)
- selected = @selections[question]
- block.call(selected) if selected
- end
- end
-end
spec/spec_helper.rb
@@ -5,10 +5,6 @@ require_relative "../lib/elelem"
Dir[File.join(__dir__, "support/**/*.rb")].each { |f| require f }
RSpec.configure do |config|
- # Enable flags like --only-failures and --next-failure
- config.example_status_persistence_file_path = ".rspec_status"
-
- # Disable RSpec exposing methods globally on `Module` and `main`
config.disable_monkey_patching!
config.expect_with :rspec do |c|
.rspec
@@ -1,3 +1,1 @@
---format documentation
---color
--require spec_helper
CHANGELOG.md
@@ -1,4 +1,73 @@
-## [Unreleased]
+## [0.9.0] - 2026-01-21
+
+### Added
+- **Plugin system** with support for custom tool definitions
+ - Load plugins from `lib/elelem/plugins/`, `~/.elelem/plugins/`, and `.elelem/plugins/`
+ - `Elelem::Plugins.register(name) { |toolbox| ... }` API
+ - Built-in plugins: `read`, `write`, `edit`, `execute`, `eval`, `verify`, `confirm`, `mcp`
+- **MCP (Model Context Protocol)** server support via `.mcp.json` configuration
+- **AGENTS.md** file support - searches up directory tree for project instructions
+- **`/init` command** to generate an AGENTS.md file for the current project
+- **`/shell` command** to drop into a shell session and capture the transcript to context
+- **`/reload` command** to hot-reload source code without restarting the process
+- **`task` tool** for delegating subtasks to focused sub-agents
+- **`edit` tool** for replacing first occurrence of text in a file
+- **`eval` tool** for executing Ruby code and dynamically registering new tools
+- **`verify` tool** for syntax checking and running project tests
+- **Pre/post tool hooks** (`toolbox.before`/`toolbox.after`) for extensibility
+- **Confirmation prompt** before executing shell commands (when TTY)
+- **Context compaction** for long conversations (summarizes old messages)
+- **Repo map** via ctags included in system prompt
+- **Markdown rendering** with [glow](https://github.com/charmbracelet/glow) for LLM responses
+- **CLI improvements**: optparse-based interface with `-p`/`-m` flags
+ - `elelem chat` - Interactive REPL (default)
+ - `elelem ask <prompt>` - One-shot query (reads stdin if piped)
+ - `elelem files` - Output files as XML
+- JSON Schema validation for tool call arguments (via `json_schemer`)
+- Tool aliases support (e.g., `bash`, `sh`, `exec` → `execute`)
+- **Dependencies documentation** in README with installation links
+
+### Changed
+- **Breaking**: Requires Ruby >= 4.0.0 (was 3.4.0)
+- **Breaking**: Removed `net-llm` dependency - LLM clients now inline in `lib/elelem/net/`
+ - `Elelem::Net::Claude` (Anthropic and Vertex AI)
+ - `Elelem::Net::OpenAI`
+ - `Elelem::Net::Ollama`
+- **Breaking**: Simplified LLM client `fetch` contract
+ - Yields `{content:, thinking:}` deltas
+ - Returns `tool_calls` array directly
+- **Breaking**: Tool schema uses OpenAI format (`{type: "function", function: {...}}`)
+- **Breaking**: Tool definitions use `description:` key (was `desc:`)
+- **Breaking**: Removed modes and permissions system entirely
+- **Breaking**: Removed slash commands (`/mode`, `/env`, `/provider`, `/model`)
+ - Remaining: `/clear`, `/context`, `/init`, `/reload`, `/shell`, `/exit`, `/help`
+- **Breaking**: Removed many dependencies
+ - Removed: `thor`, `cli-ui`, `erb`, `cgi`, `set`, `timeout`, `logger`, `net-llm`, `json-schema`
+ - Added: `json_schemer`, `optparse`, `tempfile`, `stringio`, `uri`
+- Consolidated multiple exe files into single `exe/elelem` entry point
+- Tools are now defined via plugins instead of hardcoded in Toolbox
+- System prompt includes hints for `rg`, `fd`, `sg` (ast-grep), `sed`, `patch`
+- System prompt regenerated on each fetch (includes dynamic repo map)
+- Default tool set: `read`, `write`, `edit`, `execute`, `eval`, `verify`, `task`
+- System prompt encourages using `eval` to create tools for repetitive tasks
+
+### Removed
+- `lib/elelem/application.rb` - CLI now in `exe/elelem`
+- `lib/elelem/conversation.rb` - simplified into Agent
+- `lib/elelem/git_context.rb` - inlined into Agent
+- `lib/elelem/system_prompt.erb` - now generated in Agent
+- `web_fetch`, `web_search`, `fetch`, `search_engine` tools
+- `patch` tool (use `edit` or `execute` with `sed`/`patch`)
+- `grep`, `list` tools (use `execute` with `rg`, `fd`)
+- Modes and permissions system
+- Events module
+- GitHub Actions CI workflow
+
+### Fixed
+- Handle missing args in Claude provider
+- Tool alias resolution (use canonical tool name, not alias)
+- Unknown tool error now suggests using `execute` and lists available tools
+- Duplicate write operations in edit flow
## [0.8.0] - 2026-01-14
@@ -48,7 +117,7 @@
- Tab completion for `pass` entries without requiring `show` subcommand
- Password store symlink support in tab completion
-## [0.5.0] - 2025-01-07
+## [0.5.0] - 2026-01-07
### Added
- Multi-provider support: Ollama, Anthropic, OpenAI, and VertexAI
@@ -198,4 +267,3 @@
## [0.1.0] - 2025-08-08
- Initial release
-
elelem.gemspec
@@ -12,8 +12,8 @@ Gem::Specification.new do |spec|
spec.description = "A minimal coding agent supporting Ollama, Anthropic, OpenAI, and VertexAI."
spec.homepage = "https://src.mokhan.ca/xlgmokha/elelem"
spec.license = "MIT"
- spec.required_ruby_version = ">= 3.4.0"
- spec.required_rubygems_version = ">= 3.3.11"
+ spec.required_ruby_version = ">= 4.0.0"
+ spec.required_rubygems_version = ">= 4.0.0"
spec.metadata["allowed_push_host"] = "https://rubygems.org"
spec.metadata["homepage_uri"] = spec.homepage
spec.metadata["source_code_uri"] = "https://src.mokhan.ca/xlgmokha/elelem"
@@ -27,10 +27,22 @@ Gem::Specification.new do |spec|
"exe/elelem",
"lib/elelem.rb",
"lib/elelem/agent.rb",
- "lib/elelem/application.rb",
- "lib/elelem/conversation.rb",
- "lib/elelem/git_context.rb",
- "lib/elelem/system_prompt.erb",
+ "lib/elelem/mcp.rb",
+ "lib/elelem/net.rb",
+ "lib/elelem/net/claude.rb",
+ "lib/elelem/net/ollama.rb",
+ "lib/elelem/net/openai.rb",
+ "lib/elelem/plugins.rb",
+ "lib/elelem/plugins/confirm.rb",
+ "lib/elelem/plugins/edit.rb",
+ "lib/elelem/plugins/eval.rb",
+ "lib/elelem/plugins/execute.rb",
+ "lib/elelem/plugins/mcp.rb",
+ "lib/elelem/plugins/read.rb",
+ "lib/elelem/plugins/verify.rb",
+ "lib/elelem/plugins/write.rb",
+ "lib/elelem/system_prompt.rb",
+ "lib/elelem/templates/system_prompt.erb",
"lib/elelem/terminal.rb",
"lib/elelem/tool.rb",
"lib/elelem/toolbox.rb",
@@ -40,19 +52,17 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
spec.require_paths = ["lib"]
- spec.add_dependency "cgi", "~> 0.1"
- spec.add_dependency "cli-ui", "~> 2.0"
+ spec.add_dependency "date", "~> 3.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 "json_schemer", "~> 2.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 "optparse", "~> 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"
+ spec.add_dependency "stringio", "~> 3.0"
+ spec.add_dependency "tempfile", "~> 0.3"
+ spec.add_dependency "uri", "~> 1.0"
end
Gemfile.lock
@@ -1,45 +1,42 @@
PATH
remote: .
specs:
- elelem (0.8.0)
- cgi (~> 0.1)
- cli-ui (~> 2.0)
+ elelem (0.9.0)
+ date (~> 3.0)
erb (~> 6.0)
fileutils (~> 1.0)
json (~> 2.0)
- json-schema (~> 6.0)
- logger (~> 1.0)
+ json_schemer (~> 2.0)
net-hippie (~> 1.0)
- net-llm (~> 0.5, >= 0.5.0)
open3 (~> 0.1)
+ optparse (~> 0.1)
pathname (~> 0.1)
reline (~> 0.6)
- set (~> 1.0)
- thor (~> 1.0)
- timeout (~> 0.1)
+ stringio (~> 3.0)
+ tempfile (~> 0.3)
+ uri (~> 1.0)
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.4.2)
- cli-ui (2.7.0)
date (3.5.1)
diff-lcs (1.6.2)
erb (6.0.1)
fileutils (1.8.0)
+ hana (1.3.7)
io-console (0.8.2)
irb (1.16.0)
pp (>= 0.6.0)
rdoc (>= 4.0.0)
reline (>= 0.4.2)
json (2.18.0)
- json-schema (6.1.0)
- addressable (~> 2.8)
- bigdecimal (>= 3.1, < 5)
+ json_schemer (2.5.0)
+ bigdecimal
+ hana (~> 1.3)
+ regexp_parser (~> 2.0)
+ simpleidn (~> 0.2)
logger (1.7.0)
net-hippie (1.4.0)
base64 (~> 0.1)
@@ -49,12 +46,9 @@ GEM
openssl (~> 3.0)
net-http (0.9.1)
uri (>= 0.11.1)
- net-llm (0.5.0)
- json (~> 2.0)
- net-hippie (~> 1.0)
- uri (~> 1.0)
open3 (0.2.1)
openssl (3.3.2)
+ optparse (0.8.1)
pathname (0.4.0)
pp (0.6.3)
prettyprint
@@ -62,12 +56,12 @@ GEM
psych (5.3.1)
date
stringio
- public_suffix (7.0.2)
rake (13.3.1)
- rdoc (7.0.3)
+ rdoc (7.1.0)
erb
psych (>= 4.0.0)
tsort
+ regexp_parser (2.11.3)
reline (0.6.3)
io-console (~> 0.5)
rspec (3.13.2)
@@ -83,10 +77,9 @@ GEM
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-support (3.13.6)
- set (1.1.2)
+ simpleidn (0.2.3)
stringio (3.2.0)
- thor (1.5.0)
- timeout (0.6.0)
+ tempfile (0.3.1)
tsort (0.2.0)
uri (1.1.1)
README.md
@@ -7,8 +7,7 @@ Fast, correct, autonomous – pick two.
Elelem is a minimal coding agent written in Ruby. It is designed to help
you write, edit, and manage code and plain-text files from the command line
by delegating work to an LLM. The agent exposes a simple text-based UI and a
-set of built-in tools that give the LLM access to the local file system
-and Git.
+set of built-in tools that give the LLM access to the local file system.
## Design Principles
@@ -26,6 +25,26 @@ and Git.
* Runs inside a Git repository.
* Git is available and functional.
+## Dependencies
+
+Elelem relies on several external tools. Install the ones you need:
+
+| Tool | Purpose | Install |
+|------|---------|---------|
+| [Ollama](https://ollama.ai/) | Default LLM provider | https://ollama.ai/download |
+| [glow](https://github.com/charmbracelet/glow) | Markdown rendering | `brew install glow` / `go install github.com/charmbracelet/glow@latest` |
+| [ctags](https://ctags.io/) | Repo map generation | `brew install universal-ctags` / `apt install universal-ctags` |
+| [ripgrep](https://github.com/BurntSushi/ripgrep) | Text search (`rg`) | `brew install ripgrep` / `apt install ripgrep` |
+| [fd](https://github.com/sharkdp/fd) | File discovery | `brew install fd` / `apt install fd-find` |
+| [ast-grep](https://ast-grep.github.io/) | Structural search (`sg`) | `brew install ast-grep` / `cargo install ast-grep` |
+| [Git](https://git-scm.com/) | Version control | `brew install git` / `apt install git` |
+
+**Required:** Git, Ollama (or another LLM provider)
+
+**Recommended:** glow, ctags, ripgrep, fd
+
+**Optional:** ast-grep (for structural code search)
+
## Scope
Only plain-text and source-code files are supported. No binary handling,
@@ -101,35 +120,10 @@ Each provider reads its configuration from environment variables:
| openai | `OPENAI_API_KEY`, `OPENAI_BASE_URL` |
| vertex-ai | `GOOGLE_CLOUD_PROJECT`, `GOOGLE_CLOUD_REGION` |
-## Mode System
-
-The agent exposes seven built‑in tools. You can switch which ones are
-available by changing the *mode*:
-
-| Mode | Enabled Tools |
-|---------|------------------------------------------|
-| plan | `grep`, `list`, `read` |
-| build | `grep`, `list`, `read`, `patch`, `write` |
-| verify | `grep`, `list`, `read`, `execute` |
-| auto | All tools |
-
-Use the following commands inside the REPL:
-
-```text
-/mode plan # Read‑only
-/mode build # Read + Write
-/mode verify # Read + Execute
-/mode auto # All tools
-/mode # Show current mode
-```
-
-The system prompt is adjusted per mode so the LLM knows which actions
-are permissible.
-
## Features
* **Interactive REPL** – clean, streaming chat.
-* **Toolbox** – file I/O, Git, shell execution.
+* **Toolbox** – file I/O and shell execution.
* **Streaming Responses** – output appears in real time.
* **Conversation History** – persists across turns; can be cleared.
* **Context Dump** – `/context` shows the current conversation state.
@@ -137,24 +131,15 @@ are permissible.
## Toolbox Overview
The `Toolbox` class is defined in `lib/elelem/toolbox.rb`. It supplies
-seven tools, each represented by a JSON schema that the LLM can call.
-
-| Tool | Purpose | Parameters |
-| ---- | ------- | ---------- |
-| `exec` | Run shell commands | `cmd`, `args`, `env`, `cwd`, `stdin` |
-| `eval` | Dynamically create new tools | `code` |
-| `grep` | Search Git‑tracked files | `query` |
-| `list` | List tracked files | `path` (optional) |
-| `patch` | Apply a unified diff via `git apply` | `diff` |
-| `read` | Read file contents | `path` |
-| `write` | Overwrite a file | `path`, `content` |
-
-## Tool Definition
-
-The core `Tool` wrapper is defined in `lib/elelem/tool.rb`. Each tool is
-created with a name, description, JSON schema for arguments, and a block
-that performs the operation. The LLM calls a tool by name and passes the
-arguments as a hash.
+three tools, each represented by a JSON schema that the LLM can call.
+
+| Tool | Purpose | Parameters |
+| --------- | ------------------ | ------------------ |
+| `read` | Read file contents | `path` |
+| `write` | Write file | `path`, `content` |
+| `execute` | Run shell command | `command` |
+
+Aliases: `bash`, `sh`, `exec` → `execute`; `open` → `read`
## Known Limitations