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.
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module States
5
+ module Working
6
+ class Waiting < State
7
+ def initialize(agent)
8
+ super(agent, ".", :cyan)
9
+ end
10
+
11
+ def process(message)
12
+ state_for(message)&.process(message) || self
13
+ end
14
+
15
+ private
16
+
17
+ def state_for(message)
18
+ if message["reasoning"] && !message["reasoning"].empty?
19
+ Thinking.new(agent, "*", :yellow)
20
+ elsif message["tool_calls"]&.any?
21
+ Executing.new(agent, ">", :magenta)
22
+ elsif message["content"] && !message["content"].empty?
23
+ Talking.new(agent, "~", :white)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module States
5
+ module Working
6
+ class << self
7
+ def run(agent)
8
+ state = Waiting.new(agent)
9
+
10
+ loop do
11
+ streaming_done = false
12
+ finish_reason = nil
13
+
14
+ agent.api.chat(agent.conversation.history) do |message|
15
+ if message["done"]
16
+ streaming_done = true
17
+ next
18
+ end
19
+
20
+ if message["finish_reason"]
21
+ finish_reason = message["finish_reason"]
22
+ agent.logger.debug("Working: finish_reason = #{finish_reason}")
23
+ end
24
+
25
+ new_state = state.run(message)
26
+ if new_state.class != state.class
27
+ agent.logger.info("STATE: #{state.display_name} -> #{new_state.display_name}")
28
+ end
29
+ state = new_state
30
+ end
31
+
32
+ # Only exit when task is actually complete, not just streaming done
33
+ if finish_reason == "stop"
34
+ agent.logger.debug("Working: Task complete, exiting to Idle")
35
+ break
36
+ elsif finish_reason == "tool_calls"
37
+ agent.logger.debug("Working: Tool calls finished, continuing conversation")
38
+ # Continue loop to process tool results
39
+ elsif streaming_done && finish_reason.nil?
40
+ agent.logger.debug("Working: Streaming done but no finish_reason, continuing")
41
+ # Continue for cases where finish_reason comes in separate chunk
42
+ end
43
+ end
44
+
45
+ agent.transition_to(States::Idle.new)
46
+ rescue StandardError => e
47
+ agent.logger.error(e)
48
+ agent.conversation.add(role: :tool, content: e.message)
49
+ agent.tui.say(e.message, colour: :red, newline: true)
50
+ agent.transition_to(States::Idle.new)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -1,7 +1,18 @@
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.
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
- Del is now being connected with a person.
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.
data/lib/elelem/tool.rb CHANGED
@@ -29,88 +29,4 @@ module Elelem
29
29
  }
30
30
  end
31
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
32
  end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Toolbox
5
+ class Exec < ::Elelem::Tool
6
+ attr_reader :tui
7
+
8
+ def initialize(configuration)
9
+ @tui = configuration.tui
10
+ super("exec", "Execute shell commands with pipe support", {
11
+ type: "object",
12
+ properties: {
13
+ command: {
14
+ type: "string",
15
+ description: "Shell command to execute (supports pipes, redirects, etc.)"
16
+ }
17
+ },
18
+ required: ["command"]
19
+ })
20
+ end
21
+
22
+ def call(args)
23
+ command = args["command"]
24
+ output_buffer = []
25
+
26
+ tui.say(command, newline: true)
27
+ Open3.popen3(command) do |stdin, stdout, stderr, wait_thread|
28
+ stdin.close
29
+ streams = [stdout, stderr]
30
+
31
+ until streams.empty?
32
+ ready = IO.select(streams, nil, nil, 0.1)
33
+
34
+ if ready
35
+ ready[0].each do |io|
36
+ data = io.read_nonblock(4096)
37
+ output_buffer << data
38
+
39
+ if io == stderr
40
+ tui.say(data, colour: :red, newline: false)
41
+ else
42
+ tui.say(data, newline: false)
43
+ end
44
+ rescue IO::WaitReadable
45
+ next
46
+ rescue EOFError
47
+ streams.delete(io)
48
+ end
49
+ elsif !wait_thread.alive?
50
+ break
51
+ end
52
+ end
53
+
54
+ wait_thread.value
55
+ end
56
+
57
+ output_buffer.join
58
+ end
59
+ end
60
+ end
61
+ end
@@ -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,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Elelem
4
+ module Toolbox
5
+ class MCP < ::Elelem::Tool
6
+ attr_reader :client, :tui
7
+
8
+ def initialize(client, tui, tool)
9
+ @client = client
10
+ @tui = tui
11
+ super(tool["name"], tool["description"], tool["inputSchema"] || {})
12
+ end
13
+
14
+ def call(args)
15
+ unless client.connected?
16
+ tui.say("MCP connection lost", colour: :red)
17
+ return ""
18
+ end
19
+
20
+ result = client.call(name, args)
21
+ tui.say(JSON.pretty_generate(result), newline: true)
22
+
23
+ if result.nil? || result.empty?
24
+ tui.say("Tool call failed: no response from MCP server", colour: :red)
25
+ return result
26
+ end
27
+
28
+ if result["error"]
29
+ tui.say(result["error"], colour: :red)
30
+ return result
31
+ end
32
+
33
+ result.dig("content", 0, "text") || result.to_s
34
+ end
35
+ end
36
+ end
37
+ 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"