elelem 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 139cfa01fc1cb7c7a7d7c7b86fa72d49027dfeead68754dfd1be3d864228dbff
4
- data.tar.gz: ee500832246d4a8c6e9c5c6aab4daadbbfe9b4c1724afe771de310a3dc402eb3
3
+ metadata.gz: 9d4a99b7addd5861f402c297ecfd19bc52e8edc6bba89422c2dec195a8cdcd13
4
+ data.tar.gz: d663221598cb4a843879ae191d6b7d8f62318be15d121803f58f752cecc79bbc
5
5
  SHA512:
6
- metadata.gz: 920741eb6faf5bcfe67b78c62e65496012919a65d52b1919eefea0bedafd11f65de399c16d9bd6b3d812f2d21fd47303c8145267b9368d15cdba741a92dd50ef
7
- data.tar.gz: e95155995fe0e21242bef5cd574699a47025b79064857183617b8d99c6d309885a226851db714ab2c8cb4b0a053055821b1f6b013df8e33b97034b0cd05bc270
6
+ metadata.gz: cdc7ccfb94de895a32f5be4cc310f23ca781119628fcbec4bd95a50df2c5b0eb3caa474c3a9058b6120c744e56f53001eb8c67b09cad7934f9b03292202e3c88
7
+ data.tar.gz: 9732e722f6d1c040d4c497b6826bd53ac425ad129befdd37da0e7ecd60b9bf4e6c96464a44f89a4cd118ca79b5b675ac6190ebf157c21bf4d06aa8ced13507f9
data/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.1.2] - 2025-08-14
4
+
5
+ ### Fixed
6
+ - Fixed critical bug where bash tool had nested parameters schema causing tool calls to fail with "no implicit conversion of nil into String" error
7
+
3
8
  ## [0.1.1] - 2025-08-12
4
9
 
5
10
  ### Fixed
data/lib/elelem/agent.rb CHANGED
@@ -2,14 +2,19 @@
2
2
 
3
3
  module Elelem
4
4
  class Agent
5
- attr_reader :api, :conversation, :logger
5
+ attr_reader :api, :conversation, :logger, :model, :tui
6
6
 
7
7
  def initialize(configuration)
8
8
  @api = configuration.api
9
+ @tui = configuration.tui
9
10
  @configuration = configuration
11
+ @model = configuration.model
10
12
  @conversation = configuration.conversation
11
13
  @logger = configuration.logger
12
- transition_to(Idle.new)
14
+
15
+ at_exit { cleanup }
16
+
17
+ transition_to(States::Idle.new)
13
18
  end
14
19
 
15
20
  def repl
@@ -23,14 +28,6 @@ module Elelem
23
28
  @current_state = next_state
24
29
  end
25
30
 
26
- def prompt(message)
27
- configuration.tui.prompt(message)
28
- end
29
-
30
- def say(message, colour: :default, newline: false)
31
- configuration.tui.say(message, colour: colour, newline: newline)
32
- end
33
-
34
31
  def execute(tool_call)
35
32
  logger.debug("Execute: #{tool_call}")
36
33
  configuration.tools.execute(tool_call)
@@ -38,9 +35,15 @@ module Elelem
38
35
 
39
36
  def quit
40
37
  logger.debug("Exiting...")
38
+ cleanup
41
39
  exit
42
40
  end
43
41
 
42
+ def cleanup
43
+ logger.debug("Cleaning up agent...")
44
+ configuration.cleanup
45
+ end
46
+
44
47
  private
45
48
 
46
49
  attr_reader :configuration, :current_state
data/lib/elelem/api.rb CHANGED
@@ -8,7 +8,7 @@ module Elelem
8
8
  @configuration = configuration
9
9
  end
10
10
 
11
- def chat(messages)
11
+ def chat(messages, &block)
12
12
  body = {
13
13
  messages: messages,
14
14
  model: configuration.model,
@@ -28,9 +28,7 @@ module Elelem
28
28
  configuration.http.request(req) do |response|
29
29
  raise response.inspect unless response.code == "200"
30
30
 
31
- response.read_body do |chunk|
32
- yield(chunk)
33
- end
31
+ response.read_body(&block)
34
32
  end
35
33
  end
36
34
  end
@@ -4,29 +4,29 @@ module Elelem
4
4
  class Application < Thor
5
5
  desc "chat", "Start the REPL"
6
6
  method_option :help,
7
- aliases: "-h",
8
- type: :boolean,
9
- desc: "Display usage information"
7
+ aliases: "-h",
8
+ type: :boolean,
9
+ desc: "Display usage information"
10
10
  method_option :host,
11
- aliases: "--host",
12
- type: :string,
13
- desc: "Ollama host",
14
- default: ENV.fetch("OLLAMA_HOST", "localhost:11434")
11
+ aliases: "--host",
12
+ type: :string,
13
+ desc: "Ollama host",
14
+ default: ENV.fetch("OLLAMA_HOST", "localhost:11434")
15
15
  method_option :model,
16
- aliases: "--model",
17
- type: :string,
18
- desc: "Ollama model",
19
- default: ENV.fetch("OLLAMA_MODEL", "gpt-oss")
16
+ aliases: "--model",
17
+ type: :string,
18
+ desc: "Ollama model",
19
+ default: ENV.fetch("OLLAMA_MODEL", "gpt-oss")
20
20
  method_option :token,
21
- aliases: "--token",
22
- type: :string,
23
- desc: "Ollama token",
24
- default: ENV.fetch("OLLAMA_API_KEY", nil)
21
+ aliases: "--token",
22
+ type: :string,
23
+ desc: "Ollama token",
24
+ default: ENV.fetch("OLLAMA_API_KEY", nil)
25
25
  method_option :debug,
26
- aliases: "--debug",
27
- type: :boolean,
28
- desc: "Debug mode",
29
- default: false
26
+ aliases: "--debug",
27
+ type: :boolean,
28
+ desc: "Debug mode",
29
+ default: false
30
30
  def chat(*)
31
31
  if options[:help]
32
32
  invoke :help, ["chat"]
@@ -37,17 +37,17 @@ module Elelem
37
37
  token: options[:token],
38
38
  debug: options[:debug]
39
39
  )
40
- say "Ollama Agent (#{configuration.model})", :green
41
- say "Tools:\n #{configuration.tools.banner}", :green
40
+ say "Agent (#{configuration.model})", :green
41
+ say configuration.tools.banner.to_s, :green
42
42
 
43
43
  agent = Agent.new(configuration)
44
44
  agent.repl
45
45
  end
46
46
  end
47
47
 
48
- desc "version", "spandx version"
48
+ desc "version", "The version of this CLI"
49
49
  def version
50
- puts "v#{Spandx::VERSION}"
50
+ say "v#{Elelem::VERSION}"
51
51
  end
52
52
  map %w[--version -v] => :version
53
53
  end
@@ -28,7 +28,7 @@ module Elelem
28
28
 
29
29
  def logger
30
30
  @logger ||= Logger.new(debug ? "elelem.log" : "/dev/null").tap do |logger|
31
- logger.formatter = ->(_, _, _, message) { message.strip + "\n" }
31
+ logger.formatter = ->(_, _, _, message) { "#{message.to_s.strip}\n" }
32
32
  end
33
33
  end
34
34
 
@@ -37,11 +37,22 @@ module Elelem
37
37
  end
38
38
 
39
39
  def conversation
40
- @conversation ||= Conversation.new
40
+ @conversation ||= Conversation.new.tap do |conversation|
41
+ resources = mcp_clients.map do |client|
42
+ client.resources.map do |resource|
43
+ resource["uri"]
44
+ end
45
+ end.flatten
46
+ conversation.add(role: :tool, content: resources)
47
+ end
41
48
  end
42
49
 
43
50
  def tools
44
- @tools ||= Tools.new
51
+ @tools ||= Tools.new(self, [Toolbox::Bash.new(self)] + mcp_tools)
52
+ end
53
+
54
+ def cleanup
55
+ @mcp_clients&.each(&:shutdown)
45
56
  end
46
57
 
47
58
  private
@@ -49,5 +60,24 @@ module Elelem
49
60
  def scheme
50
61
  host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? "http" : "https"
51
62
  end
63
+
64
+ def mcp_tools
65
+ @mcp_tools ||= mcp_clients.map do |client|
66
+ client.tools.map do |tool|
67
+ Toolbox::MCP.new(client, tui, tool)
68
+ end
69
+ end.flatten
70
+ end
71
+
72
+ def mcp_clients
73
+ @mcp_clients ||= begin
74
+ config = Pathname.pwd.join(".mcp.json")
75
+ return [] unless config.exist?
76
+
77
+ JSON.parse(config.read).map do |_key, value|
78
+ MCPClient.new(self, [value["command"]] + value["args"])
79
+ end
80
+ end
81
+ end
52
82
  end
53
83
  end
@@ -2,16 +2,9 @@
2
2
 
3
3
  module Elelem
4
4
  class Conversation
5
- SYSTEM_MESSAGE = <<~SYS
6
- You are ChatGPT, a helpful assistant with reasoning capabilities.
7
- Current date: #{Time.now.strftime("%Y-%m-%d")}.
8
- System info: `uname -a` output: #{`uname -a`.strip}
9
- Reasoning: high
10
- SYS
5
+ ROLES = %i[system assistant user tool].freeze
11
6
 
12
- ROLES = [:system, :assistant, :user, :tool].freeze
13
-
14
- def initialize(items = [{ role: "system", content: SYSTEM_MESSAGE }])
7
+ def initialize(items = [{ role: "system", content: system_prompt }])
15
8
  @items = items
16
9
  end
17
10
 
@@ -23,12 +16,26 @@ module Elelem
23
16
  def add(role: :user, content: "")
24
17
  role = role.to_sym
25
18
  raise "unknown role: #{role}" unless ROLES.include?(role)
26
- return if content.empty?
19
+ return if content.nil? || content.empty?
27
20
 
28
21
  if @items.last && @items.last[:role] == role
29
22
  @items.last[:content] += content
30
23
  else
31
- @items.push({ role: role, content: content })
24
+ @items.push({ role: role, content: normalize(content) })
25
+ end
26
+ end
27
+
28
+ private
29
+
30
+ def system_prompt
31
+ ERB.new(Pathname.new(__dir__).join("system_prompt.erb").read).result(binding)
32
+ end
33
+
34
+ def normalize(content)
35
+ if content.is_a?(Array)
36
+ content.join(", ")
37
+ else
38
+ content.to_s
32
39
  end
33
40
  end
34
41
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class MCPClient
5
+ attr_reader :tools, :resources
6
+
7
+ def initialize(configuration, command = [])
8
+ @configuration = configuration
9
+ @stdin, @stdout, @stderr, @worker = Open3.popen3(*command, pgroup: true)
10
+
11
+ # 1. Send initialize request
12
+ send_request(
13
+ method: "initialize",
14
+ params: {
15
+ protocolVersion: "2025-06-08",
16
+ capabilities: {
17
+ tools: {}
18
+ },
19
+ clientInfo: {
20
+ name: "Elelem",
21
+ version: Elelem::VERSION
22
+ }
23
+ }
24
+ )
25
+
26
+ # 2. Send initialized notification (optional for some MCP servers)
27
+ send_notification(method: "notifications/initialized")
28
+
29
+ # 3. Now we can request tools
30
+ @tools = send_request(method: "tools/list")&.dig("tools") || []
31
+ @resources = send_request(method: "resources/list")&.dig("resources") || []
32
+ end
33
+
34
+ def connected?
35
+ return false unless @worker&.alive?
36
+ return false unless @stdin && !@stdin.closed?
37
+ return false unless @stdout && !@stdout.closed?
38
+
39
+ begin
40
+ Process.getpgid(@worker.pid)
41
+ true
42
+ rescue Errno::ESRCH
43
+ false
44
+ end
45
+ end
46
+
47
+ def call(name, arguments = {})
48
+ send_request(
49
+ method: "tools/call",
50
+ params: {
51
+ name: name,
52
+ arguments: arguments
53
+ }
54
+ )
55
+ end
56
+
57
+ def shutdown
58
+ return unless connected?
59
+
60
+ configuration.logger.debug("Shutting down MCP client")
61
+
62
+ [@stdin, @stdout, @stderr].each do |stream|
63
+ stream&.close unless stream&.closed?
64
+ end
65
+
66
+ return unless @worker&.alive?
67
+
68
+ begin
69
+ Process.kill("TERM", @worker.pid)
70
+ # Give it 2 seconds to terminate gracefully
71
+ Timeout.timeout(2) { @worker.value }
72
+ rescue Timeout::Error
73
+ # Force kill if it doesn't respond
74
+ begin
75
+ Process.kill("KILL", @worker.pid)
76
+ rescue StandardError
77
+ nil
78
+ end
79
+ rescue Errno::ESRCH
80
+ # Process already dead
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ attr_reader :stdin, :stdout, :stderr, :worker, :configuration
87
+
88
+ def send_request(method:, params: {})
89
+ return {} unless connected?
90
+
91
+ request = {
92
+ jsonrpc: "2.0",
93
+ id: Time.now.to_i,
94
+ method: method
95
+ }
96
+ request[:params] = params unless params.empty?
97
+ configuration.logger.debug(JSON.pretty_generate(request))
98
+
99
+ @stdin.puts(JSON.generate(request))
100
+ @stdin.flush
101
+
102
+ response_line = @stdout.gets&.strip
103
+ return {} if response_line.nil? || response_line.empty?
104
+
105
+ response = JSON.parse(response_line)
106
+ configuration.logger.debug(JSON.pretty_generate(response))
107
+
108
+ if response["error"]
109
+ configuration.logger.error(response["error"]["message"])
110
+ { error: response["error"]["message"] }
111
+ else
112
+ response["result"]
113
+ end
114
+ end
115
+
116
+ def send_notification(method:, params: {})
117
+ return unless connected?
118
+
119
+ notification = {
120
+ jsonrpc: "2.0",
121
+ method: method
122
+ }
123
+ notification[:params] = params unless params.empty?
124
+ configuration.logger.debug("Sending notification: #{JSON.pretty_generate(notification)}")
125
+ @stdin.puts(JSON.generate(notification))
126
+ @stdin.flush
127
+
128
+ response_line = @stdout.gets&.strip
129
+ return {} if response_line.nil? || response_line.empty?
130
+
131
+ response = JSON.parse(response_line)
132
+ configuration.logger.debug(JSON.pretty_generate(response))
133
+ response
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module States
5
+ class Idle
6
+ def run(agent)
7
+ agent.logger.debug("Idling...")
8
+ agent.tui.say("#{Dir.pwd} (#{agent.model}) [#{git_branch}]", colour: :magenta, newline: true)
9
+ input = agent.tui.prompt("モ ")
10
+ agent.quit if input.nil? || input.empty? || input == "exit" || input == "quit"
11
+
12
+ agent.conversation.add(role: :user, content: input)
13
+ agent.transition_to(Working)
14
+ end
15
+
16
+ private
17
+
18
+ def git_branch
19
+ `git branch --no-color --show-current --no-abbrev`.strip
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module States
5
+ module Working
6
+ class Error < State
7
+ def initialize(agent, error_message)
8
+ super(agent, "X", :red)
9
+ @error_message = error_message
10
+ end
11
+
12
+ def process(_message)
13
+ agent.tui.say("\nTool execution failed: #{@error_message}", colour: :red)
14
+ Waiting.new(agent)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module States
5
+ module Working
6
+ class Executing < State
7
+ def process(message)
8
+ if message["tool_calls"]&.any?
9
+ message["tool_calls"].each do |tool_call|
10
+ agent.conversation.add(role: :tool, content: agent.execute(tool_call))
11
+ end
12
+ end
13
+
14
+ Waiting.new(agent)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module States
5
+ module Working
6
+ class State
7
+ attr_reader :agent
8
+
9
+ def initialize(agent, icon, colour)
10
+ @agent = agent
11
+
12
+ agent.logger.debug("#{display_name}...")
13
+ agent.tui.show_progress("#{display_name}...", icon, colour: colour)
14
+ end
15
+
16
+ def run(message)
17
+ process(message)
18
+ end
19
+
20
+ def display_name
21
+ self.class.name.split("::").last
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module States
5
+ module Working
6
+ class Talking < State
7
+ def process(message)
8
+ if message["content"] && !message["content"]&.empty?
9
+ agent.conversation.add(role: message["role"], content: message["content"])
10
+ agent.tui.say(message["content"], colour: :default, newline: false)
11
+ self
12
+ else
13
+ Waiting.new(agent).process(message)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module States
5
+ module Working
6
+ class Thinking < State
7
+ def process(message)
8
+ if message["thinking"] && !message["thinking"]&.empty?
9
+ agent.tui.say(message["thinking"], colour: :gray, newline: false)
10
+ self
11
+ else
12
+ Waiting.new(agent).process(message)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module States
5
+ module Working
6
+ class Waiting < State
7
+ def initialize(agent)
8
+ super(agent, ".", :cyan)
9
+ end
10
+
11
+ def process(message)
12
+ state_for(message)&.process(message)
13
+ end
14
+
15
+ private
16
+
17
+ def state_for(message)
18
+ if message["thinking"] && !message["thinking"].empty?
19
+ Thinking.new(agent, "*", :yellow)
20
+ elsif message["tool_calls"]&.any?
21
+ Executing.new(agent, ">", :magenta)
22
+ elsif message["content"] && !message["content"].empty?
23
+ Talking.new(agent, "~", :white)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module States
5
+ module Working
6
+ class << self
7
+ def run(agent)
8
+ done = false
9
+ state = Waiting.new(agent)
10
+
11
+ loop do
12
+ agent.api.chat(agent.conversation.history) do |chunk|
13
+ response = JSON.parse(chunk)
14
+ message = normalize(response["message"] || {})
15
+ done = response["done"]
16
+
17
+ agent.logger.debug("#{state.display_name}: #{message}")
18
+ state = state.run(message)
19
+ end
20
+
21
+ break if state.nil?
22
+ break if done && agent.conversation.history.last[:role] != :tool
23
+ end
24
+
25
+ agent.transition_to(States::Idle.new)
26
+ end
27
+
28
+ def normalize(message)
29
+ message.reject { |_key, value| value.empty? }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,7 @@
1
+ **Shell Master** — bash>code; compose>write; pipe everything; /proc/sys native; automate fast; streams/transforms; POSIX+GNU; man(1) first; no cleverness.
2
+
3
+ Time: `<%= Time.now.strftime("%Y-%m-%d %H:%M:%S") %>`
4
+ Project Directory: `<%= Dir.pwd %>`
5
+ System Info: `<%= `uname -a`.strip %>`
6
+
7
+ Ready to hack.
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Tool
5
+ attr_reader :name, :description, :parameters
6
+
7
+ def initialize(name, description, parameters)
8
+ @name = name
9
+ @description = description
10
+ @parameters = parameters
11
+ end
12
+
13
+ def banner
14
+ [name, parameters].join(": ")
15
+ end
16
+
17
+ def valid?(args)
18
+ JSON::Validator.validate(parameters, args, insert_defaults: true)
19
+ end
20
+
21
+ def to_h
22
+ {
23
+ type: "function",
24
+ function: {
25
+ name: name,
26
+ description: description,
27
+ parameters: parameters
28
+ }
29
+ }
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Toolbox
5
+ class Bash < ::Elelem::Tool
6
+ attr_reader :tui
7
+
8
+ def initialize(configuration)
9
+ @tui = configuration.tui
10
+ super("bash", "Run commands in /bin/bash -c. Full access to filesystem, network, processes, and all Unix tools.", {
11
+ type: "object",
12
+ properties: {
13
+ command: { type: "string" }
14
+ },
15
+ required: ["command"]
16
+ })
17
+ end
18
+
19
+ def call(args)
20
+ command = args["command"]
21
+ output_buffer = []
22
+
23
+ Open3.popen3("/bin/bash", "-c", command) do |stdin, stdout, stderr, wait_thread|
24
+ stdin.close
25
+ streams = [stdout, stderr]
26
+
27
+ until streams.empty?
28
+ ready = IO.select(streams, nil, nil, 0.1)
29
+
30
+ if ready
31
+ ready[0].each do |io|
32
+ data = io.read_nonblock(4096)
33
+ output_buffer << data
34
+
35
+ if io == stderr
36
+ tui.say(data, colour: :red, newline: false)
37
+ else
38
+ tui.say(data, newline: false)
39
+ end
40
+ rescue IO::WaitReadable
41
+ next
42
+ rescue EOFError
43
+ streams.delete(io)
44
+ end
45
+ elsif !wait_thread.alive?
46
+ break
47
+ end
48
+ end
49
+
50
+ wait_thread.value
51
+ end
52
+
53
+ output_buffer.join
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Toolbox
5
+ class MCP < ::Elelem::Tool
6
+ attr_reader :client, :tui
7
+
8
+ def initialize(client, tui, tool)
9
+ @client = client
10
+ @tui = tui
11
+ super(tool["name"], tool["description"], tool["inputSchema"] || {})
12
+ end
13
+
14
+ def call(args)
15
+ unless client.connected?
16
+ tui.say("MCP connection lost", colour: :red)
17
+ return ""
18
+ end
19
+
20
+ result = client.call(name, args)
21
+ tui.say(JSON.pretty_generate(result), newline: true)
22
+
23
+ if result.nil? || result.empty?
24
+ tui.say("Tool call failed: no response from MCP server", colour: :red)
25
+ return result
26
+ end
27
+
28
+ if result["error"]
29
+ tui.say(result["error"], colour: :red)
30
+ return result
31
+ end
32
+
33
+ result.dig("content", 0, "text") || result.to_s
34
+ end
35
+ end
36
+ end
37
+ end
data/lib/elelem/tools.rb CHANGED
@@ -2,61 +2,34 @@
2
2
 
3
3
  module Elelem
4
4
  class Tools
5
- DEFAULT_TOOLS = [
6
- {
7
- type: "function",
8
- function: {
9
- name: "execute_command",
10
- description: "Execute a shell command.",
11
- parameters: {
12
- type: "object",
13
- properties: {
14
- command: { type: "string" },
15
- },
16
- required: ["command"]
17
- }
18
- },
19
- handler: lambda { |args|
20
- stdout, stderr, _status = Open3.capture3("/bin/sh", "-c", args["command"])
21
- stdout + stderr
22
- }
23
- },
24
- ]
25
-
26
- def initialize(tools = DEFAULT_TOOLS)
5
+ def initialize(configuration, tools)
6
+ @configuration = configuration
27
7
  @tools = tools
28
8
  end
29
9
 
30
10
  def banner
31
- @tools.map do |h|
32
- [
33
- h.dig(:function, :name),
34
- h.dig(:function, :description)
35
- ].join(": ")
36
- end.sort.join("\n ")
11
+ tools.map(&:banner).sort.join("\n ")
37
12
  end
38
13
 
39
14
  def execute(tool_call)
40
15
  name = tool_call.dig("function", "name")
41
16
  args = tool_call.dig("function", "arguments")
42
17
 
43
- tool = @tools.find do |tool|
44
- tool.dig(:function, :name) == name
18
+ tool = tools.find { |tool| tool.name == name }
19
+ return "Invalid function name: #{name}" if tool.nil?
20
+ return "Invalid function arguments: #{args}" unless tool.valid?(args)
21
+
22
+ CLI::UI::Frame.open(name) do
23
+ tool.call(args)
45
24
  end
46
- tool&.fetch(:handler)&.call(args)
47
25
  end
48
26
 
49
27
  def to_h
50
- @tools.map do |tool|
51
- {
52
- type: tool[:type],
53
- function: {
54
- name: tool.dig(:function, :name),
55
- description: tool.dig(:function, :description),
56
- parameters: tool.dig(:function, :parameters)
57
- }
58
- }
59
- end
28
+ tools.map(&:to_h)
60
29
  end
30
+
31
+ private
32
+
33
+ attr_reader :configuration, :tools
61
34
  end
62
35
  end
data/lib/elelem/tui.rb CHANGED
@@ -10,24 +10,52 @@ module Elelem
10
10
  end
11
11
 
12
12
  def prompt(message)
13
- say(message)
14
- stdin.gets&.chomp
13
+ Reline.readline(message, true)
15
14
  end
16
15
 
17
16
  def say(message, colour: :default, newline: false)
18
- formatted_message = colourize(message, colour: colour)
19
17
  if newline
20
- stdout.puts(formatted_message)
18
+ stdout.puts(colourize(message, colour: colour))
21
19
  else
22
- stdout.print(formatted_message)
20
+ stdout.print(colourize(message, colour: colour))
23
21
  end
24
22
  stdout.flush
25
23
  end
26
24
 
25
+ def show_progress(message, icon = ".", colour: :gray)
26
+ timestamp = Time.now.strftime("%H:%M:%S")
27
+ say("\n[#{icon}] #{timestamp} #{message}", colour: colour, newline: true)
28
+ end
29
+
30
+ def clear_line
31
+ say("\r#{" " * 80}\r", newline: false)
32
+ end
33
+
34
+ def complete_progress(message = "Completed")
35
+ clear_line
36
+ show_progress(message, "✓", colour: :green)
37
+ end
38
+
27
39
  private
28
40
 
29
41
  def colourize(text, colour: :default)
30
42
  case colour
43
+ when :black
44
+ "\e[30m#{text}\e[0m"
45
+ when :red
46
+ "\e[31m#{text}\e[0m"
47
+ when :green
48
+ "\e[32m#{text}\e[0m"
49
+ when :yellow
50
+ "\e[33m#{text}\e[0m"
51
+ when :blue
52
+ "\e[34m#{text}\e[0m"
53
+ when :magenta
54
+ "\e[35m#{text}\e[0m"
55
+ when :cyan
56
+ "\e[36m#{text}\e[0m"
57
+ when :white
58
+ "\e[37m#{text}\e[0m"
31
59
  when :gray
32
60
  "\e[90m#{text}\e[0m"
33
61
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Elelem
4
- VERSION = "0.1.1"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/elelem.rb CHANGED
@@ -1,10 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cli/ui"
4
+ require "erb"
3
5
  require "json"
6
+ require "json-schema"
4
7
  require "logger"
5
8
  require "net/http"
6
9
  require "open3"
10
+ require "reline"
7
11
  require "thor"
12
+ require "timeout"
8
13
  require "uri"
9
14
 
10
15
  require_relative "elelem/agent"
@@ -12,11 +17,26 @@ require_relative "elelem/api"
12
17
  require_relative "elelem/application"
13
18
  require_relative "elelem/configuration"
14
19
  require_relative "elelem/conversation"
15
- require_relative "elelem/state"
20
+ require_relative "elelem/mcp_client"
21
+ require_relative "elelem/states/idle"
22
+ require_relative "elelem/states/working"
23
+ require_relative "elelem/states/working/state"
24
+ require_relative "elelem/states/working/error"
25
+ require_relative "elelem/states/working/executing"
26
+ require_relative "elelem/states/working/talking"
27
+ require_relative "elelem/states/working/thinking"
28
+ require_relative "elelem/states/working/waiting"
29
+ require_relative "elelem/tool"
30
+ require_relative "elelem/toolbox/bash"
31
+ require_relative "elelem/toolbox/mcp"
16
32
  require_relative "elelem/tools"
17
33
  require_relative "elelem/tui"
18
34
  require_relative "elelem/version"
19
35
 
36
+ CLI::UI::StdoutRouter.enable
37
+ Reline.input = $stdin
38
+ Reline.output = $stdout
39
+
20
40
  module Elelem
21
41
  class Error < StandardError; end
22
42
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: elelem
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.1
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - mo khan
@@ -9,6 +9,34 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: cli-ui
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: erb
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
12
40
  - !ruby/object:Gem::Dependency
13
41
  name: json
14
42
  requirement: !ruby/object:Gem::Requirement
@@ -23,6 +51,20 @@ dependencies:
23
51
  - - ">="
24
52
  - !ruby/object:Gem::Version
25
53
  version: '0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: json-schema
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '0'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '0'
26
68
  - !ruby/object:Gem::Dependency
27
69
  name: logger
28
70
  requirement: !ruby/object:Gem::Requirement
@@ -65,6 +107,20 @@ dependencies:
65
107
  - - ">="
66
108
  - !ruby/object:Gem::Version
67
109
  version: '0'
110
+ - !ruby/object:Gem::Dependency
111
+ name: reline
112
+ requirement: !ruby/object:Gem::Requirement
113
+ requirements:
114
+ - - ">="
115
+ - !ruby/object:Gem::Version
116
+ version: '0'
117
+ type: :runtime
118
+ prerelease: false
119
+ version_requirements: !ruby/object:Gem::Requirement
120
+ requirements:
121
+ - - ">="
122
+ - !ruby/object:Gem::Version
123
+ version: '0'
68
124
  - !ruby/object:Gem::Dependency
69
125
  name: thor
70
126
  requirement: !ruby/object:Gem::Requirement
@@ -79,6 +135,20 @@ dependencies:
79
135
  - - ">="
80
136
  - !ruby/object:Gem::Version
81
137
  version: '0'
138
+ - !ruby/object:Gem::Dependency
139
+ name: timeout
140
+ requirement: !ruby/object:Gem::Requirement
141
+ requirements:
142
+ - - ">="
143
+ - !ruby/object:Gem::Version
144
+ version: '0'
145
+ type: :runtime
146
+ prerelease: false
147
+ version_requirements: !ruby/object:Gem::Requirement
148
+ requirements:
149
+ - - ">="
150
+ - !ruby/object:Gem::Version
151
+ version: '0'
82
152
  - !ruby/object:Gem::Dependency
83
153
  name: uri
84
154
  requirement: !ruby/object:Gem::Requirement
@@ -101,8 +171,6 @@ executables:
101
171
  extensions: []
102
172
  extra_rdoc_files: []
103
173
  files:
104
- - ".rspec"
105
- - ".rubocop.yml"
106
174
  - CHANGELOG.md
107
175
  - LICENSE.txt
108
176
  - README.md
@@ -114,11 +182,22 @@ files:
114
182
  - lib/elelem/application.rb
115
183
  - lib/elelem/configuration.rb
116
184
  - lib/elelem/conversation.rb
117
- - lib/elelem/state.rb
185
+ - lib/elelem/mcp_client.rb
186
+ - lib/elelem/states/idle.rb
187
+ - lib/elelem/states/working.rb
188
+ - lib/elelem/states/working/error.rb
189
+ - lib/elelem/states/working/executing.rb
190
+ - lib/elelem/states/working/state.rb
191
+ - lib/elelem/states/working/talking.rb
192
+ - lib/elelem/states/working/thinking.rb
193
+ - lib/elelem/states/working/waiting.rb
194
+ - lib/elelem/system_prompt.erb
195
+ - lib/elelem/tool.rb
196
+ - lib/elelem/toolbox/bash.rb
197
+ - lib/elelem/toolbox/mcp.rb
118
198
  - lib/elelem/tools.rb
119
199
  - lib/elelem/tui.rb
120
200
  - lib/elelem/version.rb
121
- - mise.toml
122
201
  - sig/elelem.rbs
123
202
  homepage: https://www.mokhan.ca
124
203
  licenses:
@@ -135,7 +214,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
135
214
  requirements:
136
215
  - - ">="
137
216
  - !ruby/object:Gem::Version
138
- version: 3.1.0
217
+ version: 3.4.0
139
218
  required_rubygems_version: !ruby/object:Gem::Requirement
140
219
  requirements:
141
220
  - - ">="
data/.rspec DELETED
@@ -1,3 +0,0 @@
1
- --format documentation
2
- --color
3
- --require spec_helper
data/.rubocop.yml DELETED
@@ -1,8 +0,0 @@
1
- AllCops:
2
- TargetRubyVersion: 3.1
3
-
4
- Style/StringLiterals:
5
- EnforcedStyle: double_quotes
6
-
7
- Style/StringLiteralsInInterpolation:
8
- EnforcedStyle: double_quotes
data/lib/elelem/state.rb DELETED
@@ -1,111 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Elelem
4
- class Idle
5
- def run(agent)
6
- agent.logger.debug("Idling...")
7
- input = agent.prompt("\n> ")
8
- agent.quit if input.nil? || input.empty? || input == "exit"
9
-
10
- agent.conversation.add(role: :user, content: input)
11
- agent.transition_to(Working.new)
12
- end
13
- end
14
-
15
- class Working
16
- class State
17
- attr_reader :agent
18
-
19
- def initialize(agent)
20
- @agent = agent
21
- end
22
-
23
- def display_name
24
- self.class.name.split("::").last
25
- end
26
- end
27
-
28
- class Waiting < State
29
- def process(message)
30
- state = self
31
-
32
- if message["thinking"] && !message["thinking"].empty?
33
- state = Thinking.new(agent)
34
- elsif message["tool_calls"]&.any?
35
- state = Executing.new(agent)
36
- elsif message["content"] && !message["content"].empty?
37
- state = Talking.new(agent)
38
- else
39
- state = nil
40
- end
41
-
42
- state&.process(message)
43
- end
44
- end
45
-
46
- class Thinking < State
47
- def process(message)
48
- if message["thinking"] && !message["thinking"]&.empty?
49
- agent.say(message["thinking"], colour: :gray, newline: false)
50
- self
51
- else
52
- agent.say("", newline: true)
53
- Waiting.new(agent).process(message)
54
- end
55
- end
56
- end
57
-
58
- class Executing < State
59
- def process(message)
60
- if message["tool_calls"]&.any?
61
- message["tool_calls"].each do |tool_call|
62
- agent.conversation.add(role: :tool, content: agent.execute(tool_call))
63
- end
64
- end
65
-
66
- Waiting.new(agent)
67
- end
68
- end
69
-
70
- class Talking < State
71
- def process(message)
72
- if message["content"] && !message["content"]&.empty?
73
- agent.conversation.add(role: message["role"], content: message["content"])
74
- agent.say(message["content"], colour: :default, newline: false)
75
- self
76
- else
77
- agent.say("", newline: true)
78
- Waiting.new(agent).process(message)
79
- end
80
- end
81
- end
82
-
83
- def run(agent)
84
- agent.logger.debug("Working...")
85
- state = Waiting.new(agent)
86
- done = false
87
-
88
- loop do
89
- agent.api.chat(agent.conversation.history) do |chunk|
90
- response = JSON.parse(chunk)
91
- message = normalize(response["message"] || {})
92
- done = response["done"]
93
-
94
- agent.logger.debug("#{state.display_name}: #{message}")
95
- state = state.process(message)
96
- end
97
-
98
- break if state.nil?
99
- break if done && agent.conversation.history.last[:role] != :tool
100
- end
101
-
102
- agent.transition_to(Idle.new)
103
- end
104
-
105
- private
106
-
107
- def normalize(message)
108
- message.reject { |_key, value| value.empty? }
109
- end
110
- end
111
- end
data/mise.toml DELETED
@@ -1,2 +0,0 @@
1
- [tools]
2
- ruby = "latest"