Commit fc01784

mo khan <mo@mokhan.ca>
2025-11-05 19:36:34
refactor: hack all code in agent class
1 parent 7234c5e
lib/elelem/states/working/error.rb
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module States
-    module Working
-      class Error < State
-        def initialize(agent, error_message)
-          super(agent, "X", :red)
-          @error_message = error_message
-        end
-
-        def process(_message)
-          agent.tui.say("\nTool execution failed: #{@error_message}", colour: :red)
-          Waiting.new(agent)
-        end
-      end
-    end
-  end
-end
lib/elelem/states/working/executing.rb
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module States
-    module Working
-      class Executing < State
-        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))
-            end
-          end
-
-          Thinking.new(agent, "*", :yellow)
-        end
-      end
-    end
-  end
-end
lib/elelem/states/working/state.rb
@@ -1,26 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module States
-    module Working
-      class State
-        attr_reader :agent
-
-        def initialize(agent, icon, colour)
-          @agent = agent
-
-          agent.logger.debug("#{display_name}...")
-          agent.tui.show_progress("#{display_name}...", icon, colour: colour)
-        end
-
-        def run(message)
-          process(message)
-        end
-
-        def display_name
-          self.class.name.split("::").last
-        end
-      end
-    end
-  end
-end
lib/elelem/states/working/talking.rb
@@ -1,19 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module States
-    module Working
-      class Talking < State
-        def process(message)
-          if message["content"] && !message["content"]&.empty?
-            agent.conversation.add(role: message["role"], content: message["content"])
-            agent.tui.say(message["content"], colour: :default, newline: false)
-            self
-          else
-            Waiting.new(agent).process(message)
-          end
-        end
-      end
-    end
-  end
-end
lib/elelem/states/working/thinking.rb
@@ -1,18 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module States
-    module Working
-      class Thinking < State
-        def process(message)
-          if message["reasoning"] && !message["reasoning"]&.empty?
-            agent.tui.say(message["reasoning"], colour: :gray, newline: false)
-            self
-          else
-            Waiting.new(agent).process(message)
-          end
-        end
-      end
-    end
-  end
-end
lib/elelem/states/working/waiting.rb
@@ -1,29 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module States
-    module Working
-      class Waiting < State
-        def initialize(agent)
-          super(agent, ".", :cyan)
-        end
-
-        def process(message)
-          state_for(message)&.process(message) || self
-        end
-
-        private
-
-        def state_for(message)
-          if message["reasoning"] && !message["reasoning"].empty?
-            Thinking.new(agent, "*", :yellow)
-          elsif message["tool_calls"]&.any?
-            Executing.new(agent, ">", :magenta)
-          elsif message["content"] && !message["content"].empty?
-            Talking.new(agent, "~", :white)
-          end
-        end
-      end
-    end
-  end
-end
lib/elelem/states/idle.rb
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module States
-    class Idle
-      def run(agent)
-        agent.logger.debug("Idling...")
-        agent.tui.say("#{Dir.pwd} (#{agent.model}) [#{git_branch}]", colour: :magenta, newline: true)
-        input = agent.tui.prompt("モ ")
-        agent.quit if input.nil? || input.empty? || input == "exit" || input == "quit"
-
-        agent.conversation.add(role: :user, content: input)
-        agent.transition_to(Working)
-      end
-
-      private
-
-      def git_branch
-        `git branch --no-color --show-current --no-abbrev`.strip
-      end
-    end
-  end
-end
lib/elelem/states/working.rb
@@ -1,55 +0,0 @@
-# frozen_string_literal: true
-
-module Elelem
-  module States
-    module Working
-      class << self
-        def run(agent)
-          state = Waiting.new(agent)
-
-          loop do
-            streaming_done = false
-            finish_reason = nil
-
-            agent.api.chat(agent.conversation.history) do |message|
-              if message["done"]
-                streaming_done = true
-                next
-              end
-
-              if message["finish_reason"]
-                finish_reason = message["finish_reason"]
-                agent.logger.debug("Working: finish_reason = #{finish_reason}")
-              end
-
-              new_state = state.run(message)
-              if new_state.class != state.class
-                agent.logger.info("STATE: #{state.display_name} -> #{new_state.display_name}")
-              end
-              state = new_state
-            end
-
-            # Only exit when task is actually complete, not just streaming done
-            if finish_reason == "stop"
-              agent.logger.debug("Working: Task complete, exiting to Idle")
-              break
-            elsif finish_reason == "tool_calls"
-              agent.logger.debug("Working: Tool calls finished, continuing conversation")
-              # Continue loop to process tool results
-            elsif streaming_done && finish_reason.nil?
-              agent.logger.debug("Working: Streaming done but no finish_reason, continuing")
-              # Continue for cases where finish_reason comes in separate chunk
-            end
-          end
-
-          agent.transition_to(States::Idle.new)
-        rescue StandardError => e
-          agent.logger.error(e)
-          agent.conversation.add(role: :tool, content: e.message)
-          agent.tui.say(e.message, colour: :red, newline: true)
-          agent.transition_to(States::Idle.new)
-        end
-      end
-    end
-  end
-end
lib/elelem/agent.rb
@@ -2,43 +2,53 @@
 
 module Elelem
   class Agent
-    attr_reader :api, :conversation, :logger, :model, :tui
+    attr_reader :conversation, :model, :tui, :client, :tools
 
     def initialize(configuration)
-      @api = configuration.api
-      @tui = configuration.tui
+      @tui = TUI.new
       @configuration = configuration
-      @model = configuration.model
-      @conversation = configuration.conversation
-      @logger = configuration.logger
+      @conversation = Conversation.new
+      @client = Net::Llm::Ollama.new(
+        host: configuration.host,
+        model: configuration.model,
+      )
 
-      at_exit { cleanup }
+      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"])
+      ls_tool = build_tool("list", "List all git-tracked files in the repository, optionally filtered by path. Use this to explore project structure or find files in a directory. Returns relative paths from repo root. Tip: Use this before reading if you need to discover what files exist.", { path: { type: "string" } })
+      patch_tool = build_tool("patch", "Apply a unified diff patch via 'git apply'. Use this for surgical edits to existing files rather than rewriting entire files. Generates proper git diffs. Format: standard unified diff with --- and +++ headers. Tip: More efficient than write for small changes to large files.", { diff: { type: "string" } }, ["diff"])
+      read_tool = build_tool("read", "Read complete contents of a file. Requires exact file path. Use grep or list first if you don't know the path. Best for: understanding existing code, reading config files, reviewing implementation details. Tip: For large files, grep first to confirm relevance.", { path: { type: "string" } }, ["path"])
+      write_tool = build_tool("write", "Write complete file contents (overwrites existing files). Creates parent directories automatically. Best for: creating new files, replacing entire file contents. For small edits to existing files, consider using patch instead.", { path: { type: "string" }, content: { type: "string" } }, ["path", "content"])
+
+      @tools = {
+        read: [grep_tool, ls_tool, read_tool],
+        write: [patch_tool, write_tool],
+        execute: [exec_tool]
+      }
 
-      transition_to(States::Idle.new)
+      at_exit { cleanup }
     end
 
     def repl
-      loop do
-        current_state.run(self)
-        sleep 0.1
-      end
-    end
+      mode = Set.new([:read, :write, :execute])
 
-    def transition_to(next_state)
-      if @current_state
-        logger.info("AGENT: #{@current_state.class.name.split('::').last} -> #{next_state.class.name.split('::').last}")
-      else
-        logger.info("AGENT: Starting in #{next_state.class.name.split('::').last}")
+      loop do
+        input = tui.ask?("User> ")
+        break if input.nil?
+        if input.start_with?("/")
+          case input
+          when "/exit" then exit
+          when "/clear" then conversation.clear
+          when "/context" then tui.say(conversation.dump)
+          else
+            tui.say(help_banner)
+          end
+        else
+          conversation.add(role: :user, content: input)
+          result = execute_turn(conversation.history, tools: tools_for(mode))
+          conversation.add(role: result[:role], content: result[:content])
+        end
       end
-      @current_state = next_state
-    end
-
-    def execute(tool_call)
-      tool_name = tool_call.dig("function", "name")
-      logger.debug("TOOL: Full call - #{tool_call}")
-      result = configuration.tools.execute(tool_call)
-      logger.debug("TOOL: Result (#{result.length} chars)") if result
-      result
     end
 
     def quit
@@ -52,6 +62,118 @@ module Elelem
 
     private
 
-    attr_reader :configuration, :current_state
+    attr_reader :configuration
+
+    def help_banner
+      <<~HELP
+  /mode auto build plan verify
+  /clear
+  /context
+  /exit
+  /help
+  /shell
+      HELP
+    end
+
+    def tools_for(modes)
+      modes.map { |mode| tools[mode] }.flatten
+    end
+
+    def execute_turn(messages, tools:)
+      turn_context = []
+
+      loop do
+        content = ""
+        tool_calls = []
+
+        print "Thinking..."
+        client.chat(messages + turn_context, tools) do |chunk|
+          msg = chunk["message"]
+          if msg
+            print msg["thinking"] unless msg["thinking"]&.empty?
+
+            if msg["content"] && !msg["content"].empty?
+              print msg["content"]
+              content += msg["content"]
+            end
+
+            tool_calls += msg["tool_calls"] if msg["tool_calls"]
+          end
+        end
+
+        puts
+        turn_context << { role: "assistant", content: content, tool_calls: tool_calls }.compact
+
+        if tool_calls.any?
+          tool_calls.each do |call|
+            name = call.dig("function", "name")
+            args = call.dig("function", "arguments")
+
+            puts "Tool> #{name}(#{args})}"
+            result = run_tool(name, args)
+            turn_context << { role: "tool", content: JSON.dump(result) }
+          end
+
+          tool_calls = []
+          next
+        end
+
+        return { role: "assistant", content: content }
+      end
+    end
+
+    def run_exec(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
+      }
+    end
+
+    def expand_path(path)
+      Pathname.new(path).expand_path
+    end
+
+    def read_file(path)
+      full_path = expand_path(path)
+      full_path.exist? ? { content: full_path.read } : { error: "File not found: #{path}" }
+    end
+
+    def write_file(path, content)
+      full_path = expand_path(path)
+      FileUtils.mkdir_p(full_path.dirname)
+      { bytes_written: full_path.write(content) }
+    end
+
+    def run_tool(name, args)
+      case name
+      when "execute" then run_exec(args["cmd"], args: args["args"] || [], env: args["env"] || {}, cwd: args["cwd"].to_s.empty? ? Dir.pwd : args["cwd"], stdin: args["stdin"])
+      when "grep" then run_exec("git", args: ["grep", "-nI", args["query"]])
+      when "list" then run_exec("git", args: args["path"] ? ["ls-files", "--", args["path"]] : ["ls-files"])
+      when "patch" then run_exec("git", args: ["apply", "--index", "--whitespace=nowarn", "-p1"], stdin: args["diff"])
+      when "read" then read_file(args["path"])
+      when "write" then write_file(args["path"], args["content"])
+      else
+        { error: "Unknown tool", name: name, args: args }
+      end
+    end
+
+    def build_tool(name, description, properties, required = [])
+      {
+        type: "function",
+        function: {
+          name: name,
+          description: description,
+          parameters: {
+            type: "object",
+            properties: properties,
+            required: required
+          }
+        }
+      }
+    end
   end
 end
lib/elelem/application.rb
@@ -26,8 +26,6 @@ module Elelem
         token: options[:token],
       )
       say "Agent (#{configuration.model})", :green
-      say configuration.tools.banner.to_s, :green
-
       agent = Agent.new(configuration)
       agent.repl
     end
lib/elelem/configuration.rb
@@ -2,53 +2,23 @@
 
 module Elelem
   class Configuration
-    attr_reader :host, :model, :token
+    attr_reader :host, :model, :token, :tui
 
     def initialize(host:, model:, token:)
       @host = host
       @model = model
       @token = token
-    end
-
-    def tui
-      @tui ||= TUI.new($stdin, $stdout)
-    end
-
-    def api
-      @api ||= Api.new(self)
-    end
-
-    def logger
-      @logger ||= Logger.new("#{Time.now.strftime("%Y-%m-%d")}-elelem.log").tap do |logger|
-        logger.level = ENV.fetch("LOG_LEVEL", "warn")
-        logger.formatter = ->(severity, datetime, progname, message) {
-          timestamp = datetime.strftime("%H:%M:%S.%3N")
-          "[#{timestamp}] #{severity.ljust(5)} #{message.to_s.strip}\n"
-        }
-      end
-    end
-
-    def conversation
-      @conversation ||= Conversation.new.tap do |conversation|
-        resources = mcp_clients.map do |client|
-          client.resources.map do |resource|
-            resource["uri"]
-          end
-        end.flatten
-        conversation.add(role: :tool, content: resources)
-      end
+      @tui = TUI.new
     end
 
     def tools
-      @tools ||= Tools.new(self,
-        [
-          Toolbox::Exec.new(self),
-          Toolbox::File.new(self),
-          Toolbox::Web.new(self),
-          Toolbox::Prompt.new(self),
-          Toolbox::Memory.new(self),
-        ] + mcp_tools
-      )
+      @tools ||= Tools.new([
+        Toolbox::Exec.new(self),
+        Toolbox::File.new(self),
+        Toolbox::Web.new(self),
+        Toolbox::Prompt.new(self),
+        Toolbox::Memory.new(self),
+      ] + mcp_tools)
     end
 
     def cleanup
@@ -71,7 +41,7 @@ module Elelem
         return [] unless config.exist?
 
         JSON.parse(config.read).map do |_key, value|
-          MCPClient.new(self, [value["command"]] + value["args"])
+          MCPClient.new([value["command"]] + value["args"])
         end
       end
     end
lib/elelem/conversation.rb
@@ -4,7 +4,7 @@ module Elelem
   class Conversation
     ROLES = %i[system assistant user tool].freeze
 
-    def initialize(items = [{ role: "system", content: system_prompt }])
+    def initialize(items = default_context)
       @items = items
     end
 
@@ -25,8 +25,20 @@ module Elelem
       end
     end
 
+    def clear
+      @items = default_context
+    end
+
+    def dump
+      JSON.pretty_generate(@items)
+    end
+
     private
 
+    def default_context
+      [{ role: "system", content: system_prompt }]
+    end
+
     def system_prompt
       ERB.new(Pathname.new(__dir__).join("system_prompt.erb").read).result(binding)
     end
lib/elelem/mcp_client.rb
@@ -4,8 +4,7 @@ module Elelem
   class MCPClient
     attr_reader :tools, :resources
 
-    def initialize(configuration, command = [])
-      @configuration = configuration
+    def initialize(command = [])
       @stdin, @stdout, @stderr, @worker = Open3.popen3(*command, pgroup: true)
 
       # 1. Send initialize request
@@ -57,8 +56,6 @@ module Elelem
     def shutdown
       return unless connected?
 
-      configuration.logger.debug("Shutting down MCP client")
-
       [@stdin, @stdout, @stderr].each do |stream|
         stream&.close unless stream&.closed?
       end
@@ -83,7 +80,7 @@ module Elelem
 
     private
 
-    attr_reader :stdin, :stdout, :stderr, :worker, :configuration
+    attr_reader :stdin, :stdout, :stderr, :worker
 
     def send_request(method:, params: {})
       return {} unless connected?
@@ -94,7 +91,6 @@ module Elelem
         method: method
       }
       request[:params] = params unless params.empty?
-      configuration.logger.debug(JSON.pretty_generate(request))
 
       @stdin.puts(JSON.generate(request))
       @stdin.flush
@@ -103,10 +99,8 @@ module Elelem
       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"]
@@ -121,7 +115,6 @@ module Elelem
         method: method
       }
       notification[:params] = params unless params.empty?
-      configuration.logger.debug("Sending notification: #{JSON.pretty_generate(notification)}")
       @stdin.puts(JSON.generate(notification))
       @stdin.flush
 
@@ -129,7 +122,6 @@ module Elelem
       return {} if response_line.nil? || response_line.empty?
 
       response = JSON.parse(response_line)
-      configuration.logger.debug(JSON.pretty_generate(response))
       response
     end
   end
lib/elelem/system_prompt.erb
@@ -1,18 +1,1 @@
-You are a self-improving coding assistant with the ability to modify your own source code.
-
-**Core Behavior**: Think via `thinking`, Execute via `tool_calls`, Talk via `content`.
-
-**Your Mission**: Continuously evolve to match the intelligence of advanced AI assistants.
-
-**Guidance**: Read `COMMANDMENTS.md` for self-improvement principles using `file action=read path=COMMANDMENTS.md`
-
-**Tools**: 
-- `exec command="shell command"` - Run commands/tests
-- `file action=read|write path=filepath content=...` - Read/write files  
-- `web action=search|fetch query=... url=...` - Internet access
-- `memory action=store|retrieve|search key=... content=...` - Persistent memory
-- `prompt question="..."` - Ask user questions
-
-Context: <%= Time.now.strftime("%Y-%m-%d %H:%M:%S") %> | <%= Dir.pwd %> | <%= `uname -a`.strip %>
-
-Focus on the user's request and continuously improve your capabilities.
+You are a reasoning coding and system agent.
lib/elelem/tool.rb
@@ -10,10 +10,6 @@ module Elelem
       @parameters = parameters
     end
 
-    def banner
-      [name, parameters].join(": ")
-    end
-
     def valid?(args)
       JSON::Validator.validate(parameters, args, insert_defaults: true)
     end
lib/elelem/tools.rb
@@ -2,15 +2,10 @@
 
 module Elelem
   class Tools
-    def initialize(configuration, tools)
-      @configuration = configuration
+    def initialize(tools)
       @tools = tools
     end
 
-    def banner
-      tools.map(&:banner).sort.join("\n  ")
-    end
-
     def execute(tool_call)
       name, args = parse(tool_call)
 
@@ -29,7 +24,7 @@ module Elelem
 
     private
 
-    attr_reader :configuration, :tools
+    attr_reader :tools
 
     def parse(tool_call)
       name = tool_call.dig("function", "name")
lib/elelem/tui.rb
@@ -9,8 +9,8 @@ module Elelem
       @stdout = stdout
     end
 
-    def prompt(message)
-      Reline.readline(message, true)
+    def ask?(text)
+      Reline.readline(text, true)&.strip
     end
 
     def say(message, colour: :default, newline: false)
lib/elelem.rb
@@ -17,14 +17,6 @@ require_relative "elelem/application"
 require_relative "elelem/configuration"
 require_relative "elelem/conversation"
 require_relative "elelem/mcp_client"
-require_relative "elelem/states/idle"
-require_relative "elelem/states/working"
-require_relative "elelem/states/working/state"
-require_relative "elelem/states/working/error"
-require_relative "elelem/states/working/executing"
-require_relative "elelem/states/working/talking"
-require_relative "elelem/states/working/thinking"
-require_relative "elelem/states/working/waiting"
 require_relative "elelem/tool"
 require_relative "elelem/toolbox"
 require_relative "elelem/tools"
elelem.gemspec
@@ -42,14 +42,6 @@ Gem::Specification.new do |spec|
     "lib/elelem/configuration.rb",
     "lib/elelem/conversation.rb",
     "lib/elelem/mcp_client.rb",
-    "lib/elelem/states/idle.rb",
-    "lib/elelem/states/working.rb",
-    "lib/elelem/states/working/error.rb",
-    "lib/elelem/states/working/executing.rb",
-    "lib/elelem/states/working/state.rb",
-    "lib/elelem/states/working/talking.rb",
-    "lib/elelem/states/working/thinking.rb",
-    "lib/elelem/states/working/waiting.rb",
     "lib/elelem/system_prompt.erb",
     "lib/elelem/tool.rb",
     "lib/elelem/toolbox.rb",
README.md
@@ -29,7 +29,6 @@ elelem chat
 - `--host`: Specify Ollama host (default: localhost:11434)
 - `--model`: Specify Ollama model (default: gpt-oss, currently only tested with gpt-oss)  
 - `--token`: Provide authentication token
-- `--debug`: Enable debug logging
 
 ### Examples
 
@@ -39,9 +38,6 @@ elelem chat
 
 # Chat with specific model and host
 elelem chat --model llama2 --host remote-host:11434
-
-# Enable debug mode
-elelem chat --debug
 ```
 
 ### Features
@@ -58,10 +54,6 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
 
 To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
 
-## Contributing
-
-Bug reports and pull requests are welcome on GitHub at https://github.com/xlgmokha/elelem.
-
 ## License
 
 The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).