elelem 0.2.1 → 0.3.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.
@@ -1,19 +0,0 @@
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
@@ -1,18 +0,0 @@
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
@@ -1,29 +0,0 @@
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
@@ -1,55 +0,0 @@
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
data/lib/elelem/tool.rb DELETED
@@ -1,32 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Elelem
4
- class Tool
5
- attr_reader :name, :description, :parameters
6
-
7
- def initialize(name, description, parameters)
8
- @name = name
9
- @description = description
10
- @parameters = parameters
11
- end
12
-
13
- def banner
14
- [name, parameters].join(": ")
15
- end
16
-
17
- def valid?(args)
18
- JSON::Validator.validate(parameters, args, insert_defaults: true)
19
- end
20
-
21
- def to_h
22
- {
23
- type: "function",
24
- function: {
25
- name: name,
26
- description: description,
27
- parameters: parameters
28
- }
29
- }
30
- end
31
- end
32
- end
@@ -1,61 +0,0 @@
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
@@ -1,66 +0,0 @@
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
@@ -1,37 +0,0 @@
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
@@ -1,164 +0,0 @@
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
@@ -1,25 +0,0 @@
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
@@ -1,126 +0,0 @@
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
@@ -1,8 +0,0 @@
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 DELETED
@@ -1,41 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Elelem
4
- class Tools
5
- def initialize(configuration, tools)
6
- @configuration = configuration
7
- @tools = tools
8
- end
9
-
10
- def banner
11
- tools.map(&:banner).sort.join("\n ")
12
- end
13
-
14
- def execute(tool_call)
15
- name, args = parse(tool_call)
16
-
17
- tool = tools.find { |tool| tool.name == name }
18
- return "Invalid function name: #{name}" if tool.nil?
19
- return "Invalid function arguments: #{args}" unless tool.valid?(args)
20
-
21
- CLI::UI::Frame.open(name) do
22
- tool.call(args)
23
- end
24
- end
25
-
26
- def to_h
27
- tools.map(&:to_h)
28
- end
29
-
30
- private
31
-
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
40
- end
41
- end