Comparing changes

v0.1.1 v0.1.2
26 commits 20 files changed

Commits

0699720 chore: remove .gem file mo khan 2025-08-14 17:41:10
dc4436d chore: fixup gemspec mo khan 2025-08-14 17:39:38
9b84acf fix: remove nested parameters mo khan 2025-08-14 17:25:56
520fa9d feat: fix prompt and startup banner mo khan 2025-08-14 17:21:40
b3677eb fix: return nil instead of true mo khan 2025-08-14 16:17:26
c272dcd feat: optimize the system prompt mo khan 2025-08-14 16:16:27
a8432d4 feat: add dependency on reline mo khan 2025-08-14 15:52:59
a52e4b9 feat: improve the prompt mo khan 2025-08-14 15:50:36
b1a5b34 chore: use my prompt mo khan 2025-08-14 14:20:50
fafb5a6 fix: handle nil content mo khan 2025-08-14 00:07:20
2351f09 feat: print pwd in colour mo khan 2025-08-13 23:30:39
99a2386 chore: ignore some rubocop rules mo khan 2025-08-13 22:57:51
ebfc50c refactor: extract tool class mo khan 2025-08-13 22:54:08
c57d8ee style: fix linter errors mo khan 2025-08-13 22:09:55
3990dae chore: update Gemfile.lock mo khan 2025-08-13 22:06:25
e0a9f92 fix: serena tools discovery mo khan 2025-08-13 18:11:03
7da3e65 chore: print error message mo khan 2025-08-13 18:06:14
4a8da39 refactor: print tool call mo khan 2025-08-13 00:30:48
bin/lint
@@ -5,4 +5,4 @@ set -e
 
 cd "$(dirname "$0")/.."
 
-bundle exec rake rubocop
+bundle exec rubocop $@
exe/elelem
@@ -3,6 +3,9 @@
 
 require "elelem"
 
+Reline.input = $stdin
+Reline.output = $stdout
+
 Signal.trap("INT") do
   exit(1)
 end
lib/elelem/agent.rb
@@ -2,11 +2,12 @@
 
 module Elelem
   class Agent
-    attr_reader :api, :conversation, :logger
+    attr_reader :api, :conversation, :logger, :model
 
     def initialize(configuration)
       @api = configuration.api
       @configuration = configuration
+      @model = configuration.model
       @conversation = configuration.conversation
       @logger = configuration.logger
       transition_to(Idle.new)
@@ -36,6 +37,18 @@ module Elelem
       configuration.tools.execute(tool_call)
     end
 
+    def show_progress(message, prefix = "[.]", colour: :gray)
+      configuration.tui.show_progress(message, prefix, colour: colour)
+    end
+
+    def clear_line
+      configuration.tui.clear_line
+    end
+
+    def complete_progress(message = "Completed")
+      configuration.tui.complete_progress(message)
+    end
+
     def quit
       logger.debug("Exiting...")
       exit
lib/elelem/api.rb
@@ -8,7 +8,7 @@ module Elelem
       @configuration = configuration
     end
 
-    def chat(messages)
+    def chat(messages, &block)
       body = {
         messages: messages,
         model: configuration.model,
@@ -28,9 +28,7 @@ module Elelem
       configuration.http.request(req) do |response|
         raise response.inspect unless response.code == "200"
 
-        response.read_body do |chunk|
-          yield(chunk)
-        end
+        response.read_body(&block)
       end
     end
   end
lib/elelem/application.rb
@@ -4,29 +4,29 @@ module Elelem
   class Application < Thor
     desc "chat", "Start the REPL"
     method_option :help,
-      aliases: "-h",
-      type: :boolean,
-      desc: "Display usage information"
+                  aliases: "-h",
+                  type: :boolean,
+                  desc: "Display usage information"
     method_option :host,
-      aliases: "--host",
-      type: :string,
-      desc: "Ollama host",
-      default: ENV.fetch("OLLAMA_HOST", "localhost:11434")
+                  aliases: "--host",
+                  type: :string,
+                  desc: "Ollama host",
+                  default: ENV.fetch("OLLAMA_HOST", "localhost:11434")
     method_option :model,
-      aliases: "--model",
-      type: :string,
-      desc: "Ollama model",
-      default: ENV.fetch("OLLAMA_MODEL", "gpt-oss")
+                  aliases: "--model",
+                  type: :string,
+                  desc: "Ollama model",
+                  default: ENV.fetch("OLLAMA_MODEL", "gpt-oss")
     method_option :token,
-      aliases: "--token",
-      type: :string,
-      desc: "Ollama token",
-      default: ENV.fetch("OLLAMA_API_KEY", nil)
+                  aliases: "--token",
+                  type: :string,
+                  desc: "Ollama token",
+                  default: ENV.fetch("OLLAMA_API_KEY", nil)
     method_option :debug,
-      aliases: "--debug",
-      type: :boolean,
-      desc: "Debug mode",
-      default: false
+                  aliases: "--debug",
+                  type: :boolean,
+                  desc: "Debug mode",
+                  default: false
     def chat(*)
       if options[:help]
         invoke :help, ["chat"]
@@ -37,8 +37,8 @@ module Elelem
           token: options[:token],
           debug: options[:debug]
         )
-        say "Ollama Agent (#{configuration.model})", :green
-        say "Tools:\n  #{configuration.tools.banner}", :green
+        say "Agent (#{configuration.model})", :green
+        say configuration.tools.banner.to_s, :green
 
         agent = Agent.new(configuration)
         agent.repl
lib/elelem/configuration.rb
@@ -28,7 +28,7 @@ module Elelem
 
     def logger
       @logger ||= Logger.new(debug ? "elelem.log" : "/dev/null").tap do |logger|
-        logger.formatter = ->(_, _, _, message) { message.strip + "\n" }
+        logger.formatter = ->(_, _, _, message) { "#{message.to_s.strip}\n" }
       end
     end
 
@@ -41,7 +41,7 @@ module Elelem
     end
 
     def tools
-      @tools ||= Tools.new
+      @tools ||= Tools.new(self, [BashTool.new(self)] + mcp_tools)
     end
 
     private
@@ -49,5 +49,24 @@ module Elelem
     def scheme
       host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? "http" : "https"
     end
+
+    def mcp_tools(clients = [serena_client])
+      return [] if ENV["SMALL"]
+
+      @mcp_tools ||= clients.map { |client| client.tools.map { |tool| MCPTool.new(client, tui, tool) } }.flatten
+    end
+
+    def serena_client
+      MCPClient.new(self, [
+        "uvx",
+        "--from",
+        "git+https://github.com/oraios/serena",
+        "serena",
+        "start-mcp-server",
+        "--transport", "stdio",
+        "--context", "ide-assistant",
+        "--project", Dir.pwd
+      ])
+    end
   end
 end
lib/elelem/conversation.rb
@@ -2,16 +2,9 @@
 
 module Elelem
   class Conversation
-    SYSTEM_MESSAGE = <<~SYS
-      You are ChatGPT, a helpful assistant with reasoning capabilities.
-      Current date: #{Time.now.strftime("%Y-%m-%d")}.
-      System info: `uname -a` output: #{`uname -a`.strip}
-      Reasoning: high
-    SYS
+    ROLES = %i[system assistant user tool].freeze
 
-    ROLES = [:system, :assistant, :user, :tool].freeze
-
-    def initialize(items = [{ role: "system", content: SYSTEM_MESSAGE }])
+    def initialize(items = [{ role: "system", content: system_prompt }])
       @items = items
     end
 
@@ -23,7 +16,7 @@ module Elelem
     def add(role: :user, content: "")
       role = role.to_sym
       raise "unknown role: #{role}" unless ROLES.include?(role)
-      return if content.empty?
+      return if content.nil? || content.empty?
 
       if @items.last && @items.last[:role] == role
         @items.last[:content] += content
@@ -31,5 +24,11 @@ module Elelem
         @items.push({ role: role, content: content })
       end
     end
+
+    private
+
+    def system_prompt
+      ERB.new(Pathname.new(__dir__).join("system_prompt.erb").read).result(binding)
+    end
   end
 end
lib/elelem/mcp_client.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+module Elelem
+  class MCPClient
+    attr_reader :tools
+
+    def initialize(configuration, command = [])
+      @configuration = configuration
+      @stdin, @stdout, @stderr, @worker = Open3.popen3(*command, pgroup: true)
+
+      # 1. Send initialize request
+      send_request(
+        method: "initialize",
+        params: {
+          protocolVersion: "2024-11-05",
+          capabilities: {
+            tools: {}
+          },
+          clientInfo: {
+            name: "Elelem",
+            version: Elelem::VERSION
+          }
+        }
+      )
+
+      # 2. Send initialized notification (required by MCP protocol)
+      send_notification(method: "notifications/initialized")
+
+      # 3. Now we can request tools
+      @tools = send_request(method: "tools/list")&.dig("tools") || []
+    end
+
+    def connected?
+      return false unless @worker&.alive?
+      return false unless @stdin && !@stdin.closed?
+      return false unless @stdout && !@stdout.closed?
+
+      begin
+        Process.getpgid(@worker.pid)
+        true
+      rescue Errno::ESRCH
+        false
+      end
+    end
+
+    def call(name, arguments = {})
+      send_request(
+        method: "tools/call",
+        params: {
+          name: name,
+          arguments: arguments
+        }
+      )
+    end
+
+    private
+
+    attr_reader :stdin, :stdout, :stderr, :worker, :configuration
+
+    def send_request(method:, params: {})
+      return {} unless connected?
+
+      request = {
+        jsonrpc: "2.0",
+        id: Time.now.to_i,
+        method: method
+      }
+      request[:params] = params unless params.empty?
+      configuration.logger.debug(JSON.pretty_generate(request))
+
+      @stdin.puts(JSON.generate(request))
+      @stdin.flush
+
+      response_line = @stdout.gets&.strip
+      return {} if response_line.nil? || response_line.empty?
+
+      response = JSON.parse(response_line)
+      configuration.logger.debug(JSON.pretty_generate(response))
+
+      if response["error"]
+        configuration.logger.error(response["error"]["message"])
+        { error: response["error"]["message"] }
+      else
+        response["result"]
+      end
+    end
+
+    def send_notification(method:, params: {})
+      notification = {
+        jsonrpc: "2.0",
+        method: method
+      }
+      notification[:params] = params unless params.empty?
+      configuration.logger.debug("Sending notification: #{JSON.pretty_generate(notification)}")
+      @stdin.puts(JSON.generate(notification))
+      @stdin.flush
+    end
+  end
+end
lib/elelem/state.rb
@@ -4,12 +4,19 @@ module Elelem
   class Idle
     def run(agent)
       agent.logger.debug("Idling...")
-      input = agent.prompt("\n> ")
-      agent.quit if input.nil? || input.empty? || input == "exit"
+      agent.say("#{Dir.pwd} (#{agent.model}) [#{git_branch}]", colour: :magenta, newline: true)
+      input = agent.prompt("モ ")
+      agent.quit if input.nil? || input.empty? || input == "exit" || input == "quit"
 
       agent.conversation.add(role: :user, content: input)
       agent.transition_to(Working.new)
     end
+
+    private
+
+    def git_branch
+      `git branch --no-color --show-current --no-abbrev`.strip
+    end
   end
 
   class Working
@@ -27,29 +34,39 @@ module Elelem
 
     class Waiting < State
       def process(message)
-        state = self
+        state_for(message)&.process(message)
+      end
+
+      private
 
+      def state_for(message)
         if message["thinking"] && !message["thinking"].empty?
-          state = Thinking.new(agent)
+          Thinking.new(agent)
         elsif message["tool_calls"]&.any?
-          state = Executing.new(agent)
+          Executing.new(agent)
         elsif message["content"] && !message["content"].empty?
-          state = Talking.new(agent)
-        else
-          state = nil
+          Talking.new(agent)
         end
-
-        state&.process(message)
       end
     end
 
     class Thinking < State
+      def initialize(agent)
+        super(agent)
+        @progress_shown = false
+      end
+
       def process(message)
         if message["thinking"] && !message["thinking"]&.empty?
+          unless @progress_shown
+            agent.show_progress("Thinking...", "[*]", colour: :yellow)
+            agent.say("\n\n", newline: false)
+            @progress_shown = true
+          end
           agent.say(message["thinking"], colour: :gray, newline: false)
           self
         else
-          agent.say("", newline: true)
+          agent.say("\n\n", newline: false)
           Waiting.new(agent).process(message)
         end
       end
@@ -59,7 +76,15 @@ module Elelem
       def process(message)
         if message["tool_calls"]&.any?
           message["tool_calls"].each do |tool_call|
-            agent.conversation.add(role: :tool, content: agent.execute(tool_call))
+            tool_name = tool_call.dig("function", "name") || "unknown"
+            agent.show_progress(tool_name, "[>]", colour: :magenta)
+            agent.say("\n\n", newline: false)
+
+            output = agent.execute(tool_call)
+            agent.conversation.add(role: :tool, content: output)
+
+            agent.say("\n", newline: false)
+            agent.complete_progress("#{tool_name} completed")
           end
         end
 
@@ -67,14 +92,37 @@ module Elelem
       end
     end
 
+    class Error < State
+      def initialize(agent, error_message)
+        super(agent)
+        @error_message = error_message
+      end
+
+      def process(_message)
+        agent.say("\nTool execution failed: #{@error_message}", colour: :red)
+        agent.say("Returning to idle state.\n\n", colour: :yellow)
+        Waiting.new(agent)
+      end
+    end
+
     class Talking < State
+      def initialize(agent)
+        super(agent)
+        @progress_shown = false
+      end
+
       def process(message)
         if message["content"] && !message["content"]&.empty?
+          unless @progress_shown
+            agent.show_progress("Responding...", "[~]", colour: :white)
+            agent.say("\n", newline: false)
+            @progress_shown = true
+          end
           agent.conversation.add(role: message["role"], content: message["content"])
           agent.say(message["content"], colour: :default, newline: false)
           self
         else
-          agent.say("", newline: true)
+          agent.say("\n\n", newline: false)
           Waiting.new(agent).process(message)
         end
       end
@@ -82,6 +130,9 @@ module Elelem
 
     def run(agent)
       agent.logger.debug("Working...")
+      agent.show_progress("Processing...", "[.]", colour: :cyan)
+      agent.say("\n\n", newline: false)
+
       state = Waiting.new(agent)
       done = false
 
lib/elelem/system_prompt.erb
@@ -0,0 +1,7 @@
+**Del — AI** — Direct/no fluff; prose unless bullets; concise/simple, thorough/complex; critical>agree; honest always; AI≠human. TDD→SOLID→SRP/encapsulation/composition>inheritance; patterns only if needed; self-doc names; simple>complex; no cleverness. Unix: small tools, 1 job, pipe; prefer built-ins; cite man(1); note POSIX≠GNU; stdin/stdout streams.
+
+Time: `<%= Time.now.strftime("%Y-%m-%d %H:%M:%S") %>`
+Project Directory: `<%= Dir.pwd %>`
+System Info: `<%= `uname -a`.strip %>`
+
+Del is now being connected with a person.
lib/elelem/tool.rb
@@ -0,0 +1,116 @@
+# frozen_string_literal: true
+
+module Elelem
+  class Tool
+    attr_reader :name, :description, :parameters
+
+    def initialize(name, description, parameters)
+      @name = name
+      @description = description
+      @parameters = parameters
+    end
+
+    def banner
+      [name, parameters].join(": ")
+    end
+
+    def valid?(args)
+      JSON::Validator.validate(parameters, args, insert_defaults: true)
+    end
+
+    def to_h
+      {
+        type: "function",
+        function: {
+          name: name,
+          description: description,
+          parameters: parameters
+        }
+      }
+    end
+  end
+
+  class BashTool < Tool
+    attr_reader :tui
+
+    def initialize(configuration)
+      @tui = configuration.tui
+      super("bash", "Execute a shell command.", {
+        type: "object",
+        properties: {
+          command: { type: "string" }
+        },
+        required: ["command"]
+      })
+    end
+
+    def call(args)
+      command = args["command"]
+      output_buffer = []
+
+      Open3.popen3("/bin/sh", "-c", command) do |stdin, stdout, stderr, wait_thread|
+        stdin.close
+        streams = [stdout, stderr]
+
+        until streams.empty?
+          ready = IO.select(streams, nil, nil, 0.1)
+
+          if ready
+            ready[0].each do |io|
+              data = io.read_nonblock(4096)
+              output_buffer << data
+
+              if io == stderr
+                tui.say(data, colour: :red, newline: false)
+              else
+                tui.say(data, newline: false)
+              end
+            rescue IO::WaitReadable
+              next
+            rescue EOFError
+              streams.delete(io)
+            end
+          elsif !wait_thread.alive?
+            break
+          end
+        end
+
+        wait_thread.value
+      end
+
+      output_buffer.join
+    end
+  end
+
+  class MCPTool < Tool
+    attr_reader :client, :tui
+
+    def initialize(client, tui, tool)
+      @client = client
+      @tui = tui
+      super(tool["name"], tool["description"], tool["inputSchema"] || {})
+    end
+
+    def call(args)
+      unless client.connected?
+        tui.say("MCP connection lost", colour: :red)
+        return ""
+      end
+
+      result = client.call(name, args)
+      tui.say(result)
+
+      if result.nil? || result.empty?
+        tui.say("Tool call failed: no response from MCP server", colour: :red)
+        return result
+      end
+
+      if result["error"]
+        tui.say(result["error"], colour: :red)
+        return result
+      end
+
+      result.dig("content", 0, "text") || result.to_s
+    end
+  end
+end
lib/elelem/tools.rb
@@ -2,61 +2,32 @@
 
 module Elelem
   class Tools
-    DEFAULT_TOOLS = [
-      {
-        type: "function",
-        function: {
-          name: "execute_command",
-          description: "Execute a shell command.",
-          parameters: {
-            type: "object",
-            properties: {
-              command: { type: "string" },
-            },
-            required: ["command"]
-          }
-        },
-        handler: lambda { |args|
-          stdout, stderr, _status = Open3.capture3("/bin/sh", "-c", args["command"])
-          stdout + stderr
-        }
-      },
-    ]
-
-    def initialize(tools = DEFAULT_TOOLS)
+    def initialize(configuration, tools)
+      @configuration = configuration
       @tools = tools
     end
 
     def banner
-      @tools.map do |h|
-        [
-          h.dig(:function, :name),
-          h.dig(:function, :description)
-        ].join(": ")
-      end.sort.join("\n  ")
+      tools.map(&:banner).sort.join("\n  ")
     end
 
     def execute(tool_call)
       name = tool_call.dig("function", "name")
       args = tool_call.dig("function", "arguments")
 
-      tool = @tools.find do |tool|
-        tool.dig(:function, :name) == name
-      end
-      tool&.fetch(:handler)&.call(args)
+      tool = tools.find { |tool| tool.name == name }
+      return "Invalid function name: #{name}" if tool.nil?
+      return "Invalid function arguments: #{args}" unless tool.valid?(args)
+
+      tool.call(args)
     end
 
     def to_h
-      @tools.map do |tool|
-        {
-          type: tool[:type],
-          function: {
-            name: tool.dig(:function, :name),
-            description: tool.dig(:function, :description),
-            parameters: tool.dig(:function, :parameters)
-          }
-        }
-      end
+      tools.map(&:to_h)
     end
+
+    private
+
+    attr_reader :configuration, :tools
   end
 end
lib/elelem/tui.rb
@@ -10,8 +10,7 @@ module Elelem
     end
 
     def prompt(message)
-      say(message)
-      stdin.gets&.chomp
+      Reline.readline(message, true)
     end
 
     def say(message, colour: :default, newline: false)
@@ -24,10 +23,50 @@ module Elelem
       stdout.flush
     end
 
+    def show_progress(message, prefix = "[.]", colour: :gray)
+      timestamp = current_time_string
+      formatted_message = colourize("#{prefix} #{timestamp} #{message}", colour: colour)
+      stdout.print(formatted_message)
+      stdout.flush
+    end
+
+    def clear_line
+      stdout.print("\r#{" " * 80}\r")
+      stdout.flush
+    end
+
+    def complete_progress(message = "Completed")
+      clear_line
+      timestamp = current_time_string
+      formatted_message = colourize("[✓] #{timestamp} #{message}", colour: :green)
+      stdout.puts(formatted_message)
+      stdout.flush
+    end
+
     private
 
+    def current_time_string
+      Time.now.strftime("%H:%M:%S")
+    end
+
     def colourize(text, colour: :default)
       case colour
+      when :black
+        "\e[30m#{text}\e[0m"
+      when :red
+        "\e[31m#{text}\e[0m"
+      when :green
+        "\e[32m#{text}\e[0m"
+      when :yellow
+        "\e[33m#{text}\e[0m"
+      when :blue
+        "\e[34m#{text}\e[0m"
+      when :magenta
+        "\e[35m#{text}\e[0m"
+      when :cyan
+        "\e[36m#{text}\e[0m"
+      when :white
+        "\e[37m#{text}\e[0m"
       when :gray
         "\e[90m#{text}\e[0m"
       else
lib/elelem/version.rb
@@ -1,5 +1,5 @@
 # frozen_string_literal: true
 
 module Elelem
-  VERSION = "0.1.1"
+  VERSION = "0.1.2"
 end
lib/elelem.rb
@@ -1,9 +1,12 @@
 # frozen_string_literal: true
 
+require "erb"
 require "json"
+require "json-schema"
 require "logger"
 require "net/http"
 require "open3"
+require "reline"
 require "thor"
 require "uri"
 
@@ -12,7 +15,9 @@ require_relative "elelem/api"
 require_relative "elelem/application"
 require_relative "elelem/configuration"
 require_relative "elelem/conversation"
+require_relative "elelem/mcp_client"
 require_relative "elelem/state"
+require_relative "elelem/tool"
 require_relative "elelem/tools"
 require_relative "elelem/tui"
 require_relative "elelem/version"
.gitignore
@@ -13,6 +13,7 @@
 mkmf.log
 target/
 *.log
+*.gem
 
 # rspec failure tracking
 .rspec_status
.rubocop.yml
@@ -1,5 +1,9 @@
 AllCops:
-  TargetRubyVersion: 3.1
+  SuggestExtensions: false
+  TargetRubyVersion: 3.4
+
+Style/Documentation:
+  Enabled: false
 
 Style/StringLiterals:
   EnforcedStyle: double_quotes
CHANGELOG.md
@@ -1,5 +1,10 @@
 ## [Unreleased]
 
+## [0.1.2] - 2025-08-14
+
+### Fixed
+- Fixed critical bug where bash tool had nested parameters schema causing tool calls to fail with "no implicit conversion of nil into String" error
+
 ## [0.1.1] - 2025-08-12
 
 ### Fixed
elelem.gemspec
@@ -12,31 +12,53 @@ Gem::Specification.new do |spec|
   spec.description = "A REPL for Ollama."
   spec.homepage = "https://www.mokhan.ca"
   spec.license = "MIT"
-  spec.required_ruby_version = ">= 3.1.0"
+  spec.required_ruby_version = ">= 3.4.0"
   spec.required_rubygems_version = ">= 3.3.11"
-
   spec.metadata["allowed_push_host"] = "https://rubygems.org"
-
   spec.metadata["homepage_uri"] = spec.homepage
   spec.metadata["source_code_uri"] = "https://gitlab.com/mokhax/elelem"
   spec.metadata["changelog_uri"] = "https://gitlab.com/mokhax/elelem/-/blob/main/CHANGELOG.md"
 
   # Specify which files should be added to the gem when it is released.
   # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
-  gemspec = File.basename(__FILE__)
-  spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
-    ls.readlines("\x0", chomp: true).reject do |f|
-      (f == gemspec) || f.start_with?(*%w[bin/ test/ spec/ features/ .git Gemfile])
-    end
-  end
+  # gemspec = File.basename(__FILE__)
+  # spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls|
+  #   ls.readlines("\x0", chomp: true).reject do |f|
+  #     (f == gemspec) || f.start_with?(*%w[bin/ test/ spec/ features/ .git Gemfile])
+  #   end
+  # end
+  spec.files = [
+    "CHANGELOG.md",
+    "LICENSE.txt",
+    "README.md",
+    "Rakefile",
+    "exe/elelem",
+    "lib/elelem.rb",
+    "lib/elelem/agent.rb",
+    "lib/elelem/api.rb",
+    "lib/elelem/application.rb",
+    "lib/elelem/configuration.rb",
+    "lib/elelem/conversation.rb",
+    "lib/elelem/mcp_client.rb",
+    "lib/elelem/state.rb",
+    "lib/elelem/system_prompt.erb",
+    "lib/elelem/tool.rb",
+    "lib/elelem/tools.rb",
+    "lib/elelem/tui.rb",
+    "lib/elelem/version.rb",
+    "sig/elelem.rbs"
+  ]
   spec.bindir = "exe"
   spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
   spec.require_paths = ["lib"]
 
+  spec.add_dependency "erb"
   spec.add_dependency "json"
+  spec.add_dependency "json-schema"
   spec.add_dependency "logger"
   spec.add_dependency "net-http"
   spec.add_dependency "open3"
+  spec.add_dependency "reline"
   spec.add_dependency "thor"
   spec.add_dependency "uri"
 end
Gemfile.lock
@@ -1,18 +1,24 @@
 PATH
   remote: .
   specs:
-    elelem (0.1.1)
+    elelem (0.1.2)
+      erb
       json
+      json-schema
       logger
       net-http
       open3
+      reline
       thor
       uri
 
 GEM
   remote: https://rubygems.org/
   specs:
+    addressable (2.8.7)
+      public_suffix (>= 2.0.2, < 7.0)
     ast (2.4.3)
+    bigdecimal (3.2.2)
     date (3.4.1)
     diff-lcs (1.6.2)
     erb (5.0.2)
@@ -22,6 +28,9 @@ GEM
       rdoc (>= 4.0.0)
       reline (>= 0.4.2)
     json (2.13.2)
+    json-schema (6.0.0)
+      addressable (~> 2.8)
+      bigdecimal (~> 3.1)
     language_server-protocol (3.17.0.5)
     lint_roller (1.1.0)
     logger (1.7.0)
@@ -39,6 +48,7 @@ GEM
     psych (5.2.6)
       date
       stringio
+    public_suffix (6.0.2)
     racc (1.8.1)
     rainbow (3.1.1)
     rake (13.3.0)