Commit fc01784
Changed files (20)
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).