elelem 0.1.3 → 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: 9d4a99b7addd5861f402c297ecfd19bc52e8edc6bba89422c2dec195a8cdcd13
4
- data.tar.gz: d663221598cb4a843879ae191d6b7d8f62318be15d121803f58f752cecc79bbc
3
+ metadata.gz: d9eecf050fb965aa627ff0266d46d6085a4cfbb52224226d1375d1ee4b41c9d2
4
+ data.tar.gz: 151e6eb08baf9f1f7ee622e25048eaf33e0ff1baa9d311d504ebe370a991af97
5
5
  SHA512:
6
- metadata.gz: cdc7ccfb94de895a32f5be4cc310f23ca781119628fcbec4bd95a50df2c5b0eb3caa474c3a9058b6120c744e56f53001eb8c67b09cad7934f9b03292202e3c88
7
- data.tar.gz: 9732e722f6d1c040d4c497b6826bd53ac425ad129befdd37da0e7ecd60b9bf4e6c96464a44f89a4cd118ca79b5b675ac6190ebf157c21bf4d06aa8ced13507f9
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/lib/elelem/agent.rb CHANGED
@@ -20,27 +20,33 @@ module Elelem
20
20
  def repl
21
21
  loop do
22
22
  current_state.run(self)
23
+ sleep 0.1
23
24
  end
24
25
  end
25
26
 
26
27
  def transition_to(next_state)
27
- 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
28
33
  @current_state = next_state
29
34
  end
30
35
 
31
36
  def execute(tool_call)
32
- logger.debug("Execute: #{tool_call}")
33
- configuration.tools.execute(tool_call)
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
34
42
  end
35
43
 
36
44
  def quit
37
- logger.debug("Exiting...")
38
45
  cleanup
39
46
  exit
40
47
  end
41
48
 
42
49
  def cleanup
43
- logger.debug("Cleaning up agent...")
44
50
  configuration.cleanup
45
51
  end
46
52
 
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
@@ -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,15 +20,19 @@ 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
37
  @conversation ||= Conversation.new.tap do |conversation|
41
38
  resources = mcp_clients.map do |client|
@@ -48,7 +45,15 @@ module Elelem
48
45
  end
49
46
 
50
47
  def tools
51
- @tools ||= Tools.new(self, [Toolbox::Bash.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
+ )
52
57
  end
53
58
 
54
59
  def cleanup
@@ -57,10 +62,6 @@ module Elelem
57
62
 
58
63
  private
59
64
 
60
- def scheme
61
- host.match?(/\A(?:localhost|127\.0\.0\.1|0\.0\.0\.0)(:\d+)?\z/) ? "http" : "https"
62
- end
63
-
64
65
  def mcp_tools
65
66
  @mcp_tools ||= mcp_clients.map do |client|
66
67
  client.tools.map do |tool|
@@ -11,7 +11,7 @@ module Elelem
11
11
  end
12
12
  end
13
13
 
14
- Waiting.new(agent)
14
+ Thinking.new(agent, "*", :yellow)
15
15
  end
16
16
  end
17
17
  end
@@ -5,8 +5,8 @@ module Elelem
5
5
  module Working
6
6
  class Thinking < State
7
7
  def process(message)
8
- if message["thinking"] && !message["thinking"]&.empty?
9
- agent.tui.say(message["thinking"], colour: :gray, newline: false)
8
+ if message["reasoning"] && !message["reasoning"]&.empty?
9
+ agent.tui.say(message["reasoning"], colour: :gray, newline: false)
10
10
  self
11
11
  else
12
12
  Waiting.new(agent).process(message)
@@ -9,13 +9,13 @@ module Elelem
9
9
  end
10
10
 
11
11
  def process(message)
12
- state_for(message)&.process(message)
12
+ state_for(message)&.process(message) || self
13
13
  end
14
14
 
15
15
  private
16
16
 
17
17
  def state_for(message)
18
- if message["thinking"] && !message["thinking"].empty?
18
+ if message["reasoning"] && !message["reasoning"].empty?
19
19
  Thinking.new(agent, "*", :yellow)
20
20
  elsif message["tool_calls"]&.any?
21
21
  Executing.new(agent, ">", :magenta)
@@ -5,28 +5,49 @@ module Elelem
5
5
  module Working
6
6
  class << self
7
7
  def run(agent)
8
- done = false
9
8
  state = Waiting.new(agent)
10
9
 
11
10
  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"]
11
+ streaming_done = false
12
+ finish_reason = nil
16
13
 
17
- agent.logger.debug("#{state.display_name}: #{message}")
18
- state = state.run(message)
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
19
30
  end
20
31
 
21
- break if state.nil?
22
- break if done && agent.conversation.history.last[:role] != :tool
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
23
43
  end
24
44
 
25
45
  agent.transition_to(States::Idle.new)
26
- end
27
-
28
- def normalize(message)
29
- message.reject { |_key, value| value.empty? }
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)
30
51
  end
31
52
  end
32
53
  end
@@ -1,7 +1,18 @@
1
- **Shell Master** bash>code; compose>write; pipe everything; /proc/sys native; automate fast; streams/transforms; POSIX+GNU; man(1) first; no cleverness.
1
+ You are a self-improving coding assistant with the ability to modify your own source code.
2
2
 
3
- Time: `<%= Time.now.strftime("%Y-%m-%d %H:%M:%S") %>`
4
- Project Directory: `<%= Dir.pwd %>`
5
- System Info: `<%= `uname -a`.strip %>`
3
+ **Core Behavior**: Think via `thinking`, Execute via `tool_calls`, Talk via `content`.
6
4
 
7
- Ready to hack.
5
+ **Your Mission**: Continuously evolve to match the intelligence of advanced AI assistants.
6
+
7
+ **Guidance**: Read `COMMANDMENTS.md` for self-improvement principles using `file action=read path=COMMANDMENTS.md`
8
+
9
+ **Tools**:
10
+ - `exec command="shell command"` - Run commands/tests
11
+ - `file action=read|write path=filepath content=...` - Read/write files
12
+ - `web action=search|fetch query=... url=...` - Internet access
13
+ - `memory action=store|retrieve|search key=... content=...` - Persistent memory
14
+ - `prompt question="..."` - Ask user questions
15
+
16
+ Context: <%= Time.now.strftime("%Y-%m-%d %H:%M:%S") %> | <%= Dir.pwd %> | <%= `uname -a`.strip %>
17
+
18
+ Focus on the user's request and continuously improve your capabilities.
@@ -2,15 +2,18 @@
2
2
 
3
3
  module Elelem
4
4
  module Toolbox
5
- class Bash < ::Elelem::Tool
5
+ class Exec < ::Elelem::Tool
6
6
  attr_reader :tui
7
7
 
8
8
  def initialize(configuration)
9
9
  @tui = configuration.tui
10
- super("bash", "Run commands in /bin/bash -c. Full access to filesystem, network, processes, and all Unix tools.", {
10
+ super("exec", "Execute shell commands with pipe support", {
11
11
  type: "object",
12
12
  properties: {
13
- command: { type: "string" }
13
+ command: {
14
+ type: "string",
15
+ description: "Shell command to execute (supports pipes, redirects, etc.)"
16
+ }
14
17
  },
15
18
  required: ["command"]
16
19
  })
@@ -20,7 +23,8 @@ module Elelem
20
23
  command = args["command"]
21
24
  output_buffer = []
22
25
 
23
- Open3.popen3("/bin/bash", "-c", command) do |stdin, stdout, stderr, wait_thread|
26
+ tui.say(command, newline: true)
27
+ Open3.popen3(command) do |stdin, stdout, stderr, wait_thread|
24
28
  stdin.close
25
29
  streams = [stdout, stderr]
26
30
 
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Toolbox
5
+ class File < Tool
6
+ def initialize(configuration)
7
+ @configuration = configuration
8
+ @tui = configuration.tui
9
+
10
+ super("file", "Read and write files", {
11
+ type: :object,
12
+ properties: {
13
+ action: {
14
+ type: :string,
15
+ enum: ["read", "write"],
16
+ description: "Action to perform: read or write"
17
+ },
18
+ path: {
19
+ type: :string,
20
+ description: "File path"
21
+ },
22
+ content: {
23
+ type: :string,
24
+ description: "Content to write (only for write action)"
25
+ }
26
+ },
27
+ required: [:action, :path]
28
+ })
29
+ end
30
+
31
+ def call(args)
32
+ action = args["action"]
33
+ path = args["path"]
34
+ content = args["content"]
35
+
36
+ case action
37
+ when "read"
38
+ read_file(path)
39
+ when "write"
40
+ write_file(path, content)
41
+ else
42
+ "Invalid action: #{action}"
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ attr_reader :configuration, :tui
49
+
50
+ def read_file(path)
51
+ tui.say("Read: #{path}", newline: true)
52
+ ::File.read(path)
53
+ rescue => e
54
+ "Error reading file: #{e.message}"
55
+ end
56
+
57
+ def write_file(path, content)
58
+ tui.say("Write: #{path}", newline: true)
59
+ ::File.write(path, content)
60
+ "File written successfully"
61
+ rescue => e
62
+ "Error writing file: #{e.message}"
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Toolbox
5
+ class Memory < Tool
6
+ MEMORY_DIR = ".elelem_memory"
7
+ MAX_MEMORY_SIZE = 1_000_000
8
+
9
+ def initialize(configuration)
10
+ @configuration = configuration
11
+ @tui = configuration.tui
12
+
13
+ super("memory", "Persistent memory for learning and context retention", {
14
+ type: :object,
15
+ properties: {
16
+ action: {
17
+ type: :string,
18
+ enum: %w[store retrieve list search forget],
19
+ description: "Memory action: store, retrieve, list, search, forget"
20
+ },
21
+ key: {
22
+ type: :string,
23
+ description: "Unique key for storing/retrieving memory"
24
+ },
25
+ content: {
26
+ type: :string,
27
+ description: "Content to store (required for store action)"
28
+ },
29
+ query: {
30
+ type: :string,
31
+ description: "Search query for finding memories"
32
+ }
33
+ },
34
+ required: %w[action]
35
+ })
36
+ ensure_memory_dir
37
+ end
38
+
39
+ def call(args)
40
+ action = args["action"]
41
+
42
+ case action
43
+ when "store"
44
+ store_memory(args["key"], args["content"])
45
+ when "retrieve"
46
+ retrieve_memory(args["key"])
47
+ when "list"
48
+ list_memories
49
+ when "search"
50
+ search_memories(args["query"])
51
+ when "forget"
52
+ forget_memory(args["key"])
53
+ else
54
+ "Invalid memory action: #{action}"
55
+ end
56
+ rescue StandardError => e
57
+ "Memory error: #{e.message}"
58
+ end
59
+
60
+ private
61
+
62
+ attr_reader :configuration, :tui
63
+
64
+ def ensure_memory_dir
65
+ Dir.mkdir(MEMORY_DIR) unless Dir.exist?(MEMORY_DIR)
66
+ end
67
+
68
+ def memory_path(key)
69
+ ::File.join(MEMORY_DIR, "#{sanitize_key(key)}.json")
70
+ end
71
+
72
+ def sanitize_key(key)
73
+ key.to_s.gsub(/[^a-zA-Z0-9_-]/, "_").slice(0, 100)
74
+ end
75
+
76
+ def store_memory(key, content)
77
+ return "Key and content required for storing" unless key && content
78
+
79
+ total_size = Dir.glob("#{MEMORY_DIR}/*.json").sum { |f| ::File.size(f) }
80
+ return "Memory capacity exceeded" if total_size > MAX_MEMORY_SIZE
81
+
82
+ memory = {
83
+ key: key,
84
+ content: content,
85
+ timestamp: Time.now.iso8601,
86
+ access_count: 0
87
+ }
88
+
89
+ ::File.write(memory_path(key), JSON.pretty_generate(memory))
90
+ "Memory stored: #{key}"
91
+ end
92
+
93
+ def retrieve_memory(key)
94
+ return "Key required for retrieval" unless key
95
+
96
+ path = memory_path(key)
97
+ return "Memory not found: #{key}" unless ::File.exist?(path)
98
+
99
+ memory = JSON.parse(::File.read(path))
100
+ memory["access_count"] += 1
101
+ memory["last_accessed"] = Time.now.iso8601
102
+
103
+ ::File.write(path, JSON.pretty_generate(memory))
104
+ memory["content"]
105
+ end
106
+
107
+ def list_memories
108
+ memories = Dir.glob("#{MEMORY_DIR}/*.json").map do |file|
109
+ memory = JSON.parse(::File.read(file))
110
+ {
111
+ key: memory["key"],
112
+ timestamp: memory["timestamp"],
113
+ size: memory["content"].length,
114
+ access_count: memory["access_count"] || 0
115
+ }
116
+ end
117
+
118
+ memories.sort_by { |m| m[:timestamp] }.reverse
119
+ JSON.pretty_generate(memories)
120
+ end
121
+
122
+ def search_memories(query)
123
+ return "Query required for search" unless query
124
+
125
+ matches = Dir.glob("#{MEMORY_DIR}/*.json").filter_map do |file|
126
+ memory = JSON.parse(::File.read(file))
127
+ if memory["content"].downcase.include?(query.downcase) ||
128
+ memory["key"].downcase.include?(query.downcase)
129
+ {
130
+ key: memory["key"],
131
+ snippet: memory["content"][0, 200] + "...",
132
+ relevance: calculate_relevance(memory, query)
133
+ }
134
+ end
135
+ end
136
+
137
+ matches.sort_by { |m| -m[:relevance] }
138
+ JSON.pretty_generate(matches)
139
+ end
140
+
141
+ def forget_memory(key)
142
+ return "Key required for forgetting" unless key
143
+
144
+ path = memory_path(key)
145
+ return "Memory not found: #{key}" unless ::File.exist?(path)
146
+
147
+ ::File.delete(path)
148
+ "Memory forgotten: #{key}"
149
+ end
150
+
151
+ def calculate_relevance(memory, query)
152
+ content = memory["content"].downcase
153
+ key = memory["key"].downcase
154
+ query = query.downcase
155
+
156
+ score = 0
157
+ score += 3 if key.include?(query)
158
+ score += content.scan(query).length
159
+ score += (memory["access_count"] || 0) * 0.1
160
+ score
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Toolbox
5
+ class Prompt < Tool
6
+ def initialize(configuration)
7
+ @configuration = configuration
8
+ super("prompt", "Ask the user a question and get their response.", {
9
+ type: :object,
10
+ properties: {
11
+ question: {
12
+ type: :string,
13
+ description: "The question to ask the user."
14
+ }
15
+ },
16
+ required: [:question]
17
+ })
18
+ end
19
+
20
+ def call(args)
21
+ @configuration.tui.prompt(args["question"])
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Toolbox
5
+ class Web < Tool
6
+ def initialize(configuration)
7
+ super("web", "Fetch web content and search the internet", {
8
+ type: :object,
9
+ properties: {
10
+ action: {
11
+ type: :string,
12
+ enum: ["fetch", "search"],
13
+ description: "Action to perform: fetch URL or search"
14
+ },
15
+ url: {
16
+ type: :string,
17
+ description: "URL to fetch (for fetch action)"
18
+ },
19
+ query: {
20
+ type: :string,
21
+ description: "Search query (for search action)"
22
+ }
23
+ },
24
+ required: [:action]
25
+ })
26
+ end
27
+
28
+ def call(args)
29
+ action = args["action"]
30
+ case action
31
+ when "fetch"
32
+ fetch_url(args["url"])
33
+ when "search"
34
+ search_web(args["query"])
35
+ else
36
+ "Invalid action: #{action}"
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def fetch_url(url)
43
+ return "URL required for fetch action" unless url
44
+
45
+ uri = URI(url)
46
+ http = Net::HTTP.new(uri.host, uri.port)
47
+ http.use_ssl = uri.scheme == "https"
48
+ http.read_timeout = 10
49
+ http.open_timeout = 5
50
+
51
+ request = Net::HTTP::Get.new(uri)
52
+ request["User-Agent"] = "Elelem Agent/1.0"
53
+
54
+ response = http.request(request)
55
+
56
+ if response.is_a?(Net::HTTPSuccess)
57
+ content_type = response["content-type"] || ""
58
+ if content_type.include?("text/html")
59
+ extract_text_from_html(response.body)
60
+ else
61
+ response.body
62
+ end
63
+ else
64
+ "HTTP Error: #{response.code} #{response.message}"
65
+ end
66
+ end
67
+
68
+ def search_web(query)
69
+ return "Query required for search action" unless query
70
+
71
+ # Use DuckDuckGo instant answers API
72
+ search_url = "https://api.duckduckgo.com/?q=#{URI.encode_www_form_component(query)}&format=json&no_html=1"
73
+
74
+ result = fetch_url(search_url)
75
+ if result.start_with?("Error") || result.start_with?("HTTP Error")
76
+ result
77
+ else
78
+ format_search_results(JSON.parse(result), query)
79
+ end
80
+ end
81
+
82
+ def extract_text_from_html(html)
83
+ # Simple HTML tag stripping
84
+ text = html.gsub(/<script[^>]*>.*?<\/script>/im, "")
85
+ .gsub(/<style[^>]*>.*?<\/style>/im, "")
86
+ .gsub(/<[^>]*>/, " ")
87
+ .gsub(/\s+/, " ")
88
+ .strip
89
+
90
+ # Limit content length
91
+ text.length > 5000 ? text[0...5000] + "..." : text
92
+ end
93
+
94
+ def format_search_results(data, query)
95
+ results = []
96
+
97
+ # Instant answer
98
+ if data["Answer"] && !data["Answer"].empty?
99
+ results << "Answer: #{data["Answer"]}"
100
+ end
101
+
102
+ # Abstract
103
+ if data["Abstract"] && !data["Abstract"].empty?
104
+ results << "Summary: #{data["Abstract"]}"
105
+ end
106
+
107
+ # Related topics
108
+ if data["RelatedTopics"] && data["RelatedTopics"].any?
109
+ topics = data["RelatedTopics"].first(3).map do |topic|
110
+ topic["Text"] if topic["Text"]
111
+ end.compact
112
+
113
+ if topics.any?
114
+ results << "Related: #{topics.join("; ")}"
115
+ end
116
+ end
117
+
118
+ if results.empty?
119
+ "No direct results found for '#{query}'. Try a more specific search or use web fetch to access specific URLs."
120
+ else
121
+ results.join("\n\n")
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "toolbox/exec"
4
+ require_relative "toolbox/file"
5
+ require_relative "toolbox/web"
6
+ require_relative "toolbox/mcp"
7
+ require_relative "toolbox/prompt"
8
+ require_relative "toolbox/memory"
data/lib/elelem/tools.rb CHANGED
@@ -12,8 +12,7 @@ module Elelem
12
12
  end
13
13
 
14
14
  def execute(tool_call)
15
- name = tool_call.dig("function", "name")
16
- args = tool_call.dig("function", "arguments")
15
+ name, args = parse(tool_call)
17
16
 
18
17
  tool = tools.find { |tool| tool.name == name }
19
18
  return "Invalid function name: #{name}" if tool.nil?
@@ -31,5 +30,12 @@ module Elelem
31
30
  private
32
31
 
33
32
  attr_reader :configuration, :tools
33
+
34
+ def parse(tool_call)
35
+ name = tool_call.dig("function", "name")
36
+ arguments = tool_call.dig("function", "arguments")
37
+
38
+ [name, arguments.is_a?(String) ? JSON.parse(arguments) : arguments]
39
+ end
34
40
  end
35
41
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Elelem
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/elelem.rb CHANGED
@@ -5,12 +5,11 @@ require "erb"
5
5
  require "json"
6
6
  require "json-schema"
7
7
  require "logger"
8
- require "net/http"
8
+ require "net/llm"
9
9
  require "open3"
10
10
  require "reline"
11
11
  require "thor"
12
12
  require "timeout"
13
- require "uri"
14
13
 
15
14
  require_relative "elelem/agent"
16
15
  require_relative "elelem/api"
@@ -27,8 +26,7 @@ require_relative "elelem/states/working/talking"
27
26
  require_relative "elelem/states/working/thinking"
28
27
  require_relative "elelem/states/working/waiting"
29
28
  require_relative "elelem/tool"
30
- require_relative "elelem/toolbox/bash"
31
- require_relative "elelem/toolbox/mcp"
29
+ require_relative "elelem/toolbox"
32
30
  require_relative "elelem/tools"
33
31
  require_relative "elelem/tui"
34
32
  require_relative "elelem/version"
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.3
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - mo khan
@@ -80,7 +80,7 @@ dependencies:
80
80
  - !ruby/object:Gem::Version
81
81
  version: '0'
82
82
  - !ruby/object:Gem::Dependency
83
- name: net-http
83
+ name: net-llm
84
84
  requirement: !ruby/object:Gem::Requirement
85
85
  requirements:
86
86
  - - ">="
@@ -149,20 +149,6 @@ dependencies:
149
149
  - - ">="
150
150
  - !ruby/object:Gem::Version
151
151
  version: '0'
152
- - !ruby/object:Gem::Dependency
153
- name: uri
154
- requirement: !ruby/object:Gem::Requirement
155
- requirements:
156
- - - ">="
157
- - !ruby/object:Gem::Version
158
- version: '0'
159
- type: :runtime
160
- prerelease: false
161
- version_requirements: !ruby/object:Gem::Requirement
162
- requirements:
163
- - - ">="
164
- - !ruby/object:Gem::Version
165
- version: '0'
166
152
  description: A REPL for Ollama.
167
153
  email:
168
154
  - mo@mokhan.ca
@@ -193,12 +179,16 @@ files:
193
179
  - lib/elelem/states/working/waiting.rb
194
180
  - lib/elelem/system_prompt.erb
195
181
  - lib/elelem/tool.rb
196
- - lib/elelem/toolbox/bash.rb
182
+ - lib/elelem/toolbox.rb
183
+ - lib/elelem/toolbox/exec.rb
184
+ - lib/elelem/toolbox/file.rb
197
185
  - lib/elelem/toolbox/mcp.rb
186
+ - lib/elelem/toolbox/memory.rb
187
+ - lib/elelem/toolbox/prompt.rb
188
+ - lib/elelem/toolbox/web.rb
198
189
  - lib/elelem/tools.rb
199
190
  - lib/elelem/tui.rb
200
191
  - lib/elelem/version.rb
201
- - sig/elelem.rbs
202
192
  homepage: https://www.mokhan.ca
203
193
  licenses:
204
194
  - MIT
@@ -221,7 +211,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
221
211
  - !ruby/object:Gem::Version
222
212
  version: 3.3.11
223
213
  requirements: []
224
- rubygems_version: 3.6.9
214
+ rubygems_version: 3.7.2
225
215
  specification_version: 4
226
216
  summary: A REPL for Ollama.
227
217
  test_files: []
data/sig/elelem.rbs DELETED
@@ -1,4 +0,0 @@
1
- module Elelem
2
- VERSION: String
3
- # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
- end