Comparing changes

v0.8.0 v0.9.0
114 commits 39 files changed

Commits

88ebf43 chore: release v0.9.0 mo khan 2026-01-22 05:56:47
aca95b8 chore: update Gemfile.lock mo khan 2026-01-22 05:37:02
65ed0ec chore: update CHANGELOG.md mo khan 2026-01-22 05:19:38
4a4b19b feat: restore the eval tool mo khan 2026-01-22 05:05:55
6f1bb3f refactor: turn /shell into a one-liner mo khan 2026-01-22 05:02:35
08fd905 refactor: restore the /shell command mo khan 2026-01-22 04:58:45
da61a22 chore: update CHANGELOG.md mo khan 2026-01-22 04:53:35
5154096 feat: render markdown output with glow mo khan 2026-01-22 04:43:17
8d65978 feat: only print failures mo khan 2026-01-22 02:30:50
980079e refactor: change state of tool header mo khan 2026-01-22 02:25:05
fa1b38e docs: update CHANGELOG mo khan 2026-01-22 02:11:25
c6af4df feat: add a default edit tool mo khan 2026-01-22 00:26:54
df5321f refactor: extract a Tool class mo khan 2026-01-21 19:57:17
131253b refactor: small code changes mo khan 2026-01-21 19:53:44
713eb42 chore: fix help text mo khan 2026-01-21 18:04:16
8b2b581 test: fix specs. to_h -> to_a mo khan 2026-01-21 17:59:06
740cf9b fix: remove duplicate system prompt mo khan 2026-01-21 17:03:18
fd861c6 docs: update CHANGELOG mo khan 2026-01-20 22:52:16
7884cd4 refactor: cleanup net code mo khan 2026-01-20 22:41:08
5904cd6 refactor: simplify fetch interface mo khan 2026-01-20 22:05:08
e22a21a refactor: merge net/llm into elelem/net mo khan 2026-01-20 20:44:28
19debde refactor: remove Events module mo khan 2026-01-20 20:37:14
0ba6784 refactor: remove Plugins.init mo khan 2026-01-20 20:33:52
67c5a22 refactor: extract default verify plugin mo khan 2026-01-20 20:31:46
89322cb refactor: remove init mo khan 2026-01-20 20:07:32
14588be Remove unused #summarize method mo khan 2026-01-20 18:12:18
4ef92ae refactor: shrink size of net/llm code mo khan 2026-01-20 18:04:43
3211970 refactor: merge in net/llm code mo khan 2026-01-20 16:17:04
e6e8a57 fix: patch tool parameters mo khan 2026-01-19 23:19:56
b3ecab2 feat: add a patch tool mo khan 2026-01-19 23:17:38
a061feb refactor: use ctags based repo map mo khan 2026-01-19 23:00:10
3cb9cdb fix: the verification steps mo khan 2026-01-19 22:46:19
df74a6c feat: add pre/post tool hooks mo khan 2026-01-19 21:49:16
bae487d feat: parse the closest AGENTS.md file mo khan 2026-01-19 21:47:52
d699e05 feat: add a sub task tool mo khan 2026-01-19 20:09:46
cc7ff24 refactor: move #to_h down mo khan 2026-01-19 19:50:11
09632e2 refactor: pass history into ctor mo khan 2026-01-19 19:35:08
cc79b77 feat: fixup vertex-ai exe mo khan 2026-01-19 17:04:54
ce39eef chore: update gemfile.lock mo khan 2026-01-19 16:58:02
fdabdd6 feat: add repo map to system prompt mo khan 2026-01-19 16:50:22
49fe061 chore: remove unused file mo khan 2026-01-19 16:42:38
8ff3827 chore: simplify rspec config mo khan 2026-01-19 16:41:02
5d817c4 chore: remove github action mo khan 2026-01-19 16:38:52
18de291 feat: suggest using sed for edits mo khan 2026-01-19 16:36:36
33a2f06 refactor: tidy up the code mo khan 2026-01-18 05:25:13
d4148d1 refactor: cleanup some code mo khan 2026-01-18 04:42:34
a560c8b feat: load ollama by default mo khan 2026-01-18 04:25:32
27e9fc0 feat: add AGENTS.md support mo khan 2026-01-17 07:58:54
e016bf7 refactor: use markdown in system prompt mo khan 2026-01-17 07:45:28
049dea2 docs: update README.md mo khan 2026-01-17 07:41:42
e3d7430 refactor: tool call errors mo khan 2026-01-17 07:39:07
893d105 fix: display unknown tool calls mo khan 2026-01-17 07:25:03
7e69a98 refactor: provide env and fix bat call mo khan 2026-01-17 07:19:31
befde65 feat: use bat instead of glow mo khan 2026-01-17 07:10:32
b56133a fix: print invalid tool call in error mo khan 2026-01-17 06:25:41
ccbf688 refactor: rename tc to tool_call mo khan 2026-01-17 06:15:00
0f5e8a3 refactor: rename :output to :content mo khan 2026-01-17 05:57:50
2eecd69 feat: optimize system prompt mo khan 2026-01-17 01:19:12
129c0da refactor: tty-markdown keeps crashing mo khan 2026-01-15 19:14:29
74f552e feat: set the markdown renderer width mo khan 2026-01-15 18:24:52
e37cdc3 feat: format output mo khan 2026-01-15 18:03:42
ca54124 refactor: remove modes and permissions mo khan 2026-01-15 02:17:39
2dc443f feat: render context in markdown mo khan 2026-01-15 01:44:00
fdfae35 feat: render markdown using defaults mo khan 2026-01-15 00:59:51
9c75fae fix: bump version of net-llm mo khan 2026-01-15 00:28:44
eff3b9f chore: update dependencies mo khan 2026-01-14 23:45:10
.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