Commit d7ba2e7
Changed files (25)
.elelem
plugins
exe
lib
spec
.elelem/plugins/gitlab.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:gitlab) do |toolbox|
- toolbox.after("gitlab_search") do |_args, result|
- $stdout.puts result.inspect
+Elelem::Plugins.register(:gitlab) do |agent|
+ agent.toolbox.after("gitlab_search") do |_args, result|
+ agent.terminal.say result.inspect
end
end
exe/elelem
@@ -2,84 +2,7 @@
# frozen_string_literal: true
require "elelem"
-require "optparse"
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
-
-App.new(ARGV).run
+Elelem::CLI.new(ARGV).run
lib/elelem/plugins/builtins.rb
@@ -0,0 +1,69 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:builtins) do |agent|
+ agent.commands.register("exit", description: "Exit elelem") { exit(0) }
+
+ agent.commands.register("clear", description: "Clear conversation history") do
+ agent.conversation.clear!
+ agent.terminal.say " → context cleared"
+ end
+
+ agent.commands.register("context", description: "Show conversation context") do
+ agent.terminal.say JSON.pretty_generate(agent.context)
+ end
+
+ agent.commands.register("shell", description: "Start interactive shell") do
+ transcript = Tempfile.create do |file|
+ system("script", "-q", file.path, chdir: Dir.pwd)
+ File.read(file.path)
+ .gsub(/^Script started.*?\n/, "")
+ .gsub(/\nScript done.*$/, "")
+ .gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
+ .gsub(/\e\[\?[0-9]+[hl]/, "")
+ .gsub(/[\b]/, "")
+ .gsub(/\r/, "")
+ end
+ agent.conversation.add(role: "user", content: transcript) unless transcript.strip.empty?
+ end
+
+ agent.commands.register("init", description: "Generate AGENTS.md") do
+ system_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
+
+ sub = Agent.new(agent.client, toolbox: agent.toolbox, terminal: agent.terminal, system_prompt: system_prompt)
+ sub.turn("Generate AGENTS.md for this project")
+ end
+
+ agent.commands.register("reload", description: "Reload plugins and source") do
+ lib_dir = File.expand_path("../..", __dir__)
+ original_verbose, $VERBOSE = $VERBOSE, nil
+ Dir["#{lib_dir}/**/*.rb"].sort.each { |f| load(f) }
+ $VERBOSE = original_verbose
+ agent.toolbox = Elelem::Toolbox.new
+ agent.commands = Elelem::Commands.new
+ Elelem::Plugins.reload!(agent)
+ end
+
+ agent.commands.register("help", description: "Show available commands") do
+ agent.terminal.say agent.commands.names.join(" ")
+ end
+end
lib/elelem/plugins/confirm.rb
@@ -1,12 +1,11 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:confirm) do |toolbox|
- toolbox.before("execute") do |args|
+Elelem::Plugins.register(:confirm) do |agent|
+ agent.toolbox.before("execute") do |args|
next unless $stdin.tty?
cmd = args["command"]
- $stdout.print " Allow? [Y/n] > "
- answer = $stdin.gets&.strip&.downcase
+ answer = agent.terminal.ask(" Allow? [Y/n] > ")&.downcase
raise "User denied permission to execute: #{cmd}" if answer == "n"
end
end
lib/elelem/plugins/edit.rb
@@ -1,14 +1,14 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:edit) do |toolbox|
- toolbox.add("edit",
+Elelem::Plugins.register(:edit) do |agent|
+ agent.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
+ agent.toolbox
.run("write", { "path" => a["path"], "content" => content.sub(a["old"], a["new"]) })
.merge(replaced: a["old"], with: a["new"])
end
lib/elelem/plugins/eval.rb
@@ -1,16 +1,16 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:eval) do |toolbox|
+Elelem::Plugins.register(:eval) do |agent|
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"]}" })
+ agent.toolbox.add(name, description: "Search using rg", params: { query: { type: "string" } }, required: ["query"], aliases: []) do |args|
+ agent.toolbox.run("execute", { "command" => "rg --json -nI -F #{args["query"]}" })
end
DESC
- toolbox.add("eval",
+ agent.toolbox.add("eval",
description: description,
params: { ruby: { type: "string" } },
required: ["ruby"]
lib/elelem/plugins/execute.rb
@@ -1,18 +1,18 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:execute) do |toolbox|
- toolbox.add("execute",
+Elelem::Plugins.register(:execute) do |agent|
+ agent.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) }
+ Elelem.sh("bash", args: ["-c", a["command"]]) { |x| agent.terminal.print(x) }
end
- toolbox.after("execute") do |args, result|
+ agent.toolbox.after("execute") do |args, result|
next if result[:exit_status] == 0
- $stdout.puts toolbox.header("execute", args, state: "x")
+ agent.terminal.say agent.toolbox.header("execute", args, state: "x")
end
end
lib/elelem/plugins/mcp.rb
@@ -1,10 +1,10 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:mcp) do |toolbox|
+Elelem::Plugins.register(:mcp) do |agent|
mcp = Elelem::MCP.new
at_exit { mcp.close }
mcp.tools.each do |name, tool|
- toolbox.add(name,
+ agent.toolbox.add(name,
description: tool[:description],
params: tool[:params],
required: tool[:required],
lib/elelem/plugins/read.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:read) do |toolbox|
- toolbox.add("read",
+Elelem::Plugins.register(:read) do |agent|
+ agent.toolbox.add("read",
description: "Read file",
params: { path: { type: "string" } },
required: ["path"],
@@ -11,11 +11,11 @@ Elelem::Plugins.register(:read) do |toolbox|
path.exist? ? { content: path.read, path: a["path"] } : { error: "not found" }
end
- toolbox.after("read") do |_, result|
+ agent.toolbox.after("read") do |_, result|
if result[:error]
- $stdout.puts " ! #{result[:error]}"
- elsif !system("bat", "--paging=never", result[:path])
- $stdout.puts result[:content]
+ agent.terminal.say " ! #{result[:error]}"
+ else
+ agent.terminal.display_file(result[:path], fallback: result[:content])
end
end
end
lib/elelem/plugins/task.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:task) do |agent|
+ agent.toolbox.add("task",
+ description: "Delegate subtask to focused agent (complex searches, multi-file analysis)",
+ params: { prompt: { type: "string" } },
+ required: ["prompt"]
+ ) do |a|
+ sub = Elelem::Agent.new(agent.client, toolbox: agent.toolbox, terminal: agent.terminal,
+ system_prompt: "Research agent. Search, analyze, report. Be concise.")
+ sub.turn(a["prompt"])
+ { result: sub.conversation.last[:content] }
+ end
+end
lib/elelem/plugins/tools.rb
@@ -0,0 +1,13 @@
+# frozen_string_literal: true
+
+Elelem::Plugins.register(:tools) do |agent|
+ agent.commands.register("tools", description: "List available tools") do
+ agent.toolbox.tools.each_value do |tool|
+ agent.terminal.say ""
+ agent.terminal.say " #{tool.name}"
+ agent.terminal.say " #{tool.description}"
+ tool.params.each { |k, v| agent.terminal.say " #{k}: #{v[:type] || v["type"]}" }
+ agent.terminal.say " aliases: #{tool.aliases.join(", ")}" if tool.aliases.any?
+ end
+ end
+end
lib/elelem/plugins/verify.rb
@@ -27,16 +27,16 @@ module Elelem
end
end
- Plugins.register(:verify) do |toolbox|
- toolbox.add("verify",
+ Plugins.register(:verify) do |agent|
+ agent.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 })
+ agent.terminal.say agent.toolbox.header("execute", { "command" => cmd })
+ v = agent.toolbox.run("execute", { "command" => cmd })
break v.merge(path: path, command: cmd) if v[:exit_status] != 0
memo[:verified] << cmd
lib/elelem/plugins/write.rb
@@ -1,7 +1,7 @@
# frozen_string_literal: true
-Elelem::Plugins.register(:write) do |toolbox|
- toolbox.add("write",
+Elelem::Plugins.register(:write) do |agent|
+ agent.toolbox.add("write",
description: "Write file",
params: { path: { type: "string" }, content: { type: "string" } },
required: ["path", "content"],
@@ -12,12 +12,12 @@ Elelem::Plugins.register(:write) do |toolbox|
{ bytes: path.write(a["content"]), path: a["path"] }
end
- toolbox.after("write") do |_, result|
+ agent.toolbox.after("write") do |_, result|
if result[:error]
- $stdout.puts " ! #{result[:error]}"
+ agent.terminal.say " ! #{result[:error]}"
else
- system("bat", "--paging=never", result[:path]) || $stdout.puts(" -> #{result[:path]}")
- toolbox.run("verify", { "path" => result[:path] })
+ agent.terminal.display_file(result[:path], fallback: " -> #{result[:path]}")
+ agent.toolbox.run("verify", { "path" => result[:path] })
end
end
end
lib/elelem/templates/system_prompt.erb
@@ -47,8 +47,3 @@ self (this agent's source): <%= elelem_source %>
# Project Instructions
<%= agents_md %>
<% end %>
-<% if memory %>
-
-# Earlier Context
-<%= memory %>
-<% end %>
lib/elelem/agent.rb
@@ -2,40 +2,16 @@
module Elelem
class Agent
- 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.
+ attr_reader :conversation, :client, :toolbox, :terminal, :commands
+ attr_writer :terminal, :toolbox, :commands
- # 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)
+ def initialize(client, toolbox: Toolbox.new, terminal: nil, system_prompt: nil, commands: nil)
@client = client
@toolbox = toolbox
- @terminal = terminal || Terminal.new(commands: COMMANDS)
- @history = history || []
+ @commands = commands || Commands.new
+ @terminal = terminal
+ @conversation = Conversation.new
@system_prompt = system_prompt
- @memory = nil
- register_task_tool
end
def repl
@@ -49,27 +25,16 @@ module Elelem
end
def command(input)
- case input
- when "/exit" then exit(0)
- when "/init" then init_agents_md
- when "/reload" then reload_source!
- when "/shell"
- transcript = start_shell
- 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 COMMANDS.join(" ")
- end
+ name = input.delete_prefix("/")
+ commands.run(name) || terminal.say(commands.names.join(" "))
+ end
+
+ def context
+ @conversation.to_a(system_prompt: system_prompt)
end
def turn(input)
- compact_if_needed
- history << { role: "user", content: input }
+ @conversation.add(role: "user", content: input)
ctx = []
content = nil
@@ -85,7 +50,7 @@ module Elelem
end
end
- history << { role: "assistant", content: content }
+ @conversation.add(role: "assistant", content: content)
content
end
@@ -97,55 +62,11 @@ module Elelem
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
- end
-
- 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 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 start_shell
- Tempfile.create do |file|
- system("script", "-q", file.path, chdir: Dir.pwd)
- strip_ansi(File.read(file.path))
- 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 fetch_response(ctx)
content = String.new
tool_calls = []
- client.fetch(combined_history + ctx, toolbox.to_a) do |event|
+ client.fetch(@conversation.to_a(system_prompt: system_prompt) + ctx, toolbox.to_a) do |event|
case event[:type]
when "saying"
content << event[:text].to_s
@@ -162,34 +83,8 @@ module Elelem
["Error: #{e.message} #{e.backtrace.join("\n")}", []]
end
- def combined_history
- [{ role: "system", content: system_prompt }] + history
- end
-
def system_prompt
- @system_prompt || SystemPrompt.new(memory: @memory).render
- end
-
- def compact_if_needed
- return if history.length <= MAX_CONTEXT_MESSAGES
-
- terminal.say " → compacting context"
- keep = MAX_CONTEXT_MESSAGES / 2
- old = history.first(history.length - keep)
-
- to_summarize = @memory ? [{ role: "memory", content: @memory }, *old] : old
- @memory = summarize(to_summarize)
- @history = history.last(keep)
- end
-
- def summarize(messages)
- text = messages.map { |message| { role: message[:role], content: message[:content] } }.to_json
-
- String.new.tap do |buffer|
- client.fetch([{ role: "user", content: "Summarize key facts:\n#{text}" }], []) do |event|
- buffer << event[:text].to_s if event[:type] == "saying"
- end
- end
+ @system_prompt || SystemPrompt.new.render
end
end
end
lib/elelem/commands.rb
@@ -0,0 +1,33 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Commands
+ def initialize
+ @registry = {}
+ end
+
+ def register(name, description: "", &handler)
+ @registry[name] = { description: description, handler: handler }
+ end
+
+ def run(name)
+ entry = @registry[name]
+ return false unless entry
+
+ entry[:handler].call
+ true
+ end
+
+ def names
+ @registry.keys.map { |name| "/#{name}" }
+ end
+
+ def each
+ @registry.each { |name, entry| yield "/#{name}", entry[:description] }
+ end
+
+ def include?(name)
+ @registry.key?(name)
+ end
+ end
+end
lib/elelem/conversation.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Elelem
+ class Conversation
+ ROLES = %w[user assistant tool].freeze
+
+ def initialize
+ @messages = []
+ end
+
+ def add(role:, content:)
+ raise ArgumentError, "invalid role: #{role}" unless ROLES.include?(role)
+ @messages << { role: role, content: content }
+ end
+
+ def last = @messages.last
+ def length = @messages.length
+ def clear! = @messages.clear
+
+ def to_a(system_prompt: nil)
+ base = system_prompt ? [{ role: "system", content: system_prompt }] : []
+ base + @messages
+ end
+ end
+end
lib/elelem/mcp.rb
@@ -60,16 +60,7 @@ module Elelem
end
end
- class Server
- def initialize(command:, args: [], env: {})
- resolved_env = env.transform_values do |v|
- v.gsub(/\$\{(\w+)\}/) { ENV[$1] || raise("Missing environment variable: #{$1}") }
- end
- @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
- @id = 0
- initialize!
- end
-
+ module ServerInterface
def tools
request("tools/list")["tools"]
end
@@ -79,16 +70,9 @@ module Elelem
{ 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!
+ def handshake!
request("initialize", {
protocolVersion: "2025-06-18",
capabilities: {},
@@ -96,6 +80,26 @@ module Elelem
})
notify("notifications/initialized")
end
+ end
+
+ class Server
+ include ServerInterface
+
+ def initialize(command:, args: [], env: {})
+ resolved_env = env.transform_values do |v|
+ v.gsub(/\$\{(\w+)\}/) { ENV[$1] || raise("Missing environment variable: #{$1}") }
+ end
+ @stdin, @stdout, @stderr, @wait = Open3.popen3(resolved_env, command, *args)
+ @id = 0
+ handshake!
+ end
+
+ def close
+ [@stdin, @stdout, @stderr].each { |io| io.close rescue nil }
+ @wait.kill rescue nil
+ end
+
+ private
def request(method, params = {})
send_msg(id: @id += 1, method: method, params: params)
@@ -123,6 +127,8 @@ module Elelem
end
class HttpServer
+ include ServerInterface
+
def initialize(url:, headers: {}, http: Elelem::Net.http)
@url = url
@headers = resolve_headers(headers)
@@ -130,16 +136,7 @@ module Elelem
@id = 0
@session_id = nil
@access_token = nil
- 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") }
+ handshake!
end
def close
@@ -155,15 +152,6 @@ module Elelem
end
end
- def initialize!
- request("initialize", {
- protocolVersion: "2025-06-18",
- capabilities: {},
- clientInfo: { name: "elelem", version: VERSION }
- })
- notify("notifications/initialized")
- end
-
def request(method, params = {})
msg = { jsonrpc: "2.0", id: @id += 1, method: method, params: params }
response = post(msg)
lib/elelem/plugins.rb
@@ -8,15 +8,15 @@ module Elelem
".elelem/plugins"
].freeze
- def self.setup!(toolbox)
+ def self.setup!(agent)
load_plugins
- registry.each_value { |plugin| plugin.call(toolbox) }
+ registry.each_value { |plugin| plugin.call(agent) }
end
- def self.reload!(toolbox)
- @registry = {}
+ def self.reload!(agent)
+ registry.clear
load_plugins
- registry.each_value { |plugin| plugin.call(toolbox) }
+ registry.each_value { |plugin| plugin.call(agent) }
end
def self.load_plugins
@@ -33,7 +33,7 @@ module Elelem
end
def self.register(name, &block)
- (@registry ||= {})[name] = block
+ registry[name] = block
end
def self.registry
lib/elelem/system_prompt.rb
@@ -4,12 +4,6 @@ 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
@@ -40,7 +34,7 @@ module Elelem
return unless File.exist?(".git")
"branch: #{`git branch --show-current`.strip}"
- rescue
+ rescue Errno::ENOENT
nil
end
@@ -50,7 +44,7 @@ module Elelem
.reject { |l| l.include?("vendor/") || l.include?("node_modules/") || l.include?("spec/") }
.first(100)
.join
- rescue
+ rescue Errno::ENOENT
""
end
lib/elelem/terminal.rb
@@ -51,6 +51,12 @@ module Elelem
n.times { $stdout.puts("") }
end
+ def display_file(path, fallback: nil)
+ return if @quiet
+
+ system("bat", "--paging=never", path) || say(fallback || path)
+ end
+
def waiting
return if @quiet
lib/elelem/tool.rb
@@ -11,7 +11,8 @@ module Elelem
@required = required
@aliases = aliases
@fn = fn
- @schema = JSONSchemer.schema(schema_hash)
+ @schema_hash = { type: "object", properties: params, required: required }.freeze
+ @schema = JSONSchemer.schema(@schema_hash)
end
def call(args)
@@ -30,19 +31,9 @@ module Elelem
function: {
name: name,
description: description,
- parameters: schema_hash
+ parameters: @schema_hash
}
}
end
-
- private
-
- def schema_hash
- {
- type: "object",
- properties: params,
- required: required
- }
- end
end
end
lib/elelem.rb
@@ -9,6 +9,7 @@ require "json"
require "json_schemer"
require "net/hippie"
require "open3"
+require "optparse"
require "pathname"
require "reline"
require "securerandom"
@@ -18,6 +19,8 @@ require "uri"
require "webrick"
require_relative "elelem/agent"
+require_relative "elelem/commands"
+require_relative "elelem/conversation"
require_relative "elelem/mcp"
require_relative "elelem/net"
require_relative "elelem/plugins"
@@ -43,14 +46,94 @@ module Elelem
end
def self.start(client, toolbox: Toolbox.new)
- Plugins.setup!(toolbox)
- Agent.new(client, toolbox).repl
+ agent = Agent.new(client, toolbox: toolbox)
+ Plugins.setup!(agent)
+ agent.terminal = Terminal.new(commands: agent.commands.names)
+ agent.repl
end
def self.ask(client, prompt, toolbox: Toolbox.new)
- Plugins.setup!(toolbox)
- agent = Agent.new(client, toolbox, terminal: Terminal.new(quiet: true))
+ agent = Agent.new(client, toolbox: toolbox, terminal: Terminal.new(quiet: true))
+ Plugins.setup!(agent)
agent.turn(prompt)
- agent.history.last[:content]
+ agent.conversation.last[:content]
+ end
+
+ class CLI
+ 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)
+ end
+
+ 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
end
spec/elelem/commands_spec.rb
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Commands do
+ subject { described_class.new }
+
+ describe "#register" do
+ it "registers a command with handler" do
+ called = false
+ subject.register("test") { called = true }
+ subject.run("test")
+ expect(called).to be true
+ end
+
+ it "stores description" do
+ subject.register("test", description: "Test command") { }
+ expect(subject.include?("test")).to be true
+ end
+ end
+
+ describe "#run" do
+ it "returns true when command exists" do
+ subject.register("test") { }
+ expect(subject.run("test")).to be true
+ end
+
+ it "returns false when command does not exist" do
+ expect(subject.run("nonexistent")).to be false
+ end
+ end
+
+ describe "#names" do
+ it "returns command names with slash prefix" do
+ subject.register("exit") { }
+ subject.register("help") { }
+ expect(subject.names).to contain_exactly("/exit", "/help")
+ end
+ end
+
+ describe "#include?" do
+ it "returns true for registered commands" do
+ subject.register("test") { }
+ expect(subject.include?("test")).to be true
+ end
+
+ it "returns false for unregistered commands" do
+ expect(subject.include?("test")).to be false
+ end
+ end
+end
spec/elelem/conversation_spec.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+RSpec.describe Elelem::Conversation do
+ subject { described_class.new }
+
+ describe "#add" do
+ it "appends messages" do
+ subject.add(role: "user", content: "hello")
+ subject.add(role: "assistant", content: "hi")
+ expect(subject.length).to eq(2)
+ end
+
+ it "validates role" do
+ expect { subject.add(role: "invalid", content: "test") }.to raise_error(ArgumentError, /invalid role/)
+ end
+
+ it "accepts user role" do
+ expect { subject.add(role: "user", content: "test") }.not_to raise_error
+ end
+
+ it "accepts assistant role" do
+ expect { subject.add(role: "assistant", content: "test") }.not_to raise_error
+ end
+
+ it "accepts tool role" do
+ expect { subject.add(role: "tool", content: "test") }.not_to raise_error
+ end
+ end
+
+ describe "#last" do
+ it "returns the last message" do
+ subject.add(role: "user", content: "hello")
+ subject.add(role: "assistant", content: "hi")
+ expect(subject.last[:content]).to eq("hi")
+ end
+ end
+
+ describe "#clear!" do
+ it "removes all messages" do
+ subject.add(role: "user", content: "hello")
+ subject.clear!
+ expect(subject.length).to eq(0)
+ end
+ end
+
+ describe "#to_a" do
+ it "returns messages without system prompt by default" do
+ subject.add(role: "user", content: "hello")
+ expect(subject.to_a).to eq([{ role: "user", content: "hello" }])
+ end
+
+ it "includes system prompt when provided" do
+ subject.add(role: "user", content: "hello")
+ result = subject.to_a(system_prompt: "You are helpful.")
+ expect(result.first).to eq({ role: "system", content: "You are helpful." })
+ expect(result.last).to eq({ role: "user", content: "hello" })
+ end
+ end
+end