Commit 0fd1348

mo khan <mo@mokhan.ca>
2025-08-08 15:26:51
chore: install tool and basic repl script
1 parent 9d40d73
bin/setup
@@ -6,3 +6,4 @@ set -vx
 bundle install
 
 # Do any other automated setup that you need to do here
+mise install
exe/elelem
@@ -1,3 +1,6 @@
 #!/usr/bin/env ruby
+# frozen_string_literal: true
 
 require "elelem"
+
+Elelem::Agent.new.repl
lib/elelem.rb
@@ -5,4 +5,164 @@ require_relative "elelem/elelem"
 
 module Elelem
   class Error < StandardError; end
+
+  def env(k, d = nil)
+    ENV.fetch(k, d)
+  end
+
+  class Agent
+    attr_reader :tools, :http
+
+    def initialize
+      @host   = env('OLLAMA_HOST', 'localhost:11434')
+      @model  = env('OLLAMA_MODEL', 'gpt-oss')
+      @token  = env('OLLAMA_API_KEY', nil)
+      @debug  = env('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
@@ -38,6 +38,11 @@ Gem::Specification.new do |spec|
   # Uncomment to register a new dependency of your gem
   # spec.add_dependency "example-gem", "~> 1.0"
   spec.add_dependency "rb_sys", "~> 0.9.91"
+  spec.add_dependency 'json'
+  spec.add_dependency 'logger'
+  spec.add_dependency 'net-http'
+  spec.add_dependency 'open3'
+  spec.add_dependency 'uri'
 
   # For more information and examples about making a new gem, check out our
   # guide at: https://bundler.io/guides/creating_gem.html
Gemfile.lock
@@ -2,7 +2,12 @@ PATH
   remote: .
   specs:
     elelem (0.1.0)
+      json
+      logger
+      net-http
+      open3
       rb_sys (~> 0.9.91)
+      uri
 
 GEM
   remote: https://rubygems.org/
@@ -19,6 +24,10 @@ GEM
     json (2.13.2)
     language_server-protocol (3.17.0.5)
     lint_roller (1.1.0)
+    logger (1.7.0)
+    net-http (0.6.0)
+      uri
+    open3 (0.2.1)
     parallel (1.27.0)
     parser (3.3.9.0)
       ast (~> 2.4.1)
@@ -76,6 +85,7 @@ GEM
     unicode-display_width (3.1.4)
       unicode-emoji (~> 4.0, >= 4.0.4)
     unicode-emoji (4.0.4)
+    uri (1.0.3)
 
 PLATFORMS
   ruby
mise.toml
@@ -0,0 +1,3 @@
+[tools]
+ruby = "latest"
+rust = "latest"