Commit d63e1d1

mo khan <mo@mokhan.ca>
2025-11-05 20:42:55
refactor: cleanup
1 parent cfb2b7b
lib/elelem/toolbox/exec.rb
@@ -1,61 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module Toolbox
-    class Exec < ::Elelem::Tool
-      attr_reader :tui
-
-      def initialize(configuration)
-        @tui = configuration.tui
-        super("exec", "Execute shell commands with pipe support", {
-          type: "object",
-          properties: {
-            command: {
-              type: "string",
-              description: "Shell command to execute (supports pipes, redirects, etc.)"
-            }
-          },
-          required: ["command"]
-        })
-      end
-
-      def call(args)
-        command = args["command"]
-        output_buffer = []
-
-        tui.say(command, newline: true)
-        Open3.popen3(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
-  end
-end
lib/elelem/toolbox/file.rb
@@ -1,66 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module Toolbox
-    class File < Tool
-      def initialize(configuration)
-        @configuration = configuration
-        @tui = configuration.tui
-
-        super("file", "Read and write files", {
-          type: :object,
-          properties: {
-            action: {
-              type: :string,
-              enum: ["read", "write"],
-              description: "Action to perform: read or write"
-            },
-            path: {
-              type: :string,
-              description: "File path"
-            },
-            content: {
-              type: :string,
-              description: "Content to write (only for write action)"
-            }
-          },
-          required: [:action, :path]
-        })
-      end
-
-      def call(args)
-        action = args["action"]
-        path = args["path"]
-        content = args["content"]
-
-        case action
-        when "read"
-          read_file(path)
-        when "write"
-          write_file(path, content)
-        else
-          "Invalid action: #{action}"
-        end
-      end
-
-      private
-
-      attr_reader :configuration, :tui
-
-      def read_file(path)
-        tui.say("Read: #{path}", newline: true)
-        ::File.read(path)
-      rescue => e
-        "Error reading file: #{e.message}"
-      end
-
-      def write_file(path, content)
-        tui.say("Write: #{path}", newline: true)
-        ::File.write(path, content)
-        "File written successfully"
-      rescue => e
-        "Error writing file: #{e.message}"
-      end
-    end
-  end
-end
lib/elelem/toolbox/mcp.rb
@@ -1,37 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module Toolbox
-    class MCP < ::Elelem::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(JSON.pretty_generate(result), newline: true)
-
-        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
-end
lib/elelem/agent.rb
@@ -2,16 +2,11 @@
 
 module Elelem
   class Agent
-    attr_reader :conversation, :model, :tui, :client, :tools
+    attr_reader :conversation, :tui, :client, :tools
 
-    def initialize(configuration)
-      @tui = TUI.new
-      @configuration = configuration
+    def initialize(client)
       @conversation = Conversation.new
-      @client = Net::Llm::Ollama.new(
-        host: configuration.host,
-        model: configuration.model,
-      )
+      @client = client
 
       exec_tool = build_tool("execute", "Execute shell commands. Returns stdout, stderr, and exit code. Use for: checking system state, running tests, managing services. Common Unix tools available: git, bash, grep, etc. Tip: Check exit_status in response to determine success.", { cmd: { type: "string" }, args: { type: "array", items: { type: "string" } }, env: { type: "object", additionalProperties: { type: "string" } }, cwd: { type: "string", description: "Working directory for command execution (defaults to current directory if not specified)" }, stdin: { type: "string" } }, ["cmd"])
       grep_tool = build_tool("grep", "Search all git-tracked files using git grep. Returns file paths with matching line numbers. Use this to discover where code/configuration exists before reading files. Examples: search 'def method_name' to find method definitions. Much faster than reading multiple files.", { query: { type: "string" } }, ["query"])
@@ -25,25 +20,31 @@ module Elelem
         write: [patch_tool, write_tool],
         execute: [exec_tool]
       }
-
-      at_exit { cleanup }
     end
 
     def repl
-      mode = Set.new([:read, :write, :execute])
+      mode = Set.new([:read])
 
       loop do
-        input = tui.ask?("User> ")
+        input = ask?("User> ")
         break if input.nil?
         if input.start_with?("/")
           case input
+          when "/mode auto" then mode = Set[:read, :write, :execute]
+          when "/mode build" then mode = Set[:read, :write]
+          when "/mode plan" then mode = Set[:read]
+          when "/mode verify" then mode = Set[:read, :execute]
+          when "/mode"
+            puts("  Mode: #{mode.to_a.inspect}")
+            puts("  Tools: #{tools_for(mode).map { |t| t.dig(:function, :name) }}")
           when "/exit" then exit
           when "/clear" then conversation.clear
-          when "/context" then tui.say(conversation.dump)
+          when "/context" then puts conversation.dump
           else
-            tui.say(help_banner)
+            puts help_banner
           end
         else
+          conversation.set_system_prompt(system_prompt_for(mode))
           conversation.add(role: :user, content: input)
           result = execute_turn(conversation.history, tools: tools_for(mode))
           conversation.add(role: result[:role], content: result[:content])
@@ -51,27 +52,20 @@ module Elelem
       end
     end
 
-    def quit
-      cleanup
-      exit
-    end
-
-    def cleanup
-      configuration.cleanup
-    end
-
     private
 
-    attr_reader :configuration
+    def ask?(text)
+      Reline.readline(text, true)&.strip
+    end
 
     def help_banner
       <<~HELP
+  /chmod (+|-)rwx
   /mode auto build plan verify
   /clear
   /context
   /exit
   /help
-  /shell
       HELP
     end
 
@@ -79,6 +73,29 @@ module Elelem
       modes.map { |mode| tools[mode] }.flatten
     end
 
+    def system_prompt_for(mode)
+      base = "You are a reasoning coding and system agent."
+
+      case mode.to_a.sort
+      when [:read]
+        "#{base}\n\nRead and analyze. Understand before suggesting action."
+      when [:write]
+        "#{base}\n\nWrite clean, thoughtful code."
+      when [:execute]
+        "#{base}\n\nUse shell commands creatively to understand and manipulate the system."
+      when [:read, :write]
+        "#{base}\n\nFirst understand, then build solutions that integrate well."
+      when [:read, :execute]
+        "#{base}\n\nUse commands to deeply understand the system."
+      when [:write, :execute]
+        "#{base}\n\nCreate and execute freely. Have fun. Be kind."
+      when [:read, :write, :execute]
+        "#{base}\n\nYou have all tools. Use them wisely."
+      else
+        base
+      end
+    end
+
     def execute_turn(messages, tools:)
       turn_context = []
 
@@ -159,6 +176,8 @@ module Elelem
       else
         { error: "Unknown tool", name: name, args: args }
       end
+    rescue => error
+      { error: error.message, name: name, args: args }
     end
 
     def build_tool(name, description, properties, required = [])
lib/elelem/application.rb
@@ -13,20 +13,15 @@ module Elelem
                   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)
 
     def chat(*)
-      configuration = Configuration.new(
+      client = Net::Llm::Ollama.new(
         host: options[:host],
         model: options[:model],
-        token: options[:token],
       )
-      say "Agent (#{configuration.model})", :green
-      agent = Agent.new(configuration)
+      say "Agent (#{options[:model]})", :green
+      agent = Agent.new(client)
+
       agent.repl
     end
 
lib/elelem/configuration.rb
@@ -1,46 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  class Configuration
-    attr_reader :host, :model, :token, :tui
-
-    def initialize(host:, model:, token:)
-      @host = host
-      @model = model
-      @token = token
-      @tui = TUI.new
-    end
-
-    def tools
-      @tools ||= Tools.new([
-        Toolbox::Exec.new(self),
-        Toolbox::File.new(self),
-      ] + mcp_tools)
-    end
-
-    def cleanup
-      @mcp_clients&.each(&:shutdown)
-    end
-
-    private
-
-    def mcp_tools
-      @mcp_tools ||= mcp_clients.map do |client|
-        client.tools.map do |tool|
-          Toolbox::MCP.new(client, tui, tool)
-        end
-      end.flatten
-    end
-
-    def mcp_clients
-      @mcp_clients ||= begin
-        config = Pathname.pwd.join(".mcp.json")
-        return [] unless config.exist?
-
-        JSON.parse(config.read).map do |_key, value|
-          MCPClient.new([value["command"]] + value["args"])
-        end
-      end
-    end
-  end
-end
lib/elelem/conversation.rb
@@ -12,7 +12,6 @@ module Elelem
       @items
     end
 
-    # :TODO truncate conversation history
     def add(role: :user, content: "")
       role = role.to_sym
       raise "unknown role: #{role}" unless ROLES.include?(role)
@@ -29,6 +28,10 @@ module Elelem
       @items = default_context
     end
 
+    def set_system_prompt(prompt)
+      @items[0] = { role: :system, content: prompt }
+    end
+
     def dump
       JSON.pretty_generate(@items)
     end
lib/elelem/mcp_client.rb
@@ -1,128 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  class MCPClient
-    attr_reader :tools, :resources
-
-    def initialize(command = [])
-      @stdin, @stdout, @stderr, @worker = Open3.popen3(*command, pgroup: true)
-
-      # 1. Send initialize request
-      send_request(
-        method: "initialize",
-        params: {
-          protocolVersion: "2025-06-08",
-          capabilities: {
-            tools: {}
-          },
-          clientInfo: {
-            name: "Elelem",
-            version: Elelem::VERSION
-          }
-        }
-      )
-
-      # 2. Send initialized notification (optional for some MCP servers)
-      send_notification(method: "notifications/initialized")
-
-      # 3. Now we can request tools
-      @tools = send_request(method: "tools/list")&.dig("tools") || []
-      @resources = send_request(method: "resources/list")&.dig("resources") || []
-    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
-
-    def shutdown
-      return unless connected?
-
-      [@stdin, @stdout, @stderr].each do |stream|
-        stream&.close unless stream&.closed?
-      end
-
-      return unless @worker&.alive?
-
-      begin
-        Process.kill("TERM", @worker.pid)
-        # Give it 2 seconds to terminate gracefully
-        Timeout.timeout(2) { @worker.value }
-      rescue Timeout::Error
-        # Force kill if it doesn't respond
-        begin
-          Process.kill("KILL", @worker.pid)
-        rescue StandardError
-          nil
-        end
-      rescue Errno::ESRCH
-        # Process already dead
-      end
-    end
-
-    private
-
-    attr_reader :stdin, :stdout, :stderr, :worker
-
-    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?
-
-      @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)
-
-      if response["error"]
-        { error: response["error"]["message"] }
-      else
-        response["result"]
-      end
-    end
-
-    def send_notification(method:, params: {})
-      return unless connected?
-
-      notification = {
-        jsonrpc: "2.0",
-        method: method
-      }
-      notification[:params] = params unless params.empty?
-      @stdin.puts(JSON.generate(notification))
-      @stdin.flush
-
-      response_line = @stdout.gets&.strip
-      return {} if response_line.nil? || response_line.empty?
-
-      response = JSON.parse(response_line)
-      response
-    end
-  end
-end
lib/elelem/tool.rb
@@ -4,16 +4,21 @@ module Elelem
   class Tool
     attr_reader :name, :description, :parameters
 
-    def initialize(name, description, parameters)
+    def initialize(name, description, parameters, &block)
       @name = name
       @description = description
       @parameters = parameters
+      @block = block
     end
 
     def valid?(args)
       JSON::Validator.validate(parameters, args, insert_defaults: true)
     end
 
+    def call(*args)
+      @block.call(*args)
+    end
+
     def to_h
       {
         type: "function",
lib/elelem/toolbox.rb
@@ -1,5 +0,0 @@
-# frozen_string_literal: true
-
-require_relative "toolbox/exec"
-require_relative "toolbox/file"
-require_relative "toolbox/mcp"
lib/elelem/tools.rb
@@ -6,6 +6,10 @@ module Elelem
       @tools = tools
     end
 
+    def add(name, description, parameters, &block)
+      @tools << Tool.new(name, description, parameters, &block)
+    end
+
     def execute(tool_call)
       name, args = parse(tool_call)
 
@@ -13,9 +17,7 @@ module Elelem
       return "Invalid function name: #{name}" if tool.nil?
       return "Invalid function arguments: #{args}" unless tool.valid?(args)
 
-      CLI::UI::Frame.open(name) do
-        tool.call(args)
-      end
+      tool.call(args)
     end
 
     def to_h
lib/elelem/tui.rb
@@ -1,66 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  class TUI
-    attr_reader :stdin, :stdout
-
-    def initialize(stdin = $stdin, stdout = $stdout)
-      @stdin = stdin
-      @stdout = stdout
-    end
-
-    def ask?(text)
-      Reline.readline(text, true)&.strip
-    end
-
-    def say(message, colour: :default, newline: false)
-      if newline
-        stdout.puts(colourize(message, colour: colour))
-      else
-        stdout.print(colourize(message, colour: colour))
-      end
-      stdout.flush
-    end
-
-    def show_progress(message, icon = ".", colour: :gray)
-      timestamp = Time.now.strftime("%H:%M:%S")
-      say("\n[#{icon}] #{timestamp} #{message}", colour: colour, newline: true)
-    end
-
-    def clear_line
-      say("\r#{" " * 80}\r", newline: false)
-    end
-
-    def complete_progress(message = "Completed")
-      clear_line
-      show_progress(message, "✓", colour: :green)
-    end
-
-    private
-
-    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
-        text
-      end
-    end
-  end
-end
lib/elelem.rb
@@ -1,28 +1,25 @@
 # frozen_string_literal: true
 
-require "cli/ui"
 require "erb"
+require "fileutils"
 require "json"
 require "json-schema"
 require "logger"
 require "net/llm"
 require "open3"
+require "pathname"
 require "reline"
+require "set"
 require "thor"
 require "timeout"
 
 require_relative "elelem/agent"
 require_relative "elelem/application"
-require_relative "elelem/configuration"
 require_relative "elelem/conversation"
-require_relative "elelem/mcp_client"
 require_relative "elelem/tool"
-require_relative "elelem/toolbox"
 require_relative "elelem/tools"
-require_relative "elelem/tui"
 require_relative "elelem/version"
 
-CLI::UI::StdoutRouter.enable
 Reline.input = $stdin
 Reline.output = $stdout
 
elelem.gemspec
@@ -36,16 +36,9 @@ Gem::Specification.new do |spec|
     "lib/elelem.rb",
     "lib/elelem/agent.rb",
     "lib/elelem/application.rb",
-    "lib/elelem/configuration.rb",
     "lib/elelem/conversation.rb",
-    "lib/elelem/mcp_client.rb",
     "lib/elelem/system_prompt.erb",
     "lib/elelem/tool.rb",
-    "lib/elelem/toolbox.rb",
-    "lib/elelem/toolbox/exec.rb",
-    "lib/elelem/toolbox/file.rb",
-    "lib/elelem/toolbox/mcp.rb",
-    "lib/elelem/toolbox/web.rb",
     "lib/elelem/tools.rb",
     "lib/elelem/tui.rb",
     "lib/elelem/version.rb",
@@ -54,7 +47,6 @@ Gem::Specification.new do |spec|
   spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) }
   spec.require_paths = ["lib"]
 
-  spec.add_dependency "cli-ui"
   spec.add_dependency "erb"
   spec.add_dependency "json"
   spec.add_dependency "json-schema"
Gemfile.lock
@@ -2,7 +2,6 @@ PATH
   remote: .
   specs:
     elelem (0.2.1)
-      cli-ui
       erb
       json
       json-schema
@@ -20,7 +19,6 @@ GEM
       public_suffix (>= 2.0.2, < 7.0)
     base64 (0.3.0)
     bigdecimal (3.2.2)
-    cli-ui (2.4.0)
     date (3.4.1)
     diff-lcs (1.6.2)
     erb (5.0.2)