Commit d7ba2e7

mo khan <mo@mokhan.ca>
2026-01-26 21:57:48
refactor: extract Conversation class from Agent
- Add Conversation class for message storage with role validation - Add Commands class for slash command registry - Move inline commands to plugins/builtins.rb - Move task tool to plugins/task.rb - Remove context compaction and memory (users can /clear) - Simplify SystemPrompt (no memory parameter) Agent is now focused on the REPL loop and LLM interaction.
1 parent f8b553a
.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