elelem 0.1.2 → 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 +4 -4
- data/exe/elelem +0 -3
- data/lib/elelem/agent.rb +12 -22
- data/lib/elelem/application.rb +2 -2
- data/lib/elelem/configuration.rb +28 -17
- data/lib/elelem/conversation.rb +9 -1
- data/lib/elelem/mcp_client.rb +40 -3
- data/lib/elelem/states/idle.rb +23 -0
- data/lib/elelem/states/working/error.rb +19 -0
- data/lib/elelem/states/working/executing.rb +19 -0
- data/lib/elelem/states/working/state.rb +26 -0
- data/lib/elelem/states/working/talking.rb +19 -0
- data/lib/elelem/states/working/thinking.rb +18 -0
- data/lib/elelem/states/working/waiting.rb +29 -0
- data/lib/elelem/states/working.rb +34 -0
- data/lib/elelem/system_prompt.erb +2 -2
- data/lib/elelem/tool.rb +0 -84
- data/lib/elelem/toolbox/bash.rb +57 -0
- data/lib/elelem/toolbox/mcp.rb +37 -0
- data/lib/elelem/tools.rb +3 -1
- data/lib/elelem/tui.rb +7 -18
- data/lib/elelem/version.rb +1 -1
- data/lib/elelem.rb +16 -1
- metadata +39 -2
- data/lib/elelem/state.rb +0 -162
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 9d4a99b7addd5861f402c297ecfd19bc52e8edc6bba89422c2dec195a8cdcd13
|
4
|
+
data.tar.gz: d663221598cb4a843879ae191d6b7d8f62318be15d121803f58f752cecc79bbc
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: cdc7ccfb94de895a32f5be4cc310f23ca781119628fcbec4bd95a50df2c5b0eb3caa474c3a9058b6120c744e56f53001eb8c67b09cad7934f9b03292202e3c88
|
7
|
+
data.tar.gz: 9732e722f6d1c040d4c497b6826bd53ac425ad129befdd37da0e7ecd60b9bf4e6c96464a44f89a4cd118ca79b5b675ac6190ebf157c21bf4d06aa8ced13507f9
|
data/exe/elelem
CHANGED
data/lib/elelem/agent.rb
CHANGED
@@ -2,15 +2,19 @@
|
|
2
2
|
|
3
3
|
module Elelem
|
4
4
|
class Agent
|
5
|
-
attr_reader :api, :conversation, :logger, :model
|
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
|
10
11
|
@model = configuration.model
|
11
12
|
@conversation = configuration.conversation
|
12
13
|
@logger = configuration.logger
|
13
|
-
|
14
|
+
|
15
|
+
at_exit { cleanup }
|
16
|
+
|
17
|
+
transition_to(States::Idle.new)
|
14
18
|
end
|
15
19
|
|
16
20
|
def repl
|
@@ -24,36 +28,22 @@ module Elelem
|
|
24
28
|
@current_state = next_state
|
25
29
|
end
|
26
30
|
|
27
|
-
def prompt(message)
|
28
|
-
configuration.tui.prompt(message)
|
29
|
-
end
|
30
|
-
|
31
|
-
def say(message, colour: :default, newline: false)
|
32
|
-
configuration.tui.say(message, colour: colour, newline: newline)
|
33
|
-
end
|
34
|
-
|
35
31
|
def execute(tool_call)
|
36
32
|
logger.debug("Execute: #{tool_call}")
|
37
33
|
configuration.tools.execute(tool_call)
|
38
34
|
end
|
39
35
|
|
40
|
-
def show_progress(message, prefix = "[.]", colour: :gray)
|
41
|
-
configuration.tui.show_progress(message, prefix, colour: colour)
|
42
|
-
end
|
43
|
-
|
44
|
-
def clear_line
|
45
|
-
configuration.tui.clear_line
|
46
|
-
end
|
47
|
-
|
48
|
-
def complete_progress(message = "Completed")
|
49
|
-
configuration.tui.complete_progress(message)
|
50
|
-
end
|
51
|
-
|
52
36
|
def quit
|
53
37
|
logger.debug("Exiting...")
|
38
|
+
cleanup
|
54
39
|
exit
|
55
40
|
end
|
56
41
|
|
42
|
+
def cleanup
|
43
|
+
logger.debug("Cleaning up agent...")
|
44
|
+
configuration.cleanup
|
45
|
+
end
|
46
|
+
|
57
47
|
private
|
58
48
|
|
59
49
|
attr_reader :configuration, :current_state
|
data/lib/elelem/application.rb
CHANGED
data/lib/elelem/configuration.rb
CHANGED
@@ -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(self, [
|
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
|
@@ -50,23 +61,23 @@ module Elelem
|
|
50
61
|
host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? "http" : "https"
|
51
62
|
end
|
52
63
|
|
53
|
-
def mcp_tools
|
54
|
-
|
55
|
-
|
56
|
-
|
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
|
57
70
|
end
|
58
71
|
|
59
|
-
def
|
60
|
-
|
61
|
-
"
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
"--project", Dir.pwd
|
69
|
-
])
|
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
|
70
81
|
end
|
71
82
|
end
|
72
83
|
end
|
data/lib/elelem/conversation.rb
CHANGED
@@ -21,7 +21,7 @@ module Elelem
|
|
21
21
|
if @items.last && @items.last[:role] == role
|
22
22
|
@items.last[:content] += content
|
23
23
|
else
|
24
|
-
@items.push({ role: role, content: content })
|
24
|
+
@items.push({ role: role, content: normalize(content) })
|
25
25
|
end
|
26
26
|
end
|
27
27
|
|
@@ -30,5 +30,13 @@ module Elelem
|
|
30
30
|
def system_prompt
|
31
31
|
ERB.new(Pathname.new(__dir__).join("system_prompt.erb").read).result(binding)
|
32
32
|
end
|
33
|
+
|
34
|
+
def normalize(content)
|
35
|
+
if content.is_a?(Array)
|
36
|
+
content.join(", ")
|
37
|
+
else
|
38
|
+
content.to_s
|
39
|
+
end
|
40
|
+
end
|
33
41
|
end
|
34
42
|
end
|
data/lib/elelem/mcp_client.rb
CHANGED
@@ -2,7 +2,7 @@
|
|
2
2
|
|
3
3
|
module Elelem
|
4
4
|
class MCPClient
|
5
|
-
attr_reader :tools
|
5
|
+
attr_reader :tools, :resources
|
6
6
|
|
7
7
|
def initialize(configuration, command = [])
|
8
8
|
@configuration = configuration
|
@@ -12,7 +12,7 @@ module Elelem
|
|
12
12
|
send_request(
|
13
13
|
method: "initialize",
|
14
14
|
params: {
|
15
|
-
protocolVersion: "
|
15
|
+
protocolVersion: "2025-06-08",
|
16
16
|
capabilities: {
|
17
17
|
tools: {}
|
18
18
|
},
|
@@ -23,11 +23,12 @@ module Elelem
|
|
23
23
|
}
|
24
24
|
)
|
25
25
|
|
26
|
-
# 2. Send initialized notification (
|
26
|
+
# 2. Send initialized notification (optional for some MCP servers)
|
27
27
|
send_notification(method: "notifications/initialized")
|
28
28
|
|
29
29
|
# 3. Now we can request tools
|
30
30
|
@tools = send_request(method: "tools/list")&.dig("tools") || []
|
31
|
+
@resources = send_request(method: "resources/list")&.dig("resources") || []
|
31
32
|
end
|
32
33
|
|
33
34
|
def connected?
|
@@ -53,6 +54,33 @@ module Elelem
|
|
53
54
|
)
|
54
55
|
end
|
55
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
|
+
|
56
84
|
private
|
57
85
|
|
58
86
|
attr_reader :stdin, :stdout, :stderr, :worker, :configuration
|
@@ -86,6 +114,8 @@ module Elelem
|
|
86
114
|
end
|
87
115
|
|
88
116
|
def send_notification(method:, params: {})
|
117
|
+
return unless connected?
|
118
|
+
|
89
119
|
notification = {
|
90
120
|
jsonrpc: "2.0",
|
91
121
|
method: method
|
@@ -94,6 +124,13 @@ module Elelem
|
|
94
124
|
configuration.logger.debug("Sending notification: #{JSON.pretty_generate(notification)}")
|
95
125
|
@stdin.puts(JSON.generate(notification))
|
96
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
|
97
134
|
end
|
98
135
|
end
|
99
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
|
@@ -1,7 +1,7 @@
|
|
1
|
-
**
|
1
|
+
**Shell Master** — bash>code; compose>write; pipe everything; /proc/sys native; automate fast; streams/transforms; POSIX+GNU; man(1) first; no cleverness.
|
2
2
|
|
3
3
|
Time: `<%= Time.now.strftime("%Y-%m-%d %H:%M:%S") %>`
|
4
4
|
Project Directory: `<%= Dir.pwd %>`
|
5
5
|
System Info: `<%= `uname -a`.strip %>`
|
6
6
|
|
7
|
-
|
7
|
+
Ready to hack.
|
data/lib/elelem/tool.rb
CHANGED
@@ -29,88 +29,4 @@ module Elelem
|
|
29
29
|
}
|
30
30
|
end
|
31
31
|
end
|
32
|
-
|
33
|
-
class BashTool < Tool
|
34
|
-
attr_reader :tui
|
35
|
-
|
36
|
-
def initialize(configuration)
|
37
|
-
@tui = configuration.tui
|
38
|
-
super("bash", "Execute a shell command.", {
|
39
|
-
type: "object",
|
40
|
-
properties: {
|
41
|
-
command: { type: "string" }
|
42
|
-
},
|
43
|
-
required: ["command"]
|
44
|
-
})
|
45
|
-
end
|
46
|
-
|
47
|
-
def call(args)
|
48
|
-
command = args["command"]
|
49
|
-
output_buffer = []
|
50
|
-
|
51
|
-
Open3.popen3("/bin/sh", "-c", command) do |stdin, stdout, stderr, wait_thread|
|
52
|
-
stdin.close
|
53
|
-
streams = [stdout, stderr]
|
54
|
-
|
55
|
-
until streams.empty?
|
56
|
-
ready = IO.select(streams, nil, nil, 0.1)
|
57
|
-
|
58
|
-
if ready
|
59
|
-
ready[0].each do |io|
|
60
|
-
data = io.read_nonblock(4096)
|
61
|
-
output_buffer << data
|
62
|
-
|
63
|
-
if io == stderr
|
64
|
-
tui.say(data, colour: :red, newline: false)
|
65
|
-
else
|
66
|
-
tui.say(data, newline: false)
|
67
|
-
end
|
68
|
-
rescue IO::WaitReadable
|
69
|
-
next
|
70
|
-
rescue EOFError
|
71
|
-
streams.delete(io)
|
72
|
-
end
|
73
|
-
elsif !wait_thread.alive?
|
74
|
-
break
|
75
|
-
end
|
76
|
-
end
|
77
|
-
|
78
|
-
wait_thread.value
|
79
|
-
end
|
80
|
-
|
81
|
-
output_buffer.join
|
82
|
-
end
|
83
|
-
end
|
84
|
-
|
85
|
-
class MCPTool < Tool
|
86
|
-
attr_reader :client, :tui
|
87
|
-
|
88
|
-
def initialize(client, tui, tool)
|
89
|
-
@client = client
|
90
|
-
@tui = tui
|
91
|
-
super(tool["name"], tool["description"], tool["inputSchema"] || {})
|
92
|
-
end
|
93
|
-
|
94
|
-
def call(args)
|
95
|
-
unless client.connected?
|
96
|
-
tui.say("MCP connection lost", colour: :red)
|
97
|
-
return ""
|
98
|
-
end
|
99
|
-
|
100
|
-
result = client.call(name, args)
|
101
|
-
tui.say(result)
|
102
|
-
|
103
|
-
if result.nil? || result.empty?
|
104
|
-
tui.say("Tool call failed: no response from MCP server", colour: :red)
|
105
|
-
return result
|
106
|
-
end
|
107
|
-
|
108
|
-
if result["error"]
|
109
|
-
tui.say(result["error"], colour: :red)
|
110
|
-
return result
|
111
|
-
end
|
112
|
-
|
113
|
-
result.dig("content", 0, "text") || result.to_s
|
114
|
-
end
|
115
|
-
end
|
116
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
data/lib/elelem/tui.rb
CHANGED
@@ -14,41 +14,30 @@ module Elelem
|
|
14
14
|
end
|
15
15
|
|
16
16
|
def say(message, colour: :default, newline: false)
|
17
|
-
formatted_message = colourize(message, colour: colour)
|
18
17
|
if newline
|
19
|
-
stdout.puts(
|
18
|
+
stdout.puts(colourize(message, colour: colour))
|
20
19
|
else
|
21
|
-
stdout.print(
|
20
|
+
stdout.print(colourize(message, colour: colour))
|
22
21
|
end
|
23
22
|
stdout.flush
|
24
23
|
end
|
25
24
|
|
26
|
-
def show_progress(message,
|
27
|
-
timestamp =
|
28
|
-
|
29
|
-
stdout.print(formatted_message)
|
30
|
-
stdout.flush
|
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)
|
31
28
|
end
|
32
29
|
|
33
30
|
def clear_line
|
34
|
-
|
35
|
-
stdout.flush
|
31
|
+
say("\r#{" " * 80}\r", newline: false)
|
36
32
|
end
|
37
33
|
|
38
34
|
def complete_progress(message = "Completed")
|
39
35
|
clear_line
|
40
|
-
|
41
|
-
formatted_message = colourize("[✓] #{timestamp} #{message}", colour: :green)
|
42
|
-
stdout.puts(formatted_message)
|
43
|
-
stdout.flush
|
36
|
+
show_progress(message, "✓", colour: :green)
|
44
37
|
end
|
45
38
|
|
46
39
|
private
|
47
40
|
|
48
|
-
def current_time_string
|
49
|
-
Time.now.strftime("%H:%M:%S")
|
50
|
-
end
|
51
|
-
|
52
41
|
def colourize(text, colour: :default)
|
53
42
|
case colour
|
54
43
|
when :black
|
data/lib/elelem/version.rb
CHANGED
data/lib/elelem.rb
CHANGED
@@ -1,5 +1,6 @@
|
|
1
1
|
# frozen_string_literal: true
|
2
2
|
|
3
|
+
require "cli/ui"
|
3
4
|
require "erb"
|
4
5
|
require "json"
|
5
6
|
require "json-schema"
|
@@ -8,6 +9,7 @@ require "net/http"
|
|
8
9
|
require "open3"
|
9
10
|
require "reline"
|
10
11
|
require "thor"
|
12
|
+
require "timeout"
|
11
13
|
require "uri"
|
12
14
|
|
13
15
|
require_relative "elelem/agent"
|
@@ -16,12 +18,25 @@ require_relative "elelem/application"
|
|
16
18
|
require_relative "elelem/configuration"
|
17
19
|
require_relative "elelem/conversation"
|
18
20
|
require_relative "elelem/mcp_client"
|
19
|
-
require_relative "elelem/
|
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"
|
20
29
|
require_relative "elelem/tool"
|
30
|
+
require_relative "elelem/toolbox/bash"
|
31
|
+
require_relative "elelem/toolbox/mcp"
|
21
32
|
require_relative "elelem/tools"
|
22
33
|
require_relative "elelem/tui"
|
23
34
|
require_relative "elelem/version"
|
24
35
|
|
36
|
+
CLI::UI::StdoutRouter.enable
|
37
|
+
Reline.input = $stdin
|
38
|
+
Reline.output = $stdout
|
39
|
+
|
25
40
|
module Elelem
|
26
41
|
class Error < StandardError; end
|
27
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.
|
4
|
+
version: 0.1.3
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- mo khan
|
@@ -9,6 +9,20 @@ 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'
|
12
26
|
- !ruby/object:Gem::Dependency
|
13
27
|
name: erb
|
14
28
|
requirement: !ruby/object:Gem::Requirement
|
@@ -121,6 +135,20 @@ dependencies:
|
|
121
135
|
- - ">="
|
122
136
|
- !ruby/object:Gem::Version
|
123
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'
|
124
152
|
- !ruby/object:Gem::Dependency
|
125
153
|
name: uri
|
126
154
|
requirement: !ruby/object:Gem::Requirement
|
@@ -155,9 +183,18 @@ files:
|
|
155
183
|
- lib/elelem/configuration.rb
|
156
184
|
- lib/elelem/conversation.rb
|
157
185
|
- lib/elelem/mcp_client.rb
|
158
|
-
- lib/elelem/
|
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
|
159
194
|
- lib/elelem/system_prompt.erb
|
160
195
|
- lib/elelem/tool.rb
|
196
|
+
- lib/elelem/toolbox/bash.rb
|
197
|
+
- lib/elelem/toolbox/mcp.rb
|
161
198
|
- lib/elelem/tools.rb
|
162
199
|
- lib/elelem/tui.rb
|
163
200
|
- lib/elelem/version.rb
|
data/lib/elelem/state.rb
DELETED
@@ -1,162 +0,0 @@
|
|
1
|
-
# frozen_string_literal: true
|
2
|
-
|
3
|
-
module Elelem
|
4
|
-
class Idle
|
5
|
-
def run(agent)
|
6
|
-
agent.logger.debug("Idling...")
|
7
|
-
agent.say("#{Dir.pwd} (#{agent.model}) [#{git_branch}]", colour: :magenta, newline: true)
|
8
|
-
input = agent.prompt("モ ")
|
9
|
-
agent.quit if input.nil? || input.empty? || input == "exit" || input == "quit"
|
10
|
-
|
11
|
-
agent.conversation.add(role: :user, content: input)
|
12
|
-
agent.transition_to(Working.new)
|
13
|
-
end
|
14
|
-
|
15
|
-
private
|
16
|
-
|
17
|
-
def git_branch
|
18
|
-
`git branch --no-color --show-current --no-abbrev`.strip
|
19
|
-
end
|
20
|
-
end
|
21
|
-
|
22
|
-
class Working
|
23
|
-
class State
|
24
|
-
attr_reader :agent
|
25
|
-
|
26
|
-
def initialize(agent)
|
27
|
-
@agent = agent
|
28
|
-
end
|
29
|
-
|
30
|
-
def display_name
|
31
|
-
self.class.name.split("::").last
|
32
|
-
end
|
33
|
-
end
|
34
|
-
|
35
|
-
class Waiting < State
|
36
|
-
def process(message)
|
37
|
-
state_for(message)&.process(message)
|
38
|
-
end
|
39
|
-
|
40
|
-
private
|
41
|
-
|
42
|
-
def state_for(message)
|
43
|
-
if message["thinking"] && !message["thinking"].empty?
|
44
|
-
Thinking.new(agent)
|
45
|
-
elsif message["tool_calls"]&.any?
|
46
|
-
Executing.new(agent)
|
47
|
-
elsif message["content"] && !message["content"].empty?
|
48
|
-
Talking.new(agent)
|
49
|
-
end
|
50
|
-
end
|
51
|
-
end
|
52
|
-
|
53
|
-
class Thinking < State
|
54
|
-
def initialize(agent)
|
55
|
-
super(agent)
|
56
|
-
@progress_shown = false
|
57
|
-
end
|
58
|
-
|
59
|
-
def process(message)
|
60
|
-
if message["thinking"] && !message["thinking"]&.empty?
|
61
|
-
unless @progress_shown
|
62
|
-
agent.show_progress("Thinking...", "[*]", colour: :yellow)
|
63
|
-
agent.say("\n\n", newline: false)
|
64
|
-
@progress_shown = true
|
65
|
-
end
|
66
|
-
agent.say(message["thinking"], colour: :gray, newline: false)
|
67
|
-
self
|
68
|
-
else
|
69
|
-
agent.say("\n\n", newline: false)
|
70
|
-
Waiting.new(agent).process(message)
|
71
|
-
end
|
72
|
-
end
|
73
|
-
end
|
74
|
-
|
75
|
-
class Executing < State
|
76
|
-
def process(message)
|
77
|
-
if message["tool_calls"]&.any?
|
78
|
-
message["tool_calls"].each do |tool_call|
|
79
|
-
tool_name = tool_call.dig("function", "name") || "unknown"
|
80
|
-
agent.show_progress(tool_name, "[>]", colour: :magenta)
|
81
|
-
agent.say("\n\n", newline: false)
|
82
|
-
|
83
|
-
output = agent.execute(tool_call)
|
84
|
-
agent.conversation.add(role: :tool, content: output)
|
85
|
-
|
86
|
-
agent.say("\n", newline: false)
|
87
|
-
agent.complete_progress("#{tool_name} completed")
|
88
|
-
end
|
89
|
-
end
|
90
|
-
|
91
|
-
Waiting.new(agent)
|
92
|
-
end
|
93
|
-
end
|
94
|
-
|
95
|
-
class Error < State
|
96
|
-
def initialize(agent, error_message)
|
97
|
-
super(agent)
|
98
|
-
@error_message = error_message
|
99
|
-
end
|
100
|
-
|
101
|
-
def process(_message)
|
102
|
-
agent.say("\nTool execution failed: #{@error_message}", colour: :red)
|
103
|
-
agent.say("Returning to idle state.\n\n", colour: :yellow)
|
104
|
-
Waiting.new(agent)
|
105
|
-
end
|
106
|
-
end
|
107
|
-
|
108
|
-
class Talking < State
|
109
|
-
def initialize(agent)
|
110
|
-
super(agent)
|
111
|
-
@progress_shown = false
|
112
|
-
end
|
113
|
-
|
114
|
-
def process(message)
|
115
|
-
if message["content"] && !message["content"]&.empty?
|
116
|
-
unless @progress_shown
|
117
|
-
agent.show_progress("Responding...", "[~]", colour: :white)
|
118
|
-
agent.say("\n", newline: false)
|
119
|
-
@progress_shown = true
|
120
|
-
end
|
121
|
-
agent.conversation.add(role: message["role"], content: message["content"])
|
122
|
-
agent.say(message["content"], colour: :default, newline: false)
|
123
|
-
self
|
124
|
-
else
|
125
|
-
agent.say("\n\n", newline: false)
|
126
|
-
Waiting.new(agent).process(message)
|
127
|
-
end
|
128
|
-
end
|
129
|
-
end
|
130
|
-
|
131
|
-
def run(agent)
|
132
|
-
agent.logger.debug("Working...")
|
133
|
-
agent.show_progress("Processing...", "[.]", colour: :cyan)
|
134
|
-
agent.say("\n\n", newline: false)
|
135
|
-
|
136
|
-
state = Waiting.new(agent)
|
137
|
-
done = false
|
138
|
-
|
139
|
-
loop do
|
140
|
-
agent.api.chat(agent.conversation.history) do |chunk|
|
141
|
-
response = JSON.parse(chunk)
|
142
|
-
message = normalize(response["message"] || {})
|
143
|
-
done = response["done"]
|
144
|
-
|
145
|
-
agent.logger.debug("#{state.display_name}: #{message}")
|
146
|
-
state = state.process(message)
|
147
|
-
end
|
148
|
-
|
149
|
-
break if state.nil?
|
150
|
-
break if done && agent.conversation.history.last[:role] != :tool
|
151
|
-
end
|
152
|
-
|
153
|
-
agent.transition_to(Idle.new)
|
154
|
-
end
|
155
|
-
|
156
|
-
private
|
157
|
-
|
158
|
-
def normalize(message)
|
159
|
-
message.reject { |_key, value| value.empty? }
|
160
|
-
end
|
161
|
-
end
|
162
|
-
end
|