elelem 0.8.0 → 0.9.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 +71 -3
- data/README.md +31 -46
- data/exe/elelem +78 -3
- data/lib/elelem/agent.rb +135 -228
- data/lib/elelem/mcp.rb +96 -0
- data/lib/elelem/net/claude.rb +200 -0
- data/lib/elelem/net/ollama.rb +78 -0
- data/lib/elelem/net/openai.rb +86 -0
- data/lib/elelem/net.rb +16 -0
- data/lib/elelem/plugins/confirm.rb +12 -0
- data/lib/elelem/plugins/edit.rb +15 -0
- data/lib/elelem/plugins/eval.rb +20 -0
- data/lib/elelem/plugins/execute.rb +18 -0
- data/lib/elelem/plugins/mcp.rb +14 -0
- data/lib/elelem/plugins/read.rb +21 -0
- data/lib/elelem/plugins/verify.rb +47 -0
- data/lib/elelem/plugins/write.rb +23 -0
- data/lib/elelem/plugins.rb +43 -0
- data/lib/elelem/system_prompt.rb +65 -0
- data/lib/elelem/templates/system_prompt.erb +53 -0
- data/lib/elelem/terminal.rb +55 -65
- data/lib/elelem/tool.rb +30 -32
- data/lib/elelem/toolbox.rb +36 -94
- data/lib/elelem/version.rb +1 -1
- data/lib/elelem.rb +28 -36
- metadata +39 -61
- data/lib/elelem/application.rb +0 -45
- data/lib/elelem/conversation.rb +0 -78
- data/lib/elelem/git_context.rb +0 -79
- data/lib/elelem/system_prompt.erb +0 -16
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
module Net
|
|
5
|
+
class Claude
|
|
6
|
+
def self.anthropic(model:, api_key:, http: Elelem::Net.http)
|
|
7
|
+
new(
|
|
8
|
+
endpoint: "https://api.anthropic.com/v1/messages",
|
|
9
|
+
headers: { "x-api-key" => api_key, "anthropic-version" => "2023-06-01" },
|
|
10
|
+
model:,
|
|
11
|
+
http:
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def self.vertex(model:, project:, region: "us-east5", http: Elelem::Net.http)
|
|
16
|
+
new(
|
|
17
|
+
endpoint: "https://#{region}-aiplatform.googleapis.com/v1/projects/#{project}/locations/#{region}/publishers/anthropic/models/#{model}:rawPredict",
|
|
18
|
+
headers: -> { { "Authorization" => "Bearer #{`gcloud auth application-default print-access-token`.strip}" } },
|
|
19
|
+
model:,
|
|
20
|
+
version: "vertex-2023-10-16",
|
|
21
|
+
http:
|
|
22
|
+
)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(endpoint:, headers:, model:, version: nil, http: Elelem::Net.http)
|
|
26
|
+
@endpoint = endpoint
|
|
27
|
+
@headers_source = headers
|
|
28
|
+
@model = model
|
|
29
|
+
@version = version
|
|
30
|
+
@http = http
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def fetch(messages, tools = [], &block)
|
|
34
|
+
system_prompt, normalized_messages = extract_system(messages)
|
|
35
|
+
tool_calls = []
|
|
36
|
+
|
|
37
|
+
stream(normalized_messages, system_prompt, tools) do |event|
|
|
38
|
+
handle_event(event, tool_calls, &block)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
finalize_tool_calls(tool_calls)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def headers
|
|
47
|
+
@headers_source.respond_to?(:call) ? @headers_source.call : @headers_source
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def handle_event(event, tool_calls, &block)
|
|
51
|
+
case event["type"]
|
|
52
|
+
when "content_block_start"
|
|
53
|
+
handle_content_block_start(event, tool_calls)
|
|
54
|
+
when "content_block_delta"
|
|
55
|
+
handle_content_block_delta(event, tool_calls, &block)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def handle_content_block_start(event, tool_calls)
|
|
60
|
+
content_block = event["content_block"]
|
|
61
|
+
return unless content_block["type"] == "tool_use"
|
|
62
|
+
|
|
63
|
+
tool_calls << {
|
|
64
|
+
id: content_block["id"],
|
|
65
|
+
name: content_block["name"],
|
|
66
|
+
args: String.new
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def handle_content_block_delta(event, tool_calls, &block)
|
|
71
|
+
delta = event["delta"]
|
|
72
|
+
|
|
73
|
+
case delta["type"]
|
|
74
|
+
when "text_delta"
|
|
75
|
+
block.call(content: delta["text"], thinking: nil)
|
|
76
|
+
when "thinking_delta"
|
|
77
|
+
block.call(content: nil, thinking: delta["thinking"])
|
|
78
|
+
when "input_json_delta"
|
|
79
|
+
tool_calls.last[:args] << delta["partial_json"].to_s if tool_calls.any?
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def finalize_tool_calls(tool_calls)
|
|
84
|
+
tool_calls.each do |tool_call|
|
|
85
|
+
args = tool_call.delete(:args)
|
|
86
|
+
tool_call[:arguments] = args.empty? ? {} : JSON.parse(args)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def stream(messages, system_prompt, tools)
|
|
91
|
+
body = build_request_body(messages, system_prompt, tools)
|
|
92
|
+
|
|
93
|
+
@http.post(@endpoint, headers:, body:) do |response|
|
|
94
|
+
raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(::Net::HTTPSuccess)
|
|
95
|
+
|
|
96
|
+
read_sse_stream(response) { |event| yield event }
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_request_body(messages, system_prompt, tools)
|
|
101
|
+
body = { max_tokens: 64000, messages:, stream: true }
|
|
102
|
+
body[:model] = @model unless @version
|
|
103
|
+
body[:anthropic_version] = @version if @version
|
|
104
|
+
body[:system] = system_prompt if system_prompt
|
|
105
|
+
body[:tools] = unwrap_tools(tools) unless tools.empty?
|
|
106
|
+
body
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def read_sse_stream(response)
|
|
110
|
+
buffer = String.new
|
|
111
|
+
|
|
112
|
+
response.read_body do |chunk|
|
|
113
|
+
buffer << chunk
|
|
114
|
+
|
|
115
|
+
while (index = buffer.index("\n\n"))
|
|
116
|
+
raw_event = buffer.slice!(0, index + 2)
|
|
117
|
+
event = parse_sse(raw_event)
|
|
118
|
+
yield event if event
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def parse_sse(raw)
|
|
124
|
+
line = raw.lines.find { |l| l.start_with?("data: ") }
|
|
125
|
+
return nil unless line
|
|
126
|
+
|
|
127
|
+
data = line.delete_prefix("data: ").strip
|
|
128
|
+
return nil if data == "[DONE]"
|
|
129
|
+
|
|
130
|
+
JSON.parse(data)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def extract_system(messages)
|
|
134
|
+
system_messages, other_messages = messages.partition { |message| message[:role] == "system" }
|
|
135
|
+
system_content = system_messages.first&.dig(:content)
|
|
136
|
+
[system_content, normalize(other_messages)]
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def normalize(messages)
|
|
140
|
+
messages.map { |message| normalize_message(message) }
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
def normalize_message(message)
|
|
144
|
+
case message[:role]
|
|
145
|
+
when "tool"
|
|
146
|
+
tool_result_message(message)
|
|
147
|
+
when "assistant"
|
|
148
|
+
message[:tool_calls]&.any? ? assistant_with_tools_message(message) : message
|
|
149
|
+
else
|
|
150
|
+
message
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def tool_result_message(message)
|
|
155
|
+
{
|
|
156
|
+
role: "user",
|
|
157
|
+
content: [{
|
|
158
|
+
type: "tool_result",
|
|
159
|
+
tool_use_id: message[:tool_call_id],
|
|
160
|
+
content: message[:content]
|
|
161
|
+
}]
|
|
162
|
+
}
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def assistant_with_tools_message(message)
|
|
166
|
+
text_content = build_text_content(message[:content])
|
|
167
|
+
tool_content = build_tool_content(message[:tool_calls])
|
|
168
|
+
|
|
169
|
+
{ role: "assistant", content: text_content + tool_content }
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def build_text_content(content)
|
|
173
|
+
return [] if content.to_s.empty?
|
|
174
|
+
|
|
175
|
+
[{ type: "text", text: content }]
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def build_tool_content(tool_calls)
|
|
179
|
+
tool_calls.map do |tool_call|
|
|
180
|
+
{
|
|
181
|
+
type: "tool_use",
|
|
182
|
+
id: tool_call[:id],
|
|
183
|
+
name: tool_call[:name],
|
|
184
|
+
input: tool_call[:arguments]
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def unwrap_tools(tools)
|
|
190
|
+
tools.map do |tool|
|
|
191
|
+
{
|
|
192
|
+
name: tool.dig(:function, :name),
|
|
193
|
+
description: tool.dig(:function, :description),
|
|
194
|
+
input_schema: tool.dig(:function, :parameters)
|
|
195
|
+
}
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
end
|
|
200
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
module Net
|
|
5
|
+
class Ollama
|
|
6
|
+
def initialize(model:, host: "localhost:11434", http: Elelem::Net.http)
|
|
7
|
+
@url = normalize_url(host)
|
|
8
|
+
@model = model
|
|
9
|
+
@http = http
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def fetch(messages, tools = [], &block)
|
|
13
|
+
tool_calls = []
|
|
14
|
+
body = build_request_body(messages, tools)
|
|
15
|
+
|
|
16
|
+
stream(body) do |event|
|
|
17
|
+
handle_event(event, tool_calls, &block)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
tool_calls
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def normalize_url(host)
|
|
26
|
+
base = host.start_with?("http") ? host : "http://#{host}"
|
|
27
|
+
"#{base}/api/chat"
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def build_request_body(messages, tools)
|
|
31
|
+
{ model: @model, messages:, tools:, stream: true }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def handle_event(event, tool_calls, &block)
|
|
35
|
+
message = event["message"] || {}
|
|
36
|
+
|
|
37
|
+
unless event["done"]
|
|
38
|
+
block.call(content: message["content"], thinking: message["thinking"])
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
if message["tool_calls"]
|
|
42
|
+
tool_calls.concat(parse_tool_calls(message["tool_calls"]))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def stream(body)
|
|
47
|
+
@http.post(@url, body:) do |response|
|
|
48
|
+
raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(::Net::HTTPSuccess)
|
|
49
|
+
|
|
50
|
+
read_ndjson_stream(response) { |event| yield event }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def read_ndjson_stream(response)
|
|
55
|
+
buffer = String.new
|
|
56
|
+
|
|
57
|
+
response.read_body do |chunk|
|
|
58
|
+
buffer << chunk
|
|
59
|
+
|
|
60
|
+
while (index = buffer.index("\n"))
|
|
61
|
+
line = buffer.slice!(0, index + 1)
|
|
62
|
+
yield JSON.parse(line)
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def parse_tool_calls(tool_calls)
|
|
68
|
+
tool_calls.map do |tool_call|
|
|
69
|
+
{
|
|
70
|
+
id: tool_call["id"],
|
|
71
|
+
name: tool_call.dig("function", "name"),
|
|
72
|
+
arguments: tool_call.dig("function", "arguments") || {}
|
|
73
|
+
}
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
module Net
|
|
5
|
+
class OpenAI
|
|
6
|
+
def initialize(model:, api_key:, base_url: "https://api.openai.com/v1", http: Elelem::Net.http)
|
|
7
|
+
@url = "#{base_url}/chat/completions"
|
|
8
|
+
@model = model
|
|
9
|
+
@api_key = api_key
|
|
10
|
+
@http = http
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def fetch(messages, tools = [], &block)
|
|
14
|
+
tool_calls = {}
|
|
15
|
+
body = build_request_body(messages, tools)
|
|
16
|
+
|
|
17
|
+
stream(body) do |event|
|
|
18
|
+
handle_event(event, tool_calls, &block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
finalize_tool_calls(tool_calls)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def build_request_body(messages, tools)
|
|
27
|
+
{ model: @model, messages:, stream: true, tools:, tool_choice: "auto" }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def handle_event(event, tool_calls, &block)
|
|
31
|
+
delta = event.dig("choices", 0, "delta") || {}
|
|
32
|
+
|
|
33
|
+
block.call(content: delta["content"], thinking: nil) if delta["content"]
|
|
34
|
+
|
|
35
|
+
accumulate_tool_calls(delta["tool_calls"], tool_calls) if delta["tool_calls"]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def accumulate_tool_calls(incoming_tool_calls, tool_calls)
|
|
39
|
+
incoming_tool_calls.each do |tool_call|
|
|
40
|
+
index = tool_call["index"]
|
|
41
|
+
tool_calls[index] ||= { id: nil, name: nil, args: String.new }
|
|
42
|
+
tool_calls[index][:id] ||= tool_call["id"]
|
|
43
|
+
tool_calls[index][:name] ||= tool_call.dig("function", "name")
|
|
44
|
+
tool_calls[index][:args] << tool_call.dig("function", "arguments").to_s
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stream(body)
|
|
49
|
+
@http.post(@url, headers: headers, body:) do |response|
|
|
50
|
+
raise "HTTP #{response.code}: #{response.body}" unless response.is_a?(::Net::HTTPSuccess)
|
|
51
|
+
|
|
52
|
+
read_sse_stream(response) { |event| yield event }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def headers
|
|
57
|
+
{ "Authorization" => "Bearer #{@api_key}" }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def read_sse_stream(response)
|
|
61
|
+
buffer = String.new
|
|
62
|
+
|
|
63
|
+
response.read_body do |chunk|
|
|
64
|
+
buffer << chunk
|
|
65
|
+
|
|
66
|
+
while (index = buffer.index("\n"))
|
|
67
|
+
line = buffer.slice!(0, index + 1).strip
|
|
68
|
+
next unless line.start_with?("data: ") && line != "data: [DONE]"
|
|
69
|
+
|
|
70
|
+
yield JSON.parse(line.delete_prefix("data: "))
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def finalize_tool_calls(tool_calls)
|
|
76
|
+
tool_calls.values.map do |tool_call|
|
|
77
|
+
{
|
|
78
|
+
id: tool_call[:id],
|
|
79
|
+
name: tool_call[:name],
|
|
80
|
+
arguments: JSON.parse(tool_call[:args])
|
|
81
|
+
}
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/elelem/net.rb
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/hippie"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
require_relative "net/ollama"
|
|
7
|
+
require_relative "net/openai"
|
|
8
|
+
require_relative "net/claude"
|
|
9
|
+
|
|
10
|
+
module Elelem
|
|
11
|
+
module Net
|
|
12
|
+
def self.http
|
|
13
|
+
@http ||= ::Net::Hippie::Client.new(read_timeout: 3600, open_timeout: 10)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem::Plugins.register(:confirm) do |toolbox|
|
|
4
|
+
toolbox.before("execute") do |args|
|
|
5
|
+
next unless $stdin.tty?
|
|
6
|
+
|
|
7
|
+
cmd = args["command"]
|
|
8
|
+
$stdout.print " Allow? [Y/n] > "
|
|
9
|
+
answer = $stdin.gets&.strip&.downcase
|
|
10
|
+
raise "User denied permission to execute: #{cmd}" if answer == "n"
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem::Plugins.register(:edit) do |toolbox|
|
|
4
|
+
toolbox.add("edit",
|
|
5
|
+
description: "Replace first occurrence of text in file",
|
|
6
|
+
params: { path: { type: "string" }, old: { type: "string" }, new: { type: "string" } },
|
|
7
|
+
required: ["path", "old", "new"]
|
|
8
|
+
) do |a|
|
|
9
|
+
path = Pathname.new(a["path"]).expand_path
|
|
10
|
+
content = path.read
|
|
11
|
+
toolbox
|
|
12
|
+
.run("write", { "path" => a["path"], "content" => content.sub(a["old"], a["new"]) })
|
|
13
|
+
.merge(replaced: a["old"], with: a["new"])
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem::Plugins.register(:eval) do |toolbox|
|
|
4
|
+
description = <<~'DESC'
|
|
5
|
+
Evaluate Ruby code. Available API:
|
|
6
|
+
|
|
7
|
+
name = "search"
|
|
8
|
+
toolbox.add(name, description: "Search using rg", params: { query: { type: "string" } }, required: ["query"], aliases: []) do |args|
|
|
9
|
+
toolbox.run("execute", { "command" => "rg --json -nI -F #{args["query"]}" })
|
|
10
|
+
end
|
|
11
|
+
DESC
|
|
12
|
+
|
|
13
|
+
toolbox.add("eval",
|
|
14
|
+
description: description,
|
|
15
|
+
params: { ruby: { type: "string" } },
|
|
16
|
+
required: ["ruby"]
|
|
17
|
+
) do |args|
|
|
18
|
+
{ result: binding.eval(args["ruby"]) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem::Plugins.register(:execute) do |toolbox|
|
|
4
|
+
toolbox.add("execute",
|
|
5
|
+
description: "Run shell command (supports pipes and redirections)",
|
|
6
|
+
params: { command: { type: "string" } },
|
|
7
|
+
required: ["command"],
|
|
8
|
+
aliases: ["bash", "sh", "exec", "execute<|channel|>"]
|
|
9
|
+
) do |a|
|
|
10
|
+
Elelem.sh("bash", args: ["-c", a["command"]]) { |x| $stdout.print(x) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
toolbox.after("execute") do |args, result|
|
|
14
|
+
return if result[:exit_status] == 0
|
|
15
|
+
|
|
16
|
+
$stdout.puts toolbox.header("execute", args, state: "x")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem::Plugins.register(:mcp) do |toolbox|
|
|
4
|
+
mcp = Elelem::MCP.new
|
|
5
|
+
at_exit { mcp.close }
|
|
6
|
+
mcp.tools.each do |name, tool|
|
|
7
|
+
toolbox.add(name,
|
|
8
|
+
description: tool[:description],
|
|
9
|
+
params: tool[:params],
|
|
10
|
+
required: tool[:required],
|
|
11
|
+
&tool[:fn]
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem::Plugins.register(:read) do |toolbox|
|
|
4
|
+
toolbox.add("read",
|
|
5
|
+
description: "Read file",
|
|
6
|
+
params: { path: { type: "string" } },
|
|
7
|
+
required: ["path"],
|
|
8
|
+
aliases: ["open"]
|
|
9
|
+
) do |a|
|
|
10
|
+
path = Pathname.new(a["path"]).expand_path
|
|
11
|
+
path.exist? ? { content: path.read, path: a["path"] } : { error: "not found" }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
toolbox.after("read") do |_, result|
|
|
15
|
+
if result[:error]
|
|
16
|
+
$stdout.puts " ! #{result[:error]}"
|
|
17
|
+
elsif !system("bat", "--paging=never", result[:path])
|
|
18
|
+
$stdout.puts result[:content]
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
module Verifiers
|
|
5
|
+
SYNTAX = {
|
|
6
|
+
".rb" => "ruby -c %{path}",
|
|
7
|
+
".erb" => "erb -x %{path} | ruby -c",
|
|
8
|
+
".py" => "python -m py_compile %{path}",
|
|
9
|
+
".go" => "go vet %{path}",
|
|
10
|
+
".rs" => "cargo check --quiet",
|
|
11
|
+
".ts" => "npx tsc --noEmit %{path}",
|
|
12
|
+
".js" => "node --check %{path}",
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def self.for(path)
|
|
16
|
+
return [] unless path
|
|
17
|
+
|
|
18
|
+
cmds = []
|
|
19
|
+
ext = File.extname(path)
|
|
20
|
+
cmds << (SYNTAX[ext] % { path: path }) if SYNTAX[ext]
|
|
21
|
+
cmds << test_runner
|
|
22
|
+
cmds.compact
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.test_runner
|
|
26
|
+
%w[bin/test script/test].find { |s| File.executable?(s) }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Plugins.register(:verify) do |toolbox|
|
|
31
|
+
toolbox.add("verify",
|
|
32
|
+
description: "Verify file syntax and run tests",
|
|
33
|
+
params: { path: { type: "string" } },
|
|
34
|
+
required: ["path"]
|
|
35
|
+
) do |a|
|
|
36
|
+
path = a["path"]
|
|
37
|
+
Verifiers.for(path).inject({verified: []}) do |memo, cmd|
|
|
38
|
+
$stdout.puts toolbox.header("execute", { "command" => cmd })
|
|
39
|
+
v = toolbox.run("execute", { "command" => cmd })
|
|
40
|
+
return v.merge(path: path, command: cmd) if v[:exit_status] != 0
|
|
41
|
+
|
|
42
|
+
memo[:verified] << cmd
|
|
43
|
+
memo
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
Elelem::Plugins.register(:write) do |toolbox|
|
|
4
|
+
toolbox.add("write",
|
|
5
|
+
description: "Write file",
|
|
6
|
+
params: { path: { type: "string" }, content: { type: "string" } },
|
|
7
|
+
required: ["path", "content"],
|
|
8
|
+
aliases: ["write<|channel|>"]
|
|
9
|
+
) do |a|
|
|
10
|
+
path = Pathname.new(a["path"]).expand_path
|
|
11
|
+
FileUtils.mkdir_p(path.dirname)
|
|
12
|
+
{ bytes: path.write(a["content"]), path: a["path"] }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
toolbox.after("write") do |_, result|
|
|
16
|
+
if result[:error]
|
|
17
|
+
$stdout.puts " ! #{result[:error]}"
|
|
18
|
+
else
|
|
19
|
+
system("bat", "--paging=never", result[:path]) || $stdout.puts(" -> #{result[:path]}")
|
|
20
|
+
toolbox.run("verify", { "path" => result[:path] })
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
module Plugins
|
|
5
|
+
LOAD_PATHS = [
|
|
6
|
+
File.expand_path("plugins", __dir__),
|
|
7
|
+
"~/.elelem/plugins",
|
|
8
|
+
".elelem/plugins"
|
|
9
|
+
].freeze
|
|
10
|
+
|
|
11
|
+
def self.setup!(toolbox)
|
|
12
|
+
load_plugins
|
|
13
|
+
registry.each_value { |plugin| plugin.call(toolbox) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.reload!(toolbox)
|
|
17
|
+
@registry = {}
|
|
18
|
+
load_plugins
|
|
19
|
+
registry.each_value { |plugin| plugin.call(toolbox) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def self.load_plugins
|
|
23
|
+
LOAD_PATHS.each do |path|
|
|
24
|
+
dir = File.expand_path(path)
|
|
25
|
+
next unless File.directory?(dir)
|
|
26
|
+
|
|
27
|
+
Dir["#{dir}/*.rb"].sort.each do |file|
|
|
28
|
+
load(file)
|
|
29
|
+
rescue => e
|
|
30
|
+
warn "elelem: failed to load plugin #{file}: #{e.message}"
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.register(name, &block)
|
|
36
|
+
(@registry ||= {})[name] = block
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.registry
|
|
40
|
+
@registry ||= {}
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
class SystemPrompt
|
|
5
|
+
TEMPLATE_PATH = File.expand_path("templates/system_prompt.erb", __dir__)
|
|
6
|
+
|
|
7
|
+
attr_reader :memory
|
|
8
|
+
|
|
9
|
+
def initialize(memory: nil)
|
|
10
|
+
@memory = memory
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render
|
|
14
|
+
ERB.new(template, trim_mode: "-").result(binding)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def template
|
|
20
|
+
File.read(TEMPLATE_PATH)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def pwd
|
|
24
|
+
Dir.pwd
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def elelem_source
|
|
28
|
+
File.expand_path("../..", __dir__)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def platform
|
|
32
|
+
RUBY_PLATFORM.split("-").last
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def date
|
|
36
|
+
Date.today
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def git_branch
|
|
40
|
+
return unless File.exist?(".git")
|
|
41
|
+
|
|
42
|
+
"branch: #{`git branch --show-current`.strip}"
|
|
43
|
+
rescue
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def repo_map
|
|
48
|
+
`ctags -x --sort=no --languages=Ruby,Python,JavaScript,TypeScript,Go,Rust -R . 2>/dev/null`
|
|
49
|
+
.lines
|
|
50
|
+
.reject { |l| l.include?("vendor/") || l.include?("node_modules/") || l.include?("spec/") }
|
|
51
|
+
.first(100)
|
|
52
|
+
.join
|
|
53
|
+
rescue
|
|
54
|
+
""
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def agents_md
|
|
58
|
+
Pathname.pwd.ascend.each do |dir|
|
|
59
|
+
file = dir / "AGENTS.md"
|
|
60
|
+
return file.read if file.exist?
|
|
61
|
+
end
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|