elelem 0.1.2 → 0.2.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a5469d2d253c6e0d09f60de69dafffdffb305da3f5864a4f6aa9bbac5ae7da6
4
- data.tar.gz: 60d9d35b759e0722b557cefbb4fb6d6b1713661de432efb8283ae1cd7f951e05
3
+ metadata.gz: d9eecf050fb965aa627ff0266d46d6085a4cfbb52224226d1375d1ee4b41c9d2
4
+ data.tar.gz: 151e6eb08baf9f1f7ee622e25048eaf33e0ff1baa9d311d504ebe370a991af97
5
5
  SHA512:
6
- metadata.gz: 46073936ca9abcf83897e83355b7f979ebf2866d16351ccced55d70a8c311d8068acb3a78126b85b7b6622b45e6b2d51dc86f35914a25fdec937ad60214e4da9
7
- data.tar.gz: d7233c12ed3b2359c5c7a1d47e908951e0651f19fb8d07777e34aa0fb5ddfc51f4368a7a192136a743d1009a9a26239da572555dd5d3d90e99e1a4e2668c2d47
6
+ metadata.gz: 3d23eec1c298290ddd9480093b8ed908fbcbe1f55bb468618abaf445a6c1545fb4e43c995e8d4fce1655c7e92d255aed8237cc46fa48675e462fcb68718aa85c
7
+ data.tar.gz: f3dce29e2aa754be66f2c4cb01093f5ebb10788e96299e5925fbae59df7196cc3eed548aa154876184471ed3a4491260f969fe0409581ef8bbd89624cafedf9f
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [0.2.0] - 2025-10-15
4
+
5
+ ### Added
6
+ - New `llm-ollama` executable - minimal coding agent with streaming support for Ollama
7
+ - New `llm-openai` executable - minimal coding agent for OpenAI/compatible APIs
8
+ - Memory feature for persistent context storage and retrieval
9
+ - Web fetch tool for retrieving and analyzing web content
10
+ - Streaming responses with real-time token display
11
+ - Visual "thinking" progress indicators with dots during reasoning phase
12
+
13
+ ### Changed
14
+ - **BREAKING**: Migrated from custom Net::HTTP implementation to `net-llm` gem
15
+ - API client now uses `Net::Llm::Ollama` for better reliability and maintainability
16
+ - Removed direct dependencies on `net-http` and `uri` (now transitive through net-llm)
17
+ - Maps Ollama's `thinking` field to internal `reasoning` field
18
+ - Maps Ollama's `done_reason` to internal `finish_reason`
19
+ - Improved system prompt for better agent behavior
20
+ - Enhanced error handling and logging
21
+
22
+ ### Fixed
23
+ - Response processing for Ollama's native message format
24
+ - Tool argument parsing to handle both string and object formats
25
+ - Safe navigation operator usage to prevent nil errors
26
+
3
27
  ## [0.1.2] - 2025-08-14
4
28
 
5
29
  ### Fixed
data/README.md CHANGED
@@ -58,100 +58,6 @@ After checking out the repo, run `bin/setup` to install dependencies. Then, run
58
58
 
59
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).
60
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
-
155
61
  ## Contributing
156
62
 
157
63
  Bug reports and pull requests are welcome on GitHub at https://github.com/xlgmokha/elelem.
data/exe/elelem CHANGED
@@ -3,9 +3,6 @@
3
3
 
4
4
  require "elelem"
5
5
 
6
- Reline.input = $stdin
7
- Reline.output = $stdout
8
-
9
6
  Signal.trap("INT") do
10
7
  exit(1)
11
8
  end
data/lib/elelem/agent.rb CHANGED
@@ -2,58 +2,54 @@
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
- transition_to(Idle.new)
14
+
15
+ at_exit { cleanup }
16
+
17
+ transition_to(States::Idle.new)
14
18
  end
15
19
 
16
20
  def repl
17
21
  loop do
18
22
  current_state.run(self)
23
+ sleep 0.1
19
24
  end
20
25
  end
21
26
 
22
27
  def transition_to(next_state)
23
- logger.debug("Transition to: #{next_state.class.name}")
28
+ if @current_state
29
+ logger.info("AGENT: #{@current_state.class.name.split('::').last} -> #{next_state.class.name.split('::').last}")
30
+ else
31
+ logger.info("AGENT: Starting in #{next_state.class.name.split('::').last}")
32
+ end
24
33
  @current_state = next_state
25
34
  end
26
35
 
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
36
  def execute(tool_call)
36
- logger.debug("Execute: #{tool_call}")
37
- configuration.tools.execute(tool_call)
38
- end
39
-
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)
37
+ tool_name = tool_call.dig("function", "name")
38
+ logger.debug("TOOL: Full call - #{tool_call}")
39
+ result = configuration.tools.execute(tool_call)
40
+ logger.debug("TOOL: Result (#{result.length} chars)") if result
41
+ result
50
42
  end
51
43
 
52
44
  def quit
53
- logger.debug("Exiting...")
45
+ cleanup
54
46
  exit
55
47
  end
56
48
 
49
+ def cleanup
50
+ configuration.cleanup
51
+ end
52
+
57
53
  private
58
54
 
59
55
  attr_reader :configuration, :current_state
data/lib/elelem/api.rb CHANGED
@@ -1,35 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "net/llm"
4
+
3
5
  module Elelem
4
6
  class Api
5
- attr_reader :configuration
7
+ attr_reader :configuration, :client
6
8
 
7
9
  def initialize(configuration)
8
10
  @configuration = configuration
11
+ @client = Net::Llm::Ollama.new(
12
+ host: configuration.host,
13
+ model: configuration.model
14
+ )
9
15
  end
10
16
 
11
17
  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)
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
32
22
  end
33
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
34
47
  end
35
48
  end
@@ -45,9 +45,9 @@ module Elelem
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
@@ -11,13 +11,6 @@ module Elelem
11
11
  @debug = debug
12
12
  end
13
13
 
14
- def http
15
- @http ||= Net::HTTP.new(uri.host, uri.port).tap do |h|
16
- h.read_timeout = 3_600
17
- h.open_timeout = 10
18
- end
19
- end
20
-
21
14
  def tui
22
15
  @tui ||= TUI.new($stdin, $stdout)
23
16
  end
@@ -27,46 +20,65 @@ module Elelem
27
20
  end
28
21
 
29
22
  def logger
30
- @logger ||= Logger.new(debug ? "elelem.log" : "/dev/null").tap do |logger|
31
- logger.formatter = ->(_, _, _, message) { "#{message.to_s.strip}\n" }
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
+ }
32
33
  end
33
34
  end
34
35
 
35
- def uri
36
- @uri ||= URI("#{scheme}://#{host}/api/chat")
37
- end
38
-
39
36
  def conversation
40
- @conversation ||= Conversation.new
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
41
45
  end
42
46
 
43
47
  def tools
44
- @tools ||= Tools.new(self, [BashTool.new(self)] + mcp_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
+ )
45
57
  end
46
58
 
47
- private
48
-
49
- def scheme
50
- host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? "http" : "https"
59
+ def cleanup
60
+ @mcp_clients&.each(&:shutdown)
51
61
  end
52
62
 
53
- def mcp_tools(clients = [serena_client])
54
- return [] if ENV["SMALL"]
63
+ private
55
64
 
56
- @mcp_tools ||= clients.map { |client| client.tools.map { |tool| MCPTool.new(client, tui, tool) } }.flatten
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
57
71
  end
58
72
 
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
- ])
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
70
82
  end
71
83
  end
72
84
  end
@@ -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
@@ -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: "2024-11-05",
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 (required by MCP protocol)
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
+ Thinking.new(agent, "*", :yellow)
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["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