elelem 0.2.0 → 0.3.0

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.
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mo khan
@@ -9,20 +9,6 @@ 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
12
  - !ruby/object:Gem::Dependency
27
13
  name: erb
28
14
  requirement: !ruby/object:Gem::Requirement
@@ -164,30 +150,9 @@ files:
164
150
  - exe/elelem
165
151
  - lib/elelem.rb
166
152
  - lib/elelem/agent.rb
167
- - lib/elelem/api.rb
168
153
  - lib/elelem/application.rb
169
- - lib/elelem/configuration.rb
170
154
  - lib/elelem/conversation.rb
171
- - lib/elelem/mcp_client.rb
172
- - lib/elelem/states/idle.rb
173
- - lib/elelem/states/working.rb
174
- - lib/elelem/states/working/error.rb
175
- - lib/elelem/states/working/executing.rb
176
- - lib/elelem/states/working/state.rb
177
- - lib/elelem/states/working/talking.rb
178
- - lib/elelem/states/working/thinking.rb
179
- - lib/elelem/states/working/waiting.rb
180
155
  - lib/elelem/system_prompt.erb
181
- - lib/elelem/tool.rb
182
- - lib/elelem/toolbox.rb
183
- - lib/elelem/toolbox/exec.rb
184
- - lib/elelem/toolbox/file.rb
185
- - lib/elelem/toolbox/mcp.rb
186
- - lib/elelem/toolbox/memory.rb
187
- - lib/elelem/toolbox/prompt.rb
188
- - lib/elelem/toolbox/web.rb
189
- - lib/elelem/tools.rb
190
- - lib/elelem/tui.rb
191
156
  - lib/elelem/version.rb
192
157
  homepage: https://www.mokhan.ca
193
158
  licenses:
data/lib/elelem/api.rb DELETED
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "net/llm"
4
-
5
- module Elelem
6
- class Api
7
- attr_reader :configuration, :client
8
-
9
- def initialize(configuration)
10
- @configuration = configuration
11
- @client = Net::Llm::Ollama.new(
12
- host: configuration.host,
13
- model: configuration.model
14
- )
15
- end
16
-
17
- def chat(messages, &block)
18
- tools = configuration.tools.to_h
19
- client.chat(messages, tools) do |chunk|
20
- normalized = normalize_ollama_response(chunk)
21
- block.call(normalized) if normalized
22
- end
23
- end
24
-
25
- private
26
-
27
- def normalize_ollama_response(chunk)
28
- return done_response(chunk) if chunk["done"]
29
-
30
- normalize_message(chunk["message"])
31
- end
32
-
33
- def done_response(chunk)
34
- { "done" => true, "finish_reason" => chunk["done_reason"] || "stop" }
35
- end
36
-
37
- def normalize_message(message)
38
- return nil unless message
39
-
40
- {}.tap do |result|
41
- result["role"] = message["role"] if message["role"]
42
- result["content"] = message["content"] if message["content"]
43
- result["reasoning"] = message["thinking"] if message["thinking"]
44
- result["tool_calls"] = message["tool_calls"] if message["tool_calls"]
45
- end.then { |r| r.empty? ? nil : r }
46
- end
47
- end
48
- end
@@ -1,84 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Elelem
4
- class Configuration
5
- attr_reader :host, :model, :token, :debug
6
-
7
- def initialize(host:, model:, token:, debug: false)
8
- @host = host
9
- @model = model
10
- @token = token
11
- @debug = debug
12
- end
13
-
14
- def tui
15
- @tui ||= TUI.new($stdin, $stdout)
16
- end
17
-
18
- def api
19
- @api ||= Api.new(self)
20
- end
21
-
22
- def logger
23
- @logger ||= Logger.new("#{Time.now.strftime("%Y-%m-%d")}-elelem.log").tap do |logger|
24
- if debug
25
- logger.level = :debug
26
- else
27
- logger.level = ENV.fetch("LOG_LEVEL", "warn")
28
- end
29
- logger.formatter = ->(severity, datetime, progname, message) {
30
- timestamp = datetime.strftime("%H:%M:%S.%3N")
31
- "[#{timestamp}] #{severity.ljust(5)} #{message.to_s.strip}\n"
32
- }
33
- end
34
- end
35
-
36
- def conversation
37
- @conversation ||= Conversation.new.tap do |conversation|
38
- resources = mcp_clients.map do |client|
39
- client.resources.map do |resource|
40
- resource["uri"]
41
- end
42
- end.flatten
43
- conversation.add(role: :tool, content: resources)
44
- end
45
- end
46
-
47
- def tools
48
- @tools ||= Tools.new(self,
49
- [
50
- Toolbox::Exec.new(self),
51
- Toolbox::File.new(self),
52
- Toolbox::Web.new(self),
53
- Toolbox::Prompt.new(self),
54
- Toolbox::Memory.new(self),
55
- ] + mcp_tools
56
- )
57
- end
58
-
59
- def cleanup
60
- @mcp_clients&.each(&:shutdown)
61
- end
62
-
63
- private
64
-
65
- def mcp_tools
66
- @mcp_tools ||= mcp_clients.map do |client|
67
- client.tools.map do |tool|
68
- Toolbox::MCP.new(client, tui, tool)
69
- end
70
- end.flatten
71
- end
72
-
73
- def mcp_clients
74
- @mcp_clients ||= begin
75
- config = Pathname.pwd.join(".mcp.json")
76
- return [] unless config.exist?
77
-
78
- JSON.parse(config.read).map do |_key, value|
79
- MCPClient.new(self, [value["command"]] + value["args"])
80
- end
81
- end
82
- end
83
- end
84
- end
@@ -1,136 +0,0 @@
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
@@ -1,23 +0,0 @@
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
@@ -1,19 +0,0 @@
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
@@ -1,19 +0,0 @@
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
- Thinking.new(agent, "*", :yellow)
15
- end
16
- end
17
- end
18
- end
19
- end
@@ -1,26 +0,0 @@
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
@@ -1,19 +0,0 @@
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
@@ -1,18 +0,0 @@
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["reasoning"] && !message["reasoning"]&.empty?
9
- agent.tui.say(message["reasoning"], 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
@@ -1,29 +0,0 @@
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) || self
13
- end
14
-
15
- private
16
-
17
- def state_for(message)
18
- if message["reasoning"] && !message["reasoning"].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
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Elelem
4
- module States
5
- module Working
6
- class << self
7
- def run(agent)
8
- state = Waiting.new(agent)
9
-
10
- loop do
11
- streaming_done = false
12
- finish_reason = nil
13
-
14
- agent.api.chat(agent.conversation.history) do |message|
15
- if message["done"]
16
- streaming_done = true
17
- next
18
- end
19
-
20
- if message["finish_reason"]
21
- finish_reason = message["finish_reason"]
22
- agent.logger.debug("Working: finish_reason = #{finish_reason}")
23
- end
24
-
25
- new_state = state.run(message)
26
- if new_state.class != state.class
27
- agent.logger.info("STATE: #{state.display_name} -> #{new_state.display_name}")
28
- end
29
- state = new_state
30
- end
31
-
32
- # Only exit when task is actually complete, not just streaming done
33
- if finish_reason == "stop"
34
- agent.logger.debug("Working: Task complete, exiting to Idle")
35
- break
36
- elsif finish_reason == "tool_calls"
37
- agent.logger.debug("Working: Tool calls finished, continuing conversation")
38
- # Continue loop to process tool results
39
- elsif streaming_done && finish_reason.nil?
40
- agent.logger.debug("Working: Streaming done but no finish_reason, continuing")
41
- # Continue for cases where finish_reason comes in separate chunk
42
- end
43
- end
44
-
45
- agent.transition_to(States::Idle.new)
46
- rescue StandardError => e
47
- agent.logger.error(e)
48
- agent.conversation.add(role: :tool, content: e.message)
49
- agent.tui.say(e.message, colour: :red, newline: true)
50
- agent.transition_to(States::Idle.new)
51
- end
52
- end
53
- end
54
- end
55
- end
data/lib/elelem/tool.rb DELETED
@@ -1,32 +0,0 @@
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
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Elelem
4
- module Toolbox
5
- class Exec < ::Elelem::Tool
6
- attr_reader :tui
7
-
8
- def initialize(configuration)
9
- @tui = configuration.tui
10
- super("exec", "Execute shell commands with pipe support", {
11
- type: "object",
12
- properties: {
13
- command: {
14
- type: "string",
15
- description: "Shell command to execute (supports pipes, redirects, etc.)"
16
- }
17
- },
18
- required: ["command"]
19
- })
20
- end
21
-
22
- def call(args)
23
- command = args["command"]
24
- output_buffer = []
25
-
26
- tui.say(command, newline: true)
27
- Open3.popen3(command) do |stdin, stdout, stderr, wait_thread|
28
- stdin.close
29
- streams = [stdout, stderr]
30
-
31
- until streams.empty?
32
- ready = IO.select(streams, nil, nil, 0.1)
33
-
34
- if ready
35
- ready[0].each do |io|
36
- data = io.read_nonblock(4096)
37
- output_buffer << data
38
-
39
- if io == stderr
40
- tui.say(data, colour: :red, newline: false)
41
- else
42
- tui.say(data, newline: false)
43
- end
44
- rescue IO::WaitReadable
45
- next
46
- rescue EOFError
47
- streams.delete(io)
48
- end
49
- elsif !wait_thread.alive?
50
- break
51
- end
52
- end
53
-
54
- wait_thread.value
55
- end
56
-
57
- output_buffer.join
58
- end
59
- end
60
- end
61
- end