elelem 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +0 -94
- data/exe/elelem +0 -3
- data/lib/elelem/agent.rb +22 -26
- data/lib/elelem/api.rb +34 -21
- data/lib/elelem/application.rb +2 -2
- data/lib/elelem/configuration.rb +45 -33
- data/lib/elelem/conversation.rb +9 -1
- data/lib/elelem/mcp_client.rb +40 -3
- data/lib/elelem/states/idle.rb +23 -0
- data/lib/elelem/states/working/error.rb +19 -0
- data/lib/elelem/states/working/executing.rb +19 -0
- data/lib/elelem/states/working/state.rb +26 -0
- data/lib/elelem/states/working/talking.rb +19 -0
- data/lib/elelem/states/working/thinking.rb +18 -0
- data/lib/elelem/states/working/waiting.rb +29 -0
- data/lib/elelem/states/working.rb +55 -0
- data/lib/elelem/system_prompt.erb +16 -5
- data/lib/elelem/tool.rb +0 -84
- data/lib/elelem/toolbox/exec.rb +61 -0
- data/lib/elelem/toolbox/file.rb +66 -0
- data/lib/elelem/toolbox/mcp.rb +37 -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 +11 -3
- data/lib/elelem/tui.rb +7 -18
- data/lib/elelem/version.rb +1 -1
- data/lib/elelem.rb +16 -3
- metadata +33 -6
- data/lib/elelem/state.rb +0 -162
- data/sig/elelem.rbs +0 -4
@@ -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
|
-
|
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.
|
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
|