Commit a31d3c6

mo khan <mo@mokhan.ca>
2025-08-08 21:30:40
refactor: extract classes for different responsibilities
1 parent f4d9a67
bin/run
@@ -5,4 +5,4 @@ set -e
 
 cd "$(dirname "$0")/.."
 
-bundle exec ./exe/elelem
+bundle exec ./exe/elelem $@
exe/elelem
@@ -3,4 +3,8 @@
 
 require "elelem"
 
-Elelem::Agent.new.repl
+Signal.trap('INT') do
+  exit(1)
+end
+
+Elelem::Application.start
lib/elelem/agent.rb
@@ -0,0 +1,125 @@
+# frozen_string_literal: true
+
+module Elelem
+  class Agent
+    attr_reader :tools, :http, :configuration
+
+    def initialize(configuration)
+      @configuration = configuration
+
+      @logger = configuration.logger
+      @http = configuration.http
+      @conversation = configuration.conversation
+      @tools = configuration.tools
+    end
+
+    def protocol(host)
+      host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? 'http' : 'https'
+    end
+
+    def 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
+    end
+
+    def repl
+      puts "Ollama Agent (#{configuration.model})"
+      puts "Tools:\n  #{@tools.banner}"
+
+      loop do
+        print "\n> "
+        user = STDIN.gets&.chomp
+        break if user.nil? || user.empty? || user == 'exit'
+        process_input(user)
+        puts("\u001b[32mDone!\u001b[0m")
+      end
+    end
+
+    private
+
+    def process_input(text)
+      @conversation.add(role: 'user', content: text)
+
+      # ::TODO state machine
+      done = false
+      loop do
+        call_api(@conversation.history) do |chunk|
+          debug_print(chunk)
+
+          response = JSON.parse(chunk)
+          message = response['message'] || {}
+          if message['thinking']
+            print("\u001b[90m#{message['thinking']}\u001b[0m")
+          elsif message['tool_calls']&.any?
+            message['tool_calls'].each do |t|
+              @conversation.add(
+                role: 'tool',
+                content: execute_tool(t.dig('function', 'name'), t.dig('function', 'arguments'))
+              )
+            end
+          elsif message['content'].to_s.strip
+            print message['content'].to_s.strip
+          else
+            raise chunk.inspect
+          end
+
+          done = response['done']
+        end
+
+        break if done
+      end
+    end
+
+    def call_api(messages)
+      body = {
+        messages:   messages,
+        model:      @model,
+        stream:     true,
+        keep_alive: '5m',
+        options:      { temperature: 0.1 },
+        tools:       tools
+      }
+      json_body = body.to_json
+      debug_print(json_body)
+
+      req = Net::HTTP::Post.new(configuration.uri)
+      req['Content-Type'] = 'application/json'
+      req.body = json_body
+      req['Authorization'] = "Bearer #{@token}" if @token
+
+      http.request(req) do |response|
+        response.read_body do |chunk|
+          block_given? ? yield(chunk) : debug_print(chunk)
+          $stdout.flush
+        end
+      end
+    end
+
+    def execute_tool(name, args)
+      case name
+      when 'execute_command'
+        result = run_cmd(args['command'])
+        debug_print(result) unless result[:ok]
+        result[:output]
+      when 'ask_user'
+        puts("\u001b[35m#{args['question']}\u001b[0m")
+        print "> "
+        "User: #{STDIN.gets&.chomp}"
+      end
+    end
+
+    def run_cmd(command)
+      stdout, stderr, status = Open3.capture3('/bin/sh', '-c', command)
+      { output: stdout + stderr, ok: status.success? }
+    end
+
+    def debug_print(body = nil)
+      @logger.debug(body) if @debug && body
+    end
+  end
+end
lib/elelem/application.rb
@@ -0,0 +1,31 @@
+# frozen_string_literal: true
+
+module Elelem
+  class Application < Thor
+    desc 'chat', 'Start the REPL'
+    method_option :help, 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')
+    method_option :model, 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)
+    method_option :debug, aliases: '--debug', type: :boolean, desc: 'Debug mode', default: ENV.fetch('DEBUG', '0') == '1'
+    def chat(*)
+      if options[:help]
+        invoke :help, ['chat']
+      else
+        agent = Agent.new(Configuration.new(
+          host: options[:host],
+          model: options[:model],
+          token: options[:token],
+          debug: options[:debug],
+        ))
+        agent.repl
+      end
+    end
+
+    desc 'version', 'spandx version'
+    def version
+      puts "v#{Spandx::VERSION}"
+    end
+    map %w[--version -v] => :version
+  end
+end
lib/elelem/configuration.rb
@@ -0,0 +1,47 @@
+# frozen_string_literal: true
+
+module Elelem
+  class Configuration
+    attr_reader :host, :model, :token, :debug
+
+    def initialize(host:, model:, token:, debug: false)
+      @host = host
+      @model = model
+      @token = token
+      @debug = debug
+    end
+
+    def http
+      Net::HTTP.new(uri.host, uri.port).tap do |h|
+        h.read_timeout = 3_600
+        h.open_timeout = 10
+      end
+    end
+
+    def logger
+      @logger ||= begin
+        Logger.new(debug ? $stderr : "/dev/null").tap do |logger|
+          logger.formatter = ->(_, _, _, msg) { msg }
+        end
+      end
+    end
+
+    def uri
+      @uri ||= URI("#{scheme}://#{host}/api/chat")
+    end
+
+    def conversation
+      @conversation ||= Conversation.new
+    end
+
+    def tools
+      @tools ||= Tools.new
+    end
+
+    private
+
+    def scheme
+      host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? 'http' : 'https'
+    end
+  end
+end
lib/elelem/conversation.rb
@@ -0,0 +1,30 @@
+# frozen_string_literal: true
+
+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 = ['system', 'user', 'tool'].freeze
+
+    def initialize(items = [{ role: 'system', content: SYSTEM_MESSAGE }])
+      @items = items
+    end
+
+    def history
+      @items
+    end
+
+    # :TODO truncate conversation history
+    def add(role: 'user', content: '')
+      raise "unknown role: #{role}" unless ROLES.include?(role)
+
+      @items << { role: role, content: content }
+    end
+  end
+end
lib/elelem/tools.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+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']
+          }
+        }
+      },
+      {
+        type: 'function',
+        function: {
+          name:        'ask_user',
+          description: 'Ask the user to answer a question.',
+          parameters: {
+            type:       'object',
+            properties: { question: { type: 'string' } },
+            required:   ['question']
+          }
+        }
+      }
+    ]
+
+    def initialize(tools = DEFAULT_TOOLS)
+      @tools = tools
+    end
+
+    def banner
+      @tools.map do |h|
+        [
+          h.dig(:function, :name),
+          h.dig(:function, :description)
+        ].join(": ")
+      end.sort.join("\n  ")
+    end
+  end
+end
lib/elelem.rb
@@ -4,166 +4,16 @@ require "json"
 require "logger"
 require "net/http"
 require "open3"
+require "thor"
 require "uri"
 
+require_relative "elelem/agent"
+require_relative "elelem/application"
+require_relative "elelem/configuration"
+require_relative "elelem/conversation"
+require_relative "elelem/tools"
 require_relative "elelem/version"
 
 module Elelem
   class Error < StandardError; end
-
-  class Agent
-    attr_reader :tools, :http
-
-    def initialize
-      @host   = ENV.fetch('OLLAMA_HOST', 'localhost:11434')
-      @model  = ENV.fetch('OLLAMA_MODEL', 'gpt-oss')
-      @token  = ENV.fetch('OLLAMA_API_KEY', nil)
-      @debug  = ENV.fetch('DEBUG', '0') == '1'
-      @logger = Logger.new(@debug ? $stderr : "/dev/null")
-      @logger.formatter = ->(_, _, _, msg) { msg }
-
-      @uri = URI("#{protocol(@host)}://#{@host}/api/chat")
-      @http = Net::HTTP.new(@uri.host, @uri.port).tap do |h|
-        h.read_timeout = 3_600
-        h.open_timeout = 10
-      end
-
-      @conversation = [{ role: 'system', content: system_message }]
-
-      @tools = [
-        {
-          type: 'function',
-          function: {
-            name:        'execute_command',
-            description: 'Execute a shell command.',
-            parameters: {
-              type:       'object',
-              properties: { command: { type: 'string' } },
-              required:   ['command']
-            }
-          }
-        },
-        {
-          type: 'function',
-          function: {
-            name:        'ask_user',
-            description: 'Ask the user to answer a question.',
-            parameters: {
-              type:       'object',
-              properties: { question: { type: 'string' } },
-              required:   ['question']
-            }
-          }
-        }
-      ]
-    end
-
-    def protocol(host)
-      host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? 'http' : 'https'
-    end
-
-    def 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
-    end
-
-    def repl
-      puts "Ollama Agent (#{@model})"
-      puts "  tools:\n  #{tools.map { |h| [h.dig(:function, :name), h.dig(:function, :description)].sort.join(": ") }.join("\n  ")}"
-
-      loop do
-        print "\n> "
-        user = STDIN.gets&.chomp
-        break if user.nil? || user.empty? || user == 'exit'
-        process_input(user)
-        puts("\u001b[32mDone!\u001b[0m")
-      end
-    end
-
-    private
-
-    def process_input(text)
-      @conversation << { role: 'user', content: text }
-
-      # ::TODO state machine
-      done = false
-      loop do
-        call_api(@conversation) do |chunk|
-          debug_print(chunk)
-
-          response = JSON.parse(chunk)
-          message = response['message'] || {}
-          if message['thinking']
-            print("\u001b[90m#{message['thinking']}\u001b[0m")
-          elsif message['tool_calls']&.any?
-            message['tool_calls'].each do |t|
-              result = execute_tool(t.dig('function', 'name'), t.dig('function', 'arguments'))
-              puts result
-              @conversation << { role: 'tool', content: result }
-            end
-          elsif message['content'].to_s.strip
-            print message['content'].to_s.strip
-          else
-            raise chunk.inspect
-          end
-
-          done = response['done']
-        end
-
-        break if done
-      end
-    end
-
-    def call_api(messages)
-      body = {
-        messages:   messages,
-        model:      @model,
-        stream:     true,
-        keep_alive: '5m',
-        options:      { temperature: 0.1 },
-        tools:       tools
-      }
-      json_body = body.to_json
-      debug_print(json_body)
-
-      req = Net::HTTP::Post.new(@uri)
-      req['Content-Type'] = 'application/json'
-      req.body = json_body
-      req['Authorization'] = "Bearer #{@token}" if @token
-
-      http.request(req) do |response|
-        response.read_body do |chunk|
-          block_given? ? yield(chunk) : debug_print(chunk)
-          $stdout.flush
-        end
-      end
-    end
-
-    def execute_tool(name, args)
-      case name
-      when 'execute_command'
-        result = run_cmd(args['command'])
-        debug_print(result) unless result[:ok]
-        result[:output]
-      when 'ask_user'
-        puts("\u001b[35m#{args['question']}\u001b[0m")
-        print "> "
-        "User: #{STDIN.gets&.chomp}"
-      end
-    end
-
-    def run_cmd(command)
-      stdout, stderr, status = Open3.capture3('/bin/sh', '-c', command)
-      { output: stdout + stderr, ok: status.success? }
-    end
-
-    def debug_print(body = nil)
-      @logger.debug(body) if @debug && body
-    end
-  end
 end
elelem.gemspec
@@ -37,5 +37,6 @@ Gem::Specification.new do |spec|
   spec.add_dependency 'logger'
   spec.add_dependency 'net-http'
   spec.add_dependency 'open3'
+  spec.add_dependency 'thor'
   spec.add_dependency 'uri'
 end
Gemfile.lock
@@ -6,6 +6,7 @@ PATH
       logger
       net-http
       open3
+      thor
       uri
 
 GEM
@@ -41,8 +42,6 @@ GEM
     racc (1.8.1)
     rainbow (3.1.1)
     rake (13.3.0)
-    rake-compiler (1.3.0)
-      rake
     rdoc (6.14.2)
       erb
       psych (>= 4.0.0)
@@ -78,6 +77,7 @@ GEM
       prism (~> 1.4)
     ruby-progressbar (1.13.0)
     stringio (3.1.7)
+    thor (1.3.2)
     unicode-display_width (3.1.4)
       unicode-emoji (~> 4.0, >= 4.0.4)
     unicode-emoji (4.0.4)
@@ -91,7 +91,6 @@ DEPENDENCIES
   elelem!
   irb
   rake (~> 13.0)
-  rake-compiler
   rspec (~> 3.0)
   rubocop (~> 1.21)