elelem 0.1.0 → 0.1.2

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: 06a7abdf8e0f812a68d33858858674b65641c32df197305f3baff807bc7faa40
4
- data.tar.gz: fea3e4bf3d3c8fc2c8a9da954b704e8bb3542c5e003e5a8d4f9fcbe6a178152a
3
+ metadata.gz: 8a5469d2d253c6e0d09f60de69dafffdffb305da3f5864a4f6aa9bbac5ae7da6
4
+ data.tar.gz: 60d9d35b759e0722b557cefbb4fb6d6b1713661de432efb8283ae1cd7f951e05
5
5
  SHA512:
6
- metadata.gz: 6790c0d2703328168d5abafa4b0b1e25987b771e60512edeb40e37f4b43cf10b4bd1e4e7ed9222c261238821f6610d9ba1f810c0f45bf32e7ef1a3b14dd4a553
7
- data.tar.gz: 85f83cb98ab8d2895c77910d36b2ba3b705f614bf3de0bccb48f41b48033f248edd316cc0219c4ff5b9a4fe3a10845f4280cc2723bc6841cbbbd1ea2ff97c7cf
6
+ metadata.gz: 46073936ca9abcf83897e83355b7f979ebf2866d16351ccced55d70a8c311d8068acb3a78126b85b7b6622b45e6b2d51dc86f35914a25fdec937ad60214e4da9
7
+ data.tar.gz: d7233c12ed3b2359c5c7a1d47e908951e0651f19fb8d07777e34aa0fb5ddfc51f4368a7a192136a743d1009a9a26239da572555dd5d3d90e99e1a4e2668c2d47
data/CHANGELOG.md CHANGED
@@ -1,5 +1,17 @@
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
+
8
+ ## [0.1.1] - 2025-08-12
9
+
10
+ ### Fixed
11
+ - Fixed infinite loop bug after tool execution - loop now continues until assistant provides final response
12
+ - Fixed conversation history accumulating streaming chunks as separate entries - now properly combines same-role consecutive messages
13
+ - Improved state machine logging with better debug output
14
+
3
15
  ## [0.1.0] - 2025-08-08
4
16
 
5
17
  - Initial release
data/README.md CHANGED
@@ -1,28 +1,56 @@
1
1
  # Elelem
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
4
-
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/elelem`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Elelem is an interactive REPL (Read-Eval-Print Loop) for Ollama that provides a command-line chat interface for communicating with AI models. It features tool calling capabilities, streaming responses, and a clean state machine architecture.
6
4
 
7
5
  ## Installation
8
6
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
10
-
11
7
  Install the gem and add to the application's Gemfile by executing:
12
8
 
13
9
  ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
10
+ bundle add elelem
15
11
  ```
16
12
 
17
13
  If bundler is not being used to manage dependencies, install the gem by executing:
18
14
 
19
15
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
16
+ gem install elelem
21
17
  ```
22
18
 
23
19
  ## Usage
24
20
 
25
- TODO: Write usage instructions here
21
+ Start an interactive chat session with an Ollama model:
22
+
23
+ ```bash
24
+ elelem chat
25
+ ```
26
+
27
+ ### Options
28
+
29
+ - `--host`: Specify Ollama host (default: localhost:11434)
30
+ - `--model`: Specify Ollama model (default: gpt-oss, currently only tested with gpt-oss)
31
+ - `--token`: Provide authentication token
32
+ - `--debug`: Enable debug logging
33
+
34
+ ### Examples
35
+
36
+ ```bash
37
+ # Chat with default model
38
+ elelem chat
39
+
40
+ # Chat with specific model and host
41
+ elelem chat --model llama2 --host remote-host:11434
42
+
43
+ # Enable debug mode
44
+ elelem chat --debug
45
+ ```
46
+
47
+ ### Features
48
+
49
+ - **Interactive REPL**: Clean command-line interface for chatting
50
+ - **Tool Execution**: Execute shell commands when requested by the AI
51
+ - **Streaming Responses**: Real-time streaming of AI responses
52
+ - **State Machine**: Robust state management for different interaction modes
53
+ - **Conversation History**: Maintains context across the session
26
54
 
27
55
  ## Development
28
56
 
@@ -30,6 +58,100 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
30
58
 
31
59
  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).
32
60
 
61
+ REPL State Diagram
62
+
63
+ ```
64
+ ┌─────────────────┐
65
+ │ START/INIT │
66
+ └─────────┬───────┘
67
+
68
+ v
69
+ ┌─────────────────┐
70
+ ┌────▶│ IDLE (Prompt) │◄────┐
71
+ │ │ Shows "> " │ │
72
+ │ └─────────┬───────┘ │
73
+ │ │ │
74
+ │ │ User input │
75
+ │ v │
76
+ │ ┌─────────────────┐ │
77
+ │ │ PROCESSING │ │
78
+ │ │ INPUT │ │
79
+ │ └─────────┬───────┘ │
80
+ │ │ │
81
+ │ │ API call │
82
+ │ v │
83
+ │ ┌─────────────────┐ │
84
+ │ │ STREAMING │ │
85
+ │ ┌──▶│ RESPONSE │─────┤
86
+ │ │ └─────────┬───────┘ │
87
+ │ │ │ │ done=true
88
+ │ │ │ Parse chunk │
89
+ │ │ v │
90
+ │ │ ┌─────────────────┐ │
91
+ │ │ │ MESSAGE TYPE │ │
92
+ │ │ │ ROUTING │ │
93
+ │ │ └─────┬─┬─┬───────┘ │
94
+ │ │ │ │ │ │
95
+ ┌────────┴─┴─────────┘ │ └─────────────┴──────────┐
96
+ │ │ │
97
+ v v v
98
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
99
+ │ THINKING │ │ TOOL │ │ CONTENT │
100
+ │ STATE │ │ EXECUTION │ │ OUTPUT │
101
+ │ │ │ STATE │ │ STATE │
102
+ └─────────────┘ └─────┬───────┘ └─────────────┘
103
+ │ │ │
104
+ │ │ done=false │
105
+ └───────────────────┼──────────────────────────┘
106
+
107
+ v
108
+ ┌─────────────────┐
109
+ │ CONTINUE │
110
+ │ STREAMING │
111
+ └─────────────────┘
112
+
113
+ └─────────────────┐
114
+
115
+ ┌─────────────────┐ │
116
+ │ ERROR STATE │ │
117
+ │ (Exception) │ │
118
+ └─────────────────┘ │
119
+ ▲ │
120
+ │ Invalid response │
121
+ └────────────────────────────┘
122
+
123
+ EXIT CONDITIONS:
124
+ ┌─────────────────────────┐
125
+ │ • User enters "" │
126
+ │ • User enters "exit" │
127
+ │ • EOF (Ctrl+D) │
128
+ │ • nil input │
129
+ └─────────────────────────┘
130
+
131
+ v
132
+ ┌─────────────────────────┐
133
+ │ TERMINATE │
134
+ └─────────────────────────┘
135
+ ```
136
+
137
+ Key Transitions:
138
+
139
+ 1. IDLE → PROCESSING: User enters any non-empty, non-"exit" input
140
+ 2. PROCESSING → STREAMING: API call initiated to Ollama
141
+ 3. STREAMING → MESSAGE ROUTING: Each chunk received is parsed
142
+ 4. MESSAGE ROUTING → States: Based on message content:
143
+ - thinking → THINKING STATE
144
+ - tool_calls → TOOL EXECUTION STATE
145
+ - content → CONTENT OUTPUT STATE
146
+ - Invalid format → ERROR STATE
147
+ 5. All States → IDLE: When done=true from API response
148
+ 6. TOOL EXECUTION → STREAMING: Sets done=false to continue conversation
149
+ 7. Any State → TERMINATE: On exit conditions
150
+
151
+ The REPL operates as a continuous loop where the primary flow is IDLE → PROCESSING → STREAMING →
152
+ back to IDLE, with the streaming phase potentially cycling through multiple message types before
153
+ completion.
154
+
33
155
  ## Contributing
34
156
 
35
157
  Bug reports and pull requests are welcome on GitHub at https://github.com/xlgmokha/elelem.
data/exe/elelem CHANGED
@@ -3,7 +3,10 @@
3
3
 
4
4
  require "elelem"
5
5
 
6
- Signal.trap('INT') do
6
+ Reline.input = $stdin
7
+ Reline.output = $stdout
8
+
9
+ Signal.trap("INT") do
7
10
  exit(1)
8
11
  end
9
12
 
data/lib/elelem/agent.rb CHANGED
@@ -2,86 +2,60 @@
2
2
 
3
3
  module Elelem
4
4
  class Agent
5
- attr_reader :configuration, :conversation, :tools
5
+ attr_reader :api, :conversation, :logger, :model
6
6
 
7
7
  def initialize(configuration)
8
+ @api = configuration.api
8
9
  @configuration = configuration
10
+ @model = configuration.model
9
11
  @conversation = configuration.conversation
10
- @tools = configuration.tools
12
+ @logger = configuration.logger
13
+ transition_to(Idle.new)
11
14
  end
12
15
 
13
16
  def repl
14
17
  loop do
15
- print "\n> "
16
- user = STDIN.gets&.chomp
17
- break if user.nil? || user.empty? || user == 'exit'
18
- process_input(user)
19
- puts("\u001b[32mDone!\u001b[0m")
18
+ current_state.run(self)
20
19
  end
21
20
  end
22
21
 
23
- private
24
-
25
- def process_input(text)
26
- conversation.add(role: 'user', content: text)
27
-
28
- done = false
29
- loop do
30
- call_api(conversation.history) do |chunk|
31
- debug_print(chunk)
32
-
33
- response = JSON.parse(chunk)
34
- done = response['done']
35
- message = response['message'] || {}
22
+ def transition_to(next_state)
23
+ logger.debug("Transition to: #{next_state.class.name}")
24
+ @current_state = next_state
25
+ end
36
26
 
37
- if message['thinking']
38
- print("\u001b[90m#{message['thinking']}\u001b[0m")
39
- elsif message['tool_calls']&.any?
40
- message['tool_calls'].each do |t|
41
- conversation.add(role: 'tool', content: tools.execute(t))
42
- end
43
- done = false
44
- elsif message['content'].to_s.strip
45
- print message['content'].to_s.strip
46
- else
47
- raise chunk.inspect
48
- end
49
- end
27
+ def prompt(message)
28
+ configuration.tui.prompt(message)
29
+ end
50
30
 
51
- break if done
52
- end
31
+ def say(message, colour: :default, newline: false)
32
+ configuration.tui.say(message, colour: colour, newline: newline)
53
33
  end
54
34
 
55
- def call_api(messages)
56
- body = {
57
- messages: messages,
58
- model: configuration.model,
59
- stream: true,
60
- keep_alive: '5m',
61
- options: { temperature: 0.1 },
62
- tools: tools.to_h
63
- }
64
- json_body = body.to_json
65
- debug_print(json_body)
35
+ def execute(tool_call)
36
+ logger.debug("Execute: #{tool_call}")
37
+ configuration.tools.execute(tool_call)
38
+ end
66
39
 
67
- req = Net::HTTP::Post.new(configuration.uri)
68
- req['Content-Type'] = 'application/json'
69
- req.body = json_body
70
- req['Authorization'] = "Bearer #{configuration.token}" if configuration.token
40
+ def show_progress(message, prefix = "[.]", colour: :gray)
41
+ configuration.tui.show_progress(message, prefix, colour: colour)
42
+ end
71
43
 
72
- configuration.http.request(req) do |response|
73
- raise response.inspect unless response.code == "200"
44
+ def clear_line
45
+ configuration.tui.clear_line
46
+ end
74
47
 
75
- response.read_body do |chunk|
76
- debug_print(chunk)
77
- yield(chunk) if block_given?
78
- $stdout.flush
79
- end
80
- end
48
+ def complete_progress(message = "Completed")
49
+ configuration.tui.complete_progress(message)
81
50
  end
82
51
 
83
- def debug_print(body = nil)
84
- configuration.logger.debug(body) if configuration.debug && body
52
+ def quit
53
+ logger.debug("Exiting...")
54
+ exit
85
55
  end
56
+
57
+ private
58
+
59
+ attr_reader :configuration, :current_state
86
60
  end
87
61
  end
data/lib/elelem/api.rb ADDED
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class Api
5
+ attr_reader :configuration
6
+
7
+ def initialize(configuration)
8
+ @configuration = configuration
9
+ end
10
+
11
+ def chat(messages, &block)
12
+ body = {
13
+ messages: messages,
14
+ model: configuration.model,
15
+ stream: true,
16
+ keep_alive: "5m",
17
+ options: { temperature: 0.1 },
18
+ tools: configuration.tools.to_h
19
+ }
20
+ configuration.logger.debug(JSON.pretty_generate(body))
21
+ json_body = body.to_json
22
+
23
+ req = Net::HTTP::Post.new(configuration.uri)
24
+ req["Content-Type"] = "application/json"
25
+ req.body = json_body
26
+ req["Authorization"] = "Bearer #{configuration.token}" if configuration.token
27
+
28
+ configuration.http.request(req) do |response|
29
+ raise response.inspect unless response.code == "200"
30
+
31
+ response.read_body(&block)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -2,31 +2,50 @@
2
2
 
3
3
  module Elelem
4
4
  class Application < Thor
5
- desc 'chat', 'Start the REPL'
6
- method_option :help, aliases: '-h', type: :boolean, desc: 'Display usage information'
7
- method_option :host, aliases: '--host', type: :string, desc: 'Ollama host', default: ENV.fetch('OLLAMA_HOST', 'localhost:11434')
8
- method_option :model, aliases: '--model', type: :string, desc: 'Ollama model', default: ENV.fetch('OLLAMA_MODEL', 'gpt-oss')
9
- method_option :token, aliases: '--token', type: :string, desc: 'Ollama token', default: ENV.fetch('OLLAMA_API_KEY', nil)
10
- method_option :debug, aliases: '--debug', type: :boolean, desc: 'Debug mode', default: false
5
+ desc "chat", "Start the REPL"
6
+ method_option :help,
7
+ aliases: "-h",
8
+ type: :boolean,
9
+ desc: "Display usage information"
10
+ method_option :host,
11
+ aliases: "--host",
12
+ type: :string,
13
+ desc: "Ollama host",
14
+ default: ENV.fetch("OLLAMA_HOST", "localhost:11434")
15
+ method_option :model,
16
+ aliases: "--model",
17
+ type: :string,
18
+ desc: "Ollama model",
19
+ default: ENV.fetch("OLLAMA_MODEL", "gpt-oss")
20
+ method_option :token,
21
+ aliases: "--token",
22
+ type: :string,
23
+ desc: "Ollama token",
24
+ default: ENV.fetch("OLLAMA_API_KEY", nil)
25
+ method_option :debug,
26
+ aliases: "--debug",
27
+ type: :boolean,
28
+ desc: "Debug mode",
29
+ default: false
11
30
  def chat(*)
12
31
  if options[:help]
13
- invoke :help, ['chat']
32
+ invoke :help, ["chat"]
14
33
  else
15
34
  configuration = Configuration.new(
16
35
  host: options[:host],
17
36
  model: options[:model],
18
37
  token: options[:token],
19
- debug: options[:debug],
38
+ debug: options[:debug]
20
39
  )
21
- say "Ollama Agent (#{configuration.model})", :green
22
- say "Tools:\n #{configuration.tools.banner}", :green
40
+ say "Agent (#{configuration.model})", :green
41
+ say configuration.tools.banner.to_s, :green
23
42
 
24
43
  agent = Agent.new(configuration)
25
44
  agent.repl
26
45
  end
27
46
  end
28
47
 
29
- desc 'version', 'spandx version'
48
+ desc "version", "spandx version"
30
49
  def version
31
50
  puts "v#{Spandx::VERSION}"
32
51
  end
@@ -18,11 +18,17 @@ module Elelem
18
18
  end
19
19
  end
20
20
 
21
+ def tui
22
+ @tui ||= TUI.new($stdin, $stdout)
23
+ end
24
+
25
+ def api
26
+ @api ||= Api.new(self)
27
+ end
28
+
21
29
  def logger
22
- @logger ||= begin
23
- Logger.new(debug ? $stderr : "/dev/null").tap do |logger|
24
- logger.formatter = ->(_, _, _, msg) { msg }
25
- end
30
+ @logger ||= Logger.new(debug ? "elelem.log" : "/dev/null").tap do |logger|
31
+ logger.formatter = ->(_, _, _, message) { "#{message.to_s.strip}\n" }
26
32
  end
27
33
  end
28
34
 
@@ -35,13 +41,32 @@ module Elelem
35
41
  end
36
42
 
37
43
  def tools
38
- @tools ||= Tools.new
44
+ @tools ||= Tools.new(self, [BashTool.new(self)] + mcp_tools)
39
45
  end
40
46
 
41
47
  private
42
48
 
43
49
  def scheme
44
- host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? 'http' : 'https'
50
+ host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? "http" : "https"
51
+ end
52
+
53
+ def mcp_tools(clients = [serena_client])
54
+ return [] if ENV["SMALL"]
55
+
56
+ @mcp_tools ||= clients.map { |client| client.tools.map { |tool| MCPTool.new(client, tui, tool) } }.flatten
57
+ end
58
+
59
+ def serena_client
60
+ MCPClient.new(self, [
61
+ "uvx",
62
+ "--from",
63
+ "git+https://github.com/oraios/serena",
64
+ "serena",
65
+ "start-mcp-server",
66
+ "--transport", "stdio",
67
+ "--context", "ide-assistant",
68
+ "--project", Dir.pwd
69
+ ])
45
70
  end
46
71
  end
47
72
  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', '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
 
@@ -20,10 +13,22 @@ module Elelem
20
13
  end
21
14
 
22
15
  # :TODO truncate conversation history
23
- def add(role: 'user', content: '')
16
+ def add(role: :user, content: "")
17
+ role = role.to_sym
24
18
  raise "unknown role: #{role}" unless ROLES.include?(role)
19
+ return if content.nil? || content.empty?
20
+
21
+ if @items.last && @items.last[:role] == role
22
+ @items.last[:content] += content
23
+ else
24
+ @items.push({ role: role, content: content })
25
+ end
26
+ end
27
+
28
+ private
25
29
 
26
- @items << { role: role, content: content }
30
+ def system_prompt
31
+ ERB.new(Pathname.new(__dir__).join("system_prompt.erb").read).result(binding)
27
32
  end
28
33
  end
29
34
  end
@@ -0,0 +1,99 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class MCPClient
5
+ attr_reader :tools
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: "2024-11-05",
16
+ capabilities: {
17
+ tools: {}
18
+ },
19
+ clientInfo: {
20
+ name: "Elelem",
21
+ version: Elelem::VERSION
22
+ }
23
+ }
24
+ )
25
+
26
+ # 2. Send initialized notification (required by MCP protocol)
27
+ send_notification(method: "notifications/initialized")
28
+
29
+ # 3. Now we can request tools
30
+ @tools = send_request(method: "tools/list")&.dig("tools") || []
31
+ end
32
+
33
+ def connected?
34
+ return false unless @worker&.alive?
35
+ return false unless @stdin && !@stdin.closed?
36
+ return false unless @stdout && !@stdout.closed?
37
+
38
+ begin
39
+ Process.getpgid(@worker.pid)
40
+ true
41
+ rescue Errno::ESRCH
42
+ false
43
+ end
44
+ end
45
+
46
+ def call(name, arguments = {})
47
+ send_request(
48
+ method: "tools/call",
49
+ params: {
50
+ name: name,
51
+ arguments: arguments
52
+ }
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ attr_reader :stdin, :stdout, :stderr, :worker, :configuration
59
+
60
+ def send_request(method:, params: {})
61
+ return {} unless connected?
62
+
63
+ request = {
64
+ jsonrpc: "2.0",
65
+ id: Time.now.to_i,
66
+ method: method
67
+ }
68
+ request[:params] = params unless params.empty?
69
+ configuration.logger.debug(JSON.pretty_generate(request))
70
+
71
+ @stdin.puts(JSON.generate(request))
72
+ @stdin.flush
73
+
74
+ response_line = @stdout.gets&.strip
75
+ return {} if response_line.nil? || response_line.empty?
76
+
77
+ response = JSON.parse(response_line)
78
+ configuration.logger.debug(JSON.pretty_generate(response))
79
+
80
+ if response["error"]
81
+ configuration.logger.error(response["error"]["message"])
82
+ { error: response["error"]["message"] }
83
+ else
84
+ response["result"]
85
+ end
86
+ end
87
+
88
+ def send_notification(method:, params: {})
89
+ notification = {
90
+ jsonrpc: "2.0",
91
+ method: method
92
+ }
93
+ notification[:params] = params unless params.empty?
94
+ configuration.logger.debug("Sending notification: #{JSON.pretty_generate(notification)}")
95
+ @stdin.puts(JSON.generate(notification))
96
+ @stdin.flush
97
+ end
98
+ end
99
+ end
@@ -0,0 +1,162 @@
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
@@ -0,0 +1,7 @@
1
+ **Del — AI** — Direct/no fluff; prose unless bullets; concise/simple, thorough/complex; critical>agree; honest always; AI≠human. TDD→SOLID→SRP/encapsulation/composition>inheritance; patterns only if needed; self-doc names; simple>complex; no cleverness. Unix: small tools, 1 job, pipe; prefer built-ins; cite man(1); note POSIX≠GNU; stdin/stdout streams.
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
+ Del is now being connected with a person.
@@ -0,0 +1,116 @@
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
+
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
+ end
data/lib/elelem/tools.rb CHANGED
@@ -2,76 +2,32 @@
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: { command: { type: 'string' } },
14
- required: ['command']
15
- }
16
- },
17
- handler: -> (args) {
18
- stdout, stderr, _status = Open3.capture3('/bin/sh', '-c', args['command'])
19
- stdout + stderr
20
- }
21
- },
22
- {
23
- type: 'function',
24
- function: {
25
- name: 'ask_user',
26
- description: 'Ask the user to answer a question.',
27
- parameters: {
28
- type: 'object',
29
- properties: { question: { type: 'string' } },
30
- required: ['question']
31
- }
32
- },
33
- handler: ->(args) {
34
- puts("\u001b[35m#{args['question']}\u001b[0m")
35
- print "> "
36
- STDIN.gets&.chomp
37
- }
38
- }
39
- ]
40
-
41
- def initialize(tools = DEFAULT_TOOLS)
5
+ def initialize(configuration, tools)
6
+ @configuration = configuration
42
7
  @tools = tools
43
8
  end
44
9
 
45
10
  def banner
46
- @tools.map do |h|
47
- [
48
- h.dig(:function, :name),
49
- h.dig(:function, :description)
50
- ].join(": ")
51
- end.sort.join("\n ")
11
+ tools.map(&:banner).sort.join("\n ")
52
12
  end
53
13
 
54
14
  def execute(tool_call)
55
- name = tool_call.dig('function', 'name')
56
- args = tool_call.dig('function', 'arguments')
15
+ name = tool_call.dig("function", "name")
16
+ args = tool_call.dig("function", "arguments")
17
+
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)
57
21
 
58
- tool = @tools.find do |tool|
59
- tool.dig(:function, :name) == name
60
- end
61
- tool.fetch(:handler).call(args)
22
+ tool.call(args)
62
23
  end
63
24
 
64
25
  def to_h
65
- @tools.map do |tool|
66
- {
67
- type: tool[:type],
68
- function: {
69
- name: tool.dig(:function, :name),
70
- description: tool.dig(:function, :description),
71
- parameters: tool.dig(:function, :parameters)
72
- }
73
- }
74
- end
26
+ tools.map(&:to_h)
75
27
  end
28
+
29
+ private
30
+
31
+ attr_reader :configuration, :tools
76
32
  end
77
33
  end
data/lib/elelem/tui.rb ADDED
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ class TUI
5
+ attr_reader :stdin, :stdout
6
+
7
+ def initialize(stdin = $stdin, stdout = $stdout)
8
+ @stdin = stdin
9
+ @stdout = stdout
10
+ end
11
+
12
+ def prompt(message)
13
+ Reline.readline(message, true)
14
+ end
15
+
16
+ def say(message, colour: :default, newline: false)
17
+ formatted_message = colourize(message, colour: colour)
18
+ if newline
19
+ stdout.puts(formatted_message)
20
+ else
21
+ stdout.print(formatted_message)
22
+ end
23
+ stdout.flush
24
+ end
25
+
26
+ def show_progress(message, prefix = "[.]", colour: :gray)
27
+ timestamp = current_time_string
28
+ formatted_message = colourize("#{prefix} #{timestamp} #{message}", colour: colour)
29
+ stdout.print(formatted_message)
30
+ stdout.flush
31
+ end
32
+
33
+ def clear_line
34
+ stdout.print("\r#{" " * 80}\r")
35
+ stdout.flush
36
+ end
37
+
38
+ def complete_progress(message = "Completed")
39
+ clear_line
40
+ timestamp = current_time_string
41
+ formatted_message = colourize("[✓] #{timestamp} #{message}", colour: :green)
42
+ stdout.puts(formatted_message)
43
+ stdout.flush
44
+ end
45
+
46
+ private
47
+
48
+ def current_time_string
49
+ Time.now.strftime("%H:%M:%S")
50
+ end
51
+
52
+ def colourize(text, colour: :default)
53
+ case colour
54
+ when :black
55
+ "\e[30m#{text}\e[0m"
56
+ when :red
57
+ "\e[31m#{text}\e[0m"
58
+ when :green
59
+ "\e[32m#{text}\e[0m"
60
+ when :yellow
61
+ "\e[33m#{text}\e[0m"
62
+ when :blue
63
+ "\e[34m#{text}\e[0m"
64
+ when :magenta
65
+ "\e[35m#{text}\e[0m"
66
+ when :cyan
67
+ "\e[36m#{text}\e[0m"
68
+ when :white
69
+ "\e[37m#{text}\e[0m"
70
+ when :gray
71
+ "\e[90m#{text}\e[0m"
72
+ else
73
+ text
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Elelem
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.2"
5
5
  end
data/lib/elelem.rb CHANGED
@@ -1,17 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "erb"
3
4
  require "json"
5
+ require "json-schema"
4
6
  require "logger"
5
7
  require "net/http"
6
8
  require "open3"
9
+ require "reline"
7
10
  require "thor"
8
11
  require "uri"
9
12
 
10
13
  require_relative "elelem/agent"
14
+ require_relative "elelem/api"
11
15
  require_relative "elelem/application"
12
16
  require_relative "elelem/configuration"
13
17
  require_relative "elelem/conversation"
18
+ require_relative "elelem/mcp_client"
19
+ require_relative "elelem/state"
20
+ require_relative "elelem/tool"
14
21
  require_relative "elelem/tools"
22
+ require_relative "elelem/tui"
15
23
  require_relative "elelem/version"
16
24
 
17
25
  module Elelem
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.0
4
+ version: 0.1.2
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: erb
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: json
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -23,6 +37,20 @@ dependencies:
23
37
  - - ">="
24
38
  - !ruby/object:Gem::Version
25
39
  version: '0'
40
+ - !ruby/object:Gem::Dependency
41
+ name: json-schema
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '0'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '0'
26
54
  - !ruby/object:Gem::Dependency
27
55
  name: logger
28
56
  requirement: !ruby/object:Gem::Requirement
@@ -65,6 +93,20 @@ dependencies:
65
93
  - - ">="
66
94
  - !ruby/object:Gem::Version
67
95
  version: '0'
96
+ - !ruby/object:Gem::Dependency
97
+ name: reline
98
+ requirement: !ruby/object:Gem::Requirement
99
+ requirements:
100
+ - - ">="
101
+ - !ruby/object:Gem::Version
102
+ version: '0'
103
+ type: :runtime
104
+ prerelease: false
105
+ version_requirements: !ruby/object:Gem::Requirement
106
+ requirements:
107
+ - - ">="
108
+ - !ruby/object:Gem::Version
109
+ version: '0'
68
110
  - !ruby/object:Gem::Dependency
69
111
  name: thor
70
112
  requirement: !ruby/object:Gem::Requirement
@@ -101,8 +143,6 @@ executables:
101
143
  extensions: []
102
144
  extra_rdoc_files: []
103
145
  files:
104
- - ".rspec"
105
- - ".rubocop.yml"
106
146
  - CHANGELOG.md
107
147
  - LICENSE.txt
108
148
  - README.md
@@ -110,12 +150,17 @@ files:
110
150
  - exe/elelem
111
151
  - lib/elelem.rb
112
152
  - lib/elelem/agent.rb
153
+ - lib/elelem/api.rb
113
154
  - lib/elelem/application.rb
114
155
  - lib/elelem/configuration.rb
115
156
  - lib/elelem/conversation.rb
157
+ - lib/elelem/mcp_client.rb
158
+ - lib/elelem/state.rb
159
+ - lib/elelem/system_prompt.erb
160
+ - lib/elelem/tool.rb
116
161
  - lib/elelem/tools.rb
162
+ - lib/elelem/tui.rb
117
163
  - lib/elelem/version.rb
118
- - mise.toml
119
164
  - sig/elelem.rbs
120
165
  homepage: https://www.mokhan.ca
121
166
  licenses:
@@ -132,7 +177,7 @@ required_ruby_version: !ruby/object:Gem::Requirement
132
177
  requirements:
133
178
  - - ">="
134
179
  - !ruby/object:Gem::Version
135
- version: 3.1.0
180
+ version: 3.4.0
136
181
  required_rubygems_version: !ruby/object:Gem::Requirement
137
182
  requirements:
138
183
  - - ">="
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/mise.toml DELETED
@@ -1,2 +0,0 @@
1
- [tools]
2
- ruby = "latest"