Comparing changes
v0.1.1
→
v0.1.2
26 commits
20 files changed
Commits
Changed files (20)
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)