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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +0 -94
- data/lib/elelem/agent.rb +11 -5
- data/lib/elelem/api.rb +34 -21
- data/lib/elelem/configuration.rb +19 -18
- data/lib/elelem/states/working/executing.rb +1 -1
- data/lib/elelem/states/working/thinking.rb +2 -2
- data/lib/elelem/states/working/waiting.rb +2 -2
- data/lib/elelem/states/working.rb +34 -13
- data/lib/elelem/system_prompt.erb +16 -5
- data/lib/elelem/toolbox/{bash.rb → exec.rb} +8 -4
- data/lib/elelem/toolbox/file.rb +66 -0
- data/lib/elelem/toolbox/memory.rb +164 -0
- data/lib/elelem/toolbox/prompt.rb +25 -0
- data/lib/elelem/toolbox/web.rb +126 -0
- data/lib/elelem/toolbox.rb +8 -0
- data/lib/elelem/tools.rb +8 -2
- data/lib/elelem/version.rb +1 -1
- data/lib/elelem.rb +2 -4
- metadata +9 -19
- data/sig/elelem.rbs +0 -4
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d9eecf050fb965aa627ff0266d46d6085a4cfbb52224226d1375d1ee4b41c9d2
|
4
|
+
data.tar.gz: 151e6eb08baf9f1f7ee622e25048eaf33e0ff1baa9d311d504ebe370a991af97
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
-
|
33
|
-
|
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
|
-
|
13
|
-
|
14
|
-
|
15
|
-
|
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
|
data/lib/elelem/configuration.rb
CHANGED
@@ -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(
|
31
|
-
|
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,
|
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|
|
@@ -5,8 +5,8 @@ module Elelem
|
|
5
5
|
module Working
|
6
6
|
class Thinking < State
|
7
7
|
def process(message)
|
8
|
-
if message["
|
9
|
-
agent.tui.say(message["
|
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["
|
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
|
-
|
13
|
-
|
14
|
-
message = normalize(response["message"] || {})
|
15
|
-
done = response["done"]
|
11
|
+
streaming_done = false
|
12
|
+
finish_reason = nil
|
16
13
|
|
17
|
-
|
18
|
-
|
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
|
-
|
22
|
-
|
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
|
-
|
27
|
-
|
28
|
-
|
29
|
-
message
|
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
|
-
|
1
|
+
You are a self-improving coding assistant with the ability to modify your own source code.
|
2
2
|
|
3
|
-
|
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
|
-
|
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
|
5
|
+
class Exec < ::Elelem::Tool
|
6
6
|
attr_reader :tui
|
7
7
|
|
8
8
|
def initialize(configuration)
|
9
9
|
@tui = configuration.tui
|
10
|
-
super("
|
10
|
+
super("exec", "Execute shell commands with pipe support", {
|
11
11
|
type: "object",
|
12
12
|
properties: {
|
13
|
-
command: {
|
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
|
-
|
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
|
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
|
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
|
data/lib/elelem/version.rb
CHANGED
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/
|
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
|
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.
|
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-
|
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
|
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.
|
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